From e9436f1bb58668b13617349e22f53fbf270706c8 Mon Sep 17 00:00:00 2001 From: 2ManyProjects Date: Sun, 11 Jan 2026 09:54:16 -0600 Subject: [PATCH] larger overlap --- src/stitching_scanner.py | 740 ++++++++++++++++++--------------------- 1 file changed, 349 insertions(+), 391 deletions(-) diff --git a/src/stitching_scanner.py b/src/stitching_scanner.py index 85a92bb..4954ad5 100644 --- a/src/stitching_scanner.py +++ b/src/stitching_scanner.py @@ -4,6 +4,11 @@ Stitching Scanner v2 - Simplified unified approach Same displacement-based stitching for both horizontal rows and vertical row transitions. No complex visual matching - just track displacement and append strips. Continuous alignment correction for gear slippage compensation. + +FIXES: +- Row-start alignment now checks BOTH bottom and side edges +- Larger overlap regions for better phase correlation +- Better strip capture with more overlap """ import cv2 @@ -91,7 +96,7 @@ class StitchingScanner: self._state_lock = threading.Lock() self.mosaic: Optional[np.ndarray] = None - self._mosaic_lock = threading.Lock() + self._mosaic_lock = threading.RLock() # Changed to RLock to avoid deadlocks self._prev_frame: Optional[np.ndarray] = None self._displacement_since_append_x: float = 0.0 @@ -172,6 +177,133 @@ class StitchingScanner: return (dx, dy) + def _detect_row_start_alignment(self, frame: np.ndarray, direction: ScanDirection) -> AlignmentOffset: + """ + Detect alignment at the START of a new row by comparing against BOTH: + 1. The bottom edge of the mosaic (for Y alignment from vertical movement) + 2. The appropriate side edge (for X alignment) + + This is called after a row transition to properly position the first strip. + + Args: + frame: Current camera frame at the start of the new row + direction: The horizontal scan direction for this row (LEFT or RIGHT) + + Returns: + AlignmentOffset with combined X/Y correction needed + """ + offset = AlignmentOffset() + + with self._mosaic_lock: + if self.mosaic is None: + return offset + + mh, mw = self.mosaic.shape[:2] + fh, fw = frame.shape[:2] + + # Use larger overlap regions for better alignment at row start + max_overlap = 300 # Increased from 200 for better detection + min_overlap = 50 # Increased minimum for reliability + + # ============================================= + # Step 1: Detect Y alignment from bottom edge + # ============================================= + vertical_overlap = min(fh // 2, max_overlap) + + if vertical_overlap >= min_overlap: + # Get the position where we expect the frame to be + # After row transition, the frame should overlap with the bottom of mosaic + expected_x = int(self.state.current_x) + + # Clamp X position + if direction == ScanDirection.LEFT: + # For LEFT scanning, we're at the right edge of the mosaic + expected_x = max(0, mw - fw) + else: + # For RIGHT scanning, we're at the left edge + expected_x = 0 + + # Extract bottom of mosaic + mosaic_bottom = self.mosaic[mh - vertical_overlap:mh, + expected_x:min(expected_x + fw, mw)] + frame_top = frame[:vertical_overlap, :mosaic_bottom.shape[1]] + + if mosaic_bottom.shape[0] >= min_overlap and mosaic_bottom.shape[1] >= min_overlap: + # Ensure same size + min_h = min(mosaic_bottom.shape[0], frame_top.shape[0]) + min_w = min(mosaic_bottom.shape[1], frame_top.shape[1]) + + if min_h >= min_overlap and min_w >= min_overlap: + mosaic_bottom = mosaic_bottom[:min_h, :min_w] + frame_top = frame_top[:min_h, :min_w] + + dx_v, dy_v, conf_v = self._detect_displacement_with_confidence( + mosaic_bottom, frame_top) + + self.log(f" Row-start vertical alignment: dx={dx_v:.1f}, dy={dy_v:.1f}, conf={conf_v:.3f}") + + if conf_v > 0.05: # Lower threshold for row start + offset.y_offset = dy_v + offset.confidence = conf_v + + # ============================================= + # Step 2: Detect X alignment from side edge + # ============================================= + horizontal_overlap = min(fw // 2, max_overlap) + + if horizontal_overlap >= min_overlap: + # Calculate where we expect to align horizontally + # The frame's bottom portion should overlap with mosaic's bottom + expected_y = max(0, mh - fh) # Y position based on row overlap + + if direction == ScanDirection.LEFT: + # For LEFT scan: compare left portion of frame with right edge of mosaic + mosaic_right = self.mosaic[expected_y:min(expected_y + fh, mh), + mw - horizontal_overlap:mw] + frame_left = frame[:mosaic_right.shape[0], :horizontal_overlap] + else: + # For RIGHT scan: compare right portion of frame with left edge of mosaic + # (This is mainly for row 0, but kept for completeness) + mosaic_left = self.mosaic[expected_y:min(expected_y + fh, mh), + :horizontal_overlap] + frame_right = frame[:mosaic_left.shape[0], fw - horizontal_overlap:] + mosaic_right = mosaic_left + frame_left = frame_right + + if mosaic_right.shape[0] >= min_overlap and mosaic_right.shape[1] >= min_overlap: + # Ensure same size + min_h = min(mosaic_right.shape[0], frame_left.shape[0]) + min_w = min(mosaic_right.shape[1], frame_left.shape[1]) + + if min_h >= min_overlap and min_w >= min_overlap: + mosaic_right = mosaic_right[:min_h, :min_w] + frame_left = frame_left[:min_h, :min_w] + + dx_h, dy_h, conf_h = self._detect_displacement_with_confidence( + mosaic_right, frame_left) + + self.log(f" Row-start horizontal alignment: dx={dx_h:.1f}, dy={dy_h:.1f}, conf={conf_h:.3f}") + + if conf_h > 0.05: # Lower threshold for row start + offset.x_offset = dx_h + # Use higher confidence of the two + if conf_h > offset.confidence: + offset.confidence = conf_h + + # Validate combined offset + max_adjust = 100 # Allow larger adjustment at row start + if abs(offset.x_offset) > max_adjust or abs(offset.y_offset) > max_adjust: + self.log(f" Row-start alignment: offset too large ({offset.x_offset:.1f}, {offset.y_offset:.1f}), limiting") + offset.x_offset = max(-max_adjust, min(max_adjust, offset.x_offset)) + offset.y_offset = max(-max_adjust, min(max_adjust, offset.y_offset)) + + offset.valid = offset.confidence > 0.05 + + if offset.valid: + self.log(f" Row-start alignment FINAL: X={offset.x_offset:.1f}, Y={offset.y_offset:.1f}, conf={offset.confidence:.3f}") + + return offset + def _detect_strip_alignment(self, frame: np.ndarray, direction: ScanDirection, expected_x: int, expected_y: int) -> AlignmentOffset: """ @@ -191,95 +323,100 @@ class StitchingScanner: """ offset = AlignmentOffset() - if self.mosaic is None: - return offset - - mh, mw = self.mosaic.shape[:2] - fh, fw = frame.shape[:2] - - # Clamp expected positions - expected_y = max(0, min(expected_y, mh - fh)) - expected_x = max(0, min(expected_x, mw - fw)) - - if direction == ScanDirection.RIGHT: - # We're appending to the right - # Compare left portion of frame with right edge of mosaic - overlap_width = min(fw // 2, mw - expected_x, 200) # Use up to 200px overlap - - if overlap_width < 30: + with self._mosaic_lock: + if self.mosaic is None: return offset - # Extract regions - mosaic_region = self.mosaic[expected_y:expected_y + fh, mw - overlap_width:mw] - frame_region = frame[:, :overlap_width] + mh, mw = self.mosaic.shape[:2] + fh, fw = frame.shape[:2] - elif direction == ScanDirection.LEFT: - # We're placing within existing mosaic, moving left - # Compare right portion of frame with mosaic at expected position - overlap_width = min(fw // 2, mw - expected_x, 200) + # Clamp expected positions + expected_y = max(0, min(expected_y, mh - fh)) + expected_x = max(0, min(expected_x, mw - fw)) - if overlap_width < 30: + # Increased overlap for better detection + max_overlap = 250 # Increased from 200 + min_overlap = 40 # Increased from 30 + + if direction == ScanDirection.RIGHT: + # We're appending to the right + # Compare left portion of frame with right edge of mosaic + overlap_width = min(fw // 2, mw - expected_x, max_overlap) + + if overlap_width < min_overlap: + return offset + + # Extract regions + mosaic_region = self.mosaic[expected_y:expected_y + fh, mw - overlap_width:mw] + frame_region = frame[:, :overlap_width] + + elif direction == ScanDirection.LEFT: + # We're placing within existing mosaic, moving left + # Compare right portion of frame with mosaic at expected position + overlap_width = min(fw // 2, mw - expected_x, max_overlap) + + if overlap_width < min_overlap: + return offset + + # The frame's right edge should align with mosaic at expected_x + fw + mosaic_x_end = min(expected_x + fw, mw) + mosaic_x_start = max(mosaic_x_end - overlap_width, 0) + actual_overlap = mosaic_x_end - mosaic_x_start + + if actual_overlap < min_overlap: + return offset + + mosaic_region = self.mosaic[expected_y:expected_y + fh, mosaic_x_start:mosaic_x_end] + frame_region = frame[:, fw - actual_overlap:] + + elif direction == ScanDirection.DOWN: + # We're appending below + # Compare top portion of frame with bottom edge of mosaic + overlap_height = min(fh // 2, mh - expected_y, max_overlap) + + if overlap_height < min_overlap: + return offset + + mosaic_region = self.mosaic[mh - overlap_height:mh, expected_x:expected_x + fw] + frame_region = frame[:overlap_height, :] + + else: # UP + # Compare bottom portion of frame with top edge of mosaic + overlap_height = min(fh // 2, expected_y, max_overlap) + + if overlap_height < min_overlap: + return offset + + mosaic_region = self.mosaic[:overlap_height, expected_x:expected_x + fw] + frame_region = frame[fh - overlap_height:, :] + + # Ensure regions have the same size + min_h = min(mosaic_region.shape[0], frame_region.shape[0]) + min_w = min(mosaic_region.shape[1], frame_region.shape[1]) + + if min_h < min_overlap or min_w < min_overlap: + self.log(f"Strip alignment: overlap too small ({min_w}x{min_h})") return offset - # The frame's right edge should align with mosaic at expected_x + fw - mosaic_x_end = min(expected_x + fw, mw) - mosaic_x_start = max(mosaic_x_end - overlap_width, 0) - actual_overlap = mosaic_x_end - mosaic_x_start + mosaic_region = mosaic_region[:min_h, :min_w] + frame_region = frame_region[:min_h, :min_w] - if actual_overlap < 30: + # Detect displacement with confidence + dx, dy, confidence = self._detect_displacement_with_confidence(mosaic_region, frame_region) + + # Sanity check - reject large displacements + max_adjust = 50 # Max pixels to adjust + if abs(dx) > max_adjust or abs(dy) > max_adjust: + self.log(f"Strip alignment: displacement too large ({dx:.1f}, {dy:.1f}), ignoring") return offset - mosaic_region = self.mosaic[expected_y:expected_y + fh, mosaic_x_start:mosaic_x_end] - frame_region = frame[:, fw - actual_overlap:] + offset.x_offset = dx + offset.y_offset = dy + offset.confidence = confidence + offset.valid = confidence > 0.1 # Require minimum confidence - elif direction == ScanDirection.DOWN: - # We're appending below - # Compare top portion of frame with bottom edge of mosaic - overlap_height = min(fh // 2, mh - expected_y, 200) - - if overlap_height < 30: - return offset - - mosaic_region = self.mosaic[mh - overlap_height:mh, expected_x:expected_x + fw] - frame_region = frame[:overlap_height, :] - - else: # UP - # Compare bottom portion of frame with top edge of mosaic - overlap_height = min(fh // 2, expected_y, 200) - - if overlap_height < 30: - return offset - - mosaic_region = self.mosaic[:overlap_height, expected_x:expected_x + fw] - frame_region = frame[fh - overlap_height:, :] - - # Ensure regions have the same size - min_h = min(mosaic_region.shape[0], frame_region.shape[0]) - min_w = min(mosaic_region.shape[1], frame_region.shape[1]) - - if min_h < 30 or min_w < 30: - self.log(f"Strip alignment: overlap too small ({min_w}x{min_h})") - return offset - - mosaic_region = mosaic_region[:min_h, :min_w] - frame_region = frame_region[:min_h, :min_w] - - # Detect displacement with confidence - dx, dy, confidence = self._detect_displacement_with_confidence(mosaic_region, frame_region) - - # Sanity check - reject large displacements - max_adjust = 50 # Max pixels to adjust - if abs(dx) > max_adjust or abs(dy) > max_adjust: - self.log(f"Strip alignment: displacement too large ({dx:.1f}, {dy:.1f}), ignoring") - return offset - - offset.x_offset = dx - offset.y_offset = dy - offset.confidence = confidence - offset.valid = confidence > 0.1 # Require minimum confidence - - if offset.valid: - self.log(f" Strip alignment: X={dx:.1f}, Y={dy:.1f}, conf={confidence:.3f}") + if offset.valid: + self.log(f" Strip alignment: X={dx:.1f}, Y={dy:.1f}, conf={confidence:.3f}") return offset @@ -380,8 +517,7 @@ class StitchingScanner: y_offset = y_offset - int(round(alignment_y)) # Clamp x_offset to valid range - x_offset = max(0, min(x_offset, w_base - blend_w)) - # x_offset = 0 - min(x_offset, w_base) + x_offset = 0 - min(x_offset, w_base) # Handle strip cropping if y_offset is negative (strip protrudes above frame) strip_y_start = 0 # How much to crop from top of strip @@ -590,218 +726,9 @@ class StitchingScanner: result[sh - blend_h:sh] = blended result[sh:] = base[blend_h:] return result - - def _detect_row_start_alignment(self, frame: np.ndarray, direction: ScanDirection) -> AlignmentOffset: - """ - Detect alignment at the start of a new row by comparing the current frame - with a large region of the mosaic. - - Uses a LARGE overlap (most of the frame) because after row transition: - - There's both vertical overlap (from row above) and horizontal overlap (from start position) - - More overlap = better phase correlation accuracy - - First strip alignment is critical for the entire row - - For LEFT direction (starting at right edge): compare against bottom-right of mosaic - For RIGHT direction (starting at left edge): compare against bottom-left of mosaic - """ - offset = AlignmentOffset() - - if self.mosaic is None: - return offset - - mh, mw = self.mosaic.shape[:2] - fh, fw = frame.shape[:2] - - # Use LARGE overlap - 75% of frame dimensions for better matching - overlap_width = int(fw * 0.75) - overlap_height = int(fh * 0.75) - - # Ensure we don't exceed mosaic dimensions - overlap_width = min(overlap_width, mw) - overlap_height = min(overlap_height, mh) - - if overlap_width < 100 or overlap_height < 100: - self.log(f"Row start alignment: overlap too small ({overlap_width}x{overlap_height})") - return offset - - if direction == ScanDirection.LEFT: - # Starting at right edge, going left - # Compare frame's top-right region with mosaic's bottom-right region - - # Frame region: top-right (where it overlaps with existing mosaic) - frame_region = frame[:overlap_height, fw - overlap_width:] - - # Mosaic region: bottom-right corner - mosaic_region = self.mosaic[mh - overlap_height:mh, mw - overlap_width:mw] - - else: # RIGHT direction - # Starting at left edge, going right - # Compare frame's top-left region with mosaic's bottom-left region - - # Frame region: top-left - frame_region = frame[:overlap_height, :overlap_width] - - # Mosaic region: bottom-left corner - mosaic_region = self.mosaic[mh - overlap_height:mh, :overlap_width] - - # Ensure regions have the same size - min_h = min(mosaic_region.shape[0], frame_region.shape[0]) - min_w = min(mosaic_region.shape[1], frame_region.shape[1]) - - if min_h < 100 or min_w < 100: - self.log(f"Row start alignment: region too small ({min_w}x{min_h})") - return offset - - mosaic_region = mosaic_region[:min_h, :min_w] - frame_region = frame_region[:min_h, :min_w] - - # Detect displacement with confidence - dx, dy, confidence = self._detect_displacement_with_confidence(mosaic_region, frame_region) - - # Sanity check - allow larger adjustment at row start due to gear backlash - max_adjust = 150 - if abs(dx) > max_adjust or abs(dy) > max_adjust: - self.log(f"Row start alignment: displacement too large ({dx:.1f}, {dy:.1f}), ignoring") - return offset - - offset.x_offset = dx - offset.y_offset = dy - offset.confidence = confidence - offset.valid = confidence > 0.05 # Lower threshold - large overlap should give good confidence - - self.log(f"=== Row Start Alignment ({direction.value}) ===") - self.log(f" Mosaic: {mw}x{mh}, Frame: {fw}x{fh}") - self.log(f" Overlap region: {min_w}x{min_h} (75% of frame)") - self.log(f" Detected offset: X={dx:.1f}, Y={dy:.1f}, conf={confidence:.3f}") - self.log(f" Valid: {offset.valid}") - - return offset - - - def _append_first_strip_of_row(self, frame: np.ndarray, direction: ScanDirection, alignment: AlignmentOffset): - """ - Append the first strip of a new row with proper positioning. - - For LEFT direction: Frame overlaps with bottom-right of mosaic - For RIGHT direction: Frame overlaps with bottom-left of mosaic - """ - BLEND_WIDTH = 20 - - with self._mosaic_lock: - if self.mosaic is None: - return - - mh, mw = self.mosaic.shape[:2] - fh, fw = frame.shape[:2] - - self.log(f"=== First Strip of Row ({direction.value}) ===") - self.log(f" Mosaic: {mw}x{mh}, Frame: {fw}x{fh}") - self.log(f" Alignment input: X={alignment.x_offset:.1f}, Y={alignment.y_offset:.1f}, valid={alignment.valid}") - - # Apply alignment to cumulative tracking - if alignment.valid: - self._cumulative_align_x += alignment.x_offset - self._cumulative_align_y += alignment.y_offset - self._last_strip_alignment = alignment - - # Calculate Y position - frame overlaps with bottom of mosaic - row_overlap_pixels = int(fh * self.config.row_overlap) - y_offset = mh - row_overlap_pixels + int(round(self._cumulative_align_y)) - y_offset = max(0, min(y_offset, mh - fh)) # Clamp to valid range - - if direction == ScanDirection.LEFT: - # Starting at RIGHT edge, going LEFT - # Frame's RIGHT edge aligns with mosaic's RIGHT edge - # x_offset is where the LEFT edge of the frame goes - x_offset = mw - fw + int(round(self._cumulative_align_x)) - x_offset = max(0, min(x_offset, mw - fw)) - - # For LEFT scanning, current_x tracks where LEFT edge of current frame is - # This will DECREASE as we scan left - start_x_for_scanning = x_offset - - else: # RIGHT - # Starting at LEFT edge, going RIGHT - x_offset = int(round(self._cumulative_align_x)) - x_offset = max(0, x_offset) - start_x_for_scanning = 0 - - self.log(f" Calculated x_offset: {x_offset}, y_offset: {y_offset}") - - # Blend frame into mosaic at calculated position - # Simply overwrite with blending - no expansion needed - result = self.mosaic.copy() - - # Calculate valid region - x_end = min(x_offset + fw, mw) - y_end = min(y_offset + fh, mh) - frame_x_end = x_end - x_offset - frame_y_end = y_end - y_offset - - if frame_x_end <= 0 or frame_y_end <= 0: - self.log(f" WARNING: No valid region to blend") - return - - self.log(f" Blending region: mosaic[{y_offset}:{y_end}, {x_offset}:{x_end}]") - self.log(f" Frame region: frame[0:{frame_y_end}, 0:{frame_x_end}]") - - # Create alpha mask for smooth blending - alpha = np.ones((frame_y_end, frame_x_end), dtype=np.float32) - - # Vertical blend at top (blending with row above) - v_blend = min(row_overlap_pixels // 2, frame_y_end // 3) - if v_blend > 5: - v_gradient = np.linspace(0, 1, v_blend, dtype=np.float32)[:, np.newaxis] - alpha[:v_blend, :] *= v_gradient - - # Horizontal blend at the edge we came from - h_blend = min(BLEND_WIDTH, frame_x_end // 4) - if h_blend > 5: - if direction == ScanDirection.LEFT: - # Came from right, blend right edge - h_gradient = np.linspace(1, 0, h_blend, dtype=np.float32)[np.newaxis, :] - alpha[:, -h_blend:] *= h_gradient - else: - # Came from left (or starting), blend left edge if not at edge - if x_offset > 0: - h_gradient = np.linspace(0, 1, h_blend, dtype=np.float32)[np.newaxis, :] - alpha[:, :h_blend] *= h_gradient - - # Apply blending - alpha_3ch = alpha[:, :, np.newaxis] - mosaic_region = result[y_offset:y_end, x_offset:x_end].astype(np.float32) - frame_region = frame[:frame_y_end, :frame_x_end].astype(np.float32) - - blended = (mosaic_region * (1 - alpha_3ch) + frame_region * alpha_3ch).astype(np.uint8) - result[y_offset:y_end, x_offset:x_end] = blended - - self.mosaic = result - - # Update position tracking OUTSIDE the mosaic lock - with self._state_lock: - if direction == ScanDirection.LEFT: - # For LEFT scanning: current_x is LEFT edge of where we are - # Start at right side, will decrease as we move left - self.state.current_x = x_offset - else: - # For RIGHT scanning: current_x is RIGHT edge of mosaic - self.state.current_x = 0 - - self.state.current_y = y_offset - self.state.append_count += 1 - - self.log(f" First strip placed. current_x={self.state.current_x}, current_y={self.state.current_y}") - - # Reset displacement tracking for subsequent strips - self._displacement_since_append_x = 0.0 - self._displacement_since_append_y = 0.0 - self._prev_frame = frame.copy() - - if self.on_mosaic_updated: - self.on_mosaic_updated() def _append_strip(self, frame: np.ndarray, direction: ScanDirection): - """Append strip to mosaic based on accumulated displacement.""" + """Append strip to mosaic based on accumulated displacement with continuous alignment.""" BLEND_WIDTH = 10 SAFETY_MARGIN = 2 @@ -815,6 +742,23 @@ class StitchingScanner: dx = abs(self._displacement_since_append_x) dy = abs(self._displacement_since_append_y) + # Calculate expected position for alignment detection + expected_x = int(self.state.current_x + self._cumulative_align_x) + expected_y = int(self.state.current_y + self._cumulative_align_y) + + # Detect alignment for this strip + alignment = self._detect_strip_alignment(frame, direction, expected_x, expected_y) + + if alignment.valid: + # Update cumulative alignment + self._cumulative_align_x += alignment.x_offset + self._cumulative_align_y += alignment.y_offset + self._last_strip_alignment = alignment + + # Get total alignment offsets + align_x = self._cumulative_align_x + align_y = self._cumulative_align_y + if direction in [ScanDirection.RIGHT, ScanDirection.LEFT]: append_width = round(dx) + SAFETY_MARGIN append_width = min(append_width, w - BLEND_WIDTH - 5) @@ -825,72 +769,54 @@ class StitchingScanner: pixels_consumed = append_width - SAFETY_MARGIN fractional_remainder = dx - pixels_consumed + # Calculate Y offset for current row y_offset = int(self.state.current_y) - y_offset = max(0, min(y_offset, mh - h)) if direction == ScanDirection.RIGHT: - # Expanding to the right strip_start = max(0, w - append_width - BLEND_WIDTH) new_strip = frame[:, strip_start:] - - self.log(f"RIGHT append: strip from col {strip_start}, width {new_strip.shape[1]}") - self.mosaic = self._blend_horizontal_at_y( - self.mosaic, new_strip, BLEND_WIDTH, append_right=True, - y_offset=y_offset) - - else: # LEFT - placing within existing mosaic - # current_x is where the LEFT edge of current view is - # We're moving left, so new content is on the LEFT of the frame - # We want to place the LEFT portion of the frame - + self.mosaic, new_strip, BLEND_WIDTH, append_right=True, + x_offset=int(self.state.current_x), y_offset=y_offset, + alignment_x=align_x, alignment_y=align_y) + else: strip_end = min(w, append_width + BLEND_WIDTH) new_strip = frame[:, :strip_end] - - # Calculate where to place this strip - # current_x is decreasing as we move left - # The strip goes at current_x - append_width - new_x = int(self.state.current_x) - append_width - new_x = max(0, new_x) - - self.log(f"LEFT append: current_x={self.state.current_x}, new_x={new_x}, strip width={new_strip.shape[1]}") - - # Blend into existing mosaic - result = self.mosaic.copy() - - strip_h, strip_w = new_strip.shape[:2] - x_end = min(new_x + strip_w, mw) - y_end = min(y_offset + strip_h, mh) - actual_w = x_end - new_x - actual_h = y_end - y_offset - - if actual_w > BLEND_WIDTH and actual_h > 0: - # Create horizontal blend on RIGHT side (blending with existing content) - alpha = np.ones((actual_h, actual_w), dtype=np.float32) - blend_w = min(BLEND_WIDTH, actual_w // 2) - if blend_w > 0: - h_gradient = np.linspace(1, 0, blend_w, dtype=np.float32)[np.newaxis, :] - alpha[:, -blend_w:] = h_gradient - - alpha_3ch = alpha[:, :, np.newaxis] - mosaic_region = result[y_offset:y_end, new_x:x_end].astype(np.float32) - frame_region = new_strip[:actual_h, :actual_w].astype(np.float32) - - blended = (mosaic_region * (1 - alpha_3ch) + frame_region * alpha_3ch).astype(np.uint8) - result[y_offset:y_end, new_x:x_end] = blended - - self.mosaic = result - - # Update current_x to new position (moving left) - with self._state_lock: - self.state.current_x = new_x + self.mosaic = self._blend_horizontal_at_y( + self.mosaic, new_strip, BLEND_WIDTH, append_right=False, + x_offset=int(self.state.current_x), y_offset=y_offset, + alignment_x=align_x, alignment_y=align_y) self._displacement_since_append_x = fractional_remainder self._displacement_since_append_y = 0.0 elif direction in [ScanDirection.DOWN, ScanDirection.UP]: - # ... keep existing vertical logic ... - pass + append_height = round(dy) + SAFETY_MARGIN + append_height = min(append_height, h - BLEND_WIDTH - 5) + + if append_height < 1: + return + + pixels_consumed = append_height - SAFETY_MARGIN + fractional_remainder = dy - pixels_consumed + + if direction == ScanDirection.DOWN: + strip_end = min(h, append_height + BLEND_WIDTH) + new_strip = frame[:strip_end, :] + self.mosaic = self._blend_vertical_at_x( + self.mosaic, new_strip, BLEND_WIDTH, append_below=False, + x_off=int(self.state.current_x), + alignment_x=align_x, alignment_y=align_y) + else: + strip_start = max(0, h - append_height - BLEND_WIDTH) + new_strip = frame[strip_start:, :] + self.mosaic = self._blend_vertical_at_x( + self.mosaic, new_strip, BLEND_WIDTH, append_below=True, + x_off=int(self.state.current_x), + alignment_x=align_x, alignment_y=align_y) + + self._displacement_since_append_x = 0.0 + self._displacement_since_append_y = fractional_remainder new_mh, new_mw = self.mosaic.shape[:2] @@ -901,6 +827,7 @@ class StitchingScanner: if self.on_mosaic_updated: self.on_mosaic_updated() + # ========================================================================= # Scan Control # ========================================================================= @@ -983,15 +910,16 @@ class StitchingScanner: # Serpentine: even rows right, odd rows left h_direction = ScanDirection.RIGHT if row % 2 == 0 else ScanDirection.LEFT - # For rows after the first, detect and apply row-start alignment with large overlap + # For rows > 0, detect alignment against both edges before scanning if row > 0: frame = self._capture_frame() row_alignment = self._detect_row_start_alignment(frame, h_direction) - # Append the first strip with the detected alignment - self._append_first_strip_of_row(frame, h_direction, row_alignment) - - self.log(f"After first strip - cumulative: X={self._cumulative_align_x:.1f}, Y={self._cumulative_align_y:.1f}") + if row_alignment.valid: + self.log(f"Applying row-start alignment: X={row_alignment.x_offset:.1f}, Y={row_alignment.y_offset:.1f}") + self._cumulative_align_x += row_alignment.x_offset + self._cumulative_align_y += row_alignment.y_offset + self.log(f"New cumulative alignment: X={self._cumulative_align_x:.1f}, Y={self._cumulative_align_y:.1f}") stop_reason = self._scan_direction(h_direction) @@ -1003,7 +931,7 @@ class StitchingScanner: self.log(f"Max height reached ({self.state.mosaic_height}px)") break - # Move to next row + # Move to next row using same stitching approach if not self._move_to_next_row(): self.log("Failed to move to next row") break @@ -1032,27 +960,22 @@ class StitchingScanner: frame = self._capture_frame() h, w = frame.shape[:2] + total_x = 0 # Setup based on direction if direction in [ScanDirection.RIGHT, ScanDirection.LEFT]: threshold_pixels = w * self.config.displacement_threshold + max_dim = self.config.max_mosaic_width + current_dim = lambda: self.state.mosaic_width start_cmd = 'E' if direction == ScanDirection.RIGHT else 'W' stop_cmd = 'e' if direction == ScanDirection.RIGHT else 'w' else: threshold_pixels = h * self.config.displacement_threshold + max_dim = self.config.max_mosaic_height + current_dim = lambda: self.state.mosaic_height start_cmd = 'S' if direction == ScanDirection.DOWN else 'N' stop_cmd = 's' if direction == ScanDirection.DOWN else 'n' - # Track starting position and target for LEFT direction - if direction == ScanDirection.LEFT: - start_x = self.state.current_x - target_x = 0 # We want to reach the left edge - self.log(f"LEFT scan: starting at x={start_x}, target x={target_x}") - elif direction == ScanDirection.RIGHT: - start_x = self.state.current_x - target_x = self.config.max_mosaic_width - self.log(f"RIGHT scan: starting at x={start_x}, target x={target_x}") - self._prev_frame = frame.copy() self._displacement_since_append_x = 0.0 self._displacement_since_append_y = 0.0 @@ -1061,26 +984,29 @@ class StitchingScanner: no_movement_count = 0 max_no_movement = 50 stop_reason = 'stopped' - + self.log(f"Scanning 2..") while self.running and not self.paused: if time.time() - start_time > self.config.max_scan_time: self.log("Scan timeout") stop_reason = 'timeout' break - # Check exit conditions - if direction == ScanDirection.RIGHT: - if self.state.mosaic_width >= self.config.max_mosaic_width: - self.log(f"Max width reached ({self.state.mosaic_width}px)") - stop_reason = 'max_dim' - break - - elif direction == ScanDirection.LEFT: - # Stop when we reach the left edge - if self.state.current_x <= 0: - self.log(f"Reached left edge (current_x={self.state.current_x})") - stop_reason = 'complete' - break + if current_dim() >= max_dim and direction == ScanDirection.RIGHT: + self.log(f"Max dimension reached ({current_dim()}px)") + stop_reason = 'max_dim' + break + + if self.state.current_x >= 0 and direction == ScanDirection.LEFT: + self.log(f"Returned to start ({self.config.max_mosaic_width}px)") + self.log(f"Current X offset ({self.state.current_x}px) total_x ({total_x}px)") + stop_reason = 'max_dim' + break + + if abs(self.state.current_x) >= self.config.max_mosaic_width and direction == ScanDirection.RIGHT: + self.log(f"Max dimension reached ({self.config.max_mosaic_width}px)") + self.log(f"Current X offset ({self.state.current_x}px)") + stop_reason = 'max_dim' + break # Pulse motor self.motion.send_command(start_cmd) @@ -1092,19 +1018,12 @@ class StitchingScanner: curr_frame = self._capture_frame() dx, dy = self._detect_displacement_robust(self._prev_frame, curr_frame) - # Accumulate displacement magnitude - self._displacement_since_append_x += abs(dx) + self.log(f"Scanning dx{dx} dy{dy}..") + self._displacement_since_append_x += dx self._displacement_since_append_y += dy - - # For LEFT direction, current_x DECREASES - # Phase correlation: when camera moves LEFT, content shifts RIGHT, dx > 0 - # So for LEFT scanning, we subtract dx from current_x - if direction == ScanDirection.LEFT: - with self._state_lock: - self.state.current_x -= abs(dx) # Decrease as we go left - elif direction == ScanDirection.RIGHT: - with self._state_lock: - self.state.current_x += abs(dx) # Increase as we go right + total_x += dx + with self._state_lock: + self.state.current_x += dx with self._state_lock: self.state.cumulative_x = self._displacement_since_append_x @@ -1115,6 +1034,7 @@ class StitchingScanner: # Edge detection movement = abs(dx) if direction in [ScanDirection.RIGHT, ScanDirection.LEFT] else abs(dy) + self.log(f"Scanning movement{movement}..") if movement < 1.0: no_movement_count += 1 if no_movement_count >= max_no_movement: @@ -1124,12 +1044,14 @@ class StitchingScanner: else: no_movement_count = 0 - # Append when threshold reached - disp = self._displacement_since_append_x if direction in [ScanDirection.RIGHT, ScanDirection.LEFT] else abs(self._displacement_since_append_y) + # Append when threshold reached (with continuous alignment) + disp = abs(self._displacement_since_append_x) if direction in [ScanDirection.RIGHT, ScanDirection.LEFT] else abs(self._displacement_since_append_y) + self.log(f"Scanning disp{disp}..") if disp >= threshold_pixels: + self.log(f"Scanning threshold_pixels..") self._append_strip(curr_frame, direction) - self.log(f"Appended, current_x={self.state.current_x:.0f}, mosaic: {self.state.mosaic_width}x{self.state.mosaic_height}") + self.log(f"Appended {disp:.1f}px, mosaic: {self.state.mosaic_width}x{self.state.mosaic_height}, align: ({self._cumulative_align_x:.1f}, {self._cumulative_align_y:.1f})") self._prev_frame = curr_frame.copy() @@ -1138,7 +1060,7 @@ class StitchingScanner: self.motion.send_command(stop_cmd) time.sleep(self.config.settle_time) - self.log(f"Direction finished: {stop_reason}, final current_x={self.state.current_x}") + self.log(f"Direction finished: {stop_reason}") return stop_reason def _move_to_next_row(self) -> bool: @@ -1322,6 +1244,42 @@ class StitchingScanner: return results + def test_row_start_alignment(self, direction: str = 'left') -> dict: + """Test row-start alignment detection.""" + results = { + 'success': False, + 'x_offset': 0.0, + 'y_offset': 0.0, + 'confidence': 0.0, + 'error': None + } + + try: + self.log("Testing row-start alignment detection...") + + if self.mosaic is None: + self.log("No mosaic - initializing...") + frame = self._capture_frame() + self._init_mosaic(frame) + + frame = self._capture_frame() + scan_dir = ScanDirection.LEFT if direction == 'left' else ScanDirection.RIGHT + + alignment = self._detect_row_start_alignment(frame, scan_dir) + + results['success'] = alignment.valid + results['x_offset'] = alignment.x_offset + results['y_offset'] = alignment.y_offset + results['confidence'] = alignment.confidence + + self.log(f"Row-start alignment: valid={alignment.valid}, X={alignment.x_offset:.1f}, Y={alignment.y_offset:.1f}, conf={alignment.confidence:.3f}") + + except Exception as e: + results['error'] = str(e) + self.log(f"Test error: {e}") + + return results + def test_row_transition(self) -> dict: """Test row transition using displacement stitching.""" results = {