rasterregions 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rasterregions/__init__.py +78 -0
- rasterregions/__main__.py +3 -0
- rasterregions/_cleanup.py +115 -0
- rasterregions/_grouping.py +225 -0
- rasterregions/_image.py +92 -0
- rasterregions/_partition.py +383 -0
- rasterregions/_rows.py +80 -0
- rasterregions/_version.py +24 -0
- rasterregions/cli.py +64 -0
- rasterregions/components.py +96 -0
- rasterregions/detect.py +245 -0
- rasterregions/io.py +179 -0
- rasterregions/mask.py +295 -0
- rasterregions/models.py +532 -0
- rasterregions/py.typed +0 -0
- rasterregions/regions.py +37 -0
- rasterregions-0.1.0.dist-info/METADATA +299 -0
- rasterregions-0.1.0.dist-info/RECORD +22 -0
- rasterregions-0.1.0.dist-info/WHEEL +5 -0
- rasterregions-0.1.0.dist-info/entry_points.txt +2 -0
- rasterregions-0.1.0.dist-info/licenses/LICENSE +201 -0
- rasterregions-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Raster-region detection primitives.
|
|
2
|
+
|
|
3
|
+
The package deliberately knows nothing about OCR, handwriting, glyph labels, or
|
|
4
|
+
application-specific semantics. It detects foreground pixels and anonymous
|
|
5
|
+
ordered regions only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
11
|
+
|
|
12
|
+
from .components import components, isolate_component
|
|
13
|
+
from .detect import detect_regions, detect_rows
|
|
14
|
+
from .mask import foreground_mask
|
|
15
|
+
from .models import (
|
|
16
|
+
BBox,
|
|
17
|
+
CleanupConfig,
|
|
18
|
+
Component,
|
|
19
|
+
ComponentDecision,
|
|
20
|
+
DetectionConfig,
|
|
21
|
+
DetectionDiagnostics,
|
|
22
|
+
DetectionResult,
|
|
23
|
+
DetectionStatus,
|
|
24
|
+
ExpectedCountScope,
|
|
25
|
+
GroupingConfig,
|
|
26
|
+
IsolationCandidate,
|
|
27
|
+
IsolationReason,
|
|
28
|
+
IsolationResult,
|
|
29
|
+
MaskConfig,
|
|
30
|
+
MaskMethod,
|
|
31
|
+
MaskMode,
|
|
32
|
+
MaskResult,
|
|
33
|
+
PartitionBoundary,
|
|
34
|
+
PartitionConfig,
|
|
35
|
+
Region,
|
|
36
|
+
RowBand,
|
|
37
|
+
)
|
|
38
|
+
from .regions import crop, tighten_bbox
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
from ._version import __version__
|
|
42
|
+
except ImportError:
|
|
43
|
+
try:
|
|
44
|
+
__version__ = version("rasterregions")
|
|
45
|
+
except PackageNotFoundError: # source checkout before installation
|
|
46
|
+
__version__ = "0+unknown"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"BBox",
|
|
50
|
+
"CleanupConfig",
|
|
51
|
+
"Component",
|
|
52
|
+
"ComponentDecision",
|
|
53
|
+
"DetectionConfig",
|
|
54
|
+
"DetectionDiagnostics",
|
|
55
|
+
"DetectionResult",
|
|
56
|
+
"DetectionStatus",
|
|
57
|
+
"ExpectedCountScope",
|
|
58
|
+
"GroupingConfig",
|
|
59
|
+
"IsolationReason",
|
|
60
|
+
"MaskConfig",
|
|
61
|
+
"IsolationCandidate",
|
|
62
|
+
"IsolationResult",
|
|
63
|
+
"MaskMethod",
|
|
64
|
+
"MaskMode",
|
|
65
|
+
"MaskResult",
|
|
66
|
+
"PartitionBoundary",
|
|
67
|
+
"PartitionConfig",
|
|
68
|
+
"Region",
|
|
69
|
+
"RowBand",
|
|
70
|
+
"__version__",
|
|
71
|
+
"components",
|
|
72
|
+
"crop",
|
|
73
|
+
"detect_regions",
|
|
74
|
+
"detect_rows",
|
|
75
|
+
"foreground_mask",
|
|
76
|
+
"isolate_component",
|
|
77
|
+
"tighten_bbox",
|
|
78
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ._grouping import _size_scale
|
|
8
|
+
from .components import components as label_components
|
|
9
|
+
from .models import CleanupConfig, Component, ComponentDecision
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class CleanedPage:
|
|
14
|
+
mask: np.ndarray
|
|
15
|
+
components: tuple[Component, ...]
|
|
16
|
+
primary_area: float
|
|
17
|
+
median_height: float
|
|
18
|
+
warnings: tuple[str, ...]
|
|
19
|
+
component_decisions: tuple[ComponentDecision, ...]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_page_artifact(
|
|
23
|
+
component: Component,
|
|
24
|
+
width: int,
|
|
25
|
+
height: int,
|
|
26
|
+
config: CleanupConfig | None = None,
|
|
27
|
+
) -> bool:
|
|
28
|
+
config = config or CleanupConfig()
|
|
29
|
+
page_area = max(1, width * height)
|
|
30
|
+
box_area = component.width * component.height
|
|
31
|
+
box_fraction = box_area / page_area
|
|
32
|
+
area_fraction = component.area / page_area
|
|
33
|
+
fill_ratio = component.area / max(1, box_area)
|
|
34
|
+
touches_border = (
|
|
35
|
+
component.bbox.x0 == 0
|
|
36
|
+
or component.bbox.y0 == 0
|
|
37
|
+
or component.bbox.x1 == width
|
|
38
|
+
or component.bbox.y1 == height
|
|
39
|
+
)
|
|
40
|
+
horizontal_rule = (
|
|
41
|
+
component.width / max(1, width) >= config.rule_min_span_ratio
|
|
42
|
+
and component.width / max(1, component.height) >= config.rule_aspect_ratio
|
|
43
|
+
)
|
|
44
|
+
vertical_rule = (
|
|
45
|
+
component.height / max(1, height) >= config.rule_min_span_ratio
|
|
46
|
+
and component.height / max(1, component.width) >= config.rule_aspect_ratio
|
|
47
|
+
)
|
|
48
|
+
if horizontal_rule or vertical_rule:
|
|
49
|
+
return True
|
|
50
|
+
if touches_border and (
|
|
51
|
+
component.width >= width * config.border_span_ratio
|
|
52
|
+
or component.height >= height * config.border_span_ratio
|
|
53
|
+
):
|
|
54
|
+
return True
|
|
55
|
+
if (
|
|
56
|
+
touches_border
|
|
57
|
+
and box_fraction > config.border_bbox_ratio
|
|
58
|
+
and fill_ratio < config.border_fill_ratio
|
|
59
|
+
):
|
|
60
|
+
return True
|
|
61
|
+
return area_fraction > config.max_component_area_ratio
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def clean_page_mask(mask: np.ndarray, config: CleanupConfig | None = None) -> CleanedPage:
|
|
65
|
+
config = config or CleanupConfig()
|
|
66
|
+
height, width = mask.shape
|
|
67
|
+
found = label_components(mask)
|
|
68
|
+
if not found:
|
|
69
|
+
return CleanedPage(
|
|
70
|
+
mask.copy(),
|
|
71
|
+
(),
|
|
72
|
+
0.0,
|
|
73
|
+
0.0,
|
|
74
|
+
("No foreground components were detected.",),
|
|
75
|
+
(),
|
|
76
|
+
)
|
|
77
|
+
artifacts = {
|
|
78
|
+
component.index: _is_page_artifact(component, width, height, config) for component in found
|
|
79
|
+
}
|
|
80
|
+
non_artifacts = [component for component in found if not artifacts[component.index]]
|
|
81
|
+
primary_area, _ = _size_scale(non_artifacts)
|
|
82
|
+
cleaned = np.array(mask, dtype=bool, copy=True)
|
|
83
|
+
warnings: list[str] = []
|
|
84
|
+
decisions: list[ComponentDecision] = []
|
|
85
|
+
kept_components: list[Component] = []
|
|
86
|
+
for component in found:
|
|
87
|
+
reason = None
|
|
88
|
+
if artifacts[component.index]:
|
|
89
|
+
reason = "page border, rule, or oversized artifact"
|
|
90
|
+
elif primary_area and component.area < primary_area * config.tiny_area_ratio:
|
|
91
|
+
reason = "tiny isolated noise"
|
|
92
|
+
if reason:
|
|
93
|
+
cleaned[component.ys, component.xs] = False
|
|
94
|
+
warnings.append(f"Removed component {component.index}: {reason}.")
|
|
95
|
+
else:
|
|
96
|
+
kept_components.append(component)
|
|
97
|
+
decisions.append(
|
|
98
|
+
ComponentDecision(
|
|
99
|
+
component.index,
|
|
100
|
+
component.bbox,
|
|
101
|
+
component.area,
|
|
102
|
+
"removed" if reason else "kept",
|
|
103
|
+
reason,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
kept = tuple(kept_components)
|
|
107
|
+
primary_area, median_height = _size_scale(kept)
|
|
108
|
+
return CleanedPage(
|
|
109
|
+
cleaned,
|
|
110
|
+
kept,
|
|
111
|
+
primary_area,
|
|
112
|
+
median_height,
|
|
113
|
+
tuple(warnings),
|
|
114
|
+
tuple(decisions),
|
|
115
|
+
)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from PIL import Image
|
|
5
|
+
|
|
6
|
+
from .models import BBox, Component, GroupingConfig, Region, RowBand
|
|
7
|
+
from .regions import crop, tighten_bbox
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _size_scale(found: tuple[Component, ...] | list[Component]) -> tuple[float, float]:
|
|
11
|
+
if not found:
|
|
12
|
+
return 0.0, 0.0
|
|
13
|
+
areas = np.asarray([component.area for component in found], dtype=float)
|
|
14
|
+
upper_half = np.sort(areas)[len(areas) // 2 :]
|
|
15
|
+
primary_area = float(np.median(upper_half))
|
|
16
|
+
body = [component for component in found if component.area >= primary_area * 0.2]
|
|
17
|
+
median_height = float(np.median([component.height for component in body])) if body else 0.0
|
|
18
|
+
return primary_area, median_height
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _body_components(
|
|
22
|
+
found: tuple[Component, ...] | list[Component],
|
|
23
|
+
primary_area: float,
|
|
24
|
+
median_height: float,
|
|
25
|
+
config: GroupingConfig | None = None,
|
|
26
|
+
) -> list[Component]:
|
|
27
|
+
if not found:
|
|
28
|
+
return []
|
|
29
|
+
config = config or GroupingConfig()
|
|
30
|
+
minimum_area = primary_area * config.body_min_area_ratio
|
|
31
|
+
minimum_height = median_height * config.body_min_height_ratio
|
|
32
|
+
bodies = [
|
|
33
|
+
component
|
|
34
|
+
for component in found
|
|
35
|
+
if component.area >= minimum_area
|
|
36
|
+
and (component.height >= minimum_height or component.width >= minimum_height)
|
|
37
|
+
]
|
|
38
|
+
return bodies or [max(found, key=lambda component: component.area)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _group_nearby_bodies(
|
|
42
|
+
bodies: list[Component], median_height: float, config: GroupingConfig
|
|
43
|
+
) -> list[list[Component]]:
|
|
44
|
+
groups: list[list[Component]] = []
|
|
45
|
+
for component in sorted(bodies, key=lambda item: item.bbox.x0):
|
|
46
|
+
if not groups:
|
|
47
|
+
groups.append([component])
|
|
48
|
+
continue
|
|
49
|
+
previous = groups[-1]
|
|
50
|
+
previous_right = max(member.bbox.x1 for member in previous)
|
|
51
|
+
previous_top = min(member.bbox.y0 for member in previous)
|
|
52
|
+
previous_bottom = max(member.bbox.y1 for member in previous)
|
|
53
|
+
gap = component.bbox.x0 - previous_right
|
|
54
|
+
overlap = min(previous_bottom, component.bbox.y1) - max(previous_top, component.bbox.y0)
|
|
55
|
+
if gap <= max(1.0, median_height * config.body_join_gap_ratio) and overlap > 0:
|
|
56
|
+
previous.append(component)
|
|
57
|
+
else:
|
|
58
|
+
groups.append([component])
|
|
59
|
+
return groups
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _satellite_score(
|
|
63
|
+
satellite: Component,
|
|
64
|
+
body: Component,
|
|
65
|
+
median_height: float,
|
|
66
|
+
config: GroupingConfig,
|
|
67
|
+
) -> float | None:
|
|
68
|
+
if satellite.area >= body.area * config.satellite_max_area_ratio:
|
|
69
|
+
return None
|
|
70
|
+
horizontal_gap = max(
|
|
71
|
+
body.bbox.x0 - satellite.bbox.x1,
|
|
72
|
+
satellite.bbox.x0 - body.bbox.x1,
|
|
73
|
+
0,
|
|
74
|
+
)
|
|
75
|
+
vertical_gap = max(
|
|
76
|
+
body.bbox.y0 - satellite.bbox.y1,
|
|
77
|
+
satellite.bbox.y0 - body.bbox.y1,
|
|
78
|
+
0,
|
|
79
|
+
)
|
|
80
|
+
if (
|
|
81
|
+
horizontal_gap > median_height * config.satellite_max_horizontal_gap_ratio
|
|
82
|
+
or vertical_gap > median_height * config.satellite_max_vertical_gap_ratio
|
|
83
|
+
):
|
|
84
|
+
return None
|
|
85
|
+
overlaps_x = max(
|
|
86
|
+
0,
|
|
87
|
+
min(body.bbox.x1, satellite.bbox.x1) - max(body.bbox.x0, satellite.bbox.x0),
|
|
88
|
+
)
|
|
89
|
+
return (
|
|
90
|
+
horizontal_gap / max(1.0, median_height)
|
|
91
|
+
+ vertical_gap / max(1.0, median_height)
|
|
92
|
+
- 0.35 * overlaps_x / max(1.0, median_height)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _make_region(
|
|
97
|
+
image: Image.Image,
|
|
98
|
+
mask: np.ndarray,
|
|
99
|
+
bbox: BBox,
|
|
100
|
+
*,
|
|
101
|
+
region_id: str,
|
|
102
|
+
row_index: int,
|
|
103
|
+
column_index: int,
|
|
104
|
+
reading_order: int,
|
|
105
|
+
confidence: float,
|
|
106
|
+
component_indices: tuple[int, ...],
|
|
107
|
+
padding: int,
|
|
108
|
+
clip: BBox | None = None,
|
|
109
|
+
) -> Region:
|
|
110
|
+
height, width = mask.shape
|
|
111
|
+
clip = clip or BBox(0, 0, width, height)
|
|
112
|
+
expanded = BBox(
|
|
113
|
+
max(clip.x0, bbox.x0 - padding),
|
|
114
|
+
max(clip.y0, bbox.y0 - padding),
|
|
115
|
+
min(clip.x1, bbox.x1 + padding),
|
|
116
|
+
min(clip.y1, bbox.y1 + padding),
|
|
117
|
+
)
|
|
118
|
+
tight = tighten_bbox(mask, expanded, pad=padding)
|
|
119
|
+
return Region(
|
|
120
|
+
region_id=region_id,
|
|
121
|
+
bbox=tight,
|
|
122
|
+
row_index=row_index,
|
|
123
|
+
column_index=column_index,
|
|
124
|
+
reading_order=reading_order,
|
|
125
|
+
confidence=float(np.clip(confidence, 0.0, 1.0)),
|
|
126
|
+
component_indices=component_indices,
|
|
127
|
+
image=crop(image, tight),
|
|
128
|
+
mask=mask[tight.y0 : tight.y1, tight.x0 : tight.x1],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _automatic_regions(
|
|
133
|
+
image: Image.Image,
|
|
134
|
+
mask: np.ndarray,
|
|
135
|
+
found: tuple[Component, ...],
|
|
136
|
+
primary_area: float,
|
|
137
|
+
median_height: float,
|
|
138
|
+
rows: tuple[RowBand, ...],
|
|
139
|
+
padding: int,
|
|
140
|
+
grouping: GroupingConfig,
|
|
141
|
+
) -> list[Region]:
|
|
142
|
+
bodies = _body_components(found, primary_area, median_height, grouping)
|
|
143
|
+
body_indices = {component.index for component in bodies}
|
|
144
|
+
groups_by_row = [
|
|
145
|
+
_group_nearby_bodies(
|
|
146
|
+
[component for component in bodies if component.index in row.component_indices],
|
|
147
|
+
median_height,
|
|
148
|
+
grouping,
|
|
149
|
+
)
|
|
150
|
+
for row in rows
|
|
151
|
+
]
|
|
152
|
+
attached_by_row: list[list[list[Component]]] = [
|
|
153
|
+
[[] for _ in groups] for groups in groups_by_row
|
|
154
|
+
]
|
|
155
|
+
satellites = [component for component in found if component.index not in body_indices]
|
|
156
|
+
for satellite in satellites:
|
|
157
|
+
options: list[tuple[float, int, int]] = []
|
|
158
|
+
for row_index, groups in enumerate(groups_by_row):
|
|
159
|
+
for group_index, group in enumerate(groups):
|
|
160
|
+
scores = [
|
|
161
|
+
score
|
|
162
|
+
for body in group
|
|
163
|
+
if (score := _satellite_score(satellite, body, median_height, grouping))
|
|
164
|
+
is not None
|
|
165
|
+
]
|
|
166
|
+
if scores:
|
|
167
|
+
options.append((min(scores), row_index, group_index))
|
|
168
|
+
if options:
|
|
169
|
+
_, row_index, group_index = min(options)
|
|
170
|
+
attached_by_row[row_index][group_index].append(satellite)
|
|
171
|
+
|
|
172
|
+
regions: list[Region] = []
|
|
173
|
+
reading_order = 0
|
|
174
|
+
for row_index, groups in enumerate(groups_by_row):
|
|
175
|
+
for column_index, group in enumerate(groups):
|
|
176
|
+
marks = attached_by_row[row_index][column_index]
|
|
177
|
+
members = [*group, *marks]
|
|
178
|
+
box = BBox(
|
|
179
|
+
min(component.bbox.x0 for component in members),
|
|
180
|
+
min(component.bbox.y0 for component in members),
|
|
181
|
+
max(component.bbox.x1 for component in members),
|
|
182
|
+
max(component.bbox.y1 for component in members),
|
|
183
|
+
)
|
|
184
|
+
next_group = groups[column_index + 1] if column_index + 1 < len(groups) else None
|
|
185
|
+
previous_group = groups[column_index - 1] if column_index else None
|
|
186
|
+
left_gap = (
|
|
187
|
+
group[0].bbox.x0 - max(component.bbox.x1 for component in previous_group)
|
|
188
|
+
if previous_group
|
|
189
|
+
else median_height
|
|
190
|
+
)
|
|
191
|
+
right_gap = (
|
|
192
|
+
min(component.bbox.x0 for component in next_group)
|
|
193
|
+
- max(component.bbox.x1 for component in group)
|
|
194
|
+
if next_group
|
|
195
|
+
else median_height
|
|
196
|
+
)
|
|
197
|
+
separation = float(
|
|
198
|
+
np.clip(min(left_gap, right_gap) / max(1.0, median_height * 0.5), 0.0, 1.0)
|
|
199
|
+
)
|
|
200
|
+
body_areas = np.asarray([component.area for component in group], dtype=float)
|
|
201
|
+
median_area = float(np.median(body_areas))
|
|
202
|
+
size_consistency = float(
|
|
203
|
+
np.clip(
|
|
204
|
+
1.0 - np.median(np.abs(body_areas - median_area)) / max(1.0, median_area),
|
|
205
|
+
0.0,
|
|
206
|
+
1.0,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
confidence = 0.65 * separation + 0.35 * size_consistency
|
|
210
|
+
regions.append(
|
|
211
|
+
_make_region(
|
|
212
|
+
image,
|
|
213
|
+
mask,
|
|
214
|
+
box,
|
|
215
|
+
region_id=f"region-{reading_order:03d}",
|
|
216
|
+
row_index=row_index,
|
|
217
|
+
column_index=column_index,
|
|
218
|
+
reading_order=reading_order,
|
|
219
|
+
confidence=confidence,
|
|
220
|
+
component_indices=tuple(sorted(component.index for component in members)),
|
|
221
|
+
padding=padding,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
reading_order += 1
|
|
225
|
+
return regions
|
rasterregions/_image.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from PIL import Image, ImageOps
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class NormalizedImage:
|
|
12
|
+
image: Image.Image = field(repr=False, compare=False)
|
|
13
|
+
rgb: np.ndarray = field(repr=False, compare=False)
|
|
14
|
+
source_path: Path | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _normalized_pixels(
|
|
18
|
+
pixels: np.ndarray,
|
|
19
|
+
*,
|
|
20
|
+
alpha_background: tuple[int, int, int],
|
|
21
|
+
source_path: Path | None,
|
|
22
|
+
) -> NormalizedImage:
|
|
23
|
+
array = np.asarray(pixels)
|
|
24
|
+
if array.ndim not in (2, 3) or array.shape[0] == 0 or array.shape[1] == 0:
|
|
25
|
+
raise ValueError("image must be non-empty and have grayscale, RGB, or RGBA dimensions")
|
|
26
|
+
if array.ndim == 3 and array.shape[2] not in (3, 4):
|
|
27
|
+
raise ValueError("image must be grayscale, RGB, or RGBA")
|
|
28
|
+
if array.dtype.kind not in "uif":
|
|
29
|
+
raise ValueError("image pixels must be numeric")
|
|
30
|
+
|
|
31
|
+
values = np.asarray(array, dtype=np.float32)
|
|
32
|
+
if not np.isfinite(values).all():
|
|
33
|
+
raise ValueError("image pixels must be finite")
|
|
34
|
+
if np.issubdtype(array.dtype, np.floating) and values.max(initial=0.0) <= 1.0:
|
|
35
|
+
values = values * 255.0
|
|
36
|
+
values = np.clip(values, 0.0, 255.0)
|
|
37
|
+
|
|
38
|
+
if values.ndim == 2:
|
|
39
|
+
rgb = np.repeat(values[:, :, None], 3, axis=2)
|
|
40
|
+
elif values.shape[2] == 4:
|
|
41
|
+
alpha = values[:, :, 3:4] / 255.0
|
|
42
|
+
background = np.asarray(alpha_background, dtype=np.float32).reshape(1, 1, 3)
|
|
43
|
+
rgb = values[:, :, :3] * alpha + background * (1.0 - alpha)
|
|
44
|
+
else:
|
|
45
|
+
rgb = values
|
|
46
|
+
|
|
47
|
+
rgb = np.array(np.clip(rgb, 0.0, 255.0), dtype=np.float32, copy=True)
|
|
48
|
+
rgb.setflags(write=False)
|
|
49
|
+
image = Image.fromarray(np.rint(rgb).astype(np.uint8))
|
|
50
|
+
return NormalizedImage(image=image, rgb=rgb, source_path=source_path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _normalize_pil(
|
|
54
|
+
image: Image.Image,
|
|
55
|
+
*,
|
|
56
|
+
alpha_background: tuple[int, int, int],
|
|
57
|
+
source_path: Path | None,
|
|
58
|
+
) -> NormalizedImage:
|
|
59
|
+
oriented = ImageOps.exif_transpose(image)
|
|
60
|
+
rgba = np.asarray(oriented.convert("RGBA"), dtype=np.uint8)
|
|
61
|
+
return _normalized_pixels(
|
|
62
|
+
rgba,
|
|
63
|
+
alpha_background=alpha_background,
|
|
64
|
+
source_path=source_path,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normalize_image(
|
|
69
|
+
source: str | Path | Image.Image | np.ndarray,
|
|
70
|
+
*,
|
|
71
|
+
alpha_background: tuple[int, int, int] = (255, 255, 255),
|
|
72
|
+
) -> NormalizedImage:
|
|
73
|
+
"""Normalize a raster source to orientation-corrected RGB pixels."""
|
|
74
|
+
if isinstance(source, (str, Path)):
|
|
75
|
+
path = Path(source)
|
|
76
|
+
with Image.open(path) as image:
|
|
77
|
+
return _normalize_pil(
|
|
78
|
+
image,
|
|
79
|
+
alpha_background=alpha_background,
|
|
80
|
+
source_path=path,
|
|
81
|
+
)
|
|
82
|
+
if isinstance(source, Image.Image):
|
|
83
|
+
return _normalize_pil(
|
|
84
|
+
source,
|
|
85
|
+
alpha_background=alpha_background,
|
|
86
|
+
source_path=None,
|
|
87
|
+
)
|
|
88
|
+
return _normalized_pixels(
|
|
89
|
+
np.asarray(source),
|
|
90
|
+
alpha_background=alpha_background,
|
|
91
|
+
source_path=None,
|
|
92
|
+
)
|