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.
- package/LICENSE +21 -0
- package/README.md +260 -2
- package/dist/index.cjs +659 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +293 -0
- package/dist/index.d.ts +293 -0
- package/dist/index.js +616 -0
- package/dist/index.js.map +1 -0
- package/hosting/.dockerignore +17 -0
- package/hosting/.gcloudignore +14 -0
- package/hosting/FEATURES.md +172 -0
- package/hosting/HOSTING.md +268 -0
- package/hosting/LICENSE +21 -0
- package/hosting/README.md +369 -0
- package/hosting/core/__init__.py +0 -0
- package/hosting/core/aadhaar_extractor.py +298 -0
- package/hosting/core/back_page_extractor.py +492 -0
- package/hosting/core/barcodes.py +125 -0
- package/hosting/core/card_recovery.py +244 -0
- package/hosting/core/document_bundle.py +82 -0
- package/hosting/core/document_classifier.py +233 -0
- package/hosting/core/document_input.py +135 -0
- package/hosting/core/document_registry.py +143 -0
- package/hosting/core/driving_licence_extractor.py +510 -0
- package/hosting/core/ead_recovery.py +54 -0
- package/hosting/core/evidence.py +54 -0
- package/hosting/core/form_recovery.py +61 -0
- package/hosting/core/jobs.py +232 -0
- package/hosting/core/kyc_ocr.py +132 -0
- package/hosting/core/kyc_validation.py +411 -0
- package/hosting/core/model_setup.py +13 -0
- package/hosting/core/mrz_parser.py +336 -0
- package/hosting/core/mrz_recovery.py +193 -0
- package/hosting/core/npr_extractor.py +410 -0
- package/hosting/core/nrega_extractor.py +827 -0
- package/hosting/core/ocr_engine.py +207 -0
- package/hosting/core/page_classifier.py +137 -0
- package/hosting/core/pan_extractor.py +183 -0
- package/hosting/core/pipeline.py +712 -0
- package/hosting/core/preprocessor.py +297 -0
- package/hosting/core/recovery_budget.py +67 -0
- package/hosting/core/redaction.py +48 -0
- package/hosting/core/review.html +244 -0
- package/hosting/core/structured_documents.py +422 -0
- package/hosting/core/travel_mrz.py +164 -0
- package/hosting/core/travel_recovery.py +187 -0
- package/hosting/core/validator.py +396 -0
- package/hosting/core/validators.py +179 -0
- package/hosting/core/voter_id_extractor.py +363 -0
- package/hosting/deploy/cloudflare/deploy.sh +44 -0
- package/hosting/deploy/cloudflare/package-lock.json +3088 -0
- package/hosting/deploy/cloudflare/package.json +29 -0
- package/hosting/deploy/cloudflare/src/gateway.ts +42 -0
- package/hosting/deploy/cloudflare/src/index.ts +21 -0
- package/hosting/deploy/cloudflare/test/gateway.test.ts +59 -0
- package/hosting/deploy/cloudflare/tsconfig.json +12 -0
- package/hosting/deploy/cloudflare/worker-configuration.d.ts +16253 -0
- package/hosting/deploy/cloudflare/wrangler.jsonc +26 -0
- package/hosting/deploy/cloudrun/cloudbuild.yaml +18 -0
- package/hosting/deploy/cloudrun/deploy.sh +71 -0
- package/hosting/deploy/cloudrun/render_service.py +47 -0
- package/hosting/deploy/common.sh +14 -0
- package/hosting/deploy/docker/Dockerfile +16 -0
- package/hosting/deploy/docker/server.py +313 -0
- package/hosting/deploy/lambda/Dockerfile.lambda +11 -0
- package/hosting/deploy/lambda/deploy.sh +22 -0
- package/hosting/deploy/lambda/handler.py +132 -0
- package/hosting/deploy/lambda/template.yaml +59 -0
- package/hosting/deploy/local/dev.sh +18 -0
- package/hosting/deploy/server/Caddyfile +5 -0
- package/hosting/deploy/server/compose.jobs.yaml +36 -0
- package/hosting/deploy/server/compose.yaml +38 -0
- package/hosting/deploy/server/deploy.sh +23 -0
- package/hosting/infra/cloudrun/main.tf +104 -0
- package/hosting/infra/cloudrun/terraform.tfvars.example +7 -0
- package/hosting/infra/cloudrun/variables.tf +55 -0
- package/hosting/infra/lambda/main.tf +63 -0
- package/hosting/infra/lambda/terraform.tfvars.example +6 -0
- package/hosting/infra/lambda/variables.tf +37 -0
- package/hosting/pyproject.toml +65 -0
- package/hosting/requirements.lock +134 -0
- package/package.json +76 -4
- package/python/core/__init__.py +0 -0
- package/python/core/aadhaar_extractor.py +298 -0
- package/python/core/back_page_extractor.py +492 -0
- package/python/core/barcodes.py +125 -0
- package/python/core/card_recovery.py +244 -0
- package/python/core/document_bundle.py +82 -0
- package/python/core/document_classifier.py +233 -0
- package/python/core/document_input.py +135 -0
- package/python/core/document_registry.py +143 -0
- package/python/core/driving_licence_extractor.py +510 -0
- package/python/core/ead_recovery.py +54 -0
- package/python/core/evidence.py +54 -0
- package/python/core/form_recovery.py +61 -0
- package/python/core/jobs.py +232 -0
- package/python/core/kyc_ocr.py +132 -0
- package/python/core/kyc_validation.py +411 -0
- package/python/core/model_setup.py +13 -0
- package/python/core/mrz_parser.py +336 -0
- package/python/core/mrz_recovery.py +193 -0
- package/python/core/npr_extractor.py +410 -0
- package/python/core/nrega_extractor.py +827 -0
- package/python/core/ocr_engine.py +207 -0
- package/python/core/page_classifier.py +137 -0
- package/python/core/pan_extractor.py +183 -0
- package/python/core/pipeline.py +712 -0
- package/python/core/preprocessor.py +297 -0
- package/python/core/recovery_budget.py +67 -0
- package/python/core/redaction.py +48 -0
- package/python/core/review.html +244 -0
- package/python/core/structured_documents.py +422 -0
- package/python/core/travel_mrz.py +164 -0
- package/python/core/travel_recovery.py +187 -0
- package/python/core/validator.py +396 -0
- package/python/core/validators.py +179 -0
- package/python/core/voter_id_extractor.py +363 -0
- package/python/pyproject.toml +34 -0
- package/python/requirements.lock +134 -0
- package/python/server.py +313 -0
- package/scripts/dococr.mjs +163 -0
- package/scripts/postinstall.mjs +173 -0
- package/scripts/sync-hosting.mjs +35 -0
- 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>
|