Spaces:
Runtime error
Runtime error
| """ | |
| 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 | |
| class ColorValidation: | |
| """Color/pattern validation result.""" | |
| passed: bool | |
| confidence: float | |
| reason: str | |
| 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 = "" | |
| 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) | |
| 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 | |