diff --git a/src/stitching_scanner.py b/src/stitching_scanner.py index e7fff7b..37eee02 100644 --- a/src/stitching_scanner.py +++ b/src/stitching_scanner.py @@ -594,12 +594,15 @@ class StitchingScanner: 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 the corner region of the mosaic. + with a large region of the mosaic. - For LEFT direction (starting at right edge): compare against bottom-right corner - For RIGHT direction (starting at left edge): compare against bottom-left corner + 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 - This handles the combined X+Y offset from gear backlash during row transitions. + 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() @@ -609,38 +612,43 @@ class StitchingScanner: mh, mw = self.mosaic.shape[:2] fh, fw = frame.shape[:2] - # Use a corner region for alignment - this captures both X and Y offset - overlap_size = min(fw // 2, fh // 2, 200) # Square-ish overlap region + # Use LARGE overlap - 75% of frame dimensions for better matching + overlap_width = int(fw * 0.75) + overlap_height = int(fh * 0.75) - if overlap_size < 50: - self.log(f"Row start alignment: overlap too small ({overlap_size})") + # 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 corner with mosaic's bottom-right corner + # Compare frame's top-right region with mosaic's bottom-right region - # Frame region: top-right corner - frame_region = frame[:overlap_size, fw - overlap_size:] + # 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_size:mh, mw - overlap_size:mw] + 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 corner with mosaic's bottom-left corner + # Compare frame's top-left region with mosaic's bottom-left region - # Frame region: top-left corner - frame_region = frame[:overlap_size, :overlap_size] + # Frame region: top-left + frame_region = frame[:overlap_height, :overlap_width] - # Mosaic region: bottom-left corner - mosaic_region = self.mosaic[mh - overlap_size:mh, :overlap_size] + # 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 < 50 or min_w < 50: + if min_h < 100 or min_w < 100: self.log(f"Row start alignment: region too small ({min_w}x{min_h})") return offset @@ -650,8 +658,8 @@ class StitchingScanner: # Detect displacement with confidence dx, dy, confidence = self._detect_displacement_with_confidence(mosaic_region, frame_region) - # Sanity check - reject very large displacements - max_adjust = 100 # Allow larger adjustment at row start + # 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 @@ -659,15 +667,164 @@ class StitchingScanner: offset.x_offset = dx offset.y_offset = dy offset.confidence = confidence - offset.valid = confidence > 0.05 # Lower threshold for row start + 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}") + 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 special handling for large overlap. + + This uses the alignment detected by _detect_row_start_alignment to properly + position the first strip, which sets the baseline for the entire row. + """ + BLEND_WIDTH = 10 + + with self._mosaic_lock: + if self.mosaic is None: + return + + mh, mw = self.mosaic.shape[:2] + fh, fw = frame.shape[:2] + + # 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 + self.log(f"Applied first-strip alignment: X={alignment.x_offset:.1f}, Y={alignment.y_offset:.1f}") + + align_x = self._cumulative_align_x + align_y = self._cumulative_align_y + + # Calculate Y position - we're at the bottom of existing mosaic + # The frame overlaps with the last ~row_overlap portion of existing content + row_overlap_pixels = int(fh * self.config.row_overlap) + y_offset = mh - row_overlap_pixels + + # Apply Y alignment correction + y_offset = y_offset + int(round(align_y)) + y_offset = max(0, y_offset) + + if direction == ScanDirection.LEFT: + # Starting at right edge - place frame aligned with right side of mosaic + # The frame's right edge should align with mosaic's right edge (with X correction) + x_offset = mw - fw + int(round(align_x)) + x_offset = max(0, min(x_offset, mw - BLEND_WIDTH)) + + self.log(f"=== First Strip of Row (LEFT) ===") + self.log(f" Mosaic: {mw}x{mh}, Frame: {fw}x{fh}") + self.log(f" Placement: X={x_offset}, Y={y_offset}") + self.log(f" Alignment: X={align_x:.1f}, Y={align_y:.1f}") + + # Blend this frame into the mosaic at the calculated position + # We're placing within existing bounds, so just blend in place + + # Calculate what portion of the frame to use + # Most of it overlaps, we just need to blend it properly + blend_region_width = min(fw, mw - x_offset) + + # Create blended result + result = self.mosaic.copy() + + # Blend the overlapping region + frame_to_place = frame[:, :blend_region_width] + + # Vertical blend at top edge (blending with row above) + v_blend_h = min(row_overlap_pixels, BLEND_WIDTH * 2) + if v_blend_h > 0 and y_offset > 0: + alpha_v = np.linspace(0, 1, v_blend_h, dtype=np.float32)[:, np.newaxis, np.newaxis] + + blend_y_start = y_offset + blend_y_end = min(y_offset + v_blend_h, mh) + actual_blend_h = blend_y_end - blend_y_start + + if actual_blend_h > 0: + mosaic_overlap = result[blend_y_start:blend_y_end, x_offset:x_offset + blend_region_width].astype(np.float32) + frame_overlap = frame_to_place[:actual_blend_h, :].astype(np.float32) + + # Resize alpha if needed + alpha_v_actual = np.linspace(0, 1, actual_blend_h, dtype=np.float32)[:, np.newaxis, np.newaxis] + + min_w_blend = min(mosaic_overlap.shape[1], frame_overlap.shape[1]) + mosaic_overlap = mosaic_overlap[:, :min_w_blend] + frame_overlap = frame_overlap[:, :min_w_blend] + + blended = (mosaic_overlap * (1 - alpha_v_actual) + frame_overlap * alpha_v_actual).astype(np.uint8) + result[blend_y_start:blend_y_end, x_offset:x_offset + min_w_blend] = blended + + # Place rest of frame below blend zone + if y_offset + v_blend_h < mh: + remaining_h = min(fh - v_blend_h, mh - (y_offset + v_blend_h)) + if remaining_h > 0: + result[y_offset + v_blend_h:y_offset + v_blend_h + remaining_h, + x_offset:x_offset + min_w_blend] = frame_to_place[v_blend_h:v_blend_h + remaining_h, :min_w_blend] + + self.mosaic = result + + else: # RIGHT direction + # Starting at left edge - place frame aligned with left side of mosaic + x_offset = int(round(align_x)) + x_offset = max(0, x_offset) + + self.log(f"=== First Strip of Row (RIGHT) ===") + self.log(f" Mosaic: {mw}x{mh}, Frame: {fw}x{fh}") + self.log(f" Placement: X={x_offset}, Y={y_offset}") + self.log(f" Alignment: X={align_x:.1f}, Y={align_y:.1f}") + + # Similar blending logic for right direction + result = self.mosaic.copy() + blend_region_width = min(fw, mw - x_offset) + frame_to_place = frame[:, :blend_region_width] + + # Vertical blend at top edge + v_blend_h = min(row_overlap_pixels, BLEND_WIDTH * 2) + if v_blend_h > 0 and y_offset > 0: + blend_y_start = y_offset + blend_y_end = min(y_offset + v_blend_h, mh) + actual_blend_h = blend_y_end - blend_y_start + + if actual_blend_h > 0: + mosaic_overlap = result[blend_y_start:blend_y_end, x_offset:x_offset + blend_region_width].astype(np.float32) + frame_overlap = frame_to_place[:actual_blend_h, :].astype(np.float32) + + alpha_v_actual = np.linspace(0, 1, actual_blend_h, dtype=np.float32)[:, np.newaxis, np.newaxis] + + min_w_blend = min(mosaic_overlap.shape[1], frame_overlap.shape[1]) + mosaic_overlap = mosaic_overlap[:, :min_w_blend] + frame_overlap = frame_overlap[:, :min_w_blend] + + blended = (mosaic_overlap * (1 - alpha_v_actual) + frame_overlap * alpha_v_actual).astype(np.uint8) + result[blend_y_start:blend_y_end, x_offset:x_offset + min_w_blend] = blended + + if y_offset + v_blend_h < mh: + remaining_h = min(fh - v_blend_h, mh - (y_offset + v_blend_h)) + if remaining_h > 0: + result[y_offset + v_blend_h:y_offset + v_blend_h + remaining_h, + x_offset:x_offset + min_w_blend] = frame_to_place[v_blend_h:v_blend_h + remaining_h, :min_w_blend] + + self.mosaic = result + + # Update current position for subsequent strips + with self._state_lock: + if direction == ScanDirection.LEFT: + self.state.current_x = x_offset + else: + self.state.current_x = x_offset + blend_region_width + self.state.current_y = y_offset + self.state.append_count += 1 + + self.log(f" First strip placed. Position: ({self.state.current_x}, {self.state.current_y})") + + 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 with continuous alignment.""" BLEND_WIDTH = 10 @@ -851,16 +1008,15 @@ 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 row-start alignment + # For rows after the first, detect and apply row-start alignment with large overlap if row > 0: frame = self._capture_frame() row_alignment = self._detect_row_start_alignment(frame, h_direction) - if row_alignment.valid: - # Apply row-start alignment to cumulative - self._cumulative_align_x += row_alignment.x_offset - self._cumulative_align_y += row_alignment.y_offset - self.log(f"Applied row-start alignment: X={row_alignment.x_offset:.1f}, Y={row_alignment.y_offset:.1f}") - self.log(f"New cumulative: X={self._cumulative_align_x:.1f}, Y={self._cumulative_align_y:.1f}") + + # 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}") stop_reason = self._scan_direction(h_direction) @@ -872,7 +1028,7 @@ class StitchingScanner: self.log(f"Max height reached ({self.state.mosaic_height}px)") break - # Move to next row using same stitching approach + # Move to next row if not self._move_to_next_row(): self.log("Failed to move to next row") break @@ -891,6 +1047,7 @@ class StitchingScanner: self.motion.stop_all() with self._state_lock: self.state.is_scanning = False + def _scan_direction(self, direction: ScanDirection) -> str: """Scan in a direction until edge or max dimension reached.""" self.log(f"Scanning {direction.value}...")