From d30f5218d8b237467380ee7ec6ebf244bd09cc88 Mon Sep 17 00:00:00 2001 From: quarj0 Date: Wed, 12 Aug 2026 17:45:04 +0000 Subject: [PATCH 1/3] Fix PAD model review findings --- .env.example | 2 +- backend/ai-service/app/bootstrap_models.py | 63 ++++-- backend/ai-service/app/pad.py | 73 ++++++- backend/ai-service/app/pipeline.py | 92 ++++++--- backend/ai-service/app/settings.py | 54 ++++-- .../ai-service/tests/test_bootstrap_models.py | 11 ++ .../ai-service/tests/test_fetch_pad_model.py | 56 ++++++ backend/ai-service/tests/test_health.py | 183 ++++++++++++++++-- backend/ai-service/tests/test_pad.py | 71 ++++++- scripts/fetch_pad_model.py | 34 +++- 10 files changed, 540 insertions(+), 99 deletions(-) create mode 100644 backend/ai-service/tests/test_fetch_pad_model.py diff --git a/.env.example b/.env.example index 3c0a704c..83948688 100644 --- a/.env.example +++ b/.env.example @@ -58,7 +58,7 @@ PAD_MODEL_RELATIVE_PATH=liveness/pad.onnx PAD_MODEL_NAME=MiniFASNetV2 PAD_MODEL_VERSION=2.7_80x80 PAD_OUTPUT_KIND=logits -PAD_LIVE_CLASS_INDEX=0 +PAD_LIVE_CLASS_INDEX=1 PAD_CROP_SCALE=2.7 PAD_MIN_SCORE=0.80 diff --git a/backend/ai-service/app/bootstrap_models.py b/backend/ai-service/app/bootstrap_models.py index 2fa01bf4..b5eb0deb 100644 --- a/backend/ai-service/app/bootstrap_models.py +++ b/backend/ai-service/app/bootstrap_models.py @@ -5,6 +5,7 @@ import yaml from app.pipeline import get_insightface_analyzer, get_paddle_ocr_engine +from app.pad import validate_pad_model_contract from app.settings import get_settings @@ -27,12 +28,18 @@ def _persist_downloaded_paddle_models(settings) -> None: if settings.paddle_model_is_complete(target): continue source = next( - (root / name for root in official_roots for name in PADDLE_MODEL_CANDIDATES[kind] - if settings.paddle_model_is_complete(root / name)), + ( + root / name + for root in official_roots + for name in PADDLE_MODEL_CANDIDATES[kind] + if settings.paddle_model_is_complete(root / name) + ), None, ) if source is None: - raise RuntimeError(f"Downloaded PaddleOCR {kind} model was not found under: {', '.join(map(str, official_roots))}.") + raise RuntimeError( + f"Downloaded PaddleOCR {kind} model was not found under: {', '.join(map(str, official_roots))}." + ) shutil.copytree(source, target, dirs_exist_ok=True) @@ -49,6 +56,7 @@ def main() -> None: "PAD model is missing. Place the approved ONNX asset at " f"{settings.pad_model_path} before bootstrapping production models." ) + validate_pad_model_contract(settings.pad_model_path, settings.pad_live_class_index) paddle_directories = ( settings.paddle_text_detection_model_dir, settings.paddle_text_recognition_model_dir, @@ -61,10 +69,15 @@ def main() -> None: _persist_downloaded_paddle_models(settings) - incomplete_directories = [str(path) for path in paddle_directories if not settings.paddle_model_is_complete(path)] + incomplete_directories = [ + str(path) + for path in paddle_directories + if not settings.paddle_model_is_complete(path) + ] if incomplete_directories: raise RuntimeError( - "PaddleOCR bootstrap did not populate complete models: " + ", ".join(incomplete_directories) + "PaddleOCR bootstrap did not populate complete models: " + + ", ".join(incomplete_directories) ) files = [] manifest_path = getattr( @@ -72,21 +85,33 @@ def main() -> None: ) for path in sorted(settings.ai_model_root.rglob("*")): if path.is_file() and path != manifest_path: - files.append({ - "path": str(path.relative_to(settings.ai_model_root)), - "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), - "bytes": path.stat().st_size, - }) + files.append( + { + "path": str(path.relative_to(settings.ai_model_root)), + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "bytes": path.stat().st_size, + } + ) manifest_path.parent.mkdir(parents=True, exist_ok=True) - manifest_path.write_text(json.dumps({ - "insightface_model": settings.insightface_model_name, - "paddle_models": { - "detection": _model_name(settings.paddle_text_detection_model_dir), - "recognition": _model_name(settings.paddle_text_recognition_model_dir), - }, - "files": files, - }, indent=2), encoding="utf-8") - print(f"Model bootstrap complete: {len(files)} artifacts recorded in {manifest_path}") + manifest_path.write_text( + json.dumps( + { + "insightface_model": settings.insightface_model_name, + "paddle_models": { + "detection": _model_name(settings.paddle_text_detection_model_dir), + "recognition": _model_name( + settings.paddle_text_recognition_model_dir + ), + }, + "files": files, + }, + indent=2, + ), + encoding="utf-8", + ) + print( + f"Model bootstrap complete: {len(files)} artifacts recorded in {manifest_path}" + ) if __name__ == "__main__": diff --git a/backend/ai-service/app/pad.py b/backend/ai-service/app/pad.py index 0e56f700..2077f653 100644 --- a/backend/ai-service/app/pad.py +++ b/backend/ai-service/app/pad.py @@ -11,6 +11,7 @@ """ from functools import lru_cache +from pathlib import Path from typing import Any import cv2 @@ -30,13 +31,59 @@ def get_pad_session() -> ort.InferenceSession: f"PAD model is missing: {model_path}. Provide a verified model asset." ) try: - return ort.InferenceSession( + session = ort.InferenceSession( str(model_path), providers=["CPUExecutionProvider"] ) except Exception as exc: raise ProcessingConfigurationError( f"PAD model could not be loaded: {model_path}" ) from exc + validate_pad_session_contract(session, get_settings().pad_live_class_index) + return session + + +def validate_pad_session_contract( + session: ort.InferenceSession, live_class_index: int +) -> None: + inputs = session.get_inputs() + outputs = session.get_outputs() + if len(inputs) != 1 or not outputs: + raise ProcessingConfigurationError( + "PAD model must expose exactly one input and at least one output." + ) + input_shape = inputs[0].shape + if len(input_shape) != 4 or ( + isinstance(input_shape[1], int) and input_shape[1] != 3 + ): + raise ProcessingConfigurationError( + "PAD model input must be a four-dimensional NCHW RGB tensor." + ) + for dimension in input_shape[2:]: + if isinstance(dimension, int) and dimension <= 0: + raise ProcessingConfigurationError( + "PAD model spatial dimensions must be positive." + ) + output_shape = outputs[0].shape + if output_shape and isinstance(output_shape[-1], int): + class_count = output_shape[-1] + if class_count > 1 and not 0 <= live_class_index < class_count: + raise ProcessingConfigurationError( + "PAD live-class index is outside the model output contract." + ) + + +def validate_pad_model_contract(model_path: Path, live_class_index: int) -> None: + try: + session = ort.InferenceSession( + str(model_path), providers=["CPUExecutionProvider"] + ) + validate_pad_session_contract(session, live_class_index) + except ProcessingConfigurationError: + raise + except Exception as exc: + raise ProcessingConfigurationError( + f"PAD model could not be loaded: {model_path}" + ) from exc def _softmax(values: np.ndarray) -> np.ndarray: @@ -83,10 +130,22 @@ def run_pad_model( bbox = face_boxes[index] if face_boxes and index < len(face_boxes) else None cropped = _crop_face(frame, bbox, settings.pad_crop_scale) resized = cv2.resize(cropped, (width, height), interpolation=cv2.INTER_AREA) - tensors.append(np.transpose(resized.astype(np.float32) / 255.0, (2, 0, 1))) - - raw = session.run(None, {input_meta.name: np.stack(tensors, axis=0)})[0] - values = np.asarray(raw, dtype=np.float32) + rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB) + tensors.append(np.transpose(rgb.astype(np.float32) / 255.0, (2, 0, 1))) + + declared_batch = input_shape[0] + batch_size = declared_batch if isinstance(declared_batch, int) else len(tensors) + if batch_size <= 0: + raise ProcessingConfigurationError("PAD model batch dimension is invalid.") + output_batches = [] + for start in range(0, len(tensors), batch_size): + chunk = tensors[start : start + batch_size] + actual_size = len(chunk) + if actual_size < batch_size: + chunk.extend([chunk[-1]] * (batch_size - actual_size)) + raw = session.run(None, {input_meta.name: np.stack(chunk, axis=0)})[0] + output_batches.append(np.asarray(raw, dtype=np.float32)[:actual_size]) + values = np.concatenate(output_batches, axis=0) if values.ndim == 1: values = values[:, None] values = values.reshape(values.shape[0], -1) @@ -96,9 +155,7 @@ def run_pad_model( live_scores = 1.0 - live_scores else: probabilities = ( - _softmax(values) - if settings.pad_output_kind == "logits" - else values + _softmax(values) if settings.pad_output_kind == "logits" else values ) live_scores = probabilities[:, settings.pad_live_class_index] diff --git a/backend/ai-service/app/pipeline.py b/backend/ai-service/app/pipeline.py index f5c4f1dd..cd41bd9e 100644 --- a/backend/ai-service/app/pipeline.py +++ b/backend/ai-service/app/pipeline.py @@ -31,7 +31,9 @@ class ProcessingError(RuntimeError): class MediaAssetNotFoundError(RuntimeError): def __init__(self, storage_key: str, bucket_name: str): - message = f"Media asset '{storage_key}' was not found in bucket '{bucket_name}'." + message = ( + f"Media asset '{storage_key}' was not found in bucket '{bucket_name}'." + ) super().__init__(message) self.storage_key = storage_key self.bucket_name = bucket_name @@ -126,10 +128,14 @@ def load_media_asset( ) else: candidate_buckets.extend([media_bucket_name, temp_bucket_name]) - candidate_buckets = list(dict.fromkeys(bucket for bucket in candidate_buckets if bucket)) + candidate_buckets = list( + dict.fromkeys(bucket for bucket in candidate_buckets if bucket) + ) last_error: ClientError | None = None content: bytes | None = None - resolved_bucket_name = candidate_buckets[0] if candidate_buckets else "" + resolved_bucket_name = ( + candidate_buckets[0] if candidate_buckets else "" + ) for candidate_bucket in candidate_buckets or [""]: try: if candidate_bucket: @@ -169,7 +175,9 @@ def load_media_asset( media_mime_type=media_mime_type, ) if not frames: - raise ProcessingError(f"Could not decode media for storage key '{storage_key}'.") + raise ProcessingError( + f"Could not decode media for storage key '{storage_key}'." + ) return MediaAsset( storage_key=storage_key, content=content, @@ -326,7 +334,9 @@ def run_liveness_pipeline( face_presence_ratio = _safe_mean( [1.0 if len(detections) == 1 else 0.0 for detections in frame_detections] ) - max_face_count = max((len(detections) for detections in frame_detections), default=0) + max_face_count = max( + (len(detections) for detections in frame_detections), default=0 + ) avg_detection_confidence = _safe_mean( [ detections[0]["score"] @@ -335,12 +345,19 @@ def run_liveness_pipeline( ] ) quality_metrics = _compute_image_quality_metrics(asset.primary_frame) + valid_frame_pairs = [ + (frame, detections[0]["bbox"]) + for frame, detections in zip(asset.frames, frame_detections) + if len(detections) == 1 + ] + minimum_valid_frames = 2 if len(asset.frames) > 1 else 1 + if len(valid_frame_pairs) < minimum_valid_frames: + raise ProcessingConfigurationError( + "PAD inference requires enough frames containing exactly one detected face." + ) pad_result = run_pad_model( - asset.frames, - [ - detections[0]["bbox"] if len(detections) == 1 else None - for detections in frame_detections - ], + [frame for frame, _ in valid_frame_pairs], + [bbox for _, bbox in valid_frame_pairs], ) pad_score = float(pad_result["pad_score"]) @@ -368,7 +385,9 @@ def run_liveness_pipeline( ] score = _clamp(sum(score_components)) issues: list[str] = [] - passed = pad_score >= settings.pad_min_score and score >= settings.liveness_min_score + passed = ( + pad_score >= settings.pad_min_score and score >= settings.liveness_min_score + ) if max_face_count == 0: issues.append("no_face_detected") @@ -433,7 +452,10 @@ def get_insightface_analyzer(): analyzer = FaceAnalysis(name=settings.insightface_model_name, root=str(model_root)) analyzer.prepare( ctx_id=-1, - det_size=(settings.insightface_detection_size, settings.insightface_detection_size), + det_size=( + settings.insightface_detection_size, + settings.insightface_detection_size, + ), ) return analyzer @@ -443,7 +465,9 @@ def _pick_largest_face(faces: list[Any]): return None return max( faces, - key=lambda face: float((face.bbox[2] - face.bbox[0]) * (face.bbox[3] - face.bbox[1])), + key=lambda face: float( + (face.bbox[2] - face.bbox[0]) * (face.bbox[3] - face.bbox[1]) + ), ) @@ -462,7 +486,9 @@ def run_face_compare_pipeline( bucket_name=selfie_bucket_name, media_mime_type=selfie_mime_type, ) - document_asset = load_media_asset(document_storage_key, bucket_name=document_bucket_name) + document_asset = load_media_asset( + document_storage_key, bucket_name=document_bucket_name + ) selfie_detections = _detect_faces(selfie_asset.primary_frame) document_detections = _detect_faces(document_asset.primary_frame) @@ -490,7 +516,10 @@ def run_face_compare_pipeline( similarity = float( np.dot(selfie_face.embedding, document_face.embedding) - / (np.linalg.norm(selfie_face.embedding) * np.linalg.norm(document_face.embedding)) + / ( + np.linalg.norm(selfie_face.embedding) + * np.linalg.norm(document_face.embedding) + ) ) normalized_score = _clamp((similarity + 1.0) / 2.0) matched = normalized_score >= threshold and not issues @@ -524,7 +553,10 @@ def get_paddle_ocr_engine(): def local_model_name(path: Path) -> str | None: try: - payload = yaml.safe_load((path / "inference.yml").read_text(encoding="utf-8")) or {} + payload = ( + yaml.safe_load((path / "inference.yml").read_text(encoding="utf-8")) + or {} + ) except (OSError, yaml.YAMLError): return None global_config = payload.get("Global") or {} @@ -546,8 +578,16 @@ def local_model_name(path: Path) -> str | None: "enable_mkldnn": False, } local_model_arguments = ( - ("text_detection_model_dir", "text_detection_model_name", settings.paddle_text_detection_model_dir), - ("text_recognition_model_dir", "text_recognition_model_name", settings.paddle_text_recognition_model_dir), + ( + "text_detection_model_dir", + "text_detection_model_name", + settings.paddle_text_detection_model_dir, + ), + ( + "text_recognition_model_dir", + "text_recognition_model_name", + settings.paddle_text_recognition_model_dir, + ), ) for directory_argument, name_argument, path in local_model_arguments: if settings.paddle_model_is_complete(path): @@ -571,7 +611,9 @@ def _extract_text_lines(ocr_result: Any) -> tuple[list[str], list[float]]: return texts, scores -def _normalize_ocr_fields(texts: list[str], document_type: str, country_code: str) -> dict[str, Any]: +def _normalize_ocr_fields( + texts: list[str], document_type: str, country_code: str +) -> dict[str, Any]: joined = "\n".join(texts) normalized_texts = [text.strip() for text in texts if text.strip()] uppercase_candidates = [ @@ -584,7 +626,9 @@ def _normalize_ocr_fields(texts: list[str], document_type: str, country_code: st "document_type": document_type, "country_code": country_code, "full_name": uppercase_candidates[0].title() if uppercase_candidates else "", - "date_of_birth": date_candidates[0].replace("/", "-") if date_candidates else "", + "date_of_birth": date_candidates[0].replace("/", "-") + if date_candidates + else "", "document_number": id_candidates[0] if id_candidates else "", "raw_text_lines": normalized_texts, } @@ -698,7 +742,9 @@ def run_document_classification_pipeline( def build_mock_face_compare( selfie_storage_key: str, document_storage_key: str, threshold: float ) -> dict[str, Any]: - matched = "mismatch" not in selfie_storage_key and "mismatch" not in document_storage_key + matched = ( + "mismatch" not in selfie_storage_key and "mismatch" not in document_storage_key + ) match_score = 0.96 if matched else 0.42 return { "matched": matched, @@ -711,7 +757,9 @@ def build_mock_face_compare( def build_mock_liveness( - selfie_storage_key: str, liveness_type: str, challenge_actions: list[str] | None = None + selfie_storage_key: str, + liveness_type: str, + challenge_actions: list[str] | None = None, ) -> dict[str, Any]: passed = "spoof" not in selfie_storage_key score = 0.94 if passed else 0.23 diff --git a/backend/ai-service/app/settings.py b/backend/ai-service/app/settings.py index 647ccf4e..49e8c4db 100644 --- a/backend/ai-service/app/settings.py +++ b/backend/ai-service/app/settings.py @@ -16,7 +16,9 @@ class Settings(BaseSettings): shared_token: str = Field(default="", alias="AI_SERVICE_SHARED_TOKEN") cache_dir: str = Field(default="/tmp/identitycore-ai", alias="AI_SERVICE_CACHE_DIR") - ai_model_root: Path = Field(default=Path("/opt/identitycore/models"), alias="AI_MODEL_ROOT") + ai_model_root: Path = Field( + default=Path("/opt/identitycore/models"), alias="AI_MODEL_ROOT" + ) ai_model_manifest: Path | None = Field(default=None, alias="AI_MODEL_MANIFEST") object_storage_bucket: str = Field( default="", @@ -56,7 +58,9 @@ class Settings(BaseSettings): ) object_storage_access_key_id: str = Field( default="", - validation_alias=AliasChoices("OBJECT_STORAGE_ACCESS_KEY_ID", "R2_ACCESS_KEY_ID"), + validation_alias=AliasChoices( + "OBJECT_STORAGE_ACCESS_KEY_ID", "R2_ACCESS_KEY_ID" + ), ) object_storage_secret_access_key: str = Field( default="", @@ -95,16 +99,10 @@ class Settings(BaseSettings): pad_model_relative_path: str = Field( default="liveness/pad.onnx", alias="PAD_MODEL_RELATIVE_PATH" ) - pad_model_name: str = Field( - default="MiniFASNetV2", alias="PAD_MODEL_NAME" - ) - pad_model_version: str = Field( - default="2.7_80x80", alias="PAD_MODEL_VERSION" - ) - pad_output_kind: str = Field( - default="logits", alias="PAD_OUTPUT_KIND" - ) - pad_live_class_index: int = Field(default=0, alias="PAD_LIVE_CLASS_INDEX") + pad_model_name: str = Field(default="MiniFASNetV2", alias="PAD_MODEL_NAME") + pad_model_version: str = Field(default="2.7_80x80", alias="PAD_MODEL_VERSION") + pad_output_kind: str = Field(default="logits", alias="PAD_OUTPUT_KIND") + pad_live_class_index: int = Field(default=1, alias="PAD_LIVE_CLASS_INDEX") pad_crop_scale: float = Field(default=2.7, alias="PAD_CROP_SCALE") pad_min_score: float = Field(default=0.80, alias="PAD_MIN_SCORE") active_liveness_motion_threshold: float = Field( @@ -165,9 +163,7 @@ class Settings(BaseSettings): paddle_pdx_cache_home: str = Field(default="/tmp/identitycore-ai/paddlex") paddle_home: str = Field(default="/tmp/identitycore-ai/paddle") - matplotlib_config_dir: str = Field( - default="/tmp/identitycore-ai/matplotlib" - ) + matplotlib_config_dir: str = Field(default="/tmp/identitycore-ai/matplotlib") xdg_cache_home: str = Field(default="/tmp/identitycore-ai/.cache") paddle_disable_model_source_check: bool = Field( default=True, alias="PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK" @@ -230,6 +226,7 @@ def model_asset_integrity_errors(self) -> list[str]: return [f"models.manifest_invalid:{manifest_path}"] errors: list[str] = [] + listed_paths: set[str] = set() model_root = self.ai_model_root.resolve() for entry in files: if not isinstance(entry, dict): @@ -237,9 +234,12 @@ def model_asset_integrity_errors(self) -> list[str]: continue relative_path = entry.get("path") expected_digest = entry.get("sha256") - if not isinstance(relative_path, str) or not isinstance(expected_digest, str): + if not isinstance(relative_path, str) or not isinstance( + expected_digest, str + ): errors.append("models.manifest_entry_invalid") continue + listed_paths.add(Path(relative_path).as_posix()) try: asset_path = (model_root / relative_path).resolve() asset_path.relative_to(model_root) @@ -256,6 +256,9 @@ def model_asset_integrity_errors(self) -> list[str]: continue if len(expected_digest) != 64 or actual_digest != expected_digest.lower(): errors.append(f"models.asset_checksum_mismatch:{relative_path}") + required_pad_path = Path(self.pad_model_relative_path).as_posix() + if required_pad_path not in listed_paths: + errors.append(f"models.manifest_required_asset_missing:{required_pad_path}") return errors @property @@ -327,7 +330,8 @@ def real_inference_missing_requirements(self) -> list[str]: if not self.object_storage_secret_access_key: missing.append("object_storage.secret_access_key") - missing.extend(self.model_asset_integrity_errors()) + model_integrity_errors = self.model_asset_integrity_errors() + missing.extend(model_integrity_errors) try: pad_model_path = self.pad_model_path @@ -336,8 +340,20 @@ def real_inference_missing_requirements(self) -> list[str]: else: if not pad_model_path.is_file(): missing.append(f"models.pad:{pad_model_path}") - - if not self.insightface_allow_download and not self.insightface_model_dir.exists(): + elif not model_integrity_errors: + try: + from app.pad import validate_pad_model_contract + + validate_pad_model_contract( + pad_model_path, self.pad_live_class_index + ) + except Exception: + missing.append(f"models.pad_contract_invalid:{pad_model_path}") + + if ( + not self.insightface_allow_download + and not self.insightface_model_dir.exists() + ): missing.append(f"models.insightface:{self.insightface_model_dir}") if not self.paddle_allow_download: diff --git a/backend/ai-service/tests/test_bootstrap_models.py b/backend/ai-service/tests/test_bootstrap_models.py index f24e4320..8bf1fd6a 100644 --- a/backend/ai-service/tests/test_bootstrap_models.py +++ b/backend/ai-service/tests/test_bootstrap_models.py @@ -10,6 +10,7 @@ def test_bootstrap_creates_paddle_directories_before_initialization( settings = SimpleNamespace( ai_model_root=tmp_path, pad_model_path=tmp_path / "liveness" / "pad.onnx", + pad_live_class_index=1, paddle_text_detection_model_dir=tmp_path / "paddleocr" / "det", paddle_text_recognition_model_dir=tmp_path / "paddleocr" / "rec", insightface_model_name="buffalo_l", @@ -31,6 +32,12 @@ def initialize_paddle(): (directory / "inference.json").write_text("{}") monkeypatch.setattr(bootstrap_models, "get_settings", lambda: settings) + validate_pad = [] + monkeypatch.setattr( + bootstrap_models, + "validate_pad_model_contract", + lambda path, class_index: validate_pad.append((path, class_index)), + ) monkeypatch.setattr(bootstrap_models, "get_insightface_analyzer", lambda: None) monkeypatch.setattr(bootstrap_models, "get_paddle_ocr_engine", initialize_paddle) @@ -40,3 +47,7 @@ def initialize_paddle(): bootstrap_models.main() manifest = json.loads((tmp_path / "manifest.json").read_text()) assert "manifest.json" not in {entry["path"] for entry in manifest["files"]} + assert validate_pad == [ + (settings.pad_model_path, 1), + (settings.pad_model_path, 1), + ] diff --git a/backend/ai-service/tests/test_fetch_pad_model.py b/backend/ai-service/tests/test_fetch_pad_model.py new file mode 100644 index 00000000..aa9e633d --- /dev/null +++ b/backend/ai-service/tests/test_fetch_pad_model.py @@ -0,0 +1,56 @@ +import hashlib +import importlib.util +from pathlib import Path + +import pytest + + +SCRIPT_PATH = Path(__file__).parents[3] / "scripts" / "fetch_pad_model.py" +SPEC = importlib.util.spec_from_file_location("fetch_pad_model", SCRIPT_PATH) +assert SPEC is not None and SPEC.loader is not None +fetch_pad_model = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(fetch_pad_model) + + +def test_verified_download_preserves_existing_model_on_checksum_failure( + tmp_path, monkeypatch +): + destination = tmp_path / "pad.onnx" + destination.write_bytes(b"existing-approved-model") + + def download_bad_model(url, target): + target.write_bytes(b"corrupt-download") + + monkeypatch.setattr(fetch_pad_model, "download", download_bad_model) + + with pytest.raises(SystemExit, match="checksum mismatch"): + fetch_pad_model.download_verified_model( + "https://example.invalid/pad.onnx", + destination, + hashlib.sha256(b"expected-model").hexdigest(), + ) + + assert destination.read_bytes() == b"existing-approved-model" + assert list(tmp_path.iterdir()) == [destination] + + +def test_verified_download_atomically_replaces_model_after_checksum_passes( + tmp_path, monkeypatch +): + destination = tmp_path / "pad.onnx" + destination.write_bytes(b"old-model") + approved_model = b"new-approved-model" + + def download_approved_model(url, target): + target.write_bytes(approved_model) + + monkeypatch.setattr(fetch_pad_model, "download", download_approved_model) + + fetch_pad_model.download_verified_model( + "https://example.invalid/pad.onnx", + destination, + hashlib.sha256(approved_model).hexdigest(), + ) + + assert destination.read_bytes() == approved_model + assert list(tmp_path.iterdir()) == [destination] diff --git a/backend/ai-service/tests/test_health.py b/backend/ai-service/tests/test_health.py index 27ca1e86..3500abc0 100644 --- a/backend/ai-service/tests/test_health.py +++ b/backend/ai-service/tests/test_health.py @@ -27,10 +27,13 @@ readiness, ) from app.pipeline import ( + MediaAsset, + ProcessingConfigurationError, get_paddle_ocr_engine, run_document_classification_pipeline, run_document_ocr_pipeline, run_document_quality_pipeline, + run_liveness_pipeline, ) from app.settings import Settings, get_settings @@ -114,7 +117,12 @@ def test_document_ocr_returns_extracted_fields(): def test_document_ocr_returns_manual_review_when_media_is_missing(monkeypatch): missing_object_error = ClientError( - {"Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist."}}, + { + "Error": { + "Code": "NoSuchKey", + "Message": "The specified key does not exist.", + } + }, "GetObject", ) @@ -151,7 +159,12 @@ def test_document_quality_flags_blurry_capture(): def test_document_quality_returns_review_signal_when_media_is_missing(monkeypatch): missing_object_error = ClientError( - {"Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist."}}, + { + "Error": { + "Code": "NoSuchKey", + "Message": "The specified key does not exist.", + } + }, "GetObject", ) @@ -190,7 +203,12 @@ def fetch_from_media(storage_key, *, bucket_name=None): if bucket_name == "identitycore-media": return encoded.tobytes() raise ClientError( - {"Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist."}}, + { + "Error": { + "Code": "NoSuchKey", + "Message": "The specified key does not exist.", + } + }, "GetObject", ) @@ -200,9 +218,7 @@ def fetch_from_media(storage_key, *, bucket_name=None): get_settings.cache_clear() try: - result = run_document_quality_pipeline( - "uploads/documents/fallback.jpg" - ) + result = run_document_quality_pipeline("uploads/documents/fallback.jpg") finally: monkeypatch.delenv("OBJECT_STORAGE_MEDIA_BUCKET", raising=False) monkeypatch.delenv("OBJECT_STORAGE_TEMP_BUCKET", raising=False) @@ -235,7 +251,12 @@ def test_document_classification_returns_manual_review_when_media_is_missing( monkeypatch, ): missing_object_error = ClientError( - {"Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist."}}, + { + "Error": { + "Code": "NoSuchKey", + "Message": "The specified key does not exist.", + } + }, "GetObject", ) @@ -334,23 +355,146 @@ def test_model_manifest_detects_missing_and_altered_assets(tmp_path): asset = tmp_path / "insightface" / "models" / "buffalo_l" / "model.onnx" asset.parent.mkdir(parents=True) asset.write_bytes(b"trusted-model") - (tmp_path / "manifest.json").write_text(json.dumps({"files": [{ - "path": str(asset.relative_to(tmp_path)), - "sha256": hashlib.sha256(asset.read_bytes()).hexdigest(), - }]})) + (tmp_path / "manifest.json").write_text( + json.dumps( + { + "files": [ + { + "path": str(asset.relative_to(tmp_path)), + "sha256": hashlib.sha256(asset.read_bytes()).hexdigest(), + } + ] + } + ) + ) settings = Settings(AI_MODEL_ROOT=tmp_path) - assert settings.model_asset_integrity_errors() == [] + assert settings.model_asset_integrity_errors() == [ + "models.manifest_required_asset_missing:liveness/pad.onnx" + ] asset.write_bytes(b"tampered-model") assert settings.model_asset_integrity_errors() == [ - "models.asset_checksum_mismatch:insightface/models/buffalo_l/model.onnx" + "models.asset_checksum_mismatch:insightface/models/buffalo_l/model.onnx", + "models.manifest_required_asset_missing:liveness/pad.onnx", ] asset.unlink() assert settings.model_asset_integrity_errors() == [ - "models.asset_missing:insightface/models/buffalo_l/model.onnx" + "models.asset_missing:insightface/models/buffalo_l/model.onnx", + "models.manifest_required_asset_missing:liveness/pad.onnx", ] +def test_model_manifest_requires_configured_pad_asset(tmp_path): + pad_asset = tmp_path / "liveness" / "pad.onnx" + pad_asset.parent.mkdir(parents=True) + pad_asset.write_bytes(b"approved-pad") + (tmp_path / "manifest.json").write_text( + json.dumps( + { + "files": [ + { + "path": "liveness/pad.onnx", + "sha256": hashlib.sha256(pad_asset.read_bytes()).hexdigest(), + } + ] + } + ) + ) + + settings = Settings(AI_MODEL_ROOT=tmp_path) + + assert settings.model_asset_integrity_errors() == [] + + +def test_real_requirements_validate_pad_model_contract(tmp_path, monkeypatch): + pad_asset = tmp_path / "liveness" / "pad.onnx" + pad_asset.parent.mkdir(parents=True) + pad_asset.write_bytes(b"approved-pad") + (tmp_path / "manifest.json").write_text( + json.dumps( + { + "files": [ + { + "path": "liveness/pad.onnx", + "sha256": hashlib.sha256(pad_asset.read_bytes()).hexdigest(), + } + ] + } + ) + ) + + def reject_contract(model_path, live_class_index): + raise ProcessingConfigurationError("invalid model contract") + + monkeypatch.setattr("app.pad.validate_pad_model_contract", reject_contract) + settings = Settings( + AI_SERVICE_MODE="real", + OBJECT_STORAGE_MEDIA_BUCKET="identitycore-media", + OBJECT_STORAGE_ENDPOINT_URL="https://example.invalid", + OBJECT_STORAGE_ACCESS_KEY_ID="key", + OBJECT_STORAGE_SECRET_ACCESS_KEY="secret", + INSIGHTFACE_ALLOW_DOWNLOAD=True, + PADDLE_OCR_ALLOW_DOWNLOAD=True, + AI_MODEL_ROOT=tmp_path, + ) + + assert settings.real_inference_missing_requirements() == [ + f"models.pad_contract_invalid:{pad_asset}" + ] + + +def test_liveness_pad_only_receives_exactly_one_face_frames(monkeypatch): + frames = [ + pytest.importorskip("numpy").full((8, 8, 3), value, dtype="uint8") + for value in (10, 20, 30) + ] + asset = MediaAsset("capture.mp4", b"video", "video", frames[0], frames) + bbox = {"xmin": 0.1, "ymin": 0.1, "width": 0.5, "height": 0.5} + detections = [ + [{"score": 0.9, "bbox": bbox}], + [], + [{"score": 0.8, "bbox": bbox}], + ] + captured = {} + + monkeypatch.setattr("app.pipeline.load_media_asset", lambda *args, **kwargs: asset) + monkeypatch.setattr("app.pipeline._detect_faces", lambda frame: detections.pop(0)) + monkeypatch.setattr( + "app.pipeline._compute_image_quality_metrics", + lambda frame: {"quality_score": 1.0, "blur_variance": 1000.0}, + ) + + def run_pad(frames_to_score, bboxes_to_score): + captured["frames"] = frames_to_score + captured["bboxes"] = bboxes_to_score + return {"pad_score": 0.99, "model_name": "pad", "model_version": "test"} + + monkeypatch.setattr("app.pad.run_pad_model", run_pad) + + run_liveness_pipeline("capture.mp4", "passive") + + assert captured["frames"] == [frames[0], frames[2]] + assert captured["bboxes"] == [bbox, bbox] + + +def test_liveness_rejects_video_without_two_single_face_frames(monkeypatch): + np = pytest.importorskip("numpy") + frames = [np.zeros((8, 8, 3), dtype="uint8") for _ in range(3)] + asset = MediaAsset("capture.mp4", b"video", "video", frames[0], frames) + bbox = {"xmin": 0.1, "ymin": 0.1, "width": 0.5, "height": 0.5} + detections = [[{"score": 0.9, "bbox": bbox}], [], []] + + monkeypatch.setattr("app.pipeline.load_media_asset", lambda *args, **kwargs: asset) + monkeypatch.setattr("app.pipeline._detect_faces", lambda frame: detections.pop(0)) + monkeypatch.setattr( + "app.pipeline._compute_image_quality_metrics", + lambda frame: {"quality_score": 1.0, "blur_variance": 1000.0}, + ) + + with pytest.raises(ProcessingConfigurationError, match="exactly one detected face"): + run_liveness_pipeline("capture.mp4", "passive") + + def test_real_mode_accepts_r2_storage_aliases(tmp_path): settings = Settings( AI_SERVICE_MODE="real", @@ -369,7 +513,9 @@ def test_real_mode_accepts_r2_storage_aliases(tmp_path): ] -def test_hybrid_mode_is_degraded_but_ready_when_real_requirements_are_missing(monkeypatch): +def test_hybrid_mode_is_degraded_but_ready_when_real_requirements_are_missing( + monkeypatch, +): monkeypatch.setenv("AI_SERVICE_MODE", "hybrid") monkeypatch.setenv("OBJECT_STORAGE_MEDIA_BUCKET", "") monkeypatch.setenv("OBJECT_STORAGE_BUCKET", "") @@ -384,8 +530,8 @@ def test_hybrid_mode_is_degraded_but_ready_when_real_requirements_are_missing(mo payload = response.body.decode("utf-8") assert response.status_code == 200 - assert "\"status\":\"degraded\"" in payload - assert "\"ready\":true" in payload + assert '"status":"degraded"' in payload + assert '"ready":true' in payload monkeypatch.delenv("AI_SERVICE_MODE", raising=False) monkeypatch.delenv("INSIGHTFACE_ALLOW_DOWNLOAD", raising=False) @@ -418,8 +564,7 @@ def __init__(self, **kwargs): paddle_text_detection_model_dir=det, paddle_text_recognition_model_dir=rec, paddle_model_is_complete=lambda path: ( - (path / "inference.yml").is_file() - and (path / "inference.json").is_file() + (path / "inference.yml").is_file() and (path / "inference.json").is_file() ), ) monkeypatch.setattr("app.pipeline.get_settings", lambda: settings) diff --git a/backend/ai-service/tests/test_pad.py b/backend/ai-service/tests/test_pad.py index bc28c8b9..c3bcc6d0 100644 --- a/backend/ai-service/tests/test_pad.py +++ b/backend/ai-service/tests/test_pad.py @@ -3,18 +3,34 @@ import numpy as np from app import pad +from app.settings import Settings class FakeSession: + def __init__(self, batch="batch"): + self.batch = batch + self.inputs = [] + def get_inputs(self): - return [SimpleNamespace(name="images", shape=["batch", 3, 80, 80])] + return [SimpleNamespace(name="images", shape=[self.batch, 3, 80, 80])] + + def get_outputs(self): + return [SimpleNamespace(name="scores", shape=[self.batch, 3])] + + def run(self, _outputs, inputs): + tensor = inputs["images"] + self.inputs.append(tensor) + rows = tensor.shape[0] + return [np.tile([[2.0, 0.0, -2.0]], (rows, 1)).astype(np.float32)] - def run(self, _outputs, _inputs): - return [np.array([[2.0, 0.0, -2.0]], dtype=np.float32)] + +def test_pad_live_class_defaults_to_genuine_class(): + assert Settings().pad_live_class_index == 1 def test_pad_model_applies_live_class_softmax(monkeypatch): - monkeypatch.setattr(pad, "get_pad_session", lambda: FakeSession()) + session = FakeSession() + monkeypatch.setattr(pad, "get_pad_session", lambda: session) monkeypatch.setattr( pad, "get_settings", @@ -34,3 +50,50 @@ def test_pad_model_applies_live_class_softmax(monkeypatch): assert result["model_name"] == "MiniFASNetV2" assert 0.8 < result["pad_score"] < 0.9 + + +def test_pad_model_converts_bgr_to_rgb(monkeypatch): + session = FakeSession() + monkeypatch.setattr(pad, "get_pad_session", lambda: session) + monkeypatch.setattr( + pad, + "get_settings", + lambda: SimpleNamespace( + pad_output_kind="logits", + pad_live_class_index=0, + pad_model_name="MiniFASNetV2", + pad_model_version="2.7_80x80", + pad_crop_scale=1.0, + ), + ) + blue_bgr_frame = np.zeros((80, 80, 3), dtype=np.uint8) + blue_bgr_frame[:, :, 0] = 255 + + pad.run_pad_model([blue_bgr_frame]) + + tensor = session.inputs[0] + assert np.all(tensor[0, 0] == 0) + assert np.all(tensor[0, 2] == 1) + + +def test_pad_model_runs_fixed_batch_one_frame_at_a_time(monkeypatch): + session = FakeSession(batch=1) + monkeypatch.setattr(pad, "get_pad_session", lambda: session) + monkeypatch.setattr( + pad, + "get_settings", + lambda: SimpleNamespace( + pad_output_kind="logits", + pad_live_class_index=0, + pad_model_name="MiniFASNetV2", + pad_model_version="2.7_80x80", + pad_crop_scale=1.0, + ), + ) + + result = pad.run_pad_model( + [np.zeros((80, 80, 3), dtype=np.uint8) for _ in range(3)] + ) + + assert [batch.shape[0] for batch in session.inputs] == [1, 1, 1] + assert len(result["pad_frame_scores"]) == 3 diff --git a/scripts/fetch_pad_model.py b/scripts/fetch_pad_model.py index ba238e98..2f88480f 100644 --- a/scripts/fetch_pad_model.py +++ b/scripts/fetch_pad_model.py @@ -8,6 +8,8 @@ import argparse import hashlib +import os +import tempfile from pathlib import Path from urllib.request import Request, urlopen @@ -29,6 +31,30 @@ def download(url: str, destination: Path) -> None: destination.write_bytes(response.read()) +def download_verified_model(url: str, destination: Path, expected_sha256: str) -> str: + temporary_path = None + try: + with tempfile.NamedTemporaryFile( + dir=destination.parent, + prefix=f".{destination.name}.", + suffix=".download", + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + download(url, temporary_path) + digest = hashlib.sha256(temporary_path.read_bytes()).hexdigest() + if digest != expected_sha256: + raise SystemExit( + f"PAD model checksum mismatch: expected {expected_sha256}, got {digest}" + ) + os.replace(temporary_path, destination) + temporary_path = None + return digest + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + def main() -> None: parser = argparse.ArgumentParser() parser.add_argument( @@ -40,13 +66,7 @@ def main() -> None: model_path = target_dir / "pad.onnx" license_path = target_dir / "MiniFASNetV2-LICENSE.txt" - download(MODEL_URL, model_path) - digest = hashlib.sha256(model_path.read_bytes()).hexdigest() - if digest != EXPECTED_SHA256: - model_path.unlink(missing_ok=True) - raise SystemExit( - f"PAD model checksum mismatch: expected {EXPECTED_SHA256}, got {digest}" - ) + digest = download_verified_model(MODEL_URL, model_path, EXPECTED_SHA256) download(LICENSE_URL, license_path) print(f"Verified PAD model: {model_path} ({digest})") print(f"Saved model attribution: {license_path}") From 4e5b50ef03e2460e6c2ba8b76ee91e0d5044d863 Mon Sep 17 00:00:00 2001 From: quarj0 Date: Wed, 12 Aug 2026 17:56:21 +0000 Subject: [PATCH 2/3] Address PAD pull request review --- backend/ai-service/README.md | 3 ++- backend/ai-service/app/pad.py | 8 ++++++ backend/ai-service/app/pipeline.py | 33 ++++++++++++++++++++++--- backend/ai-service/tests/test_health.py | 30 +++++++++++++++++++--- backend/ai-service/tests/test_pad.py | 26 ++++++++++++++++++- 5 files changed, 91 insertions(+), 9 deletions(-) diff --git a/backend/ai-service/README.md b/backend/ai-service/README.md index 45d540e5..64963e50 100644 --- a/backend/ai-service/README.md +++ b/backend/ai-service/README.md @@ -32,7 +32,8 @@ docker compose --profile model-bootstrap run --rm ai-model-bootstrap ``` The initial candidate is MiniFASNetV2 2.7_80x80 with a three-logit output -(`live`, `print attack`, `replay attack`). It is a baseline candidate, +(`print attack`, `live`, `replay attack`), so `PAD_LIVE_CLASS_INDEX` defaults +to `1`. It is a baseline candidate, not a production performance claim; it must pass our held-out Ghana/device PAD evaluation before pilot use. diff --git a/backend/ai-service/app/pad.py b/backend/ai-service/app/pad.py index 2077f653..053cf2de 100644 --- a/backend/ai-service/app/pad.py +++ b/backend/ai-service/app/pad.py @@ -52,6 +52,10 @@ def validate_pad_session_contract( "PAD model must expose exactly one input and at least one output." ) input_shape = inputs[0].shape + if inputs[0].type != "tensor(float)": + raise ProcessingConfigurationError( + "PAD model input must use float32 tensor elements." + ) if len(input_shape) != 4 or ( isinstance(input_shape[1], int) and input_shape[1] != 3 ): @@ -64,6 +68,10 @@ def validate_pad_session_contract( "PAD model spatial dimensions must be positive." ) output_shape = outputs[0].shape + if len(output_shape) < 2: + raise ProcessingConfigurationError( + "PAD model output must include batch and class dimensions." + ) if output_shape and isinstance(output_shape[-1], int): class_count = output_shape[-1] if class_count > 1 and not 0 <= live_class_index < class_count: diff --git a/backend/ai-service/app/pipeline.py b/backend/ai-service/app/pipeline.py index cd41bd9e..8792517f 100644 --- a/backend/ai-service/app/pipeline.py +++ b/backend/ai-service/app/pipeline.py @@ -350,11 +350,36 @@ def run_liveness_pipeline( for frame, detections in zip(asset.frames, frame_detections) if len(detections) == 1 ] - minimum_valid_frames = 2 if len(asset.frames) > 1 else 1 + minimum_valid_frames = 2 if asset.kind == "video" else 1 if len(valid_frame_pairs) < minimum_valid_frames: - raise ProcessingConfigurationError( - "PAD inference requires enough frames containing exactly one detected face." - ) + issues = [] + if max_face_count == 0: + issues.append("no_face_detected") + elif max_face_count > 1: + issues.append("multiple_faces_detected") + else: + issues.append("insufficient_single_face_frames") + return { + "passed": False, + "score": 0.0, + "pad_score": 0.0, + "confidence_level": "low", + "issues": issues, + "metrics": { + "asset_kind": asset.kind, + "frame_count": len(asset.frames), + "face_count": max_face_count, + "face_presence_ratio": face_presence_ratio, + "avg_detection_confidence": avg_detection_confidence, + "movement_score": 0.0, + "quality_score": quality_metrics["quality_score"], + "detected_actions": [], + "challenge_actions": challenge_actions or [], + }, + "challenge_passed": False, + "model_name": settings.pad_model_name, + "model_version": settings.pad_model_version, + } pad_result = run_pad_model( [frame for frame, _ in valid_frame_pairs], [bbox for _, bbox in valid_frame_pairs], diff --git a/backend/ai-service/tests/test_health.py b/backend/ai-service/tests/test_health.py index 3500abc0..fb13e65d 100644 --- a/backend/ai-service/tests/test_health.py +++ b/backend/ai-service/tests/test_health.py @@ -477,7 +477,7 @@ def run_pad(frames_to_score, bboxes_to_score): assert captured["bboxes"] == [bbox, bbox] -def test_liveness_rejects_video_without_two_single_face_frames(monkeypatch): +def test_liveness_fails_video_without_two_single_face_frames(monkeypatch): np = pytest.importorskip("numpy") frames = [np.zeros((8, 8, 3), dtype="uint8") for _ in range(3)] asset = MediaAsset("capture.mp4", b"video", "video", frames[0], frames) @@ -491,8 +491,32 @@ def test_liveness_rejects_video_without_two_single_face_frames(monkeypatch): lambda frame: {"quality_score": 1.0, "blur_variance": 1000.0}, ) - with pytest.raises(ProcessingConfigurationError, match="exactly one detected face"): - run_liveness_pipeline("capture.mp4", "passive") + result = run_liveness_pipeline("capture.mp4", "passive") + + assert result["passed"] is False + assert result["issues"] == ["insufficient_single_face_frames"] + + +def test_liveness_requires_two_valid_frames_for_single_frame_video(monkeypatch): + np = pytest.importorskip("numpy") + frame = np.zeros((8, 8, 3), dtype="uint8") + asset = MediaAsset("capture.mp4", b"video", "video", frame, [frame]) + bbox = {"xmin": 0.1, "ymin": 0.1, "width": 0.5, "height": 0.5} + + monkeypatch.setattr("app.pipeline.load_media_asset", lambda *args, **kwargs: asset) + monkeypatch.setattr( + "app.pipeline._detect_faces", + lambda captured_frame: [{"score": 0.9, "bbox": bbox}], + ) + monkeypatch.setattr( + "app.pipeline._compute_image_quality_metrics", + lambda captured_frame: {"quality_score": 1.0, "blur_variance": 1000.0}, + ) + + result = run_liveness_pipeline("capture.mp4", "passive") + + assert result["passed"] is False + assert result["issues"] == ["insufficient_single_face_frames"] def test_real_mode_accepts_r2_storage_aliases(tmp_path): diff --git a/backend/ai-service/tests/test_pad.py b/backend/ai-service/tests/test_pad.py index c3bcc6d0..c97f7862 100644 --- a/backend/ai-service/tests/test_pad.py +++ b/backend/ai-service/tests/test_pad.py @@ -1,8 +1,10 @@ from types import SimpleNamespace import numpy as np +import pytest from app import pad +from app.pipeline import ProcessingConfigurationError from app.settings import Settings @@ -12,7 +14,11 @@ def __init__(self, batch="batch"): self.inputs = [] def get_inputs(self): - return [SimpleNamespace(name="images", shape=[self.batch, 3, 80, 80])] + return [ + SimpleNamespace( + name="images", shape=[self.batch, 3, 80, 80], type="tensor(float)" + ) + ] def get_outputs(self): return [SimpleNamespace(name="scores", shape=[self.batch, 3])] @@ -28,6 +34,24 @@ def test_pad_live_class_defaults_to_genuine_class(): assert Settings().pad_live_class_index == 1 +def test_pad_contract_rejects_non_float_input(): + session = FakeSession() + session.get_inputs = lambda: [ + SimpleNamespace(name="images", shape=[1, 3, 80, 80], type="tensor(uint8)") + ] + + with pytest.raises(ProcessingConfigurationError, match="float32"): + pad.validate_pad_session_contract(session, live_class_index=1) + + +def test_pad_contract_rejects_rank_one_output(): + session = FakeSession(batch=1) + session.get_outputs = lambda: [SimpleNamespace(name="scores", shape=[3])] + + with pytest.raises(ProcessingConfigurationError, match="batch and class"): + pad.validate_pad_session_contract(session, live_class_index=1) + + def test_pad_model_applies_live_class_softmax(monkeypatch): session = FakeSession() monkeypatch.setattr(pad, "get_pad_session", lambda: session) From 8cbbe644cfdfcdb4f8af845c3372667288107887 Mon Sep 17 00:00:00 2001 From: quarj0 Date: Wed, 12 Aug 2026 18:06:11 +0000 Subject: [PATCH 3/3] Cache and tighten PAD contract validation --- backend/ai-service/app/pad.py | 20 +++++++++++++- backend/ai-service/tests/test_pad.py | 39 ++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/backend/ai-service/app/pad.py b/backend/ai-service/app/pad.py index 053cf2de..6a6734cd 100644 --- a/backend/ai-service/app/pad.py +++ b/backend/ai-service/app/pad.py @@ -11,6 +11,7 @@ """ from functools import lru_cache +import hashlib from pathlib import Path from typing import Any @@ -68,6 +69,10 @@ def validate_pad_session_contract( "PAD model spatial dimensions must be positive." ) output_shape = outputs[0].shape + if outputs[0].type != "tensor(float)": + raise ProcessingConfigurationError( + "PAD model output must use float32 tensor elements." + ) if len(output_shape) < 2: raise ProcessingConfigurationError( "PAD model output must include batch and class dimensions." @@ -80,7 +85,10 @@ def validate_pad_session_contract( ) -def validate_pad_model_contract(model_path: Path, live_class_index: int) -> None: +@lru_cache(maxsize=8) +def _validate_pad_model_contract( + model_path: Path, live_class_index: int, model_digest: str +) -> None: try: session = ort.InferenceSession( str(model_path), providers=["CPUExecutionProvider"] @@ -94,6 +102,16 @@ def validate_pad_model_contract(model_path: Path, live_class_index: int) -> None ) from exc +def validate_pad_model_contract(model_path: Path, live_class_index: int) -> None: + try: + model_digest = hashlib.sha256(model_path.read_bytes()).hexdigest() + except OSError as exc: + raise ProcessingConfigurationError( + f"PAD model could not be read: {model_path}" + ) from exc + _validate_pad_model_contract(model_path, live_class_index, model_digest) + + def _softmax(values: np.ndarray) -> np.ndarray: shifted = values - np.max(values, axis=-1, keepdims=True) exponentials = np.exp(shifted) diff --git a/backend/ai-service/tests/test_pad.py b/backend/ai-service/tests/test_pad.py index c97f7862..6255d815 100644 --- a/backend/ai-service/tests/test_pad.py +++ b/backend/ai-service/tests/test_pad.py @@ -21,7 +21,9 @@ def get_inputs(self): ] def get_outputs(self): - return [SimpleNamespace(name="scores", shape=[self.batch, 3])] + return [ + SimpleNamespace(name="scores", shape=[self.batch, 3], type="tensor(float)") + ] def run(self, _outputs, inputs): tensor = inputs["images"] @@ -46,12 +48,45 @@ def test_pad_contract_rejects_non_float_input(): def test_pad_contract_rejects_rank_one_output(): session = FakeSession(batch=1) - session.get_outputs = lambda: [SimpleNamespace(name="scores", shape=[3])] + session.get_outputs = lambda: [ + SimpleNamespace(name="scores", shape=[3], type="tensor(float)") + ] with pytest.raises(ProcessingConfigurationError, match="batch and class"): pad.validate_pad_session_contract(session, live_class_index=1) +def test_pad_contract_rejects_non_float_output(): + session = FakeSession() + session.get_outputs = lambda: [ + SimpleNamespace(name="scores", shape=[1, 3], type="tensor(int64)") + ] + + with pytest.raises(ProcessingConfigurationError, match="output.*float32"): + pad.validate_pad_session_contract(session, live_class_index=1) + + +def test_pad_model_contract_cache_is_keyed_by_model_digest(tmp_path, monkeypatch): + model_path = tmp_path / "pad.onnx" + model_path.write_bytes(b"first-model") + sessions = [] + + def build_session(path, providers): + session = FakeSession(batch=1) + sessions.append(session) + return session + + monkeypatch.setattr(pad.ort, "InferenceSession", build_session) + pad._validate_pad_model_contract.cache_clear() + + pad.validate_pad_model_contract(model_path, live_class_index=1) + pad.validate_pad_model_contract(model_path, live_class_index=1) + model_path.write_bytes(b"replacement-model") + pad.validate_pad_model_contract(model_path, live_class_index=1) + + assert len(sessions) == 2 + + def test_pad_model_applies_live_class_softmax(monkeypatch): session = FakeSession() monkeypatch.setattr(pad, "get_pad_session", lambda: session)