csf-scraper-python 0.1.1__py3-none-any.whl → 0.2.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.
- csf_scraper/__init__.py +7 -0
- csf_scraper/image_readers/adapters/cpu/__init__.py +18 -0
- csf_scraper/image_readers/adapters/cpu/open_cv/__init__.py +37 -0
- csf_scraper/image_readers/adapters/cpu/open_cv/config.py +84 -0
- csf_scraper/image_readers/adapters/cpu/open_cv/detection.py +269 -0
- csf_scraper/image_readers/adapters/cpu/open_cv/enhance.py +103 -0
- csf_scraper/image_readers/adapters/cpu/open_cv/errors.py +18 -0
- csf_scraper/image_readers/adapters/cpu/open_cv/io.py +31 -0
- csf_scraper/image_readers/adapters/cpu/open_cv/pipeline.py +89 -0
- csf_scraper/image_readers/adapters/cpu/open_cv/qr.py +108 -0
- csf_scraper/image_readers/adapters/cpu/open_cv/transform.py +97 -0
- csf_scraper/image_readers/adapters/cpu/open_cv_image_reader.py +56 -0
- csf_scraper/scraper.py +14 -3
- {csf_scraper_python-0.1.1.dist-info → csf_scraper_python-0.2.0.dist-info}/METADATA +119 -8
- {csf_scraper_python-0.1.1.dist-info → csf_scraper_python-0.2.0.dist-info}/RECORD +18 -7
- {csf_scraper_python-0.1.1.dist-info → csf_scraper_python-0.2.0.dist-info}/WHEEL +1 -1
- {csf_scraper_python-0.1.1.dist-info → csf_scraper_python-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {csf_scraper_python-0.1.1.dist-info → csf_scraper_python-0.2.0.dist-info}/licenses/NOTICE.md +0 -0
csf_scraper/__init__.py
CHANGED
|
@@ -57,6 +57,7 @@ from csf_scraper.scraper import Scraper
|
|
|
57
57
|
from csf_scraper.value_objects import CsfImage, IdCif, Rfc
|
|
58
58
|
|
|
59
59
|
if TYPE_CHECKING:
|
|
60
|
+
from csf_scraper.image_readers.adapters.cpu import OpenCvImageReader
|
|
60
61
|
from csf_scraper.image_readers.adapters.llms import GeminiImageReader
|
|
61
62
|
|
|
62
63
|
__all__ = [
|
|
@@ -88,6 +89,7 @@ __all__ = [
|
|
|
88
89
|
"InvalidImageFormatException",
|
|
89
90
|
"InvalidRfcFormatException",
|
|
90
91
|
"LlmImageReaderService",
|
|
92
|
+
"OpenCvImageReader",
|
|
91
93
|
"PdfCsfTextParser",
|
|
92
94
|
"PdfTextProvider",
|
|
93
95
|
"PdfToTextConvertException",
|
|
@@ -115,5 +117,10 @@ def __getattr__(name: str) -> object:
|
|
|
115
117
|
from csf_scraper.image_readers.adapters.llms import GeminiImageReader
|
|
116
118
|
|
|
117
119
|
return GeminiImageReader
|
|
120
|
+
|
|
121
|
+
if name == "OpenCvImageReader":
|
|
122
|
+
from csf_scraper.image_readers.adapters.cpu import OpenCvImageReader
|
|
123
|
+
|
|
124
|
+
return OpenCvImageReader
|
|
118
125
|
|
|
119
126
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Adaptadores de los lectores de imagen."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from csf_scraper.image_readers.adapters.cpu.open_cv_image_reader import OpenCvImageReader
|
|
7
|
+
|
|
8
|
+
__all__ = ["OpenCvImageReader"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def __getattr__(name: str) -> object:
|
|
12
|
+
"""Reexporta los adaptadores concretos de forma perezosa."""
|
|
13
|
+
if name == "OpenCvImageReader":
|
|
14
|
+
from csf_scraper.image_readers.adapters.cpu.open_cv_image_reader import OpenCvImageReader
|
|
15
|
+
|
|
16
|
+
return OpenCvImageReader
|
|
17
|
+
|
|
18
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from .config import DetectionConfig, EnhancementConfig, ScanConfig
|
|
2
|
+
from .detection import DetectionResult, detect_document
|
|
3
|
+
from .enhance import enhance_for_ocr, to_gray
|
|
4
|
+
from .errors import (
|
|
5
|
+
DocumentNotFoundError,
|
|
6
|
+
DocumentScannerError,
|
|
7
|
+
ImageLoadError,
|
|
8
|
+
ImageSaveError,
|
|
9
|
+
InvalidGeometryError,
|
|
10
|
+
)
|
|
11
|
+
from .io import load_image, save_image
|
|
12
|
+
from .pipeline import ScanResult, scan_array
|
|
13
|
+
from .transform import four_point_transform, order_points, scale_points
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DetectionConfig",
|
|
19
|
+
"DetectionResult",
|
|
20
|
+
"DocumentNotFoundError",
|
|
21
|
+
"DocumentScannerError",
|
|
22
|
+
"EnhancementConfig",
|
|
23
|
+
"ImageLoadError",
|
|
24
|
+
"ImageSaveError",
|
|
25
|
+
"InvalidGeometryError",
|
|
26
|
+
"ScanConfig",
|
|
27
|
+
"ScanResult",
|
|
28
|
+
"detect_document",
|
|
29
|
+
"enhance_for_ocr",
|
|
30
|
+
"four_point_transform",
|
|
31
|
+
"load_image",
|
|
32
|
+
"order_points",
|
|
33
|
+
"save_image",
|
|
34
|
+
"scale_points",
|
|
35
|
+
"scan_array",
|
|
36
|
+
"to_gray",
|
|
37
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
OutputMode = Literal["color", "gray", "bw"]
|
|
7
|
+
ThresholdMethod = Literal["adaptive", "otsu"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _require_odd_min(value: int, name: str, minimum: int = 3) -> None:
|
|
11
|
+
if value < minimum or value % 2 == 0:
|
|
12
|
+
raise ValueError(f"{name} must be an odd integer >= {minimum}, got {value}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class DetectionConfig:
|
|
17
|
+
resize_height: int = 700
|
|
18
|
+
blur_ksize: int = 5
|
|
19
|
+
canny_low: int = 75
|
|
20
|
+
canny_high: int = 200
|
|
21
|
+
dilate_kernel: int = 9
|
|
22
|
+
dilate_iterations: int = 2
|
|
23
|
+
min_area_ratio: float = 0.05
|
|
24
|
+
max_area_ratio: float = 0.98
|
|
25
|
+
bright_open_kernel: int = 5
|
|
26
|
+
approx_epsilon_ratios: tuple[float, ...] = (0.02, 0.05, 0.08)
|
|
27
|
+
use_clahe: bool = True
|
|
28
|
+
clahe_clip_limit: float = 2.0
|
|
29
|
+
clahe_tile_grid: tuple[int, int] = (8, 8)
|
|
30
|
+
min_confidence: float = 0.08
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if self.resize_height < 100:
|
|
34
|
+
raise ValueError("resize_height must be >= 100")
|
|
35
|
+
_require_odd_min(self.blur_ksize, "blur_ksize")
|
|
36
|
+
_require_odd_min(self.dilate_kernel, "dilate_kernel")
|
|
37
|
+
_require_odd_min(self.bright_open_kernel, "bright_open_kernel")
|
|
38
|
+
if self.canny_low >= self.canny_high:
|
|
39
|
+
raise ValueError("canny_low must be lower than canny_high")
|
|
40
|
+
if not 0 < self.min_area_ratio < self.max_area_ratio <= 1:
|
|
41
|
+
raise ValueError("area ratios must satisfy 0 < min < max <= 1")
|
|
42
|
+
if self.dilate_iterations < 1:
|
|
43
|
+
raise ValueError("dilate_iterations must be >= 1")
|
|
44
|
+
if not self.approx_epsilon_ratios:
|
|
45
|
+
raise ValueError("approx_epsilon_ratios cannot be empty")
|
|
46
|
+
if any(ratio <= 0 for ratio in self.approx_epsilon_ratios):
|
|
47
|
+
raise ValueError("approx_epsilon_ratios must contain positive values")
|
|
48
|
+
if not 0 <= self.min_confidence <= 1:
|
|
49
|
+
raise ValueError("min_confidence must be between 0 and 1")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class EnhancementConfig:
|
|
54
|
+
mode: OutputMode = "bw"
|
|
55
|
+
use_clahe: bool = True
|
|
56
|
+
clahe_clip_limit: float = 2.0
|
|
57
|
+
clahe_tile_grid: tuple[int, int] = (8, 8)
|
|
58
|
+
threshold_method: ThresholdMethod = "adaptive"
|
|
59
|
+
adaptive_block_size: int = 31
|
|
60
|
+
adaptive_c: float = 10.0
|
|
61
|
+
denoise: bool = True
|
|
62
|
+
min_ocr_width: int = 1000
|
|
63
|
+
max_upscale: float = 3.0
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
if self.mode not in ("color", "gray", "bw"):
|
|
67
|
+
raise ValueError("mode must be one of: color, gray, bw")
|
|
68
|
+
if self.threshold_method not in ("adaptive", "otsu"):
|
|
69
|
+
raise ValueError("threshold_method must be adaptive or otsu")
|
|
70
|
+
_require_odd_min(self.adaptive_block_size, "adaptive_block_size")
|
|
71
|
+
if self.adaptive_c < 0:
|
|
72
|
+
raise ValueError("adaptive_c must be >= 0")
|
|
73
|
+
if self.min_ocr_width < 0:
|
|
74
|
+
raise ValueError("min_ocr_width must be >= 0")
|
|
75
|
+
if self.max_upscale < 1:
|
|
76
|
+
raise ValueError("max_upscale must be >= 1")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class ScanConfig:
|
|
81
|
+
detection: DetectionConfig = field(default_factory=DetectionConfig)
|
|
82
|
+
enhancement: EnhancementConfig = field(default_factory=EnhancementConfig)
|
|
83
|
+
allow_full_image_fallback: bool = True
|
|
84
|
+
debug: bool = False
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Sequence
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
import cv2
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .config import DetectionConfig
|
|
10
|
+
from .enhance import apply_clahe, to_gray
|
|
11
|
+
from .errors import InvalidGeometryError
|
|
12
|
+
from .transform import order_points
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class DetectionResult:
|
|
17
|
+
quad: np.ndarray | None
|
|
18
|
+
confidence: float
|
|
19
|
+
method: str
|
|
20
|
+
scale: float
|
|
21
|
+
debug: dict[str, np.ndarray] = field(default_factory=dict)
|
|
22
|
+
variant: str = ""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def found(self) -> bool:
|
|
26
|
+
return self.quad is not None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _prepare_work_image(gray: np.ndarray, config: DetectionConfig) -> tuple[np.ndarray, float]:
|
|
30
|
+
height, width = gray.shape[:2]
|
|
31
|
+
scale = min(1.0, config.resize_height / float(height))
|
|
32
|
+
if scale < 1.0:
|
|
33
|
+
size = (max(1, round(width * scale)), max(1, round(height * scale)))
|
|
34
|
+
work = cv2.resize(gray, size, interpolation=cv2.INTER_AREA)
|
|
35
|
+
else:
|
|
36
|
+
work = gray.copy()
|
|
37
|
+
|
|
38
|
+
if config.use_clahe:
|
|
39
|
+
work = apply_clahe(work, config.clahe_clip_limit, config.clahe_tile_grid)
|
|
40
|
+
|
|
41
|
+
return work, scale
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _edge_masks(
|
|
45
|
+
work: np.ndarray,
|
|
46
|
+
config: DetectionConfig,
|
|
47
|
+
) -> tuple[np.ndarray, np.ndarray, list[tuple[str, np.ndarray]]]:
|
|
48
|
+
blurred = cv2.GaussianBlur(
|
|
49
|
+
work,
|
|
50
|
+
(config.blur_ksize, config.blur_ksize),
|
|
51
|
+
0,
|
|
52
|
+
)
|
|
53
|
+
edges = cv2.Canny(blurred, config.canny_low, config.canny_high)
|
|
54
|
+
|
|
55
|
+
small_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
|
|
56
|
+
kernel = cv2.getStructuringElement(
|
|
57
|
+
cv2.MORPH_RECT,
|
|
58
|
+
(config.dilate_kernel, config.dilate_kernel),
|
|
59
|
+
)
|
|
60
|
+
small_dilated = cv2.dilate(edges, small_kernel, iterations=1)
|
|
61
|
+
dilated = cv2.dilate(edges, kernel, iterations=config.dilate_iterations)
|
|
62
|
+
closed = cv2.morphologyEx(dilated, cv2.MORPH_CLOSE, kernel)
|
|
63
|
+
|
|
64
|
+
bright_kernel = cv2.getStructuringElement(
|
|
65
|
+
cv2.MORPH_RECT,
|
|
66
|
+
(config.bright_open_kernel, config.bright_open_kernel),
|
|
67
|
+
)
|
|
68
|
+
_, bright = cv2.threshold(
|
|
69
|
+
work,
|
|
70
|
+
0,
|
|
71
|
+
255,
|
|
72
|
+
cv2.THRESH_BINARY | cv2.THRESH_OTSU,
|
|
73
|
+
)
|
|
74
|
+
bright = cv2.morphologyEx(bright, cv2.MORPH_OPEN, bright_kernel)
|
|
75
|
+
bright_ratio = cv2.countNonZero(bright) / float(bright.size)
|
|
76
|
+
|
|
77
|
+
masks = [
|
|
78
|
+
("raw", edges),
|
|
79
|
+
("dilate_small", small_dilated),
|
|
80
|
+
("dilate_close", closed),
|
|
81
|
+
]
|
|
82
|
+
if 0.02 < bright_ratio < 0.98:
|
|
83
|
+
masks.append(("bright", bright))
|
|
84
|
+
return edges, closed, masks
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _approx_quad(contour: np.ndarray, epsilon_ratios: tuple[float, ...]) -> np.ndarray | None:
|
|
88
|
+
hull = cv2.convexHull(contour)
|
|
89
|
+
perimeter = cv2.arcLength(hull, True)
|
|
90
|
+
if perimeter <= 0:
|
|
91
|
+
return None
|
|
92
|
+
for ratio in epsilon_ratios:
|
|
93
|
+
approx = cv2.approxPolyDP(hull, ratio * perimeter, True)
|
|
94
|
+
if len(approx) == 4:
|
|
95
|
+
return approx.reshape(4, 2).astype(np.float32)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _iter_candidates(
|
|
100
|
+
contours: Sequence[np.ndarray],
|
|
101
|
+
image_area: float,
|
|
102
|
+
config: DetectionConfig,
|
|
103
|
+
) -> Iterator[tuple[np.ndarray, str, float]]:
|
|
104
|
+
valid: list[np.ndarray] = []
|
|
105
|
+
for contour in contours:
|
|
106
|
+
area = cv2.contourArea(contour)
|
|
107
|
+
if area <= 0:
|
|
108
|
+
continue
|
|
109
|
+
ratio = area / image_area
|
|
110
|
+
if config.min_area_ratio <= ratio <= config.max_area_ratio:
|
|
111
|
+
valid.append(contour)
|
|
112
|
+
|
|
113
|
+
if not valid:
|
|
114
|
+
valid = sorted(contours, key=cv2.contourArea, reverse=True)[:5]
|
|
115
|
+
|
|
116
|
+
for contour in valid:
|
|
117
|
+
area = cv2.contourArea(contour)
|
|
118
|
+
hull = cv2.convexHull(contour)
|
|
119
|
+
hull_area = cv2.contourArea(hull)
|
|
120
|
+
solidity = area / hull_area if hull_area > 0 else 0.0
|
|
121
|
+
|
|
122
|
+
approx = _approx_quad(hull, config.approx_epsilon_ratios)
|
|
123
|
+
if approx is not None:
|
|
124
|
+
yield approx, "approx", solidity
|
|
125
|
+
|
|
126
|
+
rect = cv2.minAreaRect(contour)
|
|
127
|
+
box = cv2.boxPoints(rect).astype(np.float32)
|
|
128
|
+
yield box, "min_area_rect", solidity
|
|
129
|
+
|
|
130
|
+
if valid:
|
|
131
|
+
merged = np.vstack([contour.reshape(-1, 2) for contour in valid]).astype(np.float32)
|
|
132
|
+
merged_hull = cv2.convexHull(merged.reshape(-1, 1, 2))
|
|
133
|
+
|
|
134
|
+
approx = _approx_quad(merged_hull, config.approx_epsilon_ratios)
|
|
135
|
+
if approx is not None:
|
|
136
|
+
yield approx, "merged_hull", 1.0
|
|
137
|
+
|
|
138
|
+
box = cv2.boxPoints(cv2.minAreaRect(merged_hull)).astype(np.float32)
|
|
139
|
+
yield box, "merged_hull_rect", 1.0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _score_candidate(
|
|
143
|
+
quad: np.ndarray,
|
|
144
|
+
image_shape: tuple[int, int],
|
|
145
|
+
solidity: float,
|
|
146
|
+
method: str,
|
|
147
|
+
) -> tuple[float, np.ndarray | None]:
|
|
148
|
+
try:
|
|
149
|
+
ordered = order_points(quad)
|
|
150
|
+
except InvalidGeometryError:
|
|
151
|
+
return -1.0, None
|
|
152
|
+
|
|
153
|
+
image_height, image_width = image_shape
|
|
154
|
+
image_area = float(image_height * image_width)
|
|
155
|
+
area = abs(cv2.contourArea(ordered))
|
|
156
|
+
if area <= 0:
|
|
157
|
+
return -1.0, None
|
|
158
|
+
|
|
159
|
+
area_ratio = area / image_area
|
|
160
|
+
if area_ratio < 0.02 or area_ratio > 1.0:
|
|
161
|
+
return -1.0, None
|
|
162
|
+
|
|
163
|
+
rect = cv2.minAreaRect(ordered.astype(np.float32))
|
|
164
|
+
rect_width, rect_height = rect[1]
|
|
165
|
+
rect_area = max(float(rect_width) * float(rect_height), 1e-6)
|
|
166
|
+
rectangularity = min(1.0, area / rect_area)
|
|
167
|
+
|
|
168
|
+
long_side = max(rect_width, rect_height)
|
|
169
|
+
short_side = max(min(rect_width, rect_height), 1e-6)
|
|
170
|
+
aspect = long_side / short_side
|
|
171
|
+
|
|
172
|
+
score = area_ratio * (0.5 + 0.5 * rectangularity)
|
|
173
|
+
score *= 0.7 + 0.3 * max(0.0, min(1.0, solidity))
|
|
174
|
+
|
|
175
|
+
if aspect < 0.25 or aspect > 4.0:
|
|
176
|
+
score *= 0.5
|
|
177
|
+
if area_ratio > 0.97:
|
|
178
|
+
score *= 0.2
|
|
179
|
+
elif area_ratio > 0.90:
|
|
180
|
+
score *= 0.5
|
|
181
|
+
|
|
182
|
+
margin_x = 0.015 * image_width
|
|
183
|
+
margin_y = 0.015 * image_height
|
|
184
|
+
border_contacts = int(
|
|
185
|
+
np.count_nonzero(
|
|
186
|
+
(ordered[:, 0] < margin_x)
|
|
187
|
+
| (ordered[:, 0] > image_width - 1 - margin_x)
|
|
188
|
+
| (ordered[:, 1] < margin_y)
|
|
189
|
+
| (ordered[:, 1] > image_height - 1 - margin_y)
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
if border_contacts:
|
|
193
|
+
score *= 0.55**border_contacts
|
|
194
|
+
|
|
195
|
+
score *= {
|
|
196
|
+
"approx": 1.0,
|
|
197
|
+
"merged_hull": 1.0,
|
|
198
|
+
"min_area_rect": 0.6,
|
|
199
|
+
"merged_hull_rect": 0.6,
|
|
200
|
+
}.get(method, 0.8)
|
|
201
|
+
|
|
202
|
+
return float(min(score, 1.0)), ordered
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def detect_document(
|
|
206
|
+
image: np.ndarray,
|
|
207
|
+
config: DetectionConfig | None = None,
|
|
208
|
+
) -> DetectionResult:
|
|
209
|
+
cfg = config or DetectionConfig()
|
|
210
|
+
if image is None or image.size == 0:
|
|
211
|
+
raise ValueError("image cannot be empty")
|
|
212
|
+
|
|
213
|
+
gray = to_gray(image)
|
|
214
|
+
height, width = gray.shape[:2]
|
|
215
|
+
work, scale = _prepare_work_image(gray, cfg)
|
|
216
|
+
edges, closed, masks = _edge_masks(work, cfg)
|
|
217
|
+
|
|
218
|
+
image_area = float(work.shape[0] * work.shape[1])
|
|
219
|
+
image_shape = (work.shape[0], work.shape[1])
|
|
220
|
+
best_score = -1.0
|
|
221
|
+
best_quad: np.ndarray | None = None
|
|
222
|
+
best_method = "none"
|
|
223
|
+
best_variant = ""
|
|
224
|
+
|
|
225
|
+
for variant, mask in masks:
|
|
226
|
+
found_contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
227
|
+
contours = [np.asarray(contour) for contour in found_contours]
|
|
228
|
+
if not contours:
|
|
229
|
+
continue
|
|
230
|
+
for quad, method, solidity in _iter_candidates(contours, image_area, cfg):
|
|
231
|
+
score, ordered = _score_candidate(quad, image_shape, solidity, method)
|
|
232
|
+
if ordered is not None and score > best_score:
|
|
233
|
+
best_score = score
|
|
234
|
+
best_quad = ordered
|
|
235
|
+
best_method = method
|
|
236
|
+
best_variant = variant
|
|
237
|
+
|
|
238
|
+
debug = {
|
|
239
|
+
"gray": gray,
|
|
240
|
+
"work": work,
|
|
241
|
+
"edges": edges,
|
|
242
|
+
"closed": closed,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if best_quad is None:
|
|
246
|
+
return DetectionResult(None, 0.0, "none", scale, debug, "")
|
|
247
|
+
|
|
248
|
+
if best_score < cfg.min_confidence:
|
|
249
|
+
return DetectionResult(
|
|
250
|
+
None,
|
|
251
|
+
max(best_score, 0.0),
|
|
252
|
+
best_method,
|
|
253
|
+
scale,
|
|
254
|
+
debug,
|
|
255
|
+
best_variant,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
quad_original = best_quad / scale
|
|
259
|
+
quad_original[:, 0] = np.clip(quad_original[:, 0], 0, width - 1)
|
|
260
|
+
quad_original[:, 1] = np.clip(quad_original[:, 1], 0, height - 1)
|
|
261
|
+
|
|
262
|
+
return DetectionResult(
|
|
263
|
+
quad_original.astype(np.float32),
|
|
264
|
+
best_score,
|
|
265
|
+
best_method,
|
|
266
|
+
scale,
|
|
267
|
+
debug,
|
|
268
|
+
best_variant,
|
|
269
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from .config import EnhancementConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def to_gray(image: np.ndarray) -> np.ndarray:
|
|
10
|
+
if image is None or image.size == 0:
|
|
11
|
+
raise ValueError("image cannot be empty")
|
|
12
|
+
if image.ndim == 2:
|
|
13
|
+
return image
|
|
14
|
+
channels = image.shape[2]
|
|
15
|
+
if channels == 3:
|
|
16
|
+
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
|
17
|
+
if channels == 4:
|
|
18
|
+
return cv2.cvtColor(image, cv2.COLOR_BGRA2GRAY)
|
|
19
|
+
raise ValueError(f"unsupported channel count: {channels}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def apply_clahe(gray: np.ndarray, clip_limit: float, tile_grid: tuple[int, int]) -> np.ndarray:
|
|
23
|
+
clahe = cv2.createCLAHE(clipLimit=float(clip_limit), tileGridSize=tuple(tile_grid))
|
|
24
|
+
return clahe.apply(gray)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _upscale_if_needed(image: np.ndarray, min_width: int, max_upscale: float) -> np.ndarray:
|
|
28
|
+
if min_width <= 0:
|
|
29
|
+
return image
|
|
30
|
+
width = image.shape[1]
|
|
31
|
+
if width >= min_width:
|
|
32
|
+
return image
|
|
33
|
+
scale = min(min_width / float(width), max_upscale)
|
|
34
|
+
if scale <= 1.0:
|
|
35
|
+
return image
|
|
36
|
+
size = (round(image.shape[1] * scale), round(image.shape[0] * scale))
|
|
37
|
+
return cv2.resize(image, size, interpolation=cv2.INTER_CUBIC)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _enhance_color(image: np.ndarray, config: EnhancementConfig) -> np.ndarray:
|
|
41
|
+
enhanced = _upscale_if_needed(image, config.min_ocr_width, config.max_upscale)
|
|
42
|
+
if not config.use_clahe:
|
|
43
|
+
return enhanced
|
|
44
|
+
lab = cv2.cvtColor(enhanced, cv2.COLOR_BGR2LAB)
|
|
45
|
+
lightness, a_channel, b_channel = cv2.split(lab)
|
|
46
|
+
lightness = apply_clahe(lightness, config.clahe_clip_limit, config.clahe_tile_grid)
|
|
47
|
+
merged = cv2.merge([lightness, a_channel, b_channel])
|
|
48
|
+
return cv2.cvtColor(merged, cv2.COLOR_LAB2BGR)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _adaptive_block_size(requested: int, height: int, width: int) -> int | None:
|
|
52
|
+
limit = min(height, width)
|
|
53
|
+
if limit < 3:
|
|
54
|
+
return None
|
|
55
|
+
max_odd = limit if limit % 2 == 1 else limit - 1
|
|
56
|
+
return max(3, min(requested, max_odd))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _binarize(gray: np.ndarray, config: EnhancementConfig) -> np.ndarray:
|
|
60
|
+
if config.threshold_method == "otsu":
|
|
61
|
+
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
|
|
62
|
+
return binary
|
|
63
|
+
|
|
64
|
+
block_size = _adaptive_block_size(
|
|
65
|
+
config.adaptive_block_size,
|
|
66
|
+
gray.shape[0],
|
|
67
|
+
gray.shape[1],
|
|
68
|
+
)
|
|
69
|
+
if block_size is None:
|
|
70
|
+
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
|
|
71
|
+
return binary
|
|
72
|
+
|
|
73
|
+
return cv2.adaptiveThreshold(
|
|
74
|
+
gray,
|
|
75
|
+
255,
|
|
76
|
+
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
77
|
+
cv2.THRESH_BINARY,
|
|
78
|
+
block_size,
|
|
79
|
+
float(config.adaptive_c),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def enhance_for_ocr(image: np.ndarray, config: EnhancementConfig | None = None) -> np.ndarray:
|
|
84
|
+
cfg = config or EnhancementConfig()
|
|
85
|
+
if image is None or image.size == 0:
|
|
86
|
+
raise ValueError("image cannot be empty")
|
|
87
|
+
|
|
88
|
+
if cfg.mode == "color":
|
|
89
|
+
return _enhance_color(image, cfg)
|
|
90
|
+
|
|
91
|
+
gray = to_gray(image)
|
|
92
|
+
gray = _upscale_if_needed(gray, cfg.min_ocr_width, cfg.max_upscale)
|
|
93
|
+
|
|
94
|
+
if cfg.use_clahe:
|
|
95
|
+
gray = apply_clahe(gray, cfg.clahe_clip_limit, cfg.clahe_tile_grid)
|
|
96
|
+
|
|
97
|
+
if cfg.denoise:
|
|
98
|
+
gray = cv2.medianBlur(gray, 3)
|
|
99
|
+
|
|
100
|
+
if cfg.mode == "gray":
|
|
101
|
+
return gray
|
|
102
|
+
|
|
103
|
+
return _binarize(gray, cfg)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class DocumentScannerError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ImageLoadError(DocumentScannerError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ImageSaveError(DocumentScannerError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DocumentNotFoundError(DocumentScannerError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InvalidGeometryError(DocumentScannerError):
|
|
18
|
+
pass
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .errors import ImageLoadError, ImageSaveError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_image(path: str | Path) -> np.ndarray:
|
|
12
|
+
source = Path(path)
|
|
13
|
+
if not source.is_file():
|
|
14
|
+
raise ImageLoadError(f"image not found: {source}")
|
|
15
|
+
|
|
16
|
+
image = cv2.imread(str(source), cv2.IMREAD_COLOR)
|
|
17
|
+
if image is None:
|
|
18
|
+
raise ImageLoadError(f"could not decode image: {source}")
|
|
19
|
+
return image
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def save_image(path: str | Path, image: np.ndarray) -> Path:
|
|
23
|
+
destination = Path(path)
|
|
24
|
+
if image is None or image.size == 0:
|
|
25
|
+
raise ImageSaveError("refusing to save an empty image")
|
|
26
|
+
|
|
27
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
success = cv2.imwrite(str(destination), image)
|
|
29
|
+
if not success:
|
|
30
|
+
raise ImageSaveError(f"could not write image: {destination}")
|
|
31
|
+
return destination
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .config import ScanConfig
|
|
8
|
+
from .detection import DetectionResult, detect_document
|
|
9
|
+
from .enhance import enhance_for_ocr
|
|
10
|
+
from .errors import DocumentNotFoundError, InvalidGeometryError
|
|
11
|
+
from .qr import decode_qr
|
|
12
|
+
from .transform import four_point_transform
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class ScanResult:
|
|
17
|
+
image: np.ndarray
|
|
18
|
+
warped: np.ndarray
|
|
19
|
+
source: np.ndarray
|
|
20
|
+
detection: DetectionResult
|
|
21
|
+
method: str
|
|
22
|
+
confidence: float
|
|
23
|
+
config: ScanConfig = field(repr=False)
|
|
24
|
+
qr_code: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _full_image_quad(image: np.ndarray) -> np.ndarray:
|
|
28
|
+
height, width = image.shape[:2]
|
|
29
|
+
return np.array(
|
|
30
|
+
[
|
|
31
|
+
[0, 0],
|
|
32
|
+
[width - 1, 0],
|
|
33
|
+
[width - 1, height - 1],
|
|
34
|
+
[0, height - 1],
|
|
35
|
+
],
|
|
36
|
+
dtype=np.float32,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _full_image_fallback(
|
|
41
|
+
image: np.ndarray,
|
|
42
|
+
config: ScanConfig,
|
|
43
|
+
) -> tuple[np.ndarray, np.ndarray, str, float]:
|
|
44
|
+
if not config.allow_full_image_fallback:
|
|
45
|
+
raise DocumentNotFoundError("document boundary not found")
|
|
46
|
+
return image.copy(), _full_image_quad(image), "full_image_fallback", 0.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def scan_array(image: np.ndarray, config: ScanConfig | None = None) -> ScanResult:
|
|
50
|
+
cfg = config or ScanConfig()
|
|
51
|
+
if image is None or image.size == 0:
|
|
52
|
+
raise ValueError("image cannot be empty")
|
|
53
|
+
|
|
54
|
+
detection = detect_document(image, cfg.detection)
|
|
55
|
+
detected_quad = detection.quad
|
|
56
|
+
|
|
57
|
+
if detected_quad is not None:
|
|
58
|
+
try:
|
|
59
|
+
warped, _ = four_point_transform(image, detected_quad)
|
|
60
|
+
quad = detected_quad
|
|
61
|
+
method = detection.method
|
|
62
|
+
confidence = detection.confidence
|
|
63
|
+
except InvalidGeometryError:
|
|
64
|
+
if not cfg.allow_full_image_fallback:
|
|
65
|
+
raise
|
|
66
|
+
warped, quad, method, confidence = _full_image_fallback(image, cfg)
|
|
67
|
+
else:
|
|
68
|
+
warped, quad, method, confidence = _full_image_fallback(image, cfg)
|
|
69
|
+
|
|
70
|
+
qr_code = decode_qr(warped, image)
|
|
71
|
+
enhanced = enhance_for_ocr(warped, cfg.enhancement)
|
|
72
|
+
detection_out = DetectionResult(
|
|
73
|
+
quad=quad,
|
|
74
|
+
confidence=confidence,
|
|
75
|
+
method=method,
|
|
76
|
+
scale=detection.scale,
|
|
77
|
+
debug=detection.debug,
|
|
78
|
+
variant=detection.variant,
|
|
79
|
+
)
|
|
80
|
+
return ScanResult(
|
|
81
|
+
image=enhanced,
|
|
82
|
+
warped=warped,
|
|
83
|
+
source=image,
|
|
84
|
+
detection=detection_out,
|
|
85
|
+
method=method,
|
|
86
|
+
confidence=confidence,
|
|
87
|
+
config=cfg,
|
|
88
|
+
qr_code=qr_code,
|
|
89
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
import cv2
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
SAT_QR_HOST = "siat.sat.gob.mx"
|
|
10
|
+
QR_MIN_SIDE = 1000
|
|
11
|
+
QR_MAX_SIDE = 2200
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_sat_qr(code: str) -> bool:
|
|
15
|
+
host = (urlparse(code).hostname or "").lower()
|
|
16
|
+
return host == SAT_QR_HOST or host.endswith("." + SAT_QR_HOST)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resize_for_qr(image: np.ndarray) -> np.ndarray:
|
|
20
|
+
height, width = image.shape[:2]
|
|
21
|
+
max_side = max(height, width)
|
|
22
|
+
if max_side <= 0:
|
|
23
|
+
return image
|
|
24
|
+
if max_side > QR_MAX_SIDE:
|
|
25
|
+
scale = QR_MAX_SIDE / float(max_side)
|
|
26
|
+
elif max_side < QR_MIN_SIDE:
|
|
27
|
+
scale = QR_MIN_SIDE / float(max_side)
|
|
28
|
+
else:
|
|
29
|
+
return image
|
|
30
|
+
size = (max(1, round(width * scale)), max(1, round(height * scale)))
|
|
31
|
+
interpolation = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_CUBIC
|
|
32
|
+
return cv2.resize(image, size, interpolation=interpolation)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _to_gray(image: np.ndarray) -> np.ndarray | None:
|
|
36
|
+
if image.ndim == 2:
|
|
37
|
+
return image
|
|
38
|
+
if image.shape[2] == 3:
|
|
39
|
+
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
|
40
|
+
if image.shape[2] == 4:
|
|
41
|
+
return cv2.cvtColor(image, cv2.COLOR_BGRA2GRAY)
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _candidate_images(image: np.ndarray) -> Iterator[np.ndarray]:
|
|
46
|
+
if image is None or image.size == 0:
|
|
47
|
+
return
|
|
48
|
+
resized = _resize_for_qr(image)
|
|
49
|
+
yield resized
|
|
50
|
+
gray = _to_gray(resized)
|
|
51
|
+
if gray is not None and gray is not resized:
|
|
52
|
+
yield gray
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _decode_candidate(detector: cv2.QRCodeDetector, image: np.ndarray) -> str | None:
|
|
56
|
+
try:
|
|
57
|
+
text, points, _ = detector.detectAndDecode(image)
|
|
58
|
+
except cv2.error:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
text = text.strip() if text else ""
|
|
62
|
+
if text:
|
|
63
|
+
return text
|
|
64
|
+
if points is None:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
x, y, width, height = cv2.boundingRect(points.reshape(-1, 2).astype(np.float32))
|
|
69
|
+
except cv2.error:
|
|
70
|
+
return None
|
|
71
|
+
if width <= 0 or height <= 0:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
pad_x = max(1, int(width * 0.2))
|
|
75
|
+
pad_y = max(1, int(height * 0.2))
|
|
76
|
+
x0 = max(0, x - pad_x)
|
|
77
|
+
y0 = max(0, y - pad_y)
|
|
78
|
+
x1 = min(image.shape[1], x + width + pad_x)
|
|
79
|
+
y1 = min(image.shape[0], y + height + pad_y)
|
|
80
|
+
crop = image[y0:y1, x0:x1]
|
|
81
|
+
if crop.size == 0:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
crop = cv2.resize(crop, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC)
|
|
85
|
+
try:
|
|
86
|
+
text, _, _ = detector.detectAndDecode(crop)
|
|
87
|
+
except cv2.error:
|
|
88
|
+
return None
|
|
89
|
+
return text.strip() if text else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def decode_qr(*images: np.ndarray) -> str | None:
|
|
93
|
+
detector = cv2.QRCodeDetector()
|
|
94
|
+
fallback: str | None = None
|
|
95
|
+
|
|
96
|
+
for image in images:
|
|
97
|
+
if image is None or image.size == 0:
|
|
98
|
+
continue
|
|
99
|
+
for candidate in _candidate_images(image):
|
|
100
|
+
code = _decode_candidate(detector, candidate)
|
|
101
|
+
if not code:
|
|
102
|
+
continue
|
|
103
|
+
if _is_sat_qr(code):
|
|
104
|
+
return code
|
|
105
|
+
if fallback is None:
|
|
106
|
+
fallback = code
|
|
107
|
+
|
|
108
|
+
return fallback
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .errors import InvalidGeometryError
|
|
9
|
+
|
|
10
|
+
PointSet = np.ndarray | Sequence[Sequence[float]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _validate_points(pts: PointSet) -> np.ndarray:
|
|
14
|
+
points = np.asarray(pts, dtype=np.float32).reshape(-1, 2)
|
|
15
|
+
if points.shape != (4, 2):
|
|
16
|
+
raise InvalidGeometryError(f"expected 4 points, got shape {points.shape}")
|
|
17
|
+
if not np.isfinite(points).all():
|
|
18
|
+
raise InvalidGeometryError("points contain non-finite values")
|
|
19
|
+
if len(np.unique(points, axis=0)) != 4:
|
|
20
|
+
raise InvalidGeometryError("points must be distinct")
|
|
21
|
+
return points
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _signed_area(points: np.ndarray) -> float:
|
|
25
|
+
x = points[:, 0]
|
|
26
|
+
y = points[:, 1]
|
|
27
|
+
return float(0.5 * np.sum(x * np.roll(y, -1) - np.roll(x, -1) * y))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def order_points(pts: PointSet) -> np.ndarray:
|
|
31
|
+
points = _validate_points(pts)
|
|
32
|
+
center = points.mean(axis=0)
|
|
33
|
+
angles = np.arctan2(points[:, 1] - center[1], points[:, 0] - center[0])
|
|
34
|
+
ordered = points[np.argsort(angles)]
|
|
35
|
+
start = int(np.argmin(ordered.sum(axis=1)))
|
|
36
|
+
ordered = np.roll(ordered, -start, axis=0)
|
|
37
|
+
if _signed_area(ordered) < 0:
|
|
38
|
+
ordered = ordered[[0, 3, 2, 1]]
|
|
39
|
+
return ordered.astype(np.float32)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def scale_points(pts: PointSet, scale: float) -> np.ndarray:
|
|
43
|
+
points = _validate_points(pts)
|
|
44
|
+
if scale <= 0:
|
|
45
|
+
raise InvalidGeometryError("scale must be positive")
|
|
46
|
+
return points / float(scale)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def four_point_transform(
|
|
50
|
+
image: np.ndarray,
|
|
51
|
+
pts: PointSet,
|
|
52
|
+
margin: int = 0,
|
|
53
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
54
|
+
if image is None or image.size == 0:
|
|
55
|
+
raise InvalidGeometryError("image cannot be empty")
|
|
56
|
+
if margin < 0:
|
|
57
|
+
raise InvalidGeometryError("margin cannot be negative")
|
|
58
|
+
|
|
59
|
+
rect = order_points(pts)
|
|
60
|
+
if abs(_signed_area(rect)) < 1e-6:
|
|
61
|
+
raise InvalidGeometryError("points do not define a valid quadrilateral")
|
|
62
|
+
|
|
63
|
+
tl, tr, br, bl = rect
|
|
64
|
+
width = max(np.linalg.norm(br - bl), np.linalg.norm(tr - tl))
|
|
65
|
+
height = max(np.linalg.norm(tr - br), np.linalg.norm(tl - bl))
|
|
66
|
+
width = round(width)
|
|
67
|
+
height = round(height)
|
|
68
|
+
|
|
69
|
+
if width < 2 or height < 2:
|
|
70
|
+
raise InvalidGeometryError("document quad is too small")
|
|
71
|
+
|
|
72
|
+
if margin > 0:
|
|
73
|
+
width += 2 * margin
|
|
74
|
+
height += 2 * margin
|
|
75
|
+
|
|
76
|
+
offset = float(margin)
|
|
77
|
+
max_x = float(width - 1)
|
|
78
|
+
max_y = float(height - 1)
|
|
79
|
+
dst = np.array(
|
|
80
|
+
[
|
|
81
|
+
[offset, offset],
|
|
82
|
+
[max_x - offset, offset],
|
|
83
|
+
[max_x - offset, max_y - offset],
|
|
84
|
+
[offset, max_y - offset],
|
|
85
|
+
],
|
|
86
|
+
dtype=np.float32,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
matrix = cv2.getPerspectiveTransform(rect, dst)
|
|
90
|
+
warped = cv2.warpPerspective(
|
|
91
|
+
image,
|
|
92
|
+
matrix,
|
|
93
|
+
(width, height),
|
|
94
|
+
flags=cv2.INTER_LINEAR,
|
|
95
|
+
borderMode=cv2.BORDER_REPLICATE,
|
|
96
|
+
)
|
|
97
|
+
return warped, matrix
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
import cv2
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from csf_scraper.exceptions.image_reader import ImageReadingException
|
|
8
|
+
from csf_scraper.image_readers.adapters.cpu.open_cv.errors import ImageLoadError
|
|
9
|
+
from csf_scraper.image_readers.adapters.cpu.open_cv.pipeline import scan_array
|
|
10
|
+
from csf_scraper.image_readers.ports.image_reader_port import ImageReaderPort
|
|
11
|
+
from csf_scraper.rfc_cif_data import RfcCifData
|
|
12
|
+
from csf_scraper.value_objects.csf_image import CsfImage
|
|
13
|
+
from csf_scraper.value_objects.id_cif import IdCif
|
|
14
|
+
from csf_scraper.value_objects.rfc import Rfc
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OpenCvImageReader(ImageReaderPort):
|
|
18
|
+
ID_CIF_RFC_PATTERN = r"D3=(\d+)_([A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3})"
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
super().__init__()
|
|
22
|
+
|
|
23
|
+
def _decode_image(self, image: bytes | bytearray | memoryview) -> np.ndarray:
|
|
24
|
+
if not isinstance(image, (bytes, bytearray, memoryview)):
|
|
25
|
+
raise TypeError("image must be bytes")
|
|
26
|
+
if len(image) == 0:
|
|
27
|
+
raise ImageLoadError("image cannot be empty")
|
|
28
|
+
|
|
29
|
+
buffer = np.frombuffer(image, dtype=np.uint8)
|
|
30
|
+
decoded = cv2.imdecode(buffer, cv2.IMREAD_COLOR)
|
|
31
|
+
|
|
32
|
+
if decoded is None:
|
|
33
|
+
raise ImageLoadError("could not decode image bytes")
|
|
34
|
+
return decoded
|
|
35
|
+
|
|
36
|
+
def _parse_id_cif_and_rfc(self, url: str) -> tuple[str, str]:
|
|
37
|
+
coincidence = re.search(self.ID_CIF_RFC_PATTERN, url)
|
|
38
|
+
|
|
39
|
+
if coincidence:
|
|
40
|
+
id_cif = coincidence.group(1)
|
|
41
|
+
rfc = coincidence.group(2)
|
|
42
|
+
return (id_cif, rfc)
|
|
43
|
+
raise ValueError("No se encontraron los datos en la URL.")
|
|
44
|
+
|
|
45
|
+
async def read(self, image: CsfImage) -> RfcCifData:
|
|
46
|
+
decoded = await asyncio.to_thread(self._decode_image, image.as_bytes())
|
|
47
|
+
result = await asyncio.to_thread(scan_array, decoded)
|
|
48
|
+
|
|
49
|
+
if not result.qr_code:
|
|
50
|
+
raise ImageReadingException(
|
|
51
|
+
"No fue posible leer el codigo QR, verifique la calidad de la imagen"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
id_cif, rfc = self._parse_id_cif_and_rfc(result.qr_code)
|
|
55
|
+
|
|
56
|
+
return RfcCifData(id_cif=IdCif(id_cif), rfc=Rfc(rfc))
|
csf_scraper/scraper.py
CHANGED
|
@@ -29,8 +29,15 @@ class Scraper:
|
|
|
29
29
|
Todo el flujo es asíncrono: la descarga usa un
|
|
30
30
|
:class:`httpx.AsyncClient` y la extracción de datos se espera con
|
|
31
31
|
``await``. El cliente se puede cerrar con :meth:`aclose` o usando el
|
|
32
|
-
scraper como *async context manager
|
|
32
|
+
scraper como *async context manager*; usar ``async with`` es opcional::
|
|
33
33
|
|
|
34
|
+
scraper = Scraper.create()
|
|
35
|
+
try:
|
|
36
|
+
person = await scraper.obtain_from_rfc_and_cif(rfc, id_cif)
|
|
37
|
+
finally:
|
|
38
|
+
await scraper.aclose()
|
|
39
|
+
|
|
40
|
+
# Equivalente, cerrando al salir del bloque:
|
|
34
41
|
async with Scraper.create() as scraper:
|
|
35
42
|
person = await scraper.obtain_from_rfc_and_cif(rfc, id_cif)
|
|
36
43
|
"""
|
|
@@ -61,7 +68,9 @@ class Scraper:
|
|
|
61
68
|
) -> Self:
|
|
62
69
|
"""Crea un cliente HTTP asíncrono listo para usar contra el SAT."""
|
|
63
70
|
return cls(
|
|
64
|
-
httpx.AsyncClient(
|
|
71
|
+
httpx.AsyncClient(
|
|
72
|
+
follow_redirects=True, timeout=None, verify=_create_ssl_context()
|
|
73
|
+
),
|
|
65
74
|
pdf_extractor=pdf_extractor,
|
|
66
75
|
image_extractor=image_extractor,
|
|
67
76
|
)
|
|
@@ -83,7 +92,9 @@ class Scraper:
|
|
|
83
92
|
"""Cierra el cliente HTTP subyacente."""
|
|
84
93
|
await self.client.aclose()
|
|
85
94
|
|
|
86
|
-
async def obtain_from_rfc_and_cif(
|
|
95
|
+
async def obtain_from_rfc_and_cif(
|
|
96
|
+
self, rfc: str, id_cif: str
|
|
97
|
+
) -> PersonaMoral | PersonaFisica:
|
|
87
98
|
"""Obtiene la persona a partir de su RFC y su idCIF."""
|
|
88
99
|
parsed_rfc = Rfc(rfc)
|
|
89
100
|
parsed_cif = IdCif(id_cif)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: csf-scraper-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Obtiene los datos fiscales actuales de una persona moral o física dado su RFC y su idCIF (port de phpcfdi/csf-scraper)
|
|
5
5
|
Project-URL: Homepage, https://github.com/PJMP4826/csf-scraper-python
|
|
6
6
|
Project-URL: Issues, https://github.com/PJMP4826/csf-scraper-python/issues
|
|
@@ -19,6 +19,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
23
|
Classifier: Typing :: Typed
|
|
23
24
|
Requires-Python: >=3.11
|
|
24
25
|
Requires-Dist: beautifulsoup4>=4.12
|
|
@@ -35,9 +36,12 @@ Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
|
35
36
|
Provides-Extra: llm-gemini
|
|
36
37
|
Requires-Dist: google-genai>=1.0; extra == 'llm-gemini'
|
|
37
38
|
Requires-Dist: llama-index-llms-google-genai<0.12,>=0.11; extra == 'llm-gemini'
|
|
39
|
+
Provides-Extra: opencv
|
|
40
|
+
Requires-Dist: numpy>=2; extra == 'opencv'
|
|
41
|
+
Requires-Dist: opencv-python-headless>=5.0; extra == 'opencv'
|
|
38
42
|
Description-Content-Type: text/markdown
|
|
39
43
|
|
|
40
|
-
# csf-scraper
|
|
44
|
+
# csf-scraper-python
|
|
41
45
|
|
|
42
46
|
Port a Python de [phpcfdi/csf-scraper](https://github.com/phpcfdi/csf-scraper):
|
|
43
47
|
obtiene los datos fiscales actuales de una persona moral o física dado su RFC y
|
|
@@ -50,7 +54,8 @@ su idCIF (SAT, Constancia de Situación Fiscal).
|
|
|
50
54
|
`poppler-utils`), necesario **solo** para leer la CSF desde un archivo PDF
|
|
51
55
|
(`obtain_from_pdf_path`).
|
|
52
56
|
- Dependencias: `httpx`, `beautifulsoup4`, `lxml`, `pydantic`, `llama-index-core`.
|
|
53
|
-
Los clientes de cada proveedor LLM son extras opcionales (`llm-gemini`).
|
|
57
|
+
Los clientes de cada proveedor LLM son extras opcionales (`llm-gemini`). El
|
|
58
|
+
lector de imágenes basado en OpenCV requiere el extra `opencv`.
|
|
54
59
|
|
|
55
60
|
## Instalación
|
|
56
61
|
|
|
@@ -66,6 +71,12 @@ Para leer CSF escaneadas (JPEG/PNG) con Gemini, agrega el extra:
|
|
|
66
71
|
pip install "csf-scraper-python[llm-gemini]"
|
|
67
72
|
```
|
|
68
73
|
|
|
74
|
+
Para leerlas usando el lector basado en OpenCV:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install "csf-scraper-python[opencv]"
|
|
78
|
+
```
|
|
79
|
+
|
|
69
80
|
Desde el código fuente:
|
|
70
81
|
|
|
71
82
|
```bash
|
|
@@ -77,6 +88,10 @@ pip install .
|
|
|
77
88
|
Todo el flujo es asíncrono: `Scraper` envuelve un `httpx.AsyncClient` y todos
|
|
78
89
|
los métodos de lectura son corrutinas, así que se consumen con `await`.
|
|
79
90
|
|
|
91
|
+
`Scraper.create()` crea y devuelve la instancia directamente; **no** es
|
|
92
|
+
obligatorio usarla con `async with`. Basta con cerrar el cliente al terminar,
|
|
93
|
+
ya sea llamando a `aclose()` o usando el scraper como _async context manager_:
|
|
94
|
+
|
|
80
95
|
```python
|
|
81
96
|
import asyncio
|
|
82
97
|
|
|
@@ -84,14 +99,17 @@ from csf_scraper import Scraper
|
|
|
84
99
|
|
|
85
100
|
|
|
86
101
|
async def main() -> None:
|
|
87
|
-
|
|
88
|
-
|
|
102
|
+
scraper = Scraper.create()
|
|
103
|
+
|
|
104
|
+
try:
|
|
89
105
|
# RFC + idCIF directamente (se validan con los value objects Rfc e IdCif)
|
|
90
106
|
person = await scraper.obtain_from_rfc_and_cif("DIM8701081LA", "1904014102123")
|
|
91
107
|
|
|
92
108
|
# También puedes obtener los datos directamente de un archivo local PDF
|
|
93
109
|
# (requiere pdftotext / poppler-utils instalado)
|
|
94
110
|
person = await scraper.obtain_from_pdf_path("ruta/al/csf.pdf")
|
|
111
|
+
finally:
|
|
112
|
+
await scraper.aclose()
|
|
95
113
|
|
|
96
114
|
# Acceso a los datos de la persona (moral o física) por atributos
|
|
97
115
|
if person.is_fisica():
|
|
@@ -115,6 +133,16 @@ if __name__ == "__main__":
|
|
|
115
133
|
asyncio.run(main())
|
|
116
134
|
```
|
|
117
135
|
|
|
136
|
+
Si prefieres no gestionar el cierre manualmente, el scraper también funciona
|
|
137
|
+
como _async context manager_ (cierra el `AsyncClient` al salir del bloque):
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
async def main() -> None:
|
|
141
|
+
async with Scraper.create() as scraper:
|
|
142
|
+
person = await scraper.obtain_from_rfc_and_cif("DIM8701081LA", "1904014102123")
|
|
143
|
+
print(person.to_dict())
|
|
144
|
+
```
|
|
145
|
+
|
|
118
146
|
Para leer una CSF escaneada (JPEG/PNG) se inyecta el motor de lectura de imágenes.
|
|
119
147
|
Requiere el extra `llm-gemini`:
|
|
120
148
|
|
|
@@ -126,8 +154,33 @@ from csf_scraper import CsfImageExtractor, GeminiImageReader, Scraper
|
|
|
126
154
|
|
|
127
155
|
async def main() -> None:
|
|
128
156
|
reader = GeminiImageReader(api_key="…", model_name="…")
|
|
129
|
-
|
|
157
|
+
scraper = Scraper.create(image_extractor=CsfImageExtractor(reader))
|
|
158
|
+
try:
|
|
159
|
+
person = await scraper.obtain_from_image_path("ruta/al/csf.png")
|
|
160
|
+
finally:
|
|
161
|
+
await scraper.aclose()
|
|
162
|
+
print(person.to_dict())
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
asyncio.run(main())
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Para usar el lector basado en OpenCV no se necesita un proveedor LLM. Requiere
|
|
170
|
+
el extra `opencv`:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
import asyncio
|
|
174
|
+
|
|
175
|
+
from csf_scraper import CsfImageExtractor, OpenCvImageReader, Scraper
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def main() -> None:
|
|
179
|
+
scraper = Scraper.create(image_extractor=CsfImageExtractor(OpenCvImageReader()))
|
|
180
|
+
try:
|
|
130
181
|
person = await scraper.obtain_from_image_path("ruta/al/csf.png")
|
|
182
|
+
finally:
|
|
183
|
+
await scraper.aclose()
|
|
131
184
|
print(person.to_dict())
|
|
132
185
|
|
|
133
186
|
|
|
@@ -135,14 +188,31 @@ if __name__ == "__main__":
|
|
|
135
188
|
asyncio.run(main())
|
|
136
189
|
```
|
|
137
190
|
|
|
138
|
-
|
|
191
|
+
`to_dict()` devuelve el conjunto completo de propiedades del modelo que se haya
|
|
192
|
+
obtenido: los campos comunes de `Persona` más los propios de `PersonaMoral` o de
|
|
193
|
+
`PersonaFisica`. Las fechas se devuelven como `datetime` (o `None`) y los value
|
|
194
|
+
objects `Rfc`/`IdCif` como cadenas.
|
|
195
|
+
|
|
196
|
+
Salida de `person.to_dict()` para una **persona moral**:
|
|
139
197
|
|
|
140
198
|
```python
|
|
141
199
|
{
|
|
200
|
+
# Compartidos con PersonaFisica
|
|
142
201
|
"rfc": "DIM8701081LA",
|
|
143
202
|
"id_cif": "12345678",
|
|
144
203
|
"fecha_inicio_operaciones": datetime(2019, 2, 21),
|
|
145
|
-
|
|
204
|
+
"situacion_contribuyente": "ACTIVO",
|
|
205
|
+
"fecha_ultimo_cambio_situacion": datetime(2019, 2, 21),
|
|
206
|
+
"entidad_federativa": "CIUDAD DE MEXICO",
|
|
207
|
+
"municipio_delegacion": "CUAUHTEMOC",
|
|
208
|
+
"colonia": "CENTRO",
|
|
209
|
+
"tipo_vialidad": "CALLE",
|
|
210
|
+
"nombre_vialidad": "AVENIDA REFORMA",
|
|
211
|
+
"numero_exterior": "123",
|
|
212
|
+
"numero_interior": "A",
|
|
213
|
+
"codigo_postal": "06000",
|
|
214
|
+
"correo_electronico": "contacto@ejemplo.mx",
|
|
215
|
+
"al": "CIUDAD DE MEXICO",
|
|
146
216
|
"regimenes": [
|
|
147
217
|
{
|
|
148
218
|
"regimen": "Régimen General de Ley Personas Morales",
|
|
@@ -151,12 +221,53 @@ Salida de `person.to_dict()` para una persona moral (fechas como `datetime`):
|
|
|
151
221
|
},
|
|
152
222
|
],
|
|
153
223
|
"extra_data": {"localidad": ""},
|
|
224
|
+
# Propios de PersonaMoral
|
|
154
225
|
"razon_social": "Mi razón social",
|
|
155
226
|
"regimen_de_capital": "SA DE CV",
|
|
156
227
|
"fecha_constitucion": datetime(2019, 2, 21),
|
|
157
228
|
}
|
|
158
229
|
```
|
|
159
230
|
|
|
231
|
+
Salida de `person.to_dict()` para una **persona física**:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
{
|
|
235
|
+
# Compartidos con PersonaMoral
|
|
236
|
+
"rfc": "GODE561231GR8",
|
|
237
|
+
"id_cif": "12345678",
|
|
238
|
+
"fecha_inicio_operaciones": datetime(2019, 2, 21),
|
|
239
|
+
"situacion_contribuyente": "ACTIVO",
|
|
240
|
+
"fecha_ultimo_cambio_situacion": datetime(2019, 2, 21),
|
|
241
|
+
"entidad_federativa": "CIUDAD DE MEXICO",
|
|
242
|
+
"municipio_delegacion": "CUAUHTEMOC",
|
|
243
|
+
"colonia": "CENTRO",
|
|
244
|
+
"tipo_vialidad": "CALLE",
|
|
245
|
+
"nombre_vialidad": "AVENIDA REFORMA",
|
|
246
|
+
"numero_exterior": "123",
|
|
247
|
+
"numero_interior": "A",
|
|
248
|
+
"codigo_postal": "06000",
|
|
249
|
+
"correo_electronico": "contacto@ejemplo.mx",
|
|
250
|
+
"al": "CIUDAD DE MEXICO",
|
|
251
|
+
"regimenes": [
|
|
252
|
+
{
|
|
253
|
+
"regimen": "Régimen de Sueldos y Salarios e Ingresos Asimilados a Salarios",
|
|
254
|
+
"fecha_alta": datetime(2019, 2, 21),
|
|
255
|
+
"regimen_id": "605",
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
"extra_data": {"localidad": ""},
|
|
259
|
+
# Propios de PersonaFisica
|
|
260
|
+
"curp": "GODE561231HDFRRN09",
|
|
261
|
+
"nombre": "MI NOMBRE",
|
|
262
|
+
"apellido_paterno": "MI APELLIDO PATERNO",
|
|
263
|
+
"apellido_materno": "MI APELLIDO MATERNO",
|
|
264
|
+
"fecha_nacimiento": datetime(1956, 12, 31),
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Si necesitas las fechas en ISO-8601, usa `person.model_dump_json()`, que
|
|
269
|
+
serializa exactamente las mismas propiedades.
|
|
270
|
+
|
|
160
271
|
## Modelos
|
|
161
272
|
|
|
162
273
|
- `Persona` es la base de `PersonaFisica` y `PersonaMoral`; sus campos se
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
csf_scraper/__init__.py,sha256=
|
|
1
|
+
csf_scraper/__init__.py,sha256=gfQWtIIgLA7l9uJGTahDPmwMUP2K1T0l6BWnhbMdkJU,3873
|
|
2
2
|
csf_scraper/data_extractor.py,sha256=c2d48ISxK_-yFViRCdZ6Vfqrv7PChrdr3MNpoxrZ2oA,2815
|
|
3
3
|
csf_scraper/date_parser.py,sha256=bQpBXhA-Git3dOVdij98pqj1ScDU0DmQdB1rNgO9tUk,797
|
|
4
4
|
csf_scraper/persona.py,sha256=ZOiO4WEbufA4teRm265GuRlW2oCCcyUhmKJRBA-wJwo,2445
|
|
@@ -7,7 +7,7 @@ csf_scraper/persona_moral.py,sha256=4CHSmVqvxVf2xD6XMZ5Ij0NuOX4DOwBKTpgw9b7fyp4,
|
|
|
7
7
|
csf_scraper/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
8
|
csf_scraper/regimen.py,sha256=r0_wblU4dMNvkc0KoAaQ6P10oGTDBuSIm_RhDMsnxeo,2180
|
|
9
9
|
csf_scraper/rfc_cif_data.py,sha256=7UHNocdf-y8Cpb0BHtOt5JAazZ-aCOg019aWUPJTa9Q,404
|
|
10
|
-
csf_scraper/scraper.py,sha256=
|
|
10
|
+
csf_scraper/scraper.py,sha256=2_mivEQ7j1qtB-Qo36FRu5IsvMCT9atbks9Ga0D7ZII,4721
|
|
11
11
|
csf_scraper/exceptions/__init__.py,sha256=Rio4OEagZmAmxge1TQ4CsjhyUY1QEvLca3Uor9SFGo4,506
|
|
12
12
|
csf_scraper/exceptions/image_reader.py,sha256=Acu0iCF11Z6XKpeSNn-s8HRfWzM-4RESdOYNmX-Wdq0,3529
|
|
13
13
|
csf_scraper/exceptions/pdf_reader.py,sha256=zkPv7gbsvWO-Vs7vc_stt8YbG_PTLDoq_Nibeh9vvZk,845
|
|
@@ -15,6 +15,17 @@ csf_scraper/exceptions/value_objects.py,sha256=Kc2FUWud_Tc_LBhgGUMebO67ScI1CbNUQ
|
|
|
15
15
|
csf_scraper/image_readers/__init__.py,sha256=0RRLSc0nOurpV8DwE4h5d6hUlYjvuGxrjKsoXAVOr_Q,1591
|
|
16
16
|
csf_scraper/image_readers/csf_image_extractor.py,sha256=wnQFUI1BADAlbni0qK98bQhP6saGw6tq1fL-amqPWP8,1271
|
|
17
17
|
csf_scraper/image_readers/adapters/__init__.py,sha256=tO-N2ZCUo1E751TeHVkvLO1wWxUbEkG8PT8KryFxGGU,534
|
|
18
|
+
csf_scraper/image_readers/adapters/cpu/__init__.py,sha256=Fv1sPhwSitujYCMTeNB1tD7_LEurUlfXVDsauSlawf4,574
|
|
19
|
+
csf_scraper/image_readers/adapters/cpu/open_cv_image_reader.py,sha256=cIh4oNSj1_j_fTmX9YiI6S64wfxKPHqPyTgOCPoGuPc,2092
|
|
20
|
+
csf_scraper/image_readers/adapters/cpu/open_cv/__init__.py,sha256=5W264aq2TJnoDMM2662rV5LGbyErh6KSqNjbPkuTeFs,925
|
|
21
|
+
csf_scraper/image_readers/adapters/cpu/open_cv/config.py,sha256=XjSNW-W3ptoMLCpHupRmU_vjyP2illX3JXtup_soeUE,3302
|
|
22
|
+
csf_scraper/image_readers/adapters/cpu/open_cv/detection.py,sha256=hY_dwANd-nALpg2mmA-f9WDPs92pDd80UVjTZLOQ7Os,8132
|
|
23
|
+
csf_scraper/image_readers/adapters/cpu/open_cv/enhance.py,sha256=m9KYpWx5cPhfDbhcmvY3k8-XLi3NI0KRT9R-0vTgj2g,3285
|
|
24
|
+
csf_scraper/image_readers/adapters/cpu/open_cv/errors.py,sha256=pvtlyNgrTvgkRLmH9aH2BO4dveTe4PlYaDIQoM4tgm4,281
|
|
25
|
+
csf_scraper/image_readers/adapters/cpu/open_cv/io.py,sha256=xB13TzBKFrHiikv4U1q2cpIbW6FIte7JTKLclFK5ukU,886
|
|
26
|
+
csf_scraper/image_readers/adapters/cpu/open_cv/pipeline.py,sha256=SLMKxcDTooASZFHV9SIEaIhAlv_EokV75wzbEKHQ3xs,2548
|
|
27
|
+
csf_scraper/image_readers/adapters/cpu/open_cv/qr.py,sha256=Gj8xpG9biEQC3LBr7jUvV8px-RlCLggABeGH05jUKaE,3041
|
|
28
|
+
csf_scraper/image_readers/adapters/cpu/open_cv/transform.py,sha256=aRIvqauAWQXGMpyIASuqjfoIqIF7W5AppJTH6jy-h5w,2882
|
|
18
29
|
csf_scraper/image_readers/adapters/llms/__init__.py,sha256=FR0OzKO5lxMriOGS_MYaSd9RoNJ30-HaEsN7YvIGIug,1139
|
|
19
30
|
csf_scraper/image_readers/adapters/llms/gemini_image_reader.py,sha256=NCTlBBVsNUNhmSHX7ZixZaBqlkTSz1ds40RZFXpGVbw,6003
|
|
20
31
|
csf_scraper/image_readers/adapters/llms/prompts/__init__.py,sha256=tGau_NVzj8qxgfkq4IW8qNZC4XJ267NGZKbuHdXoizA,240
|
|
@@ -35,8 +46,8 @@ csf_scraper/value_objects/__init__.py,sha256=fksrywY6MHNYgSKpy6VLWXMvdfXi8Tjx3xR
|
|
|
35
46
|
csf_scraper/value_objects/csf_image.py,sha256=RzU0ZO8JomxYVPTaqARXaI_NV76ZO2zM5N_PforNE8A,2332
|
|
36
47
|
csf_scraper/value_objects/id_cif.py,sha256=8ZVVPgNEM_AeEVe-dk8x0b-x1NObW9bNhMGNVD3R6LU,1564
|
|
37
48
|
csf_scraper/value_objects/rfc.py,sha256=jLkrF0_DKC6ETV66GQFQNU3BiBxRh9ffaHdPciDoCn0,1987
|
|
38
|
-
csf_scraper_python-0.
|
|
39
|
-
csf_scraper_python-0.
|
|
40
|
-
csf_scraper_python-0.
|
|
41
|
-
csf_scraper_python-0.
|
|
42
|
-
csf_scraper_python-0.
|
|
49
|
+
csf_scraper_python-0.2.0.dist-info/METADATA,sha256=I2aljSP3CFwWcqf7bkGxrm-fJE3OK4kKXkfOU58LyYE,18472
|
|
50
|
+
csf_scraper_python-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
51
|
+
csf_scraper_python-0.2.0.dist-info/licenses/LICENSE,sha256=c9W1BLxpJQUZ21EogN1_nyVGgB0SdXqmpc5c0lvpnQI,1106
|
|
52
|
+
csf_scraper_python-0.2.0.dist-info/licenses/NOTICE.md,sha256=-0Mi9rUvfhYtXNRnDlX3SZAv8K3zU2okd5e4vg4q_sA,901
|
|
53
|
+
csf_scraper_python-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
{csf_scraper_python-0.1.1.dist-info → csf_scraper_python-0.2.0.dist-info}/licenses/NOTICE.md
RENAMED
|
File without changes
|