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 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(follow_redirects=True, timeout=None, verify=_create_ssl_context()),
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(self, rfc: str, id_cif: str) -> PersonaMoral | PersonaFisica:
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.1.1
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
- # El scraper es un context manager asíncrono: cierra el AsyncClient al salir
88
- async with Scraper.create() as scraper:
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
- async with Scraper.create(image_extractor=CsfImageExtractor(reader)) as scraper:
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
- Salida de `person.to_dict()` para una persona moral (fechas como `datetime`):
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=PaumGzitsC5f_ZzITtAcc7rGqEE9Leo3Z1C5RnvVg2s,3623
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=FqAxYFaXgCM7D1DhkTS2H3CSJ8BnKSs2DWAoK-HS9fI,4418
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.1.1.dist-info/METADATA,sha256=YNQ51ihu850s_QccnXaVP54Y2gzdSZDB38cp6b66V74,14955
39
- csf_scraper_python-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
40
- csf_scraper_python-0.1.1.dist-info/licenses/LICENSE,sha256=c9W1BLxpJQUZ21EogN1_nyVGgB0SdXqmpc5c0lvpnQI,1106
41
- csf_scraper_python-0.1.1.dist-info/licenses/NOTICE.md,sha256=-0Mi9rUvfhYtXNRnDlX3SZAv8K3zU2okd5e4vg4q_sA,901
42
- csf_scraper_python-0.1.1.dist-info/RECORD,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.4
2
+ Generator: hatchling 1.33.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any