blitzid 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blitzid/__init__.py +49 -0
- blitzid/_image.py +180 -0
- blitzid/_models.py +142 -0
- blitzid/api/__init__.py +11 -0
- blitzid/api/_analyze.py +129 -0
- blitzid/api/_app.py +429 -0
- blitzid/api/_jobs.py +399 -0
- blitzid/api/_schemas.py +35 -0
- blitzid/api/_upload.py +89 -0
- blitzid/exceptions.py +17 -0
- blitzid/face/__init__.py +5 -0
- blitzid/face/_face.py +122 -0
- blitzid/face/_scrfd.py +151 -0
- blitzid/face/detector.py +481 -0
- blitzid/py.typed +0 -0
- blitzid/reading/__init__.py +5 -0
- blitzid/reading/document.py +399 -0
- blitzid/reading/mrz.py +355 -0
- blitzid/reading/ocr.py +152 -0
- blitzid-0.1.0.dist-info/METADATA +413 -0
- blitzid-0.1.0.dist-info/RECORD +23 -0
- blitzid-0.1.0.dist-info/WHEEL +4 -0
- blitzid-0.1.0.dist-info/licenses/LICENSE +21 -0
blitzid/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""BlitzID — DNN-based face detection.
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
- ``FaceDetectorDNN`` — the SCRFD-based face detector.
|
|
6
|
+
- ``Face`` — detection record (bbox, confidence, landmarks).
|
|
7
|
+
- ``DetectionMetrics`` — metrics dataclass returned by ``detect_face_with_metrics``.
|
|
8
|
+
- ``RapidOCRReader`` — the RapidOCR-based text reader (``ocr`` extra).
|
|
9
|
+
- ``OCRText`` — recognized text line (bbox, text, confidence).
|
|
10
|
+
- ``MRZReader`` — the ICAO 9303 machine-readable zone reader (``ocr`` extra).
|
|
11
|
+
- ``MRZRecord`` — parsed MRZ fields.
|
|
12
|
+
- ``DocumentCropper`` — document localization, perspective crop, and QC.
|
|
13
|
+
- ``QualityReport`` — document crop quality-check verdict.
|
|
14
|
+
- ``BlitzIDError``, ``ModelError``, ``ImageError``, ``MRZError`` — exception hierarchy.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .exceptions import BlitzIDError, ImageError, ModelError, MRZError
|
|
18
|
+
from .face._face import DetectionMetrics, Face
|
|
19
|
+
from .face.detector import FaceDetectorDNN
|
|
20
|
+
from .reading.document import DocumentCropper, QualityReport
|
|
21
|
+
from .reading.mrz import MRZReader, MRZRecord
|
|
22
|
+
from .reading.ocr import OCRText, RapidOCRReader
|
|
23
|
+
|
|
24
|
+
FaceDetectorError = BlitzIDError
|
|
25
|
+
ModelDownloadError = ModelError
|
|
26
|
+
ImageLoadError = ImageError
|
|
27
|
+
ImageProcessingError = ImageError
|
|
28
|
+
InvalidParameterError = BlitzIDError
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"BlitzIDError",
|
|
32
|
+
"DetectionMetrics",
|
|
33
|
+
"DocumentCropper",
|
|
34
|
+
"Face",
|
|
35
|
+
"FaceDetectorDNN",
|
|
36
|
+
"FaceDetectorError",
|
|
37
|
+
"ImageError",
|
|
38
|
+
"ImageLoadError",
|
|
39
|
+
"ImageProcessingError",
|
|
40
|
+
"InvalidParameterError",
|
|
41
|
+
"MRZError",
|
|
42
|
+
"MRZReader",
|
|
43
|
+
"MRZRecord",
|
|
44
|
+
"ModelDownloadError",
|
|
45
|
+
"ModelError",
|
|
46
|
+
"OCRText",
|
|
47
|
+
"QualityReport",
|
|
48
|
+
"RapidOCRReader",
|
|
49
|
+
]
|
blitzid/_image.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Image loading and validation for face detection.
|
|
2
|
+
|
|
3
|
+
Handles loading from file paths, numpy arrays, and PIL Images.
|
|
4
|
+
Normalizes all inputs to 3-channel BGR ``np.ndarray``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, TypeGuard, Union
|
|
12
|
+
|
|
13
|
+
import cv2
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from .exceptions import ImageError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from PIL.Image import Image as PILImageType
|
|
20
|
+
|
|
21
|
+
ImageInput = Union[Path, str, np.ndarray, "PILImageType"]
|
|
22
|
+
|
|
23
|
+
MIN_DIMENSION = 10
|
|
24
|
+
MAX_DIMENSION = 10000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_pil_image(value: object) -> TypeGuard[PILImageType]:
|
|
28
|
+
"""Return whether *value* is a PIL Image (PIL is an optional dependency)."""
|
|
29
|
+
try:
|
|
30
|
+
from PIL.Image import Image as PILImage
|
|
31
|
+
except ImportError: # pragma: no cover
|
|
32
|
+
return False
|
|
33
|
+
return isinstance(value, PILImage)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_image(
|
|
37
|
+
image_input: ImageInput,
|
|
38
|
+
logger: logging.Logger,
|
|
39
|
+
) -> np.ndarray:
|
|
40
|
+
"""Load and validate image from any source.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
image_input: Path, numpy array, or PIL Image.
|
|
44
|
+
logger: Logger instance for debug messages.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Image as numpy array in BGR format.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ImageError: If image cannot be loaded or is invalid.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
if isinstance(image_input, np.ndarray):
|
|
54
|
+
img = _load_from_array(image_input, logger)
|
|
55
|
+
elif _is_pil_image(image_input):
|
|
56
|
+
img = _load_from_pil(image_input)
|
|
57
|
+
elif isinstance(image_input, (Path, str)):
|
|
58
|
+
img = _load_from_path(Path(image_input))
|
|
59
|
+
else:
|
|
60
|
+
raise ImageError(f"Unsupported image_input type: {type(image_input)}")
|
|
61
|
+
|
|
62
|
+
_validate_image(img)
|
|
63
|
+
img = _normalize_channels(img)
|
|
64
|
+
return img
|
|
65
|
+
|
|
66
|
+
except (OSError, ValueError, TypeError, cv2.error) as e:
|
|
67
|
+
raise ImageError(f"Unexpected error loading image: {e}") from e
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _load_from_path(path: Path) -> np.ndarray:
|
|
71
|
+
"""Load image from file path."""
|
|
72
|
+
if not path.exists():
|
|
73
|
+
raise ImageError(f"Image not found: {path}")
|
|
74
|
+
if not path.is_file():
|
|
75
|
+
raise ImageError(f"Path is not a file: {path}")
|
|
76
|
+
if path.stat().st_size == 0:
|
|
77
|
+
raise ImageError(f"Image file is empty: {path}")
|
|
78
|
+
|
|
79
|
+
img = cv2.imread(str(path))
|
|
80
|
+
if img is None:
|
|
81
|
+
raise ImageError(
|
|
82
|
+
f"Could not read image: {path}. "
|
|
83
|
+
"File may be corrupted or in an unsupported format."
|
|
84
|
+
)
|
|
85
|
+
return img
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _load_from_array(array: np.ndarray, logger: logging.Logger) -> np.ndarray:
|
|
89
|
+
"""Validate numpy array."""
|
|
90
|
+
if array.size == 0:
|
|
91
|
+
raise ImageError("Image is empty (size = 0)")
|
|
92
|
+
|
|
93
|
+
if array.dtype == np.float64:
|
|
94
|
+
logger.debug("Converting float64 image array to float32")
|
|
95
|
+
array = array.astype(np.float32)
|
|
96
|
+
elif array.dtype not in (np.uint8, np.float32):
|
|
97
|
+
raise ImageError(
|
|
98
|
+
f"Unsupported image dtype: {array.dtype}. "
|
|
99
|
+
"Expected uint8 or float32/float64."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return np.ascontiguousarray(array)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _load_from_pil(pil_image: PILImageType) -> np.ndarray:
|
|
106
|
+
"""Convert PIL Image to numpy array (BGR)."""
|
|
107
|
+
img_array = np.array(pil_image)
|
|
108
|
+
if img_array.size == 0:
|
|
109
|
+
raise ImageError("PIL Image is empty")
|
|
110
|
+
|
|
111
|
+
if len(img_array.shape) == 3 and img_array.shape[2] == 3:
|
|
112
|
+
img = cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR)
|
|
113
|
+
elif len(img_array.shape) == 2:
|
|
114
|
+
img = cv2.cvtColor(img_array, cv2.COLOR_GRAY2BGR)
|
|
115
|
+
else:
|
|
116
|
+
img = img_array
|
|
117
|
+
|
|
118
|
+
return img
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _validate_image(img: np.ndarray) -> None:
|
|
122
|
+
"""Validate image dimensions and properties."""
|
|
123
|
+
if img.size == 0:
|
|
124
|
+
raise ImageError("Image is empty (size = 0)")
|
|
125
|
+
if len(img.shape) < 2:
|
|
126
|
+
raise ImageError(
|
|
127
|
+
f"Image must have at least 2 dimensions, got shape {img.shape}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
h, w = img.shape[:2]
|
|
131
|
+
if h < MIN_DIMENSION or w < MIN_DIMENSION:
|
|
132
|
+
raise ImageError(
|
|
133
|
+
f"Image too small: {w}x{h}. Minimum size is {MIN_DIMENSION}x{MIN_DIMENSION}"
|
|
134
|
+
)
|
|
135
|
+
if h > MAX_DIMENSION or w > MAX_DIMENSION:
|
|
136
|
+
raise ImageError(
|
|
137
|
+
f"Image too large: {w}x{h}. Maximum size is {MAX_DIMENSION}x{MAX_DIMENSION}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _normalize_channels(img: np.ndarray) -> np.ndarray:
|
|
142
|
+
"""Convert to 3-channel BGR."""
|
|
143
|
+
if len(img.shape) == 2:
|
|
144
|
+
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
|
145
|
+
elif len(img.shape) == 3:
|
|
146
|
+
channels = img.shape[2]
|
|
147
|
+
if channels == 1:
|
|
148
|
+
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
|
149
|
+
elif channels == 4:
|
|
150
|
+
img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGR)
|
|
151
|
+
elif channels != 3:
|
|
152
|
+
raise ImageError(
|
|
153
|
+
f"Unsupported number of channels: {channels}. "
|
|
154
|
+
"Expected 1, 3, or 4 channels."
|
|
155
|
+
)
|
|
156
|
+
return img
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def crop_with_padding(
|
|
160
|
+
img: np.ndarray,
|
|
161
|
+
bbox: tuple[int, int, int, int],
|
|
162
|
+
padding: float,
|
|
163
|
+
) -> np.ndarray:
|
|
164
|
+
"""Crop an ``(x, y, w, h)`` box with relative padding, clipped to the image.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
img: BGR image array.
|
|
168
|
+
bbox: The box as ``(x, y, w, h)`` in image pixels.
|
|
169
|
+
padding: Relative padding on each side (fraction of w/h).
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
The cropped image region.
|
|
173
|
+
"""
|
|
174
|
+
height, width = img.shape[:2]
|
|
175
|
+
x, y, box_w, box_h = bbox
|
|
176
|
+
pad_w, pad_h = int(box_w * padding), int(box_h * padding)
|
|
177
|
+
x1, y1 = max(0, x - pad_w), max(0, y - pad_h)
|
|
178
|
+
x2 = min(width, x + box_w + pad_w)
|
|
179
|
+
y2 = min(height, y + box_h + pad_h)
|
|
180
|
+
return img[y1:y2, x1:x2]
|
blitzid/_models.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Model management for the SCRFD face detection network.
|
|
2
|
+
|
|
3
|
+
Downloads the SCRFD-2.5G ONNX model (InsightFace ``buffalo_m`` detection
|
|
4
|
+
weights) and creates a CPU inference session. Weights live in the
|
|
5
|
+
default models dir: ``BLITZID_MODELS_DIR`` when set, else the
|
|
6
|
+
``platformdirs`` user cache.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import onnxruntime as ort # type: ignore[import-untyped]
|
|
18
|
+
from platformdirs import user_cache_dir
|
|
19
|
+
|
|
20
|
+
from .exceptions import ModelError
|
|
21
|
+
|
|
22
|
+
_DOWNLOAD_CHUNK_BYTES = 64 * 1024
|
|
23
|
+
|
|
24
|
+
MODELS_DIR_ENV = "BLITZID_MODELS_DIR"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_model_dir(subdir: str | None = None) -> Path:
|
|
28
|
+
"""Return the default directory for storing model weights.
|
|
29
|
+
|
|
30
|
+
Honors ``BLITZID_MODELS_DIR``; otherwise uses
|
|
31
|
+
``platformdirs.user_cache_dir("blitzid")``.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
subdir: Optional subdirectory.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Resolved :class:`~pathlib.Path` that is guaranteed to exist.
|
|
38
|
+
"""
|
|
39
|
+
env_dir = os.environ.get(MODELS_DIR_ENV)
|
|
40
|
+
base = Path(env_dir) if env_dir else Path(user_cache_dir("blitzid")) / "models"
|
|
41
|
+
|
|
42
|
+
if subdir:
|
|
43
|
+
base = base / subdir
|
|
44
|
+
|
|
45
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
return base
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ModelManager:
|
|
50
|
+
"""Manages the SCRFD ONNX model file and inference session."""
|
|
51
|
+
|
|
52
|
+
MODEL_URL = (
|
|
53
|
+
"https://huggingface.co/immich-app/buffalo_m/resolve/main/detection/model.onnx"
|
|
54
|
+
)
|
|
55
|
+
MODEL_FILENAME = "scrfd_2.5g.onnx"
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
model_dir: Path,
|
|
60
|
+
logger: logging.Logger,
|
|
61
|
+
allow_downloads: bool = True,
|
|
62
|
+
download_timeout: float = 30.0,
|
|
63
|
+
):
|
|
64
|
+
self.model_dir = Path(model_dir)
|
|
65
|
+
self.model_dir.mkdir(exist_ok=True, parents=True)
|
|
66
|
+
self.logger = logger
|
|
67
|
+
self.allow_downloads = allow_downloads
|
|
68
|
+
self.download_timeout = download_timeout
|
|
69
|
+
|
|
70
|
+
self.model_path = self.model_dir / self.MODEL_FILENAME
|
|
71
|
+
|
|
72
|
+
def ensure_model_exists(self) -> None:
|
|
73
|
+
"""Ensure the model file exists, downloading if allowed.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ModelError: If the model is missing and downloads are disabled.
|
|
77
|
+
"""
|
|
78
|
+
if self.model_path.exists():
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if not self.allow_downloads:
|
|
82
|
+
raise ModelError(
|
|
83
|
+
f"Missing model file: {self.model_path.name}. "
|
|
84
|
+
"Downloads are disabled; set allow_downloads=True."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
self.logger.info("SCRFD model not found. Downloading...")
|
|
88
|
+
self._download_model()
|
|
89
|
+
|
|
90
|
+
def load_session(self) -> ort.InferenceSession:
|
|
91
|
+
"""Load the SCRFD model as a CPU inference session.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ModelError: If the model cannot be loaded.
|
|
95
|
+
"""
|
|
96
|
+
self.ensure_model_exists()
|
|
97
|
+
|
|
98
|
+
self.logger.info("Loading SCRFD face detection model...")
|
|
99
|
+
try:
|
|
100
|
+
return ort.InferenceSession(
|
|
101
|
+
str(self.model_path), providers=["CPUExecutionProvider"]
|
|
102
|
+
)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
raise ModelError(f"Failed to load model {self.model_path.name}: {e}") from e
|
|
105
|
+
|
|
106
|
+
def _download_model(self) -> None:
|
|
107
|
+
"""Download the model file.
|
|
108
|
+
|
|
109
|
+
Streams to a ``.tmp`` file and renames into place on success, so
|
|
110
|
+
a partial download never masquerades as a valid model.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ModelError: If the download fails.
|
|
114
|
+
"""
|
|
115
|
+
path = self.model_path
|
|
116
|
+
url = self.MODEL_URL
|
|
117
|
+
headers = {"User-Agent": "Mozilla/5.0 (compatible; blitzid face detector)"}
|
|
118
|
+
|
|
119
|
+
self.logger.info("Downloading %s...", path.name)
|
|
120
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
121
|
+
try:
|
|
122
|
+
req = urllib.request.Request(url, headers=headers)
|
|
123
|
+
with (
|
|
124
|
+
urllib.request.urlopen(req, timeout=self.download_timeout) as response,
|
|
125
|
+
open(tmp_path, "wb") as out_file,
|
|
126
|
+
):
|
|
127
|
+
while True:
|
|
128
|
+
chunk = response.read(_DOWNLOAD_CHUNK_BYTES)
|
|
129
|
+
if not chunk:
|
|
130
|
+
break
|
|
131
|
+
out_file.write(chunk)
|
|
132
|
+
|
|
133
|
+
tmp_path.replace(path)
|
|
134
|
+
self.logger.info(
|
|
135
|
+
"%s downloaded (%.1f KB)", path.name, path.stat().st_size / 1024
|
|
136
|
+
)
|
|
137
|
+
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
|
138
|
+
tmp_path.unlink(missing_ok=True)
|
|
139
|
+
raise ModelError(
|
|
140
|
+
f"Error downloading {path.name} from {url} "
|
|
141
|
+
f"(timeout={self.download_timeout}s): {e}"
|
|
142
|
+
) from e
|
blitzid/api/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""blitzid HTTP API — exposes ``app`` (``uvicorn blitzid.api:app``).
|
|
2
|
+
|
|
3
|
+
This is the one deliberate exception to the "no re-exports in
|
|
4
|
+
subpackage ``__init__``" rule: the ASGI entry point must be importable
|
|
5
|
+
as ``blitzid.api:app``. Requires the ``api`` extra
|
|
6
|
+
(``pip install blitzid[api]``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._app import app
|
|
10
|
+
|
|
11
|
+
__all__ = ["app"]
|
blitzid/api/_analyze.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Analysis dispatch for /analyze jobs: decode, run, build sections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from threading import Lock
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from .. import Face, FaceDetectorDNN, MRZReader, RapidOCRReader
|
|
15
|
+
from .._image import crop_with_padding
|
|
16
|
+
from ..exceptions import BlitzIDError, ModelError
|
|
17
|
+
from ._upload import decode_image_bytes, dims_within_bounds, encode_jpeg_b64
|
|
18
|
+
|
|
19
|
+
FACE_CROP_PADDING = 0.2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Engines:
|
|
24
|
+
"""Shared analysis engine singletons with one lock per engine.
|
|
25
|
+
|
|
26
|
+
The locks serialize access to engines that are not thread-safe:
|
|
27
|
+
the cache-less detector and the RapidOCR pipeline shared by the
|
|
28
|
+
OCR and MRZ sections. Fields are None when an engine is
|
|
29
|
+
unavailable (missing ``ocr`` extra, missing weights).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
detector: FaceDetectorDNN | None
|
|
33
|
+
ocr_reader: RapidOCRReader | None
|
|
34
|
+
mrz_reader: MRZReader | None
|
|
35
|
+
detector_lock: Lock = field(default_factory=Lock)
|
|
36
|
+
ocr_lock: Lock = field(default_factory=Lock)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_job(image: bytes, types: list[str], engines: Engines) -> dict[str, Any]:
|
|
40
|
+
"""Run the requested analyses and build the result dict.
|
|
41
|
+
|
|
42
|
+
Per-section failures (``BlitzIDError`` subclasses) are captured as
|
|
43
|
+
section errors; other sections still return.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
``{"state": "done", "<type>": <section>, ...}`` — duplicates
|
|
47
|
+
deduped, one section per requested type.
|
|
48
|
+
"""
|
|
49
|
+
img = decode_image_bytes(image)
|
|
50
|
+
if img is None or not dims_within_bounds(img):
|
|
51
|
+
return {"state": "failed", "error": "stored image bytes failed validation"}
|
|
52
|
+
sections: dict[str, Any] = {"state": "done"}
|
|
53
|
+
for analysis_type in dict.fromkeys(types):
|
|
54
|
+
sections[analysis_type] = _run_section(analysis_type, img, engines)
|
|
55
|
+
return sections
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _run_section(
|
|
59
|
+
analysis_type: str, img: np.ndarray, engines: Engines
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""Run one analysis, capturing BlitzIDError as a section error."""
|
|
62
|
+
start = time.perf_counter()
|
|
63
|
+
try:
|
|
64
|
+
body = _SECTION_RUNNERS[analysis_type](img, engines)
|
|
65
|
+
except BlitzIDError as e:
|
|
66
|
+
body = {"error": str(e)}
|
|
67
|
+
body["processing_time_ms"] = round((time.perf_counter() - start) * 1000, 1)
|
|
68
|
+
return body
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _face_item(img: np.ndarray, face: Face) -> dict[str, Any]:
|
|
72
|
+
"""Build one face result item (bbox, confidence, landmarks, crop)."""
|
|
73
|
+
crop = crop_with_padding(img, face.bbox, FACE_CROP_PADDING)
|
|
74
|
+
return {
|
|
75
|
+
"bbox": list(face.bbox),
|
|
76
|
+
"confidence": face.confidence,
|
|
77
|
+
"landmarks": [[x, y] for x, y in face.landmarks],
|
|
78
|
+
"crop_base64": encode_jpeg_b64(crop),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _run_face(img: np.ndarray, engines: Engines) -> dict[str, Any]:
|
|
83
|
+
"""Detect faces and build the face section."""
|
|
84
|
+
detector = engines.detector
|
|
85
|
+
if detector is None:
|
|
86
|
+
raise ModelError("face engine unavailable (SCRFD weights not loaded)")
|
|
87
|
+
with engines.detector_lock:
|
|
88
|
+
faces = detector.detect_face_landmarks(img)
|
|
89
|
+
return {"faces": [_face_item(img, face) for face in faces]}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _run_ocr(img: np.ndarray, engines: Engines) -> dict[str, Any]:
|
|
93
|
+
"""Read text lines and build the ocr section."""
|
|
94
|
+
reader = engines.ocr_reader
|
|
95
|
+
if reader is None:
|
|
96
|
+
raise ModelError("ocr analysis requires the blitzid[ocr] extra")
|
|
97
|
+
with engines.ocr_lock:
|
|
98
|
+
texts = reader.read(img)
|
|
99
|
+
return {
|
|
100
|
+
"lines": [
|
|
101
|
+
{
|
|
102
|
+
"bbox": list(text.bbox),
|
|
103
|
+
"text": text.text,
|
|
104
|
+
"confidence": text.confidence,
|
|
105
|
+
}
|
|
106
|
+
for text in texts
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _run_mrz(img: np.ndarray, engines: Engines) -> dict[str, Any]:
|
|
112
|
+
"""Read the MRZ and build the mrz section (shares the OCR engine)."""
|
|
113
|
+
reader = engines.mrz_reader
|
|
114
|
+
if reader is None:
|
|
115
|
+
raise ModelError("mrz analysis requires the blitzid[ocr] extra")
|
|
116
|
+
with engines.ocr_lock:
|
|
117
|
+
record = reader.read(img)
|
|
118
|
+
return {"record": asdict(record)}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_SECTION_RUNNERS: Mapping[str, Callable[[np.ndarray, Engines], dict[str, Any]]] = (
|
|
122
|
+
MappingProxyType(
|
|
123
|
+
{
|
|
124
|
+
"face": _run_face,
|
|
125
|
+
"ocr": _run_ocr,
|
|
126
|
+
"mrz": _run_mrz,
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
)
|