"""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