document-ocr 0.0.0-stage → 3.1.3

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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -2
  3. package/dist/index.cjs +659 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +293 -0
  6. package/dist/index.d.ts +293 -0
  7. package/dist/index.js +616 -0
  8. package/dist/index.js.map +1 -0
  9. package/hosting/.dockerignore +17 -0
  10. package/hosting/.gcloudignore +14 -0
  11. package/hosting/FEATURES.md +172 -0
  12. package/hosting/HOSTING.md +268 -0
  13. package/hosting/LICENSE +21 -0
  14. package/hosting/README.md +369 -0
  15. package/hosting/core/__init__.py +0 -0
  16. package/hosting/core/aadhaar_extractor.py +298 -0
  17. package/hosting/core/back_page_extractor.py +492 -0
  18. package/hosting/core/barcodes.py +125 -0
  19. package/hosting/core/card_recovery.py +244 -0
  20. package/hosting/core/document_bundle.py +82 -0
  21. package/hosting/core/document_classifier.py +233 -0
  22. package/hosting/core/document_input.py +135 -0
  23. package/hosting/core/document_registry.py +143 -0
  24. package/hosting/core/driving_licence_extractor.py +510 -0
  25. package/hosting/core/ead_recovery.py +54 -0
  26. package/hosting/core/evidence.py +54 -0
  27. package/hosting/core/form_recovery.py +61 -0
  28. package/hosting/core/jobs.py +232 -0
  29. package/hosting/core/kyc_ocr.py +132 -0
  30. package/hosting/core/kyc_validation.py +411 -0
  31. package/hosting/core/model_setup.py +13 -0
  32. package/hosting/core/mrz_parser.py +336 -0
  33. package/hosting/core/mrz_recovery.py +193 -0
  34. package/hosting/core/npr_extractor.py +410 -0
  35. package/hosting/core/nrega_extractor.py +827 -0
  36. package/hosting/core/ocr_engine.py +207 -0
  37. package/hosting/core/page_classifier.py +137 -0
  38. package/hosting/core/pan_extractor.py +183 -0
  39. package/hosting/core/pipeline.py +712 -0
  40. package/hosting/core/preprocessor.py +297 -0
  41. package/hosting/core/recovery_budget.py +67 -0
  42. package/hosting/core/redaction.py +48 -0
  43. package/hosting/core/review.html +244 -0
  44. package/hosting/core/structured_documents.py +422 -0
  45. package/hosting/core/travel_mrz.py +164 -0
  46. package/hosting/core/travel_recovery.py +187 -0
  47. package/hosting/core/validator.py +396 -0
  48. package/hosting/core/validators.py +179 -0
  49. package/hosting/core/voter_id_extractor.py +363 -0
  50. package/hosting/deploy/cloudflare/deploy.sh +44 -0
  51. package/hosting/deploy/cloudflare/package-lock.json +3088 -0
  52. package/hosting/deploy/cloudflare/package.json +29 -0
  53. package/hosting/deploy/cloudflare/src/gateway.ts +42 -0
  54. package/hosting/deploy/cloudflare/src/index.ts +21 -0
  55. package/hosting/deploy/cloudflare/test/gateway.test.ts +59 -0
  56. package/hosting/deploy/cloudflare/tsconfig.json +12 -0
  57. package/hosting/deploy/cloudflare/worker-configuration.d.ts +16253 -0
  58. package/hosting/deploy/cloudflare/wrangler.jsonc +26 -0
  59. package/hosting/deploy/cloudrun/cloudbuild.yaml +18 -0
  60. package/hosting/deploy/cloudrun/deploy.sh +71 -0
  61. package/hosting/deploy/cloudrun/render_service.py +47 -0
  62. package/hosting/deploy/common.sh +14 -0
  63. package/hosting/deploy/docker/Dockerfile +16 -0
  64. package/hosting/deploy/docker/server.py +313 -0
  65. package/hosting/deploy/lambda/Dockerfile.lambda +11 -0
  66. package/hosting/deploy/lambda/deploy.sh +22 -0
  67. package/hosting/deploy/lambda/handler.py +132 -0
  68. package/hosting/deploy/lambda/template.yaml +59 -0
  69. package/hosting/deploy/local/dev.sh +18 -0
  70. package/hosting/deploy/server/Caddyfile +5 -0
  71. package/hosting/deploy/server/compose.jobs.yaml +36 -0
  72. package/hosting/deploy/server/compose.yaml +38 -0
  73. package/hosting/deploy/server/deploy.sh +23 -0
  74. package/hosting/infra/cloudrun/main.tf +104 -0
  75. package/hosting/infra/cloudrun/terraform.tfvars.example +7 -0
  76. package/hosting/infra/cloudrun/variables.tf +55 -0
  77. package/hosting/infra/lambda/main.tf +63 -0
  78. package/hosting/infra/lambda/terraform.tfvars.example +6 -0
  79. package/hosting/infra/lambda/variables.tf +37 -0
  80. package/hosting/pyproject.toml +65 -0
  81. package/hosting/requirements.lock +134 -0
  82. package/package.json +76 -4
  83. package/python/core/__init__.py +0 -0
  84. package/python/core/aadhaar_extractor.py +298 -0
  85. package/python/core/back_page_extractor.py +492 -0
  86. package/python/core/barcodes.py +125 -0
  87. package/python/core/card_recovery.py +244 -0
  88. package/python/core/document_bundle.py +82 -0
  89. package/python/core/document_classifier.py +233 -0
  90. package/python/core/document_input.py +135 -0
  91. package/python/core/document_registry.py +143 -0
  92. package/python/core/driving_licence_extractor.py +510 -0
  93. package/python/core/ead_recovery.py +54 -0
  94. package/python/core/evidence.py +54 -0
  95. package/python/core/form_recovery.py +61 -0
  96. package/python/core/jobs.py +232 -0
  97. package/python/core/kyc_ocr.py +132 -0
  98. package/python/core/kyc_validation.py +411 -0
  99. package/python/core/model_setup.py +13 -0
  100. package/python/core/mrz_parser.py +336 -0
  101. package/python/core/mrz_recovery.py +193 -0
  102. package/python/core/npr_extractor.py +410 -0
  103. package/python/core/nrega_extractor.py +827 -0
  104. package/python/core/ocr_engine.py +207 -0
  105. package/python/core/page_classifier.py +137 -0
  106. package/python/core/pan_extractor.py +183 -0
  107. package/python/core/pipeline.py +712 -0
  108. package/python/core/preprocessor.py +297 -0
  109. package/python/core/recovery_budget.py +67 -0
  110. package/python/core/redaction.py +48 -0
  111. package/python/core/review.html +244 -0
  112. package/python/core/structured_documents.py +422 -0
  113. package/python/core/travel_mrz.py +164 -0
  114. package/python/core/travel_recovery.py +187 -0
  115. package/python/core/validator.py +396 -0
  116. package/python/core/validators.py +179 -0
  117. package/python/core/voter_id_extractor.py +363 -0
  118. package/python/pyproject.toml +34 -0
  119. package/python/requirements.lock +134 -0
  120. package/python/server.py +313 -0
  121. package/scripts/dococr.mjs +163 -0
  122. package/scripts/postinstall.mjs +173 -0
  123. package/scripts/sync-hosting.mjs +35 -0
  124. package/scripts/sync-python.sh +20 -0
@@ -0,0 +1,297 @@
1
+ """
2
+ Image preprocessing for passport OCR.
3
+
4
+ Accepts a raw image (file path or bytes). Returns a clean, normalised image
5
+ ready for OCR after document detection, perspective correction, and
6
+ quality checks.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Optional, Union
16
+
17
+ import cv2
18
+ import numpy as np
19
+ from PIL import Image, ImageOps, UnidentifiedImageError
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Constants
23
+ # ---------------------------------------------------------------------------
24
+
25
+ MIN_RESOLUTION = 600 # shortest dimension must be >= this
26
+ BLUR_THRESHOLD = 80 # Laplacian variance below this → blurry
27
+ GLARE_V_THRESHOLD = 250 # HSV V channel threshold for glare
28
+ GLARE_PIXEL_RATIO = 0.15 # reject if > 15 % pixels exceed V threshold
29
+ TARGET_WIDTH = 1600 # normalise output to this width
30
+ CLAHE_CLIP = 2.0
31
+ CLAHE_GRID = (8, 8)
32
+
33
+ # Document-detection sanity thresholds. Polygon approximation can produce
34
+ # degenerate quads from noise (perforations, watermarks, form-field rectangles).
35
+ # A real document boundary should cover most of the frame and be roughly
36
+ # convex with a passport-like aspect ratio.
37
+ MIN_QUAD_AREA_RATIO = 0.30 # quad must cover ≥ 30 % of image area
38
+ MIN_QUAD_ASPECT = 0.5 # reject very narrow / very tall quads
39
+ MAX_QUAD_ASPECT = 3.0
40
+ MIN_WARPED_AREA_RATIO = 0.50 # if perspective-corrected image is < 50 %
41
+ # of the input area, discard and use raw
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Result type
46
+ # ---------------------------------------------------------------------------
47
+
48
+ @dataclass
49
+ class PreprocessResult:
50
+ image: np.ndarray
51
+ warnings: list[str] = field(default_factory=list)
52
+ # Same geometry and resolution as image, without contrast enhancement.
53
+ # Retained for bounded rereads when security backgrounds worsen with CLAHE.
54
+ unenhanced_image: Optional[np.ndarray] = field(default=None, repr=False)
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Helpers
59
+ # ---------------------------------------------------------------------------
60
+
61
+ def _load_image(source: Union[str, bytes, Path]) -> np.ndarray:
62
+ """Load image from file path, bytes, or Path and return BGR numpy array."""
63
+ # Decode paths and uploads identically: OpenCV's path/byte decoders do not
64
+ # consistently handle EXIF orientation or HEIF, especially for phone photos.
65
+ data = Path(source).read_bytes() if isinstance(source, (str, Path)) else source
66
+ if data.startswith(b"%PDF-"):
67
+ return _load_pdf_first_page(data)
68
+ try:
69
+ try:
70
+ image = Image.open(io.BytesIO(data))
71
+ except UnidentifiedImageError:
72
+ import pillow_heif
73
+
74
+ pillow_heif.register_heif_opener()
75
+ image = Image.open(io.BytesIO(data))
76
+ with image:
77
+ if image.width * image.height > 40_000_000:
78
+ raise ImageQualityError("IMAGE_TOO_LARGE")
79
+ oriented = ImageOps.exif_transpose(image)
80
+ pixels = np.asarray(oriented)
81
+ if pixels.dtype.kind == "u" and pixels.dtype.itemsize == 2:
82
+ # Pillow's direct RGB conversion clips 16-bit grayscale at 255,
83
+ # erasing nearly all contrast. Match 8-bit image decoding by
84
+ # retaining the high byte across the full 16-bit range.
85
+ oriented = Image.fromarray((pixels >> 8).astype(np.uint8))
86
+ return cv2.cvtColor(np.array(oriented.convert("RGB")), cv2.COLOR_RGB2BGR)
87
+ except Image.DecompressionBombError as exc:
88
+ raise ImageQualityError("IMAGE_TOO_LARGE") from exc
89
+ except (OSError, ValueError) as exc:
90
+ raise ImageQualityError("INVALID_IMAGE") from exc
91
+
92
+
93
+ def _load_pdf_first_page(source: Union[str, bytes, Path]) -> np.ndarray:
94
+ """Render a bounded first PDF page in the common 1600-pixel coordinate plane."""
95
+ from .document_input import input_bytes, pdf_pages
96
+ return pdf_pages(input_bytes(source), first_only=True)[0].image
97
+
98
+
99
+ def _check_resolution(img: np.ndarray) -> None:
100
+ h, w = img.shape[:2]
101
+ # Card scans can retain readable text at ~800 x 480. Keep the existing
102
+ # general threshold while admitting a bounded compact-card size; blur and
103
+ # extraction completeness still have to pass independently.
104
+ compact_card = min(h, w) >= 450 and max(h, w) >= 750
105
+ if min(h, w) < MIN_RESOLUTION and not compact_card:
106
+ raise ImageQualityError("RESOLUTION_TOO_LOW")
107
+
108
+
109
+ def _check_blur(img: np.ndarray, threshold: float = BLUR_THRESHOLD) -> None:
110
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
111
+ variance = cv2.Laplacian(gray, cv2.CV_64F).var()
112
+ if variance < threshold:
113
+ raise ImageQualityError("IMAGE_TOO_BLURRY")
114
+
115
+
116
+ def _check_glare(img: np.ndarray) -> Optional[str]:
117
+ hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
118
+ v_channel = hsv[:, :, 2]
119
+ ratio = np.count_nonzero(v_channel > GLARE_V_THRESHOLD) / v_channel.size
120
+ if ratio > GLARE_PIXEL_RATIO:
121
+ return "GLARE_DETECTED"
122
+ return None
123
+
124
+
125
+ def _detect_document(img: np.ndarray) -> tuple[np.ndarray | None, list[str]]:
126
+ """Find the document quadrilateral. Returns (corners, warnings).
127
+
128
+ Only accepts a 4-corner approximation that is plausibly an entire
129
+ document: large enough, convex, and with a passport-like aspect ratio.
130
+ Without these checks, polygon approximation of noise contours
131
+ (perforations, form-field rectangles, watermark edges) can produce
132
+ degenerate quads that destroy the image during perspective correction.
133
+ """
134
+ warnings: list[str] = []
135
+ h, w = img.shape[:2]
136
+ img_area = float(h * w)
137
+
138
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
139
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
140
+ edges = cv2.Canny(blurred, 50, 150)
141
+
142
+ # dilate to close gaps
143
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
144
+ edges = cv2.dilate(edges, kernel, iterations=1)
145
+
146
+ contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
147
+
148
+ if not contours:
149
+ warnings.append("NO_DOCUMENT_BOUNDARY_DETECTED")
150
+ return None, warnings
151
+
152
+ # sort by area descending
153
+ contours = sorted(contours, key=cv2.contourArea, reverse=True)
154
+
155
+ for cnt in contours[:5]:
156
+ peri = cv2.arcLength(cnt, True)
157
+ approx = cv2.approxPolyDP(cnt, 0.02 * peri, True)
158
+ if len(approx) != 4:
159
+ continue
160
+ if _is_plausible_document_quad(approx, img_area):
161
+ return approx.reshape(4, 2).astype(np.float32), warnings
162
+
163
+ warnings.append("NO_DOCUMENT_BOUNDARY_DETECTED")
164
+ return None, warnings
165
+
166
+
167
+ def _is_plausible_document_quad(approx: np.ndarray, img_area: float) -> bool:
168
+ """A real document quad covers most of the image, is convex, and has a
169
+ sane aspect ratio. Anything else is almost certainly a noise contour."""
170
+ if not cv2.isContourConvex(approx):
171
+ return False
172
+ if cv2.contourArea(approx) < img_area * MIN_QUAD_AREA_RATIO:
173
+ return False
174
+ _, _, bw, bh = cv2.boundingRect(approx)
175
+ if bw <= 0 or bh <= 0:
176
+ return False
177
+ aspect = bw / bh
178
+ return MIN_QUAD_ASPECT <= aspect <= MAX_QUAD_ASPECT
179
+
180
+
181
+ def _order_points(pts: np.ndarray) -> np.ndarray:
182
+ """Order points: top-left, top-right, bottom-right, bottom-left."""
183
+ # Sorting each extremum separately can select the same vertex twice when
184
+ # sums/differences tie (for example a document rotated by 45 degrees).
185
+ points = np.asarray(pts, dtype=np.float32).reshape(4, 2)
186
+ center = points.mean(axis=0)
187
+ angles = np.arctan2(points[:, 1] - center[1], points[:, 0] - center[0])
188
+ ordered = points[np.argsort(angles)]
189
+ start = np.lexsort((ordered[:, 0], ordered[:, 1], ordered.sum(axis=1)))[0]
190
+ return np.roll(ordered, -start, axis=0)
191
+
192
+
193
+ def _perspective_correct(img: np.ndarray, corners: np.ndarray) -> np.ndarray:
194
+ """Apply perspective transform to produce a flat, top-down crop."""
195
+ rect = _order_points(corners)
196
+ (tl, tr, br, bl) = rect
197
+
198
+ width_a = np.linalg.norm(br - bl)
199
+ width_b = np.linalg.norm(tr - tl)
200
+ max_w = int(max(width_a, width_b))
201
+
202
+ height_a = np.linalg.norm(tr - br)
203
+ height_b = np.linalg.norm(tl - bl)
204
+ max_h = int(max(height_a, height_b))
205
+
206
+ dst = np.array([
207
+ [0, 0],
208
+ [max_w - 1, 0],
209
+ [max_w - 1, max_h - 1],
210
+ [0, max_h - 1],
211
+ ], dtype=np.float32)
212
+
213
+ matrix = cv2.getPerspectiveTransform(rect, dst)
214
+ return cv2.warpPerspective(img, matrix, (max_w, max_h))
215
+
216
+
217
+ def _resize(img: np.ndarray) -> np.ndarray:
218
+ """Use the same coordinate plane for enhanced and original-color reads."""
219
+ h, w = img.shape[:2]
220
+ if w > TARGET_WIDTH:
221
+ ratio = TARGET_WIDTH / w
222
+ new_h = int(h * ratio)
223
+ img = cv2.resize(img, (TARGET_WIDTH, new_h), interpolation=cv2.INTER_LANCZOS4)
224
+ return img
225
+
226
+
227
+ def _normalise(img: np.ndarray) -> np.ndarray:
228
+ """Downscale to standard width (if larger) and apply CLAHE contrast enhancement."""
229
+ return enhance_contrast(_resize(img))
230
+
231
+
232
+ def enhance_contrast(img: np.ndarray) -> np.ndarray:
233
+ """Enhance contrast without resizing or moving any evidence coordinates."""
234
+
235
+ # CLAHE on L channel in LAB
236
+ lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
237
+ l, a, b = cv2.split(lab)
238
+ clahe = cv2.createCLAHE(clipLimit=CLAHE_CLIP, tileGridSize=CLAHE_GRID)
239
+ l = clahe.apply(l)
240
+ lab = cv2.merge([l, a, b])
241
+ return cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Errors
246
+ # ---------------------------------------------------------------------------
247
+
248
+ class ImageQualityError(Exception):
249
+ """Raised when image quality is insufficient for OCR."""
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # Public API
254
+ # ---------------------------------------------------------------------------
255
+
256
+ def preprocess(
257
+ source: Union[str, bytes, Path],
258
+ *,
259
+ blur_threshold: float = BLUR_THRESHOLD,
260
+ ) -> PreprocessResult:
261
+ """
262
+ Full preprocessing pipeline.
263
+
264
+ 1. Load image
265
+ 2. Resolution check
266
+ 3. Blur detection
267
+ 4. Glare detection
268
+ 5. Document detection + perspective correction
269
+ 6. Normalisation (resize + CLAHE)
270
+ """
271
+ img = _load_image(source)
272
+ _check_resolution(img)
273
+ _check_blur(img, threshold=blur_threshold)
274
+ glare_warning = _check_glare(img)
275
+
276
+ corners, warnings = _detect_document(img)
277
+ if min(img.shape[:2]) < MIN_RESOLUTION:
278
+ warnings.append("COMPACT_CARD_RESOLUTION")
279
+
280
+ if glare_warning:
281
+ warnings.append(glare_warning)
282
+
283
+ if corners is not None:
284
+ original_area = img.shape[0] * img.shape[1]
285
+ warped = _perspective_correct(img, corners)
286
+ warped_area = warped.shape[0] * warped.shape[1]
287
+ if warped_area >= original_area * MIN_WARPED_AREA_RATIO:
288
+ img = warped
289
+ else:
290
+ # Perspective transform produced a degenerate strip — the detected
291
+ # quad must have been bogus. Discard it and use the raw image.
292
+ warnings.append("PERSPECTIVE_CORRECTION_DISCARDED")
293
+
294
+ unenhanced = _resize(img)
295
+ img = _normalise(unenhanced)
296
+
297
+ return PreprocessResult(image=img, warnings=warnings, unenhanced_image=unenhanced)
@@ -0,0 +1,67 @@
1
+ """Request-scoped time budget for optional recovery OCR passes.
2
+
3
+ Recovery re-reads damaged rows after the base passes have run. Each re-read is
4
+ optional, so a scan stops starting new ones once the budget measured from the
5
+ start of the scan is spent, and returns the best evidence it already has. A
6
+ pass that has started always finishes; the budget bounds when passes may begin.
7
+
8
+ DOCUMENT_OCR_RECOVERY_BUDGET_SECONDS=5 # default; 0 disables recovery
9
+ DOCUMENT_OCR_RECOVERY_BUDGET_SECONDS=off # no limit
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import contextvars
14
+ import logging
15
+ import os
16
+ import time
17
+ from contextlib import contextmanager
18
+ from typing import Iterator, Optional
19
+
20
+ logger = logging.getLogger("document-ocr.recovery_budget")
21
+
22
+ BUDGET_ENV = "DOCUMENT_OCR_RECOVERY_BUDGET_SECONDS"
23
+ DEFAULT_BUDGET_SECONDS = 5.0
24
+
25
+ _deadline: contextvars.ContextVar[Optional[float]] = contextvars.ContextVar(
26
+ "document_ocr_recovery_deadline", default=None)
27
+
28
+
29
+ def configured_budget(raw: Optional[str] = None) -> Optional[float]:
30
+ """Seconds allowed from scan start, or None for no limit."""
31
+ value = (os.getenv(BUDGET_ENV) if raw is None else raw) or ""
32
+ value = value.strip().lower()
33
+ if not value:
34
+ return DEFAULT_BUDGET_SECONDS
35
+ if value in {"off", "none", "unlimited"}:
36
+ return None
37
+ try:
38
+ seconds = float(value)
39
+ except ValueError:
40
+ # A typo in deployment config must not fail every scan.
41
+ logger.warning("%s=%r is not a number of seconds or 'off'; using %s",
42
+ BUDGET_ENV, value, DEFAULT_BUDGET_SECONDS)
43
+ return DEFAULT_BUDGET_SECONDS
44
+ return max(seconds, 0.0)
45
+
46
+
47
+ @contextmanager
48
+ def recovery_deadline(start: float) -> Iterator[None]:
49
+ """Bound optional recovery for the scan that began at ``start`` (monotonic).
50
+
51
+ A nested call inside an active scan keeps the outer deadline.
52
+ """
53
+ if _deadline.get() is not None:
54
+ yield
55
+ return
56
+ budget = configured_budget()
57
+ token = _deadline.set(None if budget is None else start + budget)
58
+ try:
59
+ yield
60
+ finally:
61
+ _deadline.reset(token)
62
+
63
+
64
+ def recovery_allowed() -> bool:
65
+ """True while an optional recovery pass may still start."""
66
+ deadline = _deadline.get()
67
+ return deadline is None or time.monotonic() < deadline
@@ -0,0 +1,48 @@
1
+ """Burn caller-reviewed redaction polygons into a metadata-free PNG raster."""
2
+ import json
3
+ import math
4
+
5
+ import cv2
6
+ import numpy as np
7
+
8
+ from .pipeline import preview_image
9
+
10
+
11
+ def render_preview(data: bytes) -> bytes:
12
+ return _png(preview_image(data))
13
+
14
+
15
+ def redact(data: bytes, boxes: str | list) -> bytes:
16
+ if isinstance(boxes, str):
17
+ try:
18
+ boxes = json.loads(boxes)
19
+ except ValueError as exc:
20
+ raise ValueError('INVALID_REDACTION_BOXES') from exc
21
+ if not isinstance(boxes, list) or not 1 <= len(boxes) <= 200:
22
+ raise ValueError('INVALID_REDACTION_BOXES')
23
+ image = preview_image(data).copy()
24
+ height, width = image.shape[:2]
25
+ for box in boxes:
26
+ if not isinstance(box, list) or len(box) != 4:
27
+ raise ValueError('INVALID_REDACTION_BOXES')
28
+ for point in box:
29
+ if (not isinstance(point, list) or len(point) != 2
30
+ or any(isinstance(x, bool) or not isinstance(x, (float, int)) or not math.isfinite(x) for x in point)
31
+ or not (0 <= point[0] <= width and 0 <= point[1] <= height)):
32
+ raise ValueError('INVALID_REDACTION_BOXES')
33
+ points = np.array(box, dtype=np.int32)
34
+ if abs(cv2.contourArea(points)) < 1:
35
+ raise ValueError('INVALID_REDACTION_BOXES')
36
+ # Bounding rectangles intentionally cover the entire text quad with a
37
+ # small ink margin. PNG output has no retained PDF text layer or EXIF.
38
+ x0, y0 = np.maximum(points.min(axis=0) - 3, 0)
39
+ x1, y1 = np.minimum(points.max(axis=0) + 4, (width, height))
40
+ image[y0:y1, x0:x1] = 0
41
+ return _png(image)
42
+
43
+
44
+ def _png(image) -> bytes:
45
+ ok, encoded = cv2.imencode('.png', image)
46
+ if not ok:
47
+ raise ValueError('IMAGE_ENCODING_FAILED')
48
+ return encoded.tobytes()
@@ -0,0 +1,244 @@
1
+ <!doctype html>
2
+ <html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
3
+ <title>Document OCR · Review</title>
4
+ <style>
5
+ :root{font-family:system-ui,sans-serif;color:#162333;background:#f4f6f8}body{max-width:1280px;margin:auto;padding:28px}h1{font-size:28px;margin:0 0 6px}p{color:#566576;line-height:1.5}header{margin-bottom:24px}.controls{display:flex;gap:12px;flex-wrap:wrap;align-items:end;padding:20px;background:white;border-radius:12px}label{font-size:13px;display:block}input,select,button{font:inherit;padding:10px;border:1px solid #c7d0d8;border-radius:6px;max-width:100%;box-sizing:border-box}button{cursor:pointer;background:#183c5e;color:white}button:disabled{opacity:.5;cursor:default}input[type=checkbox]{width:18px;height:18px}.workspace{display:grid;grid-template-columns:1.2fr 1fr;gap:20px;margin-top:20px}.panel{background:white;border-radius:12px;padding:20px;min-width:0}.image-wrap{position:relative;overflow:auto;max-height:75vh}canvas{max-width:100%;height:auto;display:block}.row{display:grid;grid-template-columns:22px 1fr;gap:10px;margin-bottom:16px}.row input[type=text]{width:100%;margin-top:5px}.source{font-size:12px;color:#647486;white-space:pre-wrap;overflow-wrap:anywhere;margin-top:4px}.status{white-space:pre-wrap;margin:18px 0;color:#183c5e}details{margin-top:16px}pre{max-height:300px;overflow:auto;font-size:12px;white-space:pre-wrap}.exports{display:flex;gap:8px;flex-wrap:wrap}.note{font-size:12px}@media(max-width:800px){body{padding:16px}.workspace{grid-template-columns:1fr}}
6
+ </style>
7
+ <header><h1>Review a document</h1><p>Inspect extracted values and their source regions. Corrections stay in this browser tab until you export them.</p></header>
8
+ <div class="controls">
9
+ <label>Image or PDF<input id="file" type="file" accept="image/*,application/pdf"></label>
10
+ <label>API token<input id="token" type="password" autocomplete="off" placeholder="If configured"></label>
11
+ <label>Country<select id="country"><option value="">Auto detect</option value="US">United States</option><option value="IN">India</option></select></label>
12
+ <label>Document<select id="type"><option value="">Auto detect</option></select></label>
13
+ <button id="scan">Extract</button><button id="clear">Clear</button>
14
+ </div>
15
+ <p class="note">This screen reviews one image or the first PDF page. Use the SDK’s scanDocument method for grouped pages. A successful extraction does not establish document authenticity.</p>
16
+ <div id="status" class="status" role="status" aria-live="polite">Choose a document to begin.</div>
17
+ <div class="workspace">
18
+ <section class="panel"><div class="image-wrap"><canvas id="canvas" aria-label="Document with highlighted source region"></canvas></div></section>
19
+ <section class="panel"><div id="fields"></div><div class="exports"><button id="json" disabled>Export JSON</button><button id="csv" disabled>Export CSV</button><button id="redact" disabled>Redact checked fields</button></div><p class="note">Redaction produces a PNG of the normalized first page. Check the downloaded image before sharing; unrecognized text, photos and unselected fields remain visible.</p><details><summary>Original extraction</summary><pre id="raw"></pre></details></section>
20
+ </div>
21
+ <script>
22
+ const $ = id => document.getElementById(id);
23
+ let result = null, scannedFile = null, preview = null;
24
+ let original = {}, corrected = {};
25
+ let selected = new Set(), objectURLs = [];
26
+ let generation = 0, controller = null;
27
+
28
+ function headers() {
29
+ return $('token').value ? { Authorization: 'Bearer ' + $('token').value } : {};
30
+ }
31
+ function form(file = scannedFile) {
32
+ const data = new FormData();
33
+ data.append('image', file);
34
+ return data;
35
+ }
36
+ function say(text) { $('status').textContent = text; }
37
+ function enableExports(enabled) {
38
+ for (const key of ['json', 'csv', 'redact']) $(key).disabled = !enabled;
39
+ }
40
+ function showImage(boxes = []) {
41
+ const canvas = $('canvas'), context = canvas.getContext('2d');
42
+ if (!preview) {
43
+ context.clearRect(0, 0, canvas.width, canvas.height);
44
+ return;
45
+ }
46
+ canvas.width = preview.width;
47
+ canvas.height = preview.height;
48
+ context.drawImage(preview, 0, 0);
49
+ context.strokeStyle = '#d94e22';
50
+ context.fillStyle = '#fb9c4030';
51
+ context.lineWidth = 3;
52
+ for (const box of boxes) {
53
+ if (!box.length) continue;
54
+ context.beginPath();
55
+ box.forEach((point, index) => index ? context.lineTo(...point) : context.moveTo(...point));
56
+ context.closePath();
57
+ context.fill();
58
+ context.stroke();
59
+ }
60
+ }
61
+ function releaseURLs() {
62
+ for (const url of objectURLs) URL.revokeObjectURL(url);
63
+ objectURLs = [];
64
+ }
65
+ function download(blob, name) {
66
+ const url = URL.createObjectURL(blob);
67
+ objectURLs.push(url);
68
+ const link = document.createElement('a');
69
+ link.href = url;
70
+ link.download = name;
71
+ link.click();
72
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
73
+ }
74
+ async function readResponse(response) {
75
+ const data = await response.json();
76
+ if (!response.ok && !data.documentType) {
77
+ throw new Error(data.detail || data.error || 'Request failed');
78
+ }
79
+ return data;
80
+ }
81
+ async function catalog() {
82
+ try {
83
+ const response = await fetch('/documents', { headers: headers() });
84
+ if (!response.ok) return;
85
+ const data = await response.json();
86
+ $('type').replaceChildren(new Option('Auto detect', ''));
87
+ for (const doc of data.documents) {
88
+ $('type').add(new Option(doc.label + (doc.experimental ? ' · experimental' : ''), doc.documentType));
89
+ }
90
+ } catch { /* The scan endpoint will report connection or authentication errors. */ }
91
+ }
92
+ $('token').addEventListener('change', catalog);
93
+ catalog();
94
+
95
+ function renderFields() {
96
+ for (const [key, value] of Object.entries(original)) {
97
+ const row = document.createElement('div');
98
+ row.className = 'row';
99
+ const check = document.createElement('input');
100
+ check.type = 'checkbox';
101
+ check.setAttribute('aria-label', 'Redact ' + key);
102
+ const evidence = result.fieldEvidence?.[key] || [];
103
+ check.disabled = !evidence.length;
104
+ check.onchange = () => {
105
+ if (check.checked) selected.add(key); else selected.delete(key);
106
+ showImage([...selected].flatMap(field => (result.fieldEvidence?.[field] || []).map(item => item.bbox)));
107
+ };
108
+ const block = document.createElement('div'), label = document.createElement('label');
109
+ label.textContent = key;
110
+ const input = document.createElement('input');
111
+ input.type = 'text';
112
+ input.value = value === null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
113
+ input.oninput = () => { corrected[key] = input.value; };
114
+ input.onfocus = () => showImage(evidence.map(item => item.bbox));
115
+ label.append(input);
116
+ block.append(label);
117
+ const source = document.createElement('div');
118
+ source.className = 'source';
119
+ source.textContent = evidence.length
120
+ ? evidence.map(item => `${item.source} · ${Math.round(item.confidence * 100)}% · ${item.text}`).join('\n')
121
+ : 'No matched source region';
122
+ block.append(source);
123
+ row.append(check, block);
124
+ $('fields').append(row);
125
+ }
126
+ }
127
+
128
+ $('scan').onclick = async () => {
129
+ const file = $('file').files[0];
130
+ if (!file) { say('Choose an image or PDF first.'); return; }
131
+ controller?.abort();
132
+ const requestController = new AbortController();
133
+ controller = requestController;
134
+ const current = ++generation;
135
+ const requestHeaders = headers();
136
+ $('scan').disabled = true;
137
+ result = preview = scannedFile = null;
138
+ original = {}; corrected = {};
139
+ selected.clear();
140
+ releaseURLs();
141
+ $('fields').replaceChildren();
142
+ $('raw').textContent = '';
143
+ showImage();
144
+ enableExports(false);
145
+ try {
146
+ say('Extracting document…');
147
+ const data = form(file);
148
+ data.append('include_evidence', 'true');
149
+ if ($('country').value) data.append('country', $('country').value);
150
+ if ($('type').value) data.append('document_type', $('type').value);
151
+ const extracted = await readResponse(await fetch('/scan', {
152
+ method: 'POST', headers: requestHeaders, body: data, signal: requestController.signal,
153
+ }));
154
+ if (current !== generation) return;
155
+ const imageResponse = await fetch('/preview', {
156
+ method: 'POST', headers: requestHeaders, body: form(file), signal: requestController.signal,
157
+ });
158
+ if (current !== generation) return;
159
+ let loadedImage = null;
160
+ if (imageResponse.ok) {
161
+ const blob = await imageResponse.blob();
162
+ if (current !== generation) return;
163
+ const url = URL.createObjectURL(blob);
164
+ objectURLs.push(url);
165
+ loadedImage = await new Promise((resolve, reject) => {
166
+ const image = new Image();
167
+ image.onload = () => resolve(image);
168
+ image.onerror = reject;
169
+ image.src = url;
170
+ });
171
+ if (current !== generation) { URL.revokeObjectURL(url); return; }
172
+ }
173
+ result = extracted;
174
+ scannedFile = file;
175
+ preview = loadedImage;
176
+ original = { ...(result.documentFields || {}) };
177
+ corrected = { ...original };
178
+ $('raw').textContent = JSON.stringify(result, null, 2);
179
+ showImage();
180
+ renderFields();
181
+ enableExports(true);
182
+ say(`${result.documentType} · ${result.status} · confidence ${Math.round(result.confidence * 100)}%\n` +
183
+ [...(result.errors || []), ...(result.warnings || [])].join(', '));
184
+ } catch (error) {
185
+ if (current === generation && error.name !== 'AbortError') {
186
+ say(error.message || 'Unable to extract document');
187
+ }
188
+ } finally {
189
+ if (current === generation) $('scan').disabled = false;
190
+ }
191
+ };
192
+
193
+ function changes() {
194
+ return Object.keys(corrected)
195
+ .filter(key => JSON.stringify(corrected[key]) !== JSON.stringify(original[key]))
196
+ .map(field => ({ field, original: original[field], corrected: corrected[field] }));
197
+ }
198
+ $('json').onclick = () => download(new Blob([JSON.stringify({
199
+ extraction: result,
200
+ review: { correctedFields: corrected, changes: changes(), reviewedAt: new Date().toISOString() },
201
+ }, null, 2)], { type: 'application/json' }), 'reviewed-document.json');
202
+ function csv(value) {
203
+ let text = typeof value === 'object' ? JSON.stringify(value) : String(value ?? '');
204
+ if (/^[=+@\-\t\r]/.test(text)) text = "'" + text;
205
+ return '"' + text.replaceAll('"', '""') + '"';
206
+ }
207
+ $('csv').onclick = () => download(new Blob(['field,original,corrected\r\n' +
208
+ Object.keys(corrected).map(key => [key, original[key], corrected[key]].map(csv).join(',')).join('\r\n')],
209
+ { type: 'text/csv' }), 'reviewed-document.csv');
210
+ $('redact').onclick = async () => {
211
+ const boxes = [...selected].flatMap(key => (result.fieldEvidence?.[key] || []).map(item => item.bbox));
212
+ if (!boxes.length) { say('Check at least one field with source evidence to redact.'); return; }
213
+ const current = generation;
214
+ try {
215
+ const data = form(scannedFile);
216
+ data.append('boxes', JSON.stringify(boxes));
217
+ const response = await fetch('/redact', { method: 'POST', headers: headers(), body: data, signal: controller.signal });
218
+ if (current !== generation) return;
219
+ if (!response.ok) throw new Error((await response.json()).error || 'Redaction failed');
220
+ const blob = await response.blob();
221
+ if (current !== generation) return;
222
+ download(blob, 'redacted.png');
223
+ say('Redacted PNG exported. Inspect it before sharing.');
224
+ } catch (error) {
225
+ if (current === generation && error.name !== 'AbortError') say(error.message);
226
+ }
227
+ };
228
+ $('clear').onclick = () => {
229
+ ++generation;
230
+ controller?.abort();
231
+ controller = null;
232
+ result = preview = scannedFile = null;
233
+ original = {}; corrected = {};
234
+ selected.clear();
235
+ $('file').value = '';
236
+ $('fields').replaceChildren();
237
+ $('raw').textContent = '';
238
+ showImage();
239
+ releaseURLs();
240
+ enableExports(false);
241
+ $('scan').disabled = false;
242
+ say('Document cleared.');
243
+ };
244
+ </script></html>