tcg-space / Code /Backend /src /validators /multilayer_validation.py
github-actions[bot]
deploy: backend bundle from e30f205a156e2874746858ea018a9649ff80d3b5
2747d77
"""
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