Source code for segnomms.validation.composition

"""Composition validation for frame and centerpiece features.

This module provides validation logic for QR code composition features including
frame shapes, centerpiece reserves, and quiet zone enhancements.

Note: This module was previously named phase4.py. The CompositionValidator class
was previously named Phase4Validator. Both old names are retained as deprecated
aliases for backward compatibility.
"""

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 CompositionValidatorConfig, 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 CompositionValidator: """Validates QR code composition including frames and centerpieces. This class provides comprehensive validation for frame shapes, centerpiece reserves, and their interaction with existing QR code features. It validates that composition choices maintain QR code scannability. Note: This class was previously named Phase4Validator. The old name is retained as a deprecated alias for backward compatibility. Example: >>> validator = CompositionValidator( ... qr_version=7, error_level='M', matrix_size=45 ... ) >>> # Or using Pydantic model >>> config = CompositionValidatorConfig( ... qr_version=7, error_level='m', matrix_size=45 ... ) >>> validator = CompositionValidator(**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 = CompositionValidatorConfig( 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 composition 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, )
# Deprecated alias for backward compatibility # Direct imports from this module (e.g., from segnomms.validation.composition import Phase4Validator) # will work without warning. The warning is emitted via __getattr__ in the package __init__.py # when importing from the package level (e.g., from segnomms.validation import Phase4Validator). Phase4Validator = CompositionValidator