Source code for segnomms.core.detector

"""Core module detection functionality for QR codes.

This module contains the ModuleDetector class which identifies different
types of modules in a QR code (finder patterns, timing patterns, etc.).

The detector analyzes QR code matrices to classify each module as:

* Finder patterns (including inner regions)
* Separator modules
* Timing patterns
* Alignment patterns
* Format information
* Version information
* Data modules

"""

from typing import List, Optional, Tuple, Union

from .interfaces import QRCodeAnalyzer
from .models import ModuleDetectorConfig, NeighborAnalysis

#: Positions of finder patterns (top-left, top-right, bottom-left)
FINDER_PATTERN_POSITIONS = [(0, 0), (0, -7), (-7, 0)]

#: Size of finder patterns in modules
FINDER_SIZE = 7

#: Size of alignment patterns in modules
ALIGNMENT_PATTERN_SIZE = 5


[docs] class ModuleDetector(QRCodeAnalyzer): """Detects QR code module types and properties. This class analyzes a QR code matrix to identify different module types such as finder patterns, timing patterns, alignment patterns, and data modules. Attributes: matrix: The QR code matrix as a 2D boolean list size: Size of the QR code (modules per side) version: QR code version (1-40) alignment_positions: Calculated alignment pattern positions Example: >>> # Create a minimal valid matrix (21x21 for version 1) >>> matrix = [[True] * 21 for _ in range(21)] >>> detector = ModuleDetector(matrix, version=1) >>> module_type = detector.get_module_type(10, 10) >>> print(module_type) # 'data' """
[docs] def __init__(self, matrix: List[List[bool]], version: Optional[Union[int, str]] = None): """Initialize the detector with QR code matrix and optional version. Args: matrix: The QR code matrix as a 2D boolean list version: QR code version (1-40, 'M1'-'M4', or None for auto-detection) Example: >>> # Create a valid matrix for version 1 QR code (21x21) >>> matrix = [[True] * 21 for _ in range(21)] >>> detector = ModuleDetector(matrix, version=1) >>> # Or using Pydantic model >>> config = ModuleDetectorConfig(matrix=matrix, version=1) >>> detector = ModuleDetector(**config.model_dump()) """ # Validate inputs using Pydantic config = ModuleDetectorConfig(matrix=matrix, version=version) # Use validated values self.matrix = config.matrix self.size = len(config.matrix) # Parse version from validated format if config.version is not None: self.version = self._parse_version(config.version) else: self.version = self._estimate_version() self.alignment_positions = self._get_alignment_positions()
def _parse_version(self, version: Union[int, str, None]) -> int: """Parse version from various formats. Args: version: Version in various formats (int, str, or None) Returns: int: Parsed version number Note: Handles formats like 'M3' (Micro QR), '4', or plain integers. """ if isinstance(version, int): return version elif isinstance(version, str): # Handle formats like 'M3', 'M4' (Micro QR) or plain numbers if version.startswith("M"): # Micro QR version - extract number return int(version[1:]) else: # Regular version string return int(version) else: # Fallback to estimation return self._estimate_version() def _estimate_version(self) -> int: """Estimate QR version from matrix size. Returns: int: Estimated version (1-40) Note: Uses the formula: version = (size - 21) / 4 + 1 """ return (self.size - 21) // 4 + 1 if self.size >= 21 else 1 def _get_alignment_positions(self) -> List[Tuple[int, int]]: """Calculate alignment pattern positions based on version. Returns: List[Tuple[int, int]]: List of (row, col) positions Note: Version 1 has no alignment patterns. Higher versions have complex alignment pattern arrangements. """ # Simplified alignment position calculation if self.version == 1: return [] elif self.version <= 6: return [(self.size - 7, self.size - 7)] else: # More complex calculation for higher versions positions: List[Tuple[int, int]] = [] # Add positions based on version... return positions
[docs] def get_module_type(self, row: int, col: int) -> str: """Determine the type of module at given position. Args: row: Row index (0-based) col: Column index (0-based) Returns: str: Module type identifier: - 'finder': Finder pattern module - 'finder_inner': Inner finder pattern module - 'separator': Separator module - 'timing': Timing pattern module - 'alignment': Alignment pattern module - 'format': Format information module - 'version': Version information module - 'data': Data or error correction module Raises: IndexError: If row or col is out of bounds """ # Validate bounds if not (0 <= row < self.size and 0 <= col < self.size): raise IndexError(f"Position ({row}, {col}) out of bounds for {self.size}x{self.size} matrix") size = self.size # Finder patterns for base_row, base_col in FINDER_PATTERN_POSITIONS: abs_row = base_row if base_row >= 0 else size + base_row abs_col = base_col if base_col >= 0 else size + base_col if abs_row <= row < abs_row + FINDER_SIZE and abs_col <= col < abs_col + FINDER_SIZE: # Check if it's the inner part if abs_row + 2 <= row < abs_row + 5 and abs_col + 2 <= col < abs_col + 5: return "finder_inner" return "finder" # Separators (white border around finder patterns) for base_row, base_col in FINDER_PATTERN_POSITIONS: abs_row = base_row if base_row >= 0 else size + base_row abs_col = base_col if base_col >= 0 else size + base_col # Horizontal separator if row == abs_row + FINDER_SIZE and abs_col <= col < abs_col + FINDER_SIZE + 1: return "separator" # Vertical separator if col == abs_col + FINDER_SIZE and abs_row <= row < abs_row + FINDER_SIZE + 1: return "separator" # Timing patterns if row == 6 or col == 6: return "timing" # Alignment patterns for align_row, align_col in self.alignment_positions: if abs(row - align_row) <= 2 and abs(col - align_col) <= 2: if row == align_row and col == align_col: return "alignment_center" return "alignment" # Dark module (always dark module near bottom-left finder) if row == 4 * self.version + 9 and col == 8: return "dark" # Format information if (row == 8 and (col < 9 or col >= size - 8)) or (col == 8 and (row < 9 or row >= size - 7)): return "format" # Version information (for version 7+) if self.version >= 7: if (row < 6 and col >= size - 11) or (col < 6 and row >= size - 11): return "version" return "data"
[docs] def get_version(self) -> int: """Get the QR code version. Returns: int: QR code version (1-40) """ return self.version
[docs] def get_size(self) -> int: """Get the size of the QR code matrix. Returns: int: Number of modules per side """ return self.size
[docs] def is_module_active(self, row: int, col: int) -> bool: """Check if the module at given position is active (dark). Args: row: Row index col: Column index Returns: bool: True if module is dark, False otherwise """ if 0 <= row < self.size and 0 <= col < self.size: return self.matrix[row][col] return False
[docs] def get_neighbors(self, row: int, col: int, neighborhood: str = "von_neumann") -> List[Tuple[int, int]]: """ Get neighboring positions for a module. Args: row: Row position col: Column position neighborhood: 'von_neumann' (4-connected) or 'moore' (8-connected) Returns: List of valid neighbor positions """ neighbors = [] if neighborhood == "von_neumann": # 4-connected neighbors (cardinal directions) deltas = [(-1, 0), (1, 0), (0, -1), (0, 1)] else: # moore # 8-connected neighbors (cardinal + diagonal) deltas = [ (-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1), ] for dr, dc in deltas: nr, nc = row + dr, col + dc if 0 <= nr < self.size and 0 <= nc < self.size: neighbors.append((nr, nc)) return neighbors
[docs] def get_weighted_neighbor_analysis( self, row: int, col: int, module_type: str = "data" ) -> NeighborAnalysis: """ Enhanced neighbor analysis using Moore neighborhood (8-connected) with weighted connectivity. Returns comprehensive neighbor analysis including connectivity strength, flow direction, and shape hints for advanced rendering. Args: row: Row position col: Column position module_type: Type of module for weighting Returns: NeighborAnalysis model with comprehensive neighbor data Raises: IndexError: If row or col is out of bounds """ # Validate bounds if not (0 <= row < self.size and 0 <= col < self.size): raise IndexError(f"Position ({row}, {col}) out of bounds for {self.size}x{self.size} matrix") neighbors = self.get_neighbors(row, col, "moore") # Separate cardinal and diagonal neighbors cardinal_neighbors = [] diagonal_neighbors = [] cardinal_deltas = [(-1, 0), (1, 0), (0, -1), (0, 1)] for nr, nc in neighbors: dr, dc = nr - row, nc - col if (dr, dc) in cardinal_deltas: cardinal_neighbors.append(self.is_module_active(nr, nc)) else: diagonal_neighbors.append(self.is_module_active(nr, nc)) # Count active neighbors cardinal_count = sum(cardinal_neighbors) diagonal_count = sum(diagonal_neighbors) # Calculate weighted connectivity strength # Cardinal neighbors have full weight, diagonals have reduced weight connectivity_strength = cardinal_count + (diagonal_count * 0.7) # Flow weights based on module type flow_weights = { "finder": 0.5, "finder_inner": 0.3, "timing": 0.8, "data": 1.0, "alignment": 0.6, "format": 0.7, } weighted_strength = connectivity_strength * flow_weights.get(module_type, 1.0) # Determine flow direction (for pill shapes, etc.) horizontal_flow = 0.0 vertical_flow = 0.0 if len(cardinal_neighbors) >= 4: horizontal_flow = (cardinal_neighbors[2] + cardinal_neighbors[3]) / 2 # left + right vertical_flow = (cardinal_neighbors[0] + cardinal_neighbors[1]) / 2 # up + down # Get active neighbor positions active_neighbors = [(nr, nc) for nr, nc in neighbors if self.is_module_active(nr, nc)] return NeighborAnalysis( cardinal_count=cardinal_count, diagonal_count=diagonal_count, connectivity_strength=connectivity_strength, weighted_strength=weighted_strength, horizontal_flow=horizontal_flow, vertical_flow=vertical_flow, flow_direction=("horizontal" if horizontal_flow > vertical_flow else "vertical"), isolation_level=4 - cardinal_count, corner_connections=diagonal_count, active_neighbors=active_neighbors, )