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 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
@@ -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"]
@@ -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
+ )