""" Shared multi-layer validation flow for API and Gradio paths. This module centralizes pre-DL checks so both entry points make identical validation decisions and emit consistent diagnostics. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Dict, List, Optional import cv2 import numpy as np from src.preprocessing.card_detector import ( POKEMON_CARD_DETECTION_CONFIG, detect_card_boundary_with_hand, ) from src.preprocessing.image_utils import check_image_quality FRONT_SATURATION_THRESHOLD = 35.0 BACK_SATURATION_THRESHOLD = 15.0 FRONT_BACK_BLUE_THRESHOLD = 0.25 @dataclass class ColorValidation: """Color/pattern validation result.""" passed: bool confidence: float reason: str @dataclass class SideValidation: """Layered validation state for one image side.""" side: str provided: bool quality: Dict[str, Any] corners_detected: bool saturation: float saturation_threshold: float layer1_passed: bool layer1_failure: str = "" failure: str = "" @dataclass class MultiLayerValidationResult: """Unified validation output consumed by API and Gradio.""" front: SideValidation back: SideValidation processed_sides: List[str] pokemon_back_validation: Optional[ColorValidation] = None front_not_back_validation: Optional[ColorValidation] = None rejection_category: Optional[str] = None rejection_message: Optional[str] = None rejection_details: Dict[str, Any] = field(default_factory=dict) @property def rejected(self) -> bool: return self.rejection_category is not None def _default_quality() -> Dict[str, Any]: return { "blur_score": 0.0, "brightness": 0.0, "contrast": 0.0, "is_acceptable": False, } def _extract_quality_info(image: Optional[np.ndarray]) -> Dict[str, Any]: if image is None: return _default_quality() try: quality = check_image_quality(image) return { "blur_score": float(quality["blur_score"]), "brightness": float(quality["brightness"]), "contrast": float(quality["contrast"]), "is_acceptable": bool(quality["is_acceptable"]), } except Exception: return _default_quality() def _mean_saturation_in_region(image: np.ndarray, corners: np.ndarray, max_dim: int = 512) -> float: """ Estimate mean HSV saturation inside the detected card polygon. """ try: height, width = image.shape[:2] if height <= 0 or width <= 0: return 0.0 scale = 1.0 work_image = image if max(height, width) > max_dim: scale = max_dim / float(max(height, width)) new_w = max(1, int(width * scale)) new_h = max(1, int(height * scale)) work_image = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA) scaled_corners = (corners * scale).astype(np.int32) hsv = cv2.cvtColor(work_image, cv2.COLOR_BGR2HSV) mask = np.zeros(work_image.shape[:2], dtype=np.uint8) cv2.fillPoly(mask, [scaled_corners], 255) if cv2.countNonZero(mask) == 0: return 0.0 saturation = hsv[:, :, 1] return float(saturation[mask == 255].mean()) except Exception: return 0.0 def _validate_layer1(side: str, image: Optional[np.ndarray], sat_threshold: float) -> SideValidation: quality = _extract_quality_info(image) if image is None: return SideValidation( side=side, provided=False, quality=quality, corners_detected=False, saturation=0.0, saturation_threshold=sat_threshold, layer1_passed=False, layer1_failure="not provided", failure="not provided", ) corners = detect_card_boundary_with_hand(image, **POKEMON_CARD_DETECTION_CONFIG) corners_detected = corners is not None saturation = _mean_saturation_in_region(image, corners) if corners_detected else 0.0 layer1_passed = corners_detected and saturation >= sat_threshold if layer1_passed: failure = "" layer1_failure = "" elif not corners_detected: failure = "aspect ratio" layer1_failure = "aspect ratio" else: failure = "low saturation" layer1_failure = "low saturation" return SideValidation( side=side, provided=True, quality=quality, corners_detected=corners_detected, saturation=saturation, saturation_threshold=sat_threshold, layer1_passed=layer1_passed, layer1_failure=layer1_failure, failure=failure, ) def _build_no_side_rejection( front: SideValidation, back: SideValidation, back_validation: Optional[ColorValidation], front_not_back_validation: Optional[ColorValidation], ) -> tuple[str, str, Dict[str, Any]]: if front.failure == "front_is_back" and front_not_back_validation is not None: return ( "front_is_back", "Both images appear to be card backs", { "front_confidence": front_not_back_validation.confidence, "reason": front_not_back_validation.reason, }, ) if back.failure == "back_pattern" and back_validation is not None: return ( "back_pattern", "Back image does not show a Pokemon card", { "validation_confidence": back_validation.confidence, "reason": back_validation.reason, }, ) if front.provided and back.provided: return ( "geometry", "No Pokemon card detected in either image", { "front_failure": front.layer1_failure or front.failure, "back_failure": back.layer1_failure or back.failure, "front_saturation": front.saturation, "back_saturation": back.saturation, }, ) if front.provided: return ( "geometry", "No Pokemon card detected in front image", { "front_failure": front.layer1_failure or front.failure, "front_saturation": front.saturation, }, ) if back.provided: return ( "geometry", "No Pokemon card detected in back image", { "back_failure": back.layer1_failure or back.failure, "back_saturation": back.saturation, }, ) return ( "geometry", "No input images provided", {}, ) def run_multilayer_validation( front_image: Optional[np.ndarray], back_image: Optional[np.ndarray], feature_validator: Optional[Any] = None, require_both_sides: bool = False, ) -> MultiLayerValidationResult: """ Run shared pre-DL validation and return side-level decisions. Args: front_image: Front image in BGR format. back_image: Back image in BGR format. feature_validator: Optional FeatureBasedValidator instance. require_both_sides: If True, reject when only one side is valid. """ front = _validate_layer1("front", front_image, FRONT_SATURATION_THRESHOLD) back = _validate_layer1("back", back_image, BACK_SATURATION_THRESHOLD) processed_sides: List[str] = [] if front.layer1_passed: processed_sides.append("front") if back.layer1_passed: processed_sides.append("back") back_validation: Optional[ColorValidation] = None if "back" in processed_sides and feature_validator is not None and back_image is not None: is_pokemon_back, confidence, reason = feature_validator.validate_pokemon_back_colors(back_image) back_validation = ColorValidation( passed=bool(is_pokemon_back), confidence=float(confidence), reason=reason, ) if not is_pokemon_back: processed_sides.remove("back") back.failure = "back_pattern" front_not_back_validation: Optional[ColorValidation] = None if "front" in processed_sides and feature_validator is not None and front_image is not None: is_front_also_back, confidence, reason = feature_validator.validate_pokemon_back_colors( front_image, blue_threshold=FRONT_BACK_BLUE_THRESHOLD, ) front_not_back_validation = ColorValidation( passed=not bool(is_front_also_back), confidence=float(confidence), reason=reason, ) if is_front_also_back: processed_sides.remove("front") front.failure = "front_is_back" result = MultiLayerValidationResult( front=front, back=back, processed_sides=processed_sides, pokemon_back_validation=back_validation, front_not_back_validation=front_not_back_validation, ) if ( require_both_sides and front.provided and back.provided and len(processed_sides) == 1 ): passing_side = processed_sides[0] failing_side = "back" if passing_side == "front" else "front" result.rejection_category = "mismatch" result.rejection_message = f"Only {passing_side} image shows a valid card" result.rejection_details = { "front_passed": passing_side == "front", "back_passed": passing_side == "back", "failing_side": failing_side, } return result if not processed_sides: category, message, details = _build_no_side_rejection( front=front, back=back, back_validation=back_validation, front_not_back_validation=front_not_back_validation, ) result.rejection_category = category result.rejection_message = message result.rejection_details = details return result