Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |