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.
@@ -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,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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
@@ -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
+ )