""" Edge refinement using Sobel gradient filtering. This module implements v1's core innovation: replacing contour-based width measurement with gradient-based edge detection for improved accuracy. Functions: - extract_ring_zone_roi: Extract ROI around ring zone - apply_sobel_filters: Bidirectional Sobel filtering - detect_edges_per_row: Find left/right edges in each cross-section - refine_edge_subpixel: Sub-pixel edge localization (Phase 3) - measure_width_from_edges: Compute width from edge positions - compute_edge_quality_score: Assess edge detection quality (Phase 3) - should_use_sobel_measurement: Auto fallback logic (Phase 3) - refine_edges_sobel: Main entry point for edge refinement """ import cv2 import numpy as np import logging from typing import Dict, Any, Optional, Tuple, List from src.edge_refinement_constants import ( # Sobel Filter DEFAULT_KERNEL_SIZE, VALID_KERNEL_SIZES, # Edge Detection DEFAULT_GRADIENT_THRESHOLD, MIN_FINGER_WIDTH_CM, MAX_FINGER_WIDTH_CM, WIDTH_TOLERANCE_FACTOR, # Sub-Pixel Refinement MAX_SUBPIXEL_OFFSET, MIN_PARABOLA_DENOMINATOR, # Outlier Filtering MAD_OUTLIER_THRESHOLD, # Edge Quality Scoring GRADIENT_STRENGTH_NORMALIZER, SMOOTHNESS_VARIANCE_NORMALIZER, QUALITY_WEIGHT_GRADIENT, QUALITY_WEIGHT_CONSISTENCY, QUALITY_WEIGHT_SMOOTHNESS, QUALITY_WEIGHT_SYMMETRY, # Auto Fallback Decision MIN_QUALITY_SCORE_THRESHOLD, MIN_CONSISTENCY_THRESHOLD, MIN_REALISTIC_WIDTH_CM, MAX_REALISTIC_WIDTH_CM, MAX_CONTOUR_DIFFERENCE_PCT, ) # Configure logging logger = logging.getLogger(__name__) # ============================================================================= # Helper Functions (extracted from nested scope) # ============================================================================= def _get_axis_x_at_row(row_y: float, axis_center: Optional[np.ndarray], axis_direction: Optional[np.ndarray], width: int) -> float: """ Get axis x-coordinate at given row y-coordinate. Args: row_y: Row y-coordinate axis_center: Axis center point (x, y) axis_direction: Axis direction vector (dx, dy) width: Image width (for fallback) Returns: X-coordinate of axis at given row """ if axis_center is None or axis_direction is None: return width / 2 # Fallback to center if abs(axis_direction[1]) < 1e-6: # Nearly horizontal axis return axis_center[0] else: # Parametric line: P = axis_center + t * axis_direction t = (row_y - axis_center[1]) / axis_direction[1] return axis_center[0] + t * axis_direction[0] def _find_edges_from_axis( row_gradient: np.ndarray, row_y: float, axis_x: float, threshold: float, min_width_px: Optional[float], max_width_px: Optional[float], row_mask: Optional[np.ndarray] = None, row_gradient_left_to_right: Optional[np.ndarray] = None, row_gradient_right_to_left: Optional[np.ndarray] = None, ) -> Optional[Tuple[float, float, float, float]]: """ Find left and right edges by expanding from axis position. Strategy: - MASK-CONSTRAINED MODE (when row_mask provided): 1. Find leftmost/rightmost mask pixels (finger boundaries) 2. Search for strongest gradient within ±10px of mask boundaries 3. Combines anatomical accuracy (mask) with sub-pixel precision (gradient) - AXIS-EXPANSION MODE (when row_mask is None): 1. Start at axis x-coordinate (INSIDE the finger) 2. Search LEFT/RIGHT from axis for closest salient edge 3. Validate width is within realistic range Args: row_gradient: Gradient magnitude for this row row_y: Row y-coordinate axis_x: Axis x-coordinate at this row threshold: Gradient threshold for valid edge min_width_px: Minimum valid width in pixels (None to skip) max_width_px: Maximum valid width in pixels (None to skip) row_mask: Optional mask row (True = finger pixel) for constrained search row_gradient_left_to_right: Optional directional gradient map for right edge search row_gradient_right_to_left: Optional directional gradient map for left edge search Returns: Tuple of (left_x, right_x, left_strength, right_strength) or None if invalid """ if axis_x < 0 or axis_x >= len(row_gradient): return None # Direction-aware gradient maps (preferred when available): # - left boundary should come from right-to-left transition # - right boundary should come from left-to-right transition left_search_gradient = row_gradient_right_to_left if row_gradient_right_to_left is not None else row_gradient right_search_gradient = row_gradient_left_to_right if row_gradient_left_to_right is not None else row_gradient # MASK-CONSTRAINED MODE (preferred when available) if row_mask is not None and np.any(row_mask): # Strategy: Search FROM axis OUTWARD, constrained by mask # This avoids picking background edges while using gradient precision mask_indices = np.where(row_mask)[0] if len(mask_indices) < 2: return None # Mask too small left_mask_boundary = mask_indices[0] right_mask_boundary = mask_indices[-1] # Search LEFT from axis, stopping at mask boundary left_edge_x = None left_strength = 0 # Start from axis, go left until we reach left mask boundary search_start = max(left_mask_boundary, int(axis_x)) for x in range(search_start, left_mask_boundary - 1, -1): if x < 0 or x >= len(row_gradient): continue if left_search_gradient[x] > threshold: # Found a strong edge - update if stronger than previous if left_search_gradient[x] > left_strength: left_edge_x = x left_strength = left_search_gradient[x] # If no edge found with full threshold, try with relaxed threshold if left_edge_x is None: relaxed_threshold = threshold * 0.5 for x in range(search_start, left_mask_boundary - 1, -1): if x < 0 or x >= len(row_gradient): continue if left_search_gradient[x] > relaxed_threshold: if left_search_gradient[x] > left_strength: left_edge_x = x left_strength = left_search_gradient[x] # Search RIGHT from axis, stopping at mask boundary right_edge_x = None right_strength = 0 # Start from axis, go right until we reach right mask boundary search_start = min(right_mask_boundary, int(axis_x)) for x in range(search_start, right_mask_boundary + 1): if x < 0 or x >= len(row_gradient): continue if right_search_gradient[x] > threshold: # Found a strong edge - update if stronger than previous if right_search_gradient[x] > right_strength: right_edge_x = x right_strength = right_search_gradient[x] # If no edge found with full threshold, try with relaxed threshold if right_edge_x is None: relaxed_threshold = threshold * 0.5 for x in range(search_start, right_mask_boundary + 1): if x < 0 or x >= len(row_gradient): continue if right_search_gradient[x] > relaxed_threshold: if right_search_gradient[x] > right_strength: right_edge_x = x right_strength = right_search_gradient[x] if left_edge_x is None or right_edge_x is None: return None # No valid edges found else: # AXIS-EXPANSION MODE (fallback when no mask) # Search LEFT from axis (go leftward) left_edge_x = None left_strength = 0 for x in range(int(axis_x), -1, -1): if left_search_gradient[x] > threshold: # Found a salient edge - this is our left boundary left_edge_x = x left_strength = left_search_gradient[x] break # Search RIGHT from axis (go rightward) right_edge_x = None right_strength = 0 for x in range(int(axis_x), len(row_gradient)): if right_search_gradient[x] > threshold: # Found a salient edge - this is our right boundary right_edge_x = x right_strength = right_search_gradient[x] break if left_edge_x is None or right_edge_x is None: return None # Validate width is within realistic finger range width = right_edge_x - left_edge_x if min_width_px is not None and max_width_px is not None: if width < min_width_px or width > max_width_px: return None # Width out of realistic range return (left_edge_x, right_edge_x, left_strength, right_strength) # ============================================================================= # Main Functions # ============================================================================= def extract_ring_zone_roi( image: np.ndarray, axis_data: Dict[str, Any], zone_data: Dict[str, Any], rotate_align: bool = False ) -> Dict[str, Any]: """ Extract ROI around ring zone. The ROI is sized from the zone length (|DIP - PIP|): 1.5x wide, 0.5x tall, centered on the ring zone center. This scales naturally with camera distance since it's derived from anatomical landmarks. Args: image: Input BGR image axis_data: Output from estimate_finger_axis() zone_data: Output from localize_ring_zone() rotate_align: If True, rotate ROI so finger axis is vertical Returns: Dictionary containing: - roi_image: Extracted ROI (grayscale) - roi_mask: Full ROI mask (all 255) - roi_bounds: (x_min, y_min, x_max, y_max) in original image - transform_matrix: 3x3 matrix to map ROI coords -> original coords - inverse_transform: 3x3 matrix to map original -> ROI coords - rotation_angle: Rotation angle applied (degrees) - roi_width: ROI width in pixels - roi_height: ROI height in pixels """ h, w = image.shape[:2] # ROI centered on ring zone center, sized from |DIP - PIP| distance: # height = 0.5x zone length (along finger axis) # width = 1.5x zone length (perpendicular, wider to capture full finger edges) zone_length = zone_data["length"] center = zone_data["center_point"] direction = axis_data["direction"] half_height = zone_length * 0.25 # 0.5x / 2 half_width = zone_length * 0.6 # 1.5x / 2 x_min = int(np.clip(center[0] - half_width, 0, w - 1)) x_max = int(np.clip(center[0] + half_width, 0, w - 1)) y_min = int(np.clip(center[1] - half_height, 0, h - 1)) y_max = int(np.clip(center[1] + half_height, 0, h - 1)) roi_width = x_max - x_min roi_height = y_max - y_min if roi_width < 10 or roi_height < 10: raise ValueError(f"ROI too small: {roi_width}x{roi_height}") # Extract ROI roi_bgr = image[y_min:y_max, x_min:x_max].copy() # Convert to grayscale for edge detection roi_gray = cv2.cvtColor(roi_bgr, cv2.COLOR_BGR2GRAY) # Full ROI mask — the ROI rectangle itself is the search constraint roi_mask = np.ones((roi_height, roi_width), dtype=np.uint8) * 255 # Create transform matrix (ROI coords -> original coords) # Simple translation for non-rotated case transform = np.eye(3, dtype=np.float32) transform[0, 2] = x_min # Translation in x transform[1, 2] = y_min # Translation in y inverse_transform = np.linalg.inv(transform) rotation_angle = 0.0 # Optional rotation alignment if rotate_align: # Calculate rotation angle to make finger vertical # Finger direction -> make it point upward (0, -1) # Current direction is (dx, dy), want to rotate to (0, -1) rotation_angle = np.degrees(np.arctan2(-direction[0], direction[1])) # Get rotation matrix roi_center = (roi_width / 2.0, roi_height / 2.0) rotation_matrix = cv2.getRotationMatrix2D(roi_center, rotation_angle, 1.0) # Rotate ROI roi_gray = cv2.warpAffine( roi_gray, rotation_matrix, (roi_width, roi_height), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE ) # Update transform matrices # Rotation matrix is 2x3, convert to 3x3 for composition rotation_matrix_3x3 = np.eye(3, dtype=np.float32) rotation_matrix_3x3[:2, :] = rotation_matrix # Compose: translate then rotate transform = np.dot(rotation_matrix_3x3, transform) inverse_transform = np.linalg.inv(transform) # Convert axis center point and direction to ROI coordinates axis_center = axis_data.get("center", center) roi_offset = np.array([x_min, y_min], dtype=np.float32) axis_center_in_roi = axis_center - roi_offset # Direction vector stays the same (it's not affected by translation) axis_direction_in_roi = direction.copy() zone_start = zone_data["start_point"] zone_end = zone_data["end_point"] return { "roi_image": roi_gray, "roi_mask": roi_mask, "roi_bgr": roi_bgr, # Keep BGR for debug visualization "roi_bounds": (x_min, y_min, x_max, y_max), "transform_matrix": transform, "inverse_transform": inverse_transform, "rotation_angle": rotation_angle, "roi_width": roi_width, "roi_height": roi_height, "zone_start_in_roi": zone_start - roi_offset, "zone_end_in_roi": zone_end - roi_offset, "axis_center_in_roi": axis_center_in_roi, "axis_direction_in_roi": axis_direction_in_roi, } def apply_sobel_filters( roi_image: np.ndarray, kernel_size: int = DEFAULT_KERNEL_SIZE, axis_direction: str = "auto" ) -> Dict[str, Any]: """ Apply bidirectional Sobel filters to detect edges. For vertical finger (axis_direction="vertical"): - Use horizontal Sobel kernels (detect left/right edges) For horizontal finger (axis_direction="horizontal"): - Use vertical Sobel kernels (detect top/bottom edges) Auto mode detects orientation from ROI aspect ratio. Args: roi_image: Grayscale ROI image kernel_size: Sobel kernel size (3, 5, or 7) axis_direction: Finger axis direction ("auto", "vertical", "horizontal") Returns: Dictionary containing: - gradient_x: Horizontal gradient (Sobel X) - gradient_y: Vertical gradient (Sobel Y) - gradient_left_to_right: Positive X-gradient map (right-half gated in horizontal mode) - gradient_right_to_left: Negative X-gradient map (left-half gated in horizontal mode) - gradient_magnitude: Combined gradient magnitude - gradient_direction: Edge orientation (radians) - kernel_size: Kernel size used - filter_orientation: "horizontal" or "vertical" """ if kernel_size not in VALID_KERNEL_SIZES: raise ValueError(f"Invalid kernel_size: {kernel_size}. Use {VALID_KERNEL_SIZES}") h, w = roi_image.shape # Determine filter orientation if axis_direction == "auto": # After rotation normalization, finger is always vertical (upright) # Finger runs vertically → detect left/right edges → use horizontal filter # # NOTE: ROI aspect ratio is NOT reliable after rotation normalization! # The ROI may be wider than tall even when finger is vertical. # Always use horizontal filter orientation for upright hands. filter_orientation = "horizontal" # Detect left/right edges for vertical finger elif axis_direction == "vertical": filter_orientation = "horizontal" elif axis_direction == "horizontal": filter_orientation = "vertical" else: raise ValueError(f"Invalid axis_direction: {axis_direction}") # Apply Sobel filters # Sobel X detects vertical edges (left/right boundaries) # Sobel Y detects horizontal edges (top/bottom boundaries) # Use cv2.Sobel for standard implementation grad_x = cv2.Sobel(roi_image, cv2.CV_64F, 1, 0, ksize=kernel_size) grad_y = cv2.Sobel(roi_image, cv2.CV_64F, 0, 1, ksize=kernel_size) # Directional Sobel responses along X: # - left_to_right: rising intensity while moving left -> right # - right_to_left: falling intensity while moving left -> right gradient_left_to_right = np.maximum(grad_x, 0.0) gradient_right_to_left = np.maximum(-grad_x, 0.0) # Spatial gating to reduce nearby non-target finger interference: # - left_to_right only on ROI right half # - right_to_left only on ROI left half roi_split_x = w // 2 if filter_orientation == "horizontal": gradient_left_to_right[:, :roi_split_x] = 0.0 gradient_right_to_left[:, roi_split_x:] = 0.0 gradient_magnitude = np.sqrt(gradient_left_to_right**2 + gradient_right_to_left**2) else: # Vertical mode fallback keeps the original behavior. gradient_magnitude = np.sqrt(grad_x**2 + grad_y**2) # Calculate gradient direction (angle) gradient_direction = np.arctan2(grad_y, grad_x) # Normalize gradients to 0-255 for visualization grad_x_normalized = np.clip(np.abs(grad_x), 0, 255).astype(np.uint8) grad_y_normalized = np.clip(np.abs(grad_y), 0, 255).astype(np.uint8) grad_mag_normalized = np.clip(gradient_magnitude, 0, 255).astype(np.uint8) grad_l2r_normalized = np.clip(gradient_left_to_right, 0, 255).astype(np.uint8) grad_r2l_normalized = np.clip(gradient_right_to_left, 0, 255).astype(np.uint8) return { "gradient_x": grad_x, "gradient_y": grad_y, "gradient_left_to_right": gradient_left_to_right, "gradient_right_to_left": gradient_right_to_left, "gradient_magnitude": gradient_magnitude, "gradient_direction": gradient_direction, "gradient_x_normalized": grad_x_normalized, "gradient_y_normalized": grad_y_normalized, "gradient_left_to_right_normalized": grad_l2r_normalized, "gradient_right_to_left_normalized": grad_r2l_normalized, "gradient_mag_normalized": grad_mag_normalized, "kernel_size": kernel_size, "filter_orientation": filter_orientation, "roi_split_x": roi_split_x, } def detect_edges_per_row( gradient_data: Dict[str, Any], roi_data: Dict[str, Any], threshold: float = DEFAULT_GRADIENT_THRESHOLD, expected_width_px: Optional[float] = None, scale_px_per_cm: Optional[float] = None ) -> Dict[str, Any]: """ Detect left and right finger edges for each row (cross-section). Uses mask-constrained mode when roi_mask is available: 1. Find leftmost/rightmost mask pixels (anatomical finger boundaries) 2. Search for gradient peaks within ±10px of mask boundaries 3. Combines anatomical accuracy with sub-pixel gradient precision Falls back to axis-expansion mode when no mask: 1. Start at finger axis (guaranteed inside finger) 2. Expand left/right to find nearest salient edges 3. Validate width is within realistic range Args: gradient_data: Output from apply_sobel_filters() roi_data: Output from extract_ring_zone_roi() threshold: Minimum gradient magnitude for valid edge expected_width_px: Expected finger width from contour (optional) scale_px_per_cm: Scale factor for width validation (optional) Returns: Dictionary containing: - left_edges: Array of left edge x-coordinates (one per row) - right_edges: Array of right edge x-coordinates (one per row) - edge_strengths_left: Gradient magnitude at left edges - edge_strengths_right: Gradient magnitude at right edges - valid_rows: Boolean mask of rows with successful detection - num_valid_rows: Count of successful detections - mode_used: "mask_constrained" or "axis_expansion" """ gradient_magnitude = gradient_data["gradient_magnitude"] gradient_left_to_right = gradient_data.get("gradient_left_to_right") gradient_right_to_left = gradient_data.get("gradient_right_to_left") filter_orientation = gradient_data["filter_orientation"] h, w = gradient_magnitude.shape # Calculate realistic finger width range in pixels min_width_px = None max_width_px = None if scale_px_per_cm is not None: min_width_px = MIN_FINGER_WIDTH_CM * scale_px_per_cm max_width_px = MAX_FINGER_WIDTH_CM * scale_px_per_cm logger.debug(f"Width constraint: {min_width_px:.1f}-{max_width_px:.1f}px ({MIN_FINGER_WIDTH_CM}-{MAX_FINGER_WIDTH_CM}cm)") elif expected_width_px is not None: # Use expected width with tolerance min_width_px = expected_width_px * (1 - WIDTH_TOLERANCE_FACTOR) max_width_px = expected_width_px * (1 + WIDTH_TOLERANCE_FACTOR) logger.debug(f"Width constraint: {min_width_px:.1f}-{max_width_px:.1f}px (±{WIDTH_TOLERANCE_FACTOR*100}% of expected)") else: logger.debug("No width constraint (scale and expected width both None)") # Get axis information - this is our strong anchor point (INSIDE the finger) axis_center = roi_data.get("axis_center_in_roi") axis_direction = roi_data.get("axis_direction_in_roi") zone_start = roi_data.get("zone_start_in_roi") zone_end = roi_data.get("zone_end_in_roi") # Get finger mask for constrained edge detection (if available) roi_mask = roi_data.get("roi_mask") mode_used = "mask_constrained" if roi_mask is not None else "axis_expansion" if roi_mask is not None: logger.debug(f"Using MASK-CONSTRAINED edge detection (mask shape: {roi_mask.shape})") else: logger.debug("Using AXIS-EXPANSION edge detection (no mask available)") # For horizontal filter orientation (detecting left/right edges) # Process each row to find left and right edges if filter_orientation == "horizontal": num_rows = h left_edges = np.full(num_rows, -1.0, dtype=np.float32) right_edges = np.full(num_rows, -1.0, dtype=np.float32) edge_strengths_left = np.zeros(num_rows, dtype=np.float32) edge_strengths_right = np.zeros(num_rows, dtype=np.float32) valid_rows = np.zeros(num_rows, dtype=bool) for row in range(num_rows): # Get axis position (our anchor point INSIDE the finger) axis_x = _get_axis_x_at_row(row, axis_center, axis_direction, w) # Get gradient for this row row_gradient = gradient_magnitude[row, :] row_gradient_l2r = gradient_left_to_right[row, :] if gradient_left_to_right is not None else None row_gradient_r2l = gradient_right_to_left[row, :] if gradient_right_to_left is not None else None # Get mask for this row (if available) row_mask = roi_mask[row, :] if roi_mask is not None else None # Find edges using mask-constrained or axis-expansion method result = _find_edges_from_axis(row_gradient, row, axis_x, threshold, min_width_px, max_width_px, row_mask, row_gradient_left_to_right=row_gradient_l2r, row_gradient_right_to_left=row_gradient_r2l) if result is None: continue # No valid edges found left_edge_x, right_edge_x, left_strength, right_strength = result # Mark as valid left_edges[row] = float(left_edge_x) right_edges[row] = float(right_edge_x) edge_strengths_left[row] = left_strength edge_strengths_right[row] = right_strength valid_rows[row] = True else: # Vertical filter orientation (detecting top/bottom edges) # Process each column num_cols = w left_edges = np.full(num_cols, -1.0, dtype=np.float32) right_edges = np.full(num_cols, -1.0, dtype=np.float32) edge_strengths_left = np.zeros(num_cols, dtype=np.float32) edge_strengths_right = np.zeros(num_cols, dtype=np.float32) valid_rows = np.zeros(num_cols, dtype=bool) roi_center_y = h / 2.0 for col in range(num_cols): col_gradient = gradient_magnitude[:, col] strong_edges = np.where(col_gradient > threshold)[0] if len(strong_edges) < 2: continue top_candidates = strong_edges[strong_edges < roi_center_y] bottom_candidates = strong_edges[strong_edges >= roi_center_y] if len(top_candidates) == 0 or len(bottom_candidates) == 0: continue # Select edges closest to center (finger boundaries) top_edge_y = top_candidates[-1] # Bottommost of top candidates bottom_edge_y = bottom_candidates[0] # Topmost of bottom candidates top_strength = col_gradient[top_edge_y] bottom_strength = col_gradient[bottom_edge_y] height = bottom_edge_y - top_edge_y if expected_width_px is not None: if height < expected_width_px * 0.5 or height > expected_width_px * 1.5: continue left_edges[col] = float(top_edge_y) right_edges[col] = float(bottom_edge_y) edge_strengths_left[col] = top_strength edge_strengths_right[col] = bottom_strength valid_rows[col] = True num_valid = np.sum(valid_rows) return { "left_edges": left_edges, "right_edges": right_edges, "edge_strengths_left": edge_strengths_left, "edge_strengths_right": edge_strengths_right, "valid_rows": valid_rows, "num_valid_rows": int(num_valid), "filter_orientation": filter_orientation, "mode_used": mode_used, # "mask_constrained" or "axis_expansion" } def refine_edge_subpixel( gradient_magnitude: np.ndarray, edge_positions: np.ndarray, valid_mask: np.ndarray, method: str = "parabola" ) -> np.ndarray: """ Refine edge positions to sub-pixel precision. Uses parabola fitting on gradient magnitude to find peak position with <0.5 pixel accuracy. Args: gradient_magnitude: 2D gradient magnitude array edge_positions: Integer edge positions (one per row/col) valid_mask: Boolean mask indicating which positions are valid method: Refinement method ("parabola" or "gaussian") Returns: Refined edge positions (float, sub-pixel precision) """ refined_positions = edge_positions.copy() if method == "parabola": # Parabola fitting: fit f(x) = ax^2 + bx + c to 3 points # Peak at x = -b/(2a) for i in range(len(edge_positions)): if not valid_mask[i]: continue edge_pos = int(edge_positions[i]) # Get gradient magnitude at edge and neighbors # Handle edge cases (pun intended) if edge_pos <= 0 or edge_pos >= gradient_magnitude.shape[1] - 1: continue # Can't refine at image boundaries # For horizontal orientation (row-wise edge detection) if len(gradient_magnitude.shape) == 2 and i < gradient_magnitude.shape[0]: # Sample gradient at x-1, x, x+1 x_minus = edge_pos - 1 x_center = edge_pos x_plus = edge_pos + 1 g_minus = gradient_magnitude[i, x_minus] g_center = gradient_magnitude[i, x_center] g_plus = gradient_magnitude[i, x_plus] # Fit parabola: f(x) = ax^2 + bx + c # Using x = -1, 0, 1 for simplicity # f(-1) = a - b + c = g_minus # f(0) = c = g_center # f(1) = a + b + c = g_plus c = g_center a = (g_plus + g_minus - 2 * c) / 2.0 b = (g_plus - g_minus) / 2.0 # Peak at x_peak = -b/(2a) if abs(a) > MIN_PARABOLA_DENOMINATOR: # Avoid division by zero x_peak = -b / (2.0 * a) # Constrain to reasonable range if abs(x_peak) <= MAX_SUBPIXEL_OFFSET: refined_positions[i] = edge_pos + x_peak elif method == "gaussian": # Gaussian fitting (more complex, not implemented yet) # Would fit Gaussian to 5-pixel window # For now, fall back to parabola return refine_edge_subpixel(gradient_magnitude, edge_positions, valid_mask, method="parabola") else: raise ValueError(f"Unknown refinement method: {method}") return refined_positions def measure_width_from_edges( edge_data: Dict[str, Any], roi_data: Dict[str, Any], scale_px_per_cm: float, gradient_data: Optional[Dict[str, Any]] = None, use_subpixel: bool = True ) -> Dict[str, Any]: """ Compute finger width from detected edges. Steps: 1. Apply sub-pixel refinement if gradient data available 2. Calculate width for each valid row: width_px = right_edge - left_edge 3. Filter outliers (>3 MAD from median) 4. Compute statistics (median, mean, std) 5. Convert width from pixels to cm Args: edge_data: Output from detect_edges_per_row() roi_data: Output from extract_ring_zone_roi() scale_px_per_cm: Pixels per cm from card detection gradient_data: Optional gradient data for sub-pixel refinement use_subpixel: Enable sub-pixel refinement (default True) Returns: Dictionary containing: - widths_px: Array of width measurements (pixels) - median_width_px: Median width in pixels - median_width_cm: Median width in cm (final measurement) - mean_width_px: Mean width in pixels - std_width_px: Standard deviation of widths - num_samples: Number of valid width measurements - outliers_removed: Number of outliers filtered - subpixel_refinement_used: Whether sub-pixel refinement was applied """ left_edges = edge_data["left_edges"].copy() right_edges = edge_data["right_edges"].copy() valid_rows = edge_data["valid_rows"] # Apply sub-pixel refinement if available subpixel_used = False if use_subpixel and gradient_data is not None: try: gradient_magnitude = gradient_data["gradient_magnitude"] # Refine left edges left_edges = refine_edge_subpixel( gradient_magnitude, left_edges, valid_rows, method="parabola" ) # Refine right edges right_edges = refine_edge_subpixel( gradient_magnitude, right_edges, valid_rows, method="parabola" ) subpixel_used = True except Exception as e: logger.warning(f"Sub-pixel refinement failed: {e}, using integer positions") # Fall back to integer positions left_edges = edge_data["left_edges"] right_edges = edge_data["right_edges"] # Calculate widths for valid rows widths_px = [] for i in range(len(valid_rows)): if valid_rows[i]: width = right_edges[i] - left_edges[i] if width > 0: widths_px.append(width) if len(widths_px) == 0: raise ValueError("No valid width measurements found") widths_px = np.array(widths_px) # Filter outliers using median absolute deviation (MAD) median = np.median(widths_px) mad = np.median(np.abs(widths_px - median)) # Outliers are >3 MAD from median (more robust than std dev) if mad > 0: is_outlier = np.abs(widths_px - median) > (MAD_OUTLIER_THRESHOLD * mad) widths_filtered = widths_px[~is_outlier] outliers_removed = np.sum(is_outlier) else: widths_filtered = widths_px outliers_removed = 0 if len(widths_filtered) == 0: # All measurements were outliers, use original widths_filtered = widths_px outliers_removed = 0 # Calculate statistics median_width_px = float(np.median(widths_filtered)) mean_width_px = float(np.mean(widths_filtered)) std_width_px = float(np.std(widths_filtered)) # Convert to cm median_width_cm = median_width_px / scale_px_per_cm # Log measurements logger.debug(f"Raw median width: {median_width_px:.2f}px, scale: {scale_px_per_cm:.2f} px/cm → {median_width_cm:.4f}cm") logger.debug(f"Width range: {np.min(widths_filtered):.1f}-{np.max(widths_filtered):.1f}px, std: {std_width_px:.1f}px") return { "widths_px": widths_filtered.tolist(), "median_width_px": median_width_px, "median_width_cm": median_width_cm, "mean_width_px": mean_width_px, "std_width_px": std_width_px, "num_samples": len(widths_filtered), "outliers_removed": int(outliers_removed), "subpixel_refinement_used": subpixel_used, } def compute_edge_quality_score( gradient_data: Dict[str, Any], edge_data: Dict[str, Any], width_data: Dict[str, Any] ) -> Dict[str, Any]: """ Assess quality of edge detection for confidence scoring. Computes 4 quality metrics: 1. Gradient strength: Average gradient magnitude at detected edges 2. Edge consistency: Percentage of rows with valid edge pairs 3. Edge smoothness: Variance of edge positions along finger 4. Bilateral symmetry: Correlation between left/right edge quality Args: gradient_data: Output from apply_sobel_filters() edge_data: Output from detect_edges_per_row() width_data: Output from measure_width_from_edges() Returns: Dictionary containing: - overall_score: Weighted average (0-1) - gradient_strength_score: Gradient strength metric (0-1) - consistency_score: Edge detection success rate (0-1) - smoothness_score: Edge position smoothness (0-1) - symmetry_score: Left/right balance (0-1) - metrics: Dict with raw metric values """ gradient_magnitude = gradient_data["gradient_magnitude"] left_edges = edge_data["left_edges"] right_edges = edge_data["right_edges"] valid_rows = edge_data["valid_rows"] edge_strengths_left = edge_data["edge_strengths_left"] edge_strengths_right = edge_data["edge_strengths_right"] # Metric 1: Gradient Strength # Average gradient magnitude at detected edges, normalized valid_left_strengths = edge_strengths_left[valid_rows] valid_right_strengths = edge_strengths_right[valid_rows] if len(valid_left_strengths) > 0: avg_gradient_strength = (np.mean(valid_left_strengths) + np.mean(valid_right_strengths)) / 2.0 # Normalize: typical strong edge is 20-50, weak is <10 gradient_strength_score = min(avg_gradient_strength / GRADIENT_STRENGTH_NORMALIZER, 1.0) else: avg_gradient_strength = 0.0 gradient_strength_score = 0.0 # Metric 2: Edge Consistency # Percentage of rows with valid edge pairs total_rows = len(valid_rows) num_valid = np.sum(valid_rows) consistency_score = num_valid / total_rows if total_rows > 0 else 0.0 # Metric 3: Edge Smoothness # Measure variance of edge positions (smoother = better) # Lower variance = higher score if num_valid > 1: # Calculate variance of left and right edges separately valid_left = left_edges[valid_rows] valid_right = right_edges[valid_rows] left_variance = np.var(valid_left) right_variance = np.var(valid_right) avg_variance = (left_variance + right_variance) / 2.0 # Normalize: typical finger has variance <100, noisy edges >500 smoothness_score = np.exp(-avg_variance / SMOOTHNESS_VARIANCE_NORMALIZER) else: avg_variance = 0.0 smoothness_score = 0.0 # Metric 4: Bilateral Symmetry # Correlation between left and right edge quality (strength balance) if len(valid_left_strengths) > 1: # Calculate ratio of average strengths avg_left = np.mean(valid_left_strengths) avg_right = np.mean(valid_right_strengths) if avg_left > 0 and avg_right > 0: # Symmetric ratio close to 1.0 is good ratio = min(avg_left, avg_right) / max(avg_left, avg_right) symmetry_score = ratio # Already 0-1 else: symmetry_score = 0.0 else: symmetry_score = 0.0 # Weighted overall score overall_score = ( QUALITY_WEIGHT_GRADIENT * gradient_strength_score + QUALITY_WEIGHT_CONSISTENCY * consistency_score + QUALITY_WEIGHT_SMOOTHNESS * smoothness_score + QUALITY_WEIGHT_SYMMETRY * symmetry_score ) return { "overall_score": float(overall_score), "gradient_strength_score": float(gradient_strength_score), "consistency_score": float(consistency_score), "smoothness_score": float(smoothness_score), "symmetry_score": float(symmetry_score), "metrics": { "avg_gradient_strength": float(avg_gradient_strength), "edge_consistency_pct": float(consistency_score * 100), "avg_variance": float(avg_variance) if num_valid > 1 else 0.0, "left_right_strength_ratio": float(symmetry_score), } } def should_use_sobel_measurement( sobel_result: Dict[str, Any], contour_result: Optional[Dict[str, Any]] = None, min_quality_score: float = MIN_QUALITY_SCORE_THRESHOLD, min_consistency: float = MIN_CONSISTENCY_THRESHOLD, max_difference_pct: float = MAX_CONTOUR_DIFFERENCE_PCT ) -> Tuple[bool, str]: """ Decide whether to use Sobel measurement or fall back to contour. Decision criteria: 1. Edge quality score > min_quality_score (default 0.7) 2. Edge consistency > min_consistency (default 0.5 = 50%) 3. If contour available: Sobel and contour agree within max_difference_pct Args: sobel_result: Output from refine_edges_sobel() contour_result: Optional output from compute_cross_section_width() min_quality_score: Minimum acceptable quality score min_consistency: Minimum edge detection success rate max_difference_pct: Maximum allowed difference from contour (%) Returns: Tuple of (should_use_sobel, reason) """ # Check if edge quality data available if "edge_quality" not in sobel_result: return False, "edge_quality_data_missing" edge_quality = sobel_result["edge_quality"] # Check 1: Overall quality score if edge_quality["overall_score"] < min_quality_score: return False, f"quality_score_low_{edge_quality['overall_score']:.2f}" # Check 2: Consistency (success rate) if edge_quality["consistency_score"] < min_consistency: return False, f"consistency_low_{edge_quality['consistency_score']:.2f}" # Check 3: Measurement reasonableness sobel_width = sobel_result.get("median_width_cm") if sobel_width is None or sobel_width <= 0: return False, "invalid_measurement" # Typical finger width range if sobel_width < MIN_REALISTIC_WIDTH_CM or sobel_width > MAX_REALISTIC_WIDTH_CM: return False, f"unrealistic_width_{sobel_width:.2f}cm" # Check 4: Agreement with contour (if available) if contour_result is not None: contour_width = contour_result.get("median_width_px") sobel_width_px = sobel_result.get("median_width_px") if contour_width and sobel_width_px: diff_pct = abs(sobel_width_px - contour_width) / contour_width * 100 if diff_pct > max_difference_pct: return False, f"disagrees_with_contour_{diff_pct:.1f}pct" # All checks passed return True, "quality_acceptable" def refine_edges_sobel( image: np.ndarray, axis_data: Dict[str, Any], zone_data: Dict[str, Any], scale_px_per_cm: float, finger_landmarks: Optional[np.ndarray] = None, sobel_threshold: float = DEFAULT_GRADIENT_THRESHOLD, kernel_size: int = DEFAULT_KERNEL_SIZE, rotate_align: bool = False, use_subpixel: bool = True, expected_width_px: Optional[float] = None, debug_dir: Optional[str] = None, ) -> Dict[str, Any]: """ Main entry point for Sobel-based edge refinement. Replaces contour-based width measurement with gradient-based edge detection. Pipeline: 1. Extract ROI around ring zone 2. Apply bidirectional Sobel filters 3. Detect left/right edges per row 4. Measure width from edges 5. Convert to cm and return measurement Args: image: Input BGR image axis_data: Output from estimate_finger_axis() zone_data: Output from localize_ring_zone() scale_px_per_cm: Pixels per cm from card detection finger_landmarks: Optional 4x2 array of finger landmarks for debug sobel_threshold: Minimum gradient magnitude for valid edge kernel_size: Sobel kernel size (3, 5, or 7) rotate_align: Rotate ROI for vertical finger alignment use_subpixel: Enable sub-pixel edge localization expected_width_px: Expected width for validation (optional) debug_dir: Directory to save debug visualizations (None to skip) Returns: Dictionary containing: - median_width_cm: Final measurement in cm - median_width_px: Measurement in pixels - std_width_px: Standard deviation - num_samples: Number of valid measurements - edge_detection_success_rate: % of rows with valid edges - roi_data: ROI extraction data - gradient_data: Sobel filter data - edge_data: Edge detection data - method: "sobel" """ # Initialize debug observer if debug_dir provided if debug_dir: from src.debug_observer import DebugObserver, draw_landmark_axis, draw_ring_zone_roi from src.debug_observer import draw_roi_extraction, draw_gradient_visualization from src.debug_observer import draw_edge_candidates, draw_filtered_edge_candidates from src.debug_observer import draw_selected_edges from src.debug_observer import draw_width_measurements, draw_outlier_detection from src.debug_observer import draw_comprehensive_edge_overlay observer = DebugObserver(debug_dir) # Stage A: Axis & Zone Visualization if debug_dir: # A.1: Landmark axis observer.draw_and_save("01_landmark_axis", image, draw_landmark_axis, axis_data, finger_landmarks) # A.2: Ring zone + ROI bounds (need to extract bounds first) # We'll save this after ROI extraction # Step 1: Extract ROI roi_data = extract_ring_zone_roi( image, axis_data, zone_data, rotate_align=rotate_align ) logger.debug(f"ROI size: {roi_data['roi_width']}x{roi_data['roi_height']}px") logger.debug(f"ROI bounds: {roi_data['roi_bounds']}") if debug_dir: # A.2: Ring zone + ROI bounds roi_bounds = roi_data["roi_bounds"] observer.draw_and_save("02_ring_zone_roi", image, draw_ring_zone_roi, zone_data, roi_bounds) # A.3: ROI extraction observer.draw_and_save("03_roi_extraction", roi_data["roi_image"], draw_roi_extraction, roi_data.get("roi_mask")) # Step 2: Apply Sobel filters gradient_data = apply_sobel_filters( roi_data["roi_image"], kernel_size=kernel_size, axis_direction="auto" ) if debug_dir: # Stage B: Sobel Filtering # B.1: Left-to-right gradient grad_left = draw_gradient_visualization(gradient_data["gradient_left_to_right"], cv2.COLORMAP_JET) observer.save_stage("04_sobel_left_to_right", grad_left) # B.2: Right-to-left gradient grad_right = draw_gradient_visualization(gradient_data["gradient_right_to_left"], cv2.COLORMAP_JET) observer.save_stage("05_sobel_right_to_left", grad_right) # B.3: Gradient magnitude grad_mag = draw_gradient_visualization(gradient_data["gradient_magnitude"], cv2.COLORMAP_HOT) observer.save_stage("06_gradient_magnitude", grad_mag) # Step 3: Detect edges per row edge_data = detect_edges_per_row( gradient_data, roi_data, threshold=sobel_threshold, expected_width_px=expected_width_px, scale_px_per_cm=scale_px_per_cm ) logger.debug(f"Valid rows: {edge_data['num_valid_rows']}/{len(edge_data['valid_rows'])} ({edge_data['num_valid_rows']/len(edge_data['valid_rows'])*100:.1f}%)") if edge_data['num_valid_rows'] > 0: valid_left = edge_data['left_edges'][edge_data['valid_rows']] valid_right = edge_data['right_edges'][edge_data['valid_rows']] logger.debug(f"Left edges range: {np.min(valid_left):.1f}-{np.max(valid_left):.1f}px") logger.debug(f"Right edges range: {np.min(valid_right):.1f}-{np.max(valid_right):.1f}px") widths = valid_right - valid_left logger.debug(f"Raw widths range: {np.min(widths):.1f}-{np.max(widths):.1f}px, median: {np.median(widths):.1f}px") if debug_dir: # B.4a: All edge candidates (raw threshold, shows noise) observer.draw_and_save("07a_all_candidates", roi_data["roi_image"], draw_edge_candidates, gradient_data["gradient_magnitude"], sobel_threshold) # B.4b: Filtered edge candidates (spatially-filtered, what algorithm uses) observer.draw_and_save("07b_filtered_candidates", roi_data["roi_image"], draw_filtered_edge_candidates, gradient_data["gradient_magnitude"], sobel_threshold, roi_data.get("roi_mask"), roi_data["axis_center_in_roi"], roi_data["axis_direction_in_roi"]) # B.5: Selected edges (final detected edges) observer.draw_and_save("09_selected_edges", roi_data["roi_image"], draw_selected_edges, edge_data) # Step 4: Measure width from edges (with sub-pixel refinement) width_data = measure_width_from_edges( edge_data, roi_data, scale_px_per_cm, gradient_data=gradient_data, use_subpixel=use_subpixel ) if debug_dir: # Stage C: Measurement # C.1: Sub-pixel refinement (use selected edges for now) observer.draw_and_save("10_subpixel_refinement", roi_data["roi_image"], draw_selected_edges, edge_data) # C.2: Width measurements observer.draw_and_save("11_width_measurements", roi_data["roi_image"], draw_width_measurements, edge_data, width_data) # C.3: Width distribution (histogram - requires matplotlib) try: _save_width_distribution(width_data, debug_dir) except: pass # Skip if matplotlib not available # C.4: Outlier detection observer.draw_and_save("13_outlier_detection", roi_data["roi_image"], draw_outlier_detection, edge_data, width_data) # C.5: Comprehensive edge overlay on full image observer.draw_and_save("14_comprehensive_overlay", image, draw_comprehensive_edge_overlay, edge_data, roi_data["roi_bounds"], axis_data, zone_data, width_data, scale_px_per_cm) # Step 5: Compute edge quality score edge_quality = compute_edge_quality_score( gradient_data, edge_data, width_data ) # Calculate success rate total_rows = len(edge_data["valid_rows"]) success_rate = edge_data["num_valid_rows"] / total_rows if total_rows > 0 else 0.0 # Combine results return { "median_width_cm": width_data["median_width_cm"], "median_width_px": width_data["median_width_px"], "mean_width_px": width_data["mean_width_px"], "std_width_px": width_data["std_width_px"], "num_samples": width_data["num_samples"], "outliers_removed": width_data["outliers_removed"], "subpixel_refinement_used": width_data["subpixel_refinement_used"], "edge_detection_success_rate": success_rate, "edge_quality": edge_quality, "roi_data": roi_data, "gradient_data": gradient_data, "edge_data": edge_data, "width_data": width_data, "method": "sobel", } def _save_width_distribution(width_data: Dict[str, Any], debug_dir: str) -> None: """Helper to save width distribution histogram.""" try: import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt import os except ImportError: return widths_px = width_data.get("widths_px", []) if len(widths_px) == 0: return median_width_px = width_data["median_width_px"] mean_width_px = width_data["mean_width_px"] # Create histogram fig, ax = plt.subplots(figsize=(10, 6)) ax.hist(widths_px, bins=30, color='skyblue', edgecolor='black', alpha=0.7) ax.axvline(median_width_px, color='red', linestyle='--', linewidth=2, label=f'Median: {median_width_px:.1f} px') ax.axvline(mean_width_px, color='orange', linestyle='--', linewidth=2, label=f'Mean: {mean_width_px:.1f} px') ax.set_xlabel('Width (pixels)', fontsize=12) ax.set_ylabel('Frequency', fontsize=12) ax.set_title('Distribution of Cross-Section Widths', fontsize=14, fontweight='bold') ax.legend(fontsize=10) ax.grid(True, alpha=0.3) # Save output_path = os.path.join(debug_dir, "12_width_distribution.png") plt.savefig(output_path, dpi=150, bbox_inches='tight') plt.close() def compare_edge_methods( contour_result: Dict[str, Any], sobel_result: Dict[str, Any], scale_px_per_cm: float ) -> Dict[str, Any]: """ Compare contour-based and Sobel-based edge detection methods. Provides detailed analysis of differences, quality metrics, and recommendation on which method to use. Args: contour_result: Output from compute_cross_section_width() sobel_result: Output from refine_edges_sobel() scale_px_per_cm: Scale factor for unit conversion Returns: Dictionary containing: - contour: Summary of contour method results - sobel: Summary of Sobel method results - difference: Comparison metrics - recommendation: Which method to use and why - quality_comparison: Quality metrics comparison """ # Extract measurements contour_width_cm = contour_result["median_width_px"] / scale_px_per_cm sobel_width_cm = sobel_result["median_width_cm"] contour_width_px = contour_result["median_width_px"] sobel_width_px = sobel_result["median_width_px"] # Calculate differences diff_cm = sobel_width_cm - contour_width_cm diff_px = sobel_width_px - contour_width_px diff_pct = (diff_cm / contour_width_cm) * 100 if contour_width_cm > 0 else 0.0 # Quality comparison contour_cv = (contour_result["std_width_px"] / contour_result["median_width_px"]) if contour_result["median_width_px"] > 0 else 0.0 sobel_cv = (sobel_result["std_width_px"] / sobel_result["median_width_px"]) if sobel_result["median_width_px"] > 0 else 0.0 # Determine recommendation should_use_sobel, reason = should_use_sobel_measurement(sobel_result, contour_result) # Build summary result = { "contour": { "width_cm": float(contour_width_cm), "width_px": float(contour_width_px), "std_dev_px": float(contour_result["std_width_px"]), "coefficient_variation": float(contour_cv), "num_samples": int(contour_result["num_samples"]), "method": "contour", }, "sobel": { "width_cm": float(sobel_width_cm), "width_px": float(sobel_width_px), "std_dev_px": float(sobel_result["std_width_px"]), "coefficient_variation": float(sobel_cv), "num_samples": int(sobel_result["num_samples"]), "subpixel_used": bool(sobel_result["subpixel_refinement_used"]), "success_rate": float(sobel_result["edge_detection_success_rate"]), "edge_quality_score": float(sobel_result["edge_quality"]["overall_score"]), "method": "sobel", }, "difference": { "absolute_cm": float(diff_cm), "absolute_px": float(diff_px), "relative_pct": float(diff_pct), "precision_improvement": float(contour_result["std_width_px"] - sobel_result["std_width_px"]), }, "recommendation": { "use_sobel": bool(should_use_sobel), "reason": str(reason), "preferred_method": "sobel" if should_use_sobel else "contour", }, "quality_comparison": { "contour_cv": float(contour_cv), "sobel_cv": float(sobel_cv), "sobel_quality_score": float(sobel_result["edge_quality"]["overall_score"]), "sobel_gradient_strength": float(sobel_result["edge_quality"]["gradient_strength_score"]), "sobel_consistency": float(sobel_result["edge_quality"]["consistency_score"]), "sobel_smoothness": float(sobel_result["edge_quality"]["smoothness_score"]), "sobel_symmetry": float(sobel_result["edge_quality"]["symmetry_score"]), }, } return result