""" Feature-Based Pokemon Card Validator Validates that extracted features match Pokemon card characteristics. This acts as a safety net to catch non-Pokemon cards that pass geometric and back pattern validation. """ import numpy as np from typing import Dict, Tuple, Any, List, Union import logging logger = logging.getLogger(__name__) class FeatureBasedValidator: """ Validates Pokemon cards using extracted feature ranges. Uses knowledge of Pokemon card characteristics to detect non-Pokemon cards: - Aspect ratio should be close to 0.716 (standard Pokemon card) - Saturation should be moderate (colorful fronts, blue backs) - Texture and print quality should match Pokemon cards """ # Expected feature ranges for Pokemon cards (based on training data) # Tightened ranges to better distinguish Pokemon from other trading cards POKEMON_FEATURE_RANGES = { # Geometric features - Pokemon cards have very consistent aspect ratio 'aspect_ratio_accuracy': (0.90, 1.00), # Very close to 0.716 target (tightened from 0.85) # Color features (Pokemon cards have moderate-high saturation and blue backs) 'mean_saturation': (35.0, 160.0), # HSV saturation (tightened lower bound) 'dominant_color_blue': (0.10, 0.65), # Pokemon backs have blue dominance (tightened) 'mean_hue': (100.0, 130.0), # Pokemon backs have blue hue (~120 degrees) # Texture features (Pokemon cards have fine, complex print quality) 'glcm_contrast': (15.0, 220.0), # GLCM texture contrast (tightened) 'glcm_energy': (0.001, 0.40), # Energy/uniformity (tightened upper bound) 'glcm_homogeneity': (0.30, 0.90), # Texture homogeneity # Print quality features (Pokemon cards have distinctive print patterns) 'mean_fft_magnitude': (8.0, 55.0), # Frequency domain patterns (tightened) 'blur_score': (100.0, 4000.0), # Laplacian variance (tightened lower bound) } def __init__(self, confidence_threshold: float = 0.75, feature_extractor=None): """ Initialize the feature-based validator. Args: confidence_threshold: Minimum Pokemon likelihood score (0.0-1.0) feature_extractor: Optional FeatureExtractor instance for converting lists to dicts """ self.confidence_threshold = confidence_threshold self.feature_extractor = feature_extractor def _features_to_dict(self, features: Union[List[float], np.ndarray], feature_names: List[str]) -> Dict[str, float]: """ Convert feature list to dictionary using feature names. Args: features: List or array of feature values feature_names: List of feature names Returns: Dictionary mapping feature names to values """ if len(features) != len(feature_names): logger.warning(f"Feature count mismatch: {len(features)} features vs {len(feature_names)} names") return {name: float(value) for name, value in zip(feature_names, features)} def validate_features(self, features: Union[Dict[str, Any], List[float], np.ndarray], feature_names: List[str] = None) -> Tuple[bool, float, str]: """ Validate that features match Pokemon card characteristics. Args: features: Dictionary of extracted features, or list/array of feature values feature_names: Optional list of feature names (required if features is a list/array) Returns: Tuple of (is_pokemon_like, confidence, reason): - is_pokemon_like: True if features match Pokemon patterns - confidence: Pokemon likelihood score (0.0-1.0) - reason: Human-readable explanation """ try: # Convert list/array to dict if needed if isinstance(features, (list, np.ndarray)): if feature_names is None: if self.feature_extractor is not None: feature_names = self.feature_extractor.get_feature_names() else: # Cannot validate without feature names return True, 1.0, "Feature validation skipped (no feature names available)" features = self._features_to_dict(features, feature_names) # Calculate how many features match Pokemon ranges matches = [] checks = [] for feature_name, (min_val, max_val) in self.POKEMON_FEATURE_RANGES.items(): if feature_name in features: value = features[feature_name] is_match = min_val <= value <= max_val matches.append(is_match) checks.append({ 'feature': feature_name, 'value': value, 'range': (min_val, max_val), 'match': is_match }) # Calculate Pokemon likelihood score if len(matches) > 0: confidence = sum(matches) / len(matches) else: # No features available, pass through return True, 1.0, "Feature validation skipped (no features extracted)" # Check threshold is_pokemon_like = confidence >= self.confidence_threshold # Build detailed reason if is_pokemon_like: reason = ( f"Features match Pokemon card patterns " f"({sum(matches)}/{len(matches)} checks passed, " f"score: {confidence:.2%})" ) else: # List failing checks failing_checks = [c for c in checks if not c['match']] reason = ( f"Features do not match Pokemon card patterns " f"({sum(matches)}/{len(matches)} checks passed, " f"score: {confidence:.2%}, threshold: {self.confidence_threshold:.2%}). " f"Failing checks: " + ", ".join([ f"{c['feature']}={c['value']:.2f} (expected {c['range'][0]:.2f}-{c['range'][1]:.2f})" for c in failing_checks[:3] # Show first 3 failures ]) ) return is_pokemon_like, confidence, reason except Exception as e: logger.error(f"Error during feature-based validation: {e}") # On error, pass through to avoid blocking legitimate cards return True, 0.0, f"Feature validation error: {str(e)}" def get_feature_summary(self, features: Dict[str, Any]) -> Dict[str, Any]: """ Get a summary of feature validation results. Args: features: Dictionary of extracted features Returns: Dictionary with detailed validation results for each feature """ summary = { 'total_checks': 0, 'passed_checks': 0, 'failed_checks': 0, 'details': [] } for feature_name, (min_val, max_val) in self.POKEMON_FEATURE_RANGES.items(): if feature_name in features: value = features[feature_name] is_match = min_val <= value <= max_val summary['total_checks'] += 1 if is_match: summary['passed_checks'] += 1 else: summary['failed_checks'] += 1 summary['details'].append({ 'feature': feature_name, 'value': float(value), 'expected_range': (float(min_val), float(max_val)), 'passed': bool(is_match) }) if summary['total_checks'] > 0: summary['confidence'] = summary['passed_checks'] / summary['total_checks'] else: summary['confidence'] = 1.0 return summary def validate_pokemon_back_colors(self, back_image, blue_threshold: float = 0.08) -> Tuple[bool, float, str]: """ Validate that back image has Pokemon-specific color characteristics. Pokemon card backs have distinctive blue color (HSV hue ~180-220 degrees). This is a quick heuristic check that doesn't require TensorFlow. Args: back_image: Back card image (BGR numpy array) blue_threshold: Minimum fraction of blue pixels required (default 0.08). Use a higher value (e.g. 0.25) when checking whether a *front* image is erroneously a back, to avoid false positives on blue-heavy fronts. Returns: Tuple of (is_pokemon_back, confidence, reason) """ import cv2 import numpy as np try: # Convert to HSV hsv = cv2.cvtColor(back_image, cv2.COLOR_BGR2HSV) # Calculate blue pixel percentage # Pokemon backs have hue in range 100-130 (blue) hue = hsv[:, :, 0] saturation = hsv[:, :, 1] # Blue pixels: hue 100-125 (cyan-blue, NOT purple which is 130-160) # Pokemon card backs are distinctively blue, not purple/violet blue_mask = ((hue >= 100) & (hue <= 125) & (saturation >= 30)) blue_percentage = np.sum(blue_mask) / blue_mask.size is_pokemon_back = blue_percentage >= blue_threshold confidence = float(min(blue_percentage / blue_threshold, 1.0)) if is_pokemon_back: reason = f"Back image has Pokemon blue color pattern ({blue_percentage:.1%} blue pixels)" else: reason = ( f"Back image lacks Pokemon blue color pattern " f"({blue_percentage:.1%} blue pixels, expected ≥{blue_threshold:.0%})" ) return is_pokemon_back, confidence, reason except Exception as e: logger.error(f"Error during back color validation: {e}") return True, 0.0, f"Back color validation error: {str(e)}"