Source code for segnomms.validation.phase4

"""Validation for Phase 4 frame and centerpiece features.

This module provides validation logic for the new Phase 4 features including
frame shapes, centerpiece reserves, and quiet zone enhancements.
"""

import logging
from typing import TYPE_CHECKING, List, Literal, Optional

if TYPE_CHECKING:
    from tests.helpers.scanning_harness import QRScanabilityHarness

from ..color.color_analysis import suggest_color_improvements, validate_qr_contrast
from ..config import CenterpieceConfig, FrameConfig, MergeStrategy, RenderingConfig
from ..core.geometry import CenterpieceGeometry
from .models import Phase4ValidatorConfig, ValidationResult

try:
    # Import from test helpers - graceful degradation if not available in
    # testing environment
    from tests.helpers.scanning_harness import get_scanability_harness
except ImportError:
    # Fallback if tests module is not available
[docs] def get_scanability_harness() -> Optional["QRScanabilityHarness"]: return None
logger = logging.getLogger(__name__)
[docs] class Phase4Validator: """Validation for Phase 4 frame and centerpiece features. This class provides comprehensive validation for frame shapes, centerpiece reserves, and their interaction with existing QR code features. Example: >>> validator = Phase4Validator( ... qr_version=7, error_level='M', matrix_size=45 ... ) >>> # Or using Pydantic model >>> config = Phase4ValidatorConfig( ... qr_version=7, error_level='m', matrix_size=45 ... ) >>> validator = Phase4Validator(**config.model_dump()) """
[docs] def __init__( self, qr_version: int, error_level: Literal["L", "M", "Q", "H"], matrix_size: int, ): """Initialize the validator. Args: qr_version: QR code version (1-40) error_level: Error correction level ('L', 'M', 'Q', 'H') matrix_size: Size of the QR matrix in modules """ # Validate inputs using Pydantic config = Phase4ValidatorConfig( qr_version=qr_version, error_level=error_level, matrix_size=matrix_size ) # Use validated values self.qr_version = config.qr_version self.error_level = config.error_level self.matrix_size = config.matrix_size
[docs] def validate_frame_safety(self, frame_config: FrameConfig, border_modules: int) -> List[str]: """Validate frame configuration for QR code safety. Args: frame_config: Frame configuration to validate border_modules: Number of border modules (quiet zone) Returns: List of warning messages (empty if all valid) """ warnings = [] # Non-square frames need adequate quiet zone if frame_config.shape != "square" and border_modules < 4: warnings.append( f"Non-square frame shape '{frame_config.shape}' requires minimum " f"4-module quiet zone for reliable scanning (current: {border_modules})" ) # Fade mode with custom shapes warning if frame_config.clip_mode == "fade" and frame_config.shape == "custom": warnings.append( "Fade mode with custom frame shapes may produce unexpected results. " "Consider using 'clip' mode for custom shapes." ) # Circle frames with small QR codes if frame_config.shape == "circle" and self.matrix_size < 25: warnings.append( f"Circle frame with small QR code (size {self.matrix_size}) may " "clip important corner areas. Consider increasing QR size or using " "rounded-rect frame." ) # Validate corner radius for rounded rectangles if frame_config.shape == "rounded-rect": if frame_config.corner_radius > 0.5: warnings.append( f"Large corner radius ({frame_config.corner_radius}) may impact " "scannability. Consider using values <= 0.3 for better " "compatibility." ) return warnings
[docs] def validate_centerpiece_safety(self, centerpiece_config: CenterpieceConfig) -> List[str]: """Validate centerpiece size against error correction capacity. Args: centerpiece_config: Centerpiece configuration to validate Returns: List of error messages (empty if valid) """ if not centerpiece_config.enabled: return [] errors = [] # Calculate safe size geometry = CenterpieceGeometry(self.matrix_size) safe_size = geometry.calculate_safe_reserve_size(self.qr_version, self.error_level) # Check if size exceeds safe limit if centerpiece_config.size > safe_size: errors.append( f"Centerpiece size {centerpiece_config.size:.1%} exceeds safe limit " f"{safe_size:.1%} for error level {self.error_level}. " f"Reduce size or use higher error correction level." ) # Warn about large centerpieces even if within limits elif centerpiece_config.size > safe_size * 0.9: logger.warning( f"Centerpiece size {centerpiece_config.size:.1%} is close to limit " f"{safe_size:.1%}. Consider reducing for better reliability." ) # Check offset bounds max_offset = 0.5 - centerpiece_config.size if abs(centerpiece_config.offset_x) > max_offset: errors.append( f"Centerpiece X offset {centerpiece_config.offset_x} with size " f"{centerpiece_config.size} would extend beyond QR bounds" ) if abs(centerpiece_config.offset_y) > max_offset: errors.append( f"Centerpiece Y offset {centerpiece_config.offset_y} with size " f"{centerpiece_config.size} would extend beyond QR bounds" ) # Version-specific warnings if self.qr_version <= 2 and centerpiece_config.size > 0.1: errors.append( f"QR version {self.qr_version} is too small for centerpiece. " "Minimum version 3 recommended for centerpiece reserves." ) # Margin validation if centerpiece_config.margin > 5: logger.warning( f"Large centerpiece margin ({centerpiece_config.margin} modules) " "may unnecessarily reduce QR capacity" ) return errors
[docs] def validate_contrast_ratio(self, config: RenderingConfig, min_ratio: float = 3.0) -> List[str]: """Validate color contrast ratio for scanability. Args: config: Rendering configuration containing colors min_ratio: Minimum acceptable contrast ratio (default: 3.0) Returns: List of error messages (empty if valid) """ errors = [] # Get colors from config dark_color = getattr(config, "dark", "black") light_color = getattr(config, "light", "white") # Validate contrast is_valid, actual_ratio, message = validate_qr_contrast(dark_color, light_color, min_ratio) if not is_valid: errors.append(message) # Add specific suggestions suggestions = suggest_color_improvements(dark_color, light_color) for suggestion in suggestions: errors.append(f"Suggestion: {suggestion}") return errors
[docs] def validate_module_size_scanability(self, config: RenderingConfig) -> List[str]: """Validate module size against scanability requirements. Args: config: Rendering configuration Returns: List of warning messages """ warnings = [] # Module size (scale) validation scale = getattr(config, "scale", 10) # Very small modules if scale < 3: warnings.append( f"Module size {scale}px is very small and may not scan reliably " "on some devices. Consider using at least 5px for better compatibility." ) elif scale < 5: warnings.append( f"Module size {scale}px is small. Consider using at least 8px " "for optimal scanning across devices." ) # Very large modules (memory/performance warning) elif scale > 50: warnings.append( f"Module size {scale}px is very large and may impact performance. " "Consider using smaller scale values for better efficiency." ) # Module size vs QR version compatibility total_pixels = self.matrix_size * scale # Density validation based on total pixel count if total_pixels < 441: # 21x21 minimum for readability warnings.append( f"Very small QR code ({total_pixels} total pixels). " "May be difficult to scan reliably." ) elif total_pixels > 1000000: # 1000x1000 maximum practical size warnings.append( f"Very large QR code ({total_pixels} total pixels). " "May cause performance issues or memory constraints." ) # Warn about pixel density issues if scale >= 1 and scale <= 2: warnings.append( "Small module sizes may cause aliasing issues on high-DPI displays. " "Test on various screen densities." ) # Large QR codes with small modules if self.matrix_size > 50 and scale < 5: warnings.append( f"Large QR code ({self.matrix_size}x{self.matrix_size}) with small " f"modules ({scale}px) may be difficult to scan. Consider larger " f"modules." ) # Frame shape interaction with module size frame_shape = getattr(config.frame, "shape", "square") if frame_shape != "square" and scale < 8: warnings.append( f"Non-square frame with small modules ({scale}px) may impact " "edge scanning reliability. Consider larger modules or square frame." ) return warnings
[docs] def run_automated_scanability_test( self, config: RenderingConfig, minimum_success_rate: float = 0.8, test_data: str = "SegnoMMS Test", ) -> List[str]: """Run automated scanability testing harness. Args: config: Rendering configuration to test minimum_success_rate: Minimum required success rate test_data: Data to encode for testing Returns: List of error messages (empty if passes) """ errors = [] # Get scanning harness harness = get_scanability_harness() if not harness: # No scanning libraries available - skip test logger.warning("Automated scanability testing skipped - no scanning libraries " "available") errors.append( "Automated scanability testing unavailable. " "Install PIL, opencv-python, and pyzbar for comprehensive validation." ) return errors try: # Import SVG generator dynamically to avoid circular imports from ..plugin import generate_interactive_svg # Run comprehensive scanability test meets_threshold, results = harness.validate_scanability_threshold( config, minimum_success_rate, test_data, svg_generator=generate_interactive_svg, ) if not meets_threshold: success_rate = results.get("success_rate", 0) errors.append( f"Automated scanability test failed: {success_rate:.1%} success " f"rate " f"(minimum {minimum_success_rate:.1%} required). " f"Configuration may not scan reliably across devices and " f"conditions." ) # Add specific failure information failure_count = results.get("failure_count", 0) error_count = results.get("error_count", 0) if failure_count > 0: errors.append( f"Failed {failure_count} scanning tests. " "Consider adjusting contrast, module size, or frame settings." ) if error_count > 0: errors.append( f"Encountered {error_count} test errors. " "Configuration may be too complex for reliable scanning." ) else: # Test passed - log success success_rate = results.get("success_rate", 0) logger.info( f"Automated scanability test passed: {success_rate:.1%} success " f"rate across {results.get('total_tests', 0)} test conditions" ) except Exception as e: errors.append(f"Automated scanability test failed with error: {str(e)}") logger.error(f"Scanability test error: {e}") return errors
[docs] def validate_combined_features(self, config: RenderingConfig) -> List[str]: """Check for problematic feature combinations. Args: config: Complete rendering configuration Returns: List of warning messages """ warnings = [] # Circle frame with large centerpiece if config.frame.shape == "circle" and config.centerpiece.enabled and config.centerpiece.size > 0.3: warnings.append( "Large centerpiece with circle frame may significantly impact " "scannability, especially at frame edges. Consider using a " "smaller centerpiece or different frame shape." ) # Aggressive merging with centerpiece if config.geometry.merge == MergeStrategy.AGGRESSIVE and config.centerpiece.enabled: warnings.append( "Aggressive module merging with centerpiece reserve may create " "scanning issues. Consider using 'soft' or 'none' merge strategy." ) # Small quiet zone with non-square frame if config.frame.shape != "square" and config.border < 4: warnings.append( f"Frame shape '{config.frame.shape}' with {config.border}-module " "border may not scan reliably. Increase border to at least 4." ) # Fade mode with interactive features if config.frame.clip_mode == "fade" and config.style.interactive: warnings.append("Fade frame mode may interfere with interactive hover effects " "at frame edges.") # Low error correction with multiple advanced features feature_count = sum( [ config.frame.shape != "square", config.centerpiece.enabled, config.geometry.merge != MergeStrategy.NONE, config.phase3.enabled, ] ) if self.error_level == "L" and feature_count >= 2: warnings.append( f"Low error correction with {feature_count} advanced features " "may impact reliability. Consider using error level 'M' or higher." ) # Centerpiece with certain shapes if config.centerpiece.enabled and config.geometry.shape in [ "star", "triangle", "hexagon", ]: warnings.append( f"Module shape '{config.geometry.shape}' with centerpiece " "may create visual conflicts. Test thoroughly." ) return warnings
[docs] def get_recommendations(self, config: RenderingConfig) -> List[str]: """Get recommendations for optimal configuration. Args: config: Rendering configuration to analyze Returns: List of recommendation messages """ recommendations = [] # Frame recommendations if config.frame.shape == "circle" and self.matrix_size > 45: recommendations.append( "For large QR codes, consider 'rounded-rect' or 'squircle' " "frames to preserve more corner area." ) # Centerpiece recommendations if config.centerpiece.enabled: if self.error_level in ["L", "M"] and config.centerpiece.size > 0.15: recommendations.append( f"For {config.centerpiece.size:.0%} centerpiece, consider " f"using error level 'Q' or 'H' instead of '{self.error_level}'." ) if config.centerpiece.shape == "rect" and config.frame.shape == "circle": recommendations.append( "Consider using circular centerpiece to match circle frame " "for better visual harmony." ) # Performance recommendations if config.frame.clip_mode == "fade" and config.phase3.enabled: recommendations.append( "Fade frame with Phase 3 bezier curves may impact performance. " "Consider using 'clip' mode for faster rendering." ) return recommendations
[docs] def validate_all( self, config: RenderingConfig, min_contrast_ratio: float = 3.0, run_scanability_tests: bool = False, min_scanability_success_rate: float = 0.8, ) -> ValidationResult: """Perform complete validation of Phase 4 features. Args: config: Complete rendering configuration min_contrast_ratio: Minimum contrast ratio for scanability (default: 3.0) run_scanability_tests: Whether to run automated scanning tests (default: False) min_scanability_success_rate: Minimum success rate for scanning tests (default: 0.8) Returns: ValidationResult with errors, warnings, and recommendations """ errors = [] warnings = [] # Configuration object validation is now handled by Pydantic automatically # during model creation, so no additional validation needed here # Enhanced scanability validation (new features) errors.extend(self.validate_contrast_ratio(config, min_contrast_ratio)) warnings.extend(self.validate_module_size_scanability(config)) # Automated scanability testing (optional, expensive) if run_scanability_tests: errors.extend(self.run_automated_scanability_test(config, min_scanability_success_rate)) # Existing safety validation warnings.extend(self.validate_frame_safety(config.frame, config.border)) errors.extend(self.validate_centerpiece_safety(config.centerpiece)) # Combined feature validation warnings.extend(self.validate_combined_features(config)) # Get recommendations recommendations = self.get_recommendations(config) return ValidationResult( errors=errors, warnings=warnings, recommendations=recommendations, valid=len(errors) == 0, )