From 26bfd663d5d791003793174d694871049cc5d5ee Mon Sep 17 00:00:00 2001 From: 2ManyProjects Date: Sun, 11 Jan 2026 21:11:34 -0600 Subject: [PATCH] better region matching --- src/stitching_scanner.py | 237 ++++++++++++++++++++++----------------- 1 file changed, 131 insertions(+), 106 deletions(-) diff --git a/src/stitching_scanner.py b/src/stitching_scanner.py index 3abf237..0a4d78c 100644 --- a/src/stitching_scanner.py +++ b/src/stitching_scanner.py @@ -6,23 +6,30 @@ 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 +- Row-start alignment now compares with TRANSITION STRIPS, not old row 1 +- Correct X position tracking after row transition +- Fixed LEFT scan stop condition DEBUG BORDER COLORS (for debugging row 2 placement): ================================================= In _detect_row_start_alignment(): - - WHITE (thick): Outline of where OLD ROW 1 content is located in mosaic - - BLUE line: Y=0 line (where NEW ROW should start) - - ORANGE line: Y=frame_height (bottom of new frame placement area) - - CYAN: Mosaic region used for Y alignment comparison + - WHITE (thick): Outline of where TRANSITION STRIPS are in mosaic (X=x_offset, Y=0 to transition_height) + - BLUE line: Y=transition_height (boundary between transition strips and old row 1) + - CYAN: Mosaic region used for Y alignment comparison (bottom of transition strips) - MAGENTA: Where the new frame is EXPECTED to be placed - - YELLOW: Mosaic region used for X alignment comparison + - YELLOW: Mosaic region used for X alignment comparison (edge of transition strips) In _blend_horizontal_at_y (append_left): - RED (thick): Where strip WOULD have been placed (ORIGINAL position before alignment) - GREEN: Where strip was ACTUALLY placed (ADJUSTED position after alignment) + +MOSAIC LAYOUT AFTER DOWN TRANSITION: +==================================== + Y=0 to Y=transition_height: TRANSITION STRIPS (at X=x_offset to X=x_offset+fw) + Y=transition_height to Y=mh: OLD ROW 1 (shifted down, at X=0 to X=mw) + + Where x_offset = mosaic_width - initial_frame_width + And transition_height = mosaic_height - initial_frame_height """ import cv2 @@ -124,6 +131,11 @@ class StitchingScanner: # This is critical for placing strips at the correct vertical position self._row_start_y: int = 0 + # Track the X position in the mosaic where the current row starts + # For row 1 (RIGHT scan): starts at X=0 + # For row 2 (LEFT scan): starts at X = mosaic_width - frame_width (right edge) + self._row_start_x: int = 0 + # Last strip's alignment for continuity self._last_strip_alignment = AlignmentOffset() @@ -200,10 +212,11 @@ class StitchingScanner: Detect alignment at the START of a new row. After a DOWN transition with prepend, the mosaic layout is: - - Y=0 to Y≈(transition_amount): NEW content from transition - - Y≈(transition_amount) to Y=mosaic_height: OLD row 1 content (shifted down) + - Y=0 to Y≈transition_height: TRANSITION STRIPS (placed at x_offset) + - Y≈transition_height to Y=mosaic_height: OLD row 1 content (shifted down) - The new frame should overlap visually with OLD row 1 content. + The current frame should overlap with the TRANSITION STRIPS, not old row 1. + The camera is at the X position where transition strips were placed. """ offset = AlignmentOffset() @@ -214,27 +227,30 @@ class StitchingScanner: mh, mw = self.mosaic.shape[:2] fh, fw = frame.shape[:2] - # After prepending during row transition: - # Old row 1 content was originally at Y=0 to Y=fh in the old mosaic - # Now it's shifted down by (mh - fh) pixels - # So old row 1 starts at approximately Y = mh - fh - old_row1_start = mh - fh + # Calculate where transition strips are in the mosaic + # x_offset is where the DOWN transition strips were placed horizontally + x_offset = max(0, mw - self.state.mosaic_init_width) + + # Transition height is how much was added during DOWN transition + # transition_height = mh - original_height_before_transition + # But since row 1 was just fh tall, transition_height = mh - fh + transition_height = mh - fh self.log(f" Row-start alignment: mosaic {mw}x{mh}, frame {fw}x{fh}") - self.log(f" Old row 1 estimated start: Y={old_row1_start}") + self.log(f" Transition strips at: X={x_offset}, Y=0 to Y={transition_height}") + self.log(f" Old row 1 shifted to: Y={transition_height} to Y={mh}") - # ========== DEBUG: Draw WHITE border showing where OLD ROW 1 content is ========== - if old_row1_start >= 0: - cv2.rectangle(self.mosaic, - (0, old_row1_start), - (mw, min(old_row1_start + fh, mh)), - (255, 255, 255), 3) # WHITE - old row 1 location - self.log(f" DEBUG: WHITE border - OLD ROW 1 location Y={old_row1_start}:{min(old_row1_start + fh, mh)}") + # ========== DEBUG: Draw borders showing layout ========== + # WHITE border: Where TRANSITION STRIPS are (this is what we should compare with) + cv2.rectangle(self.mosaic, + (x_offset, 0), + (min(x_offset + fw, mw), transition_height), + (255, 255, 255), 3) # WHITE - transition strips location + self.log(f" DEBUG: WHITE border - TRANSITION STRIPS at X={x_offset}:{min(x_offset + fw, mw)}, Y=0:{transition_height}") - # ========== DEBUG: Draw BLUE line at Y=0 showing where NEW ROW starts ========== - cv2.line(self.mosaic, (0, 0), (mw, 0), (255, 0, 0), 3) # BLUE line at Y=0 - cv2.line(self.mosaic, (0, fh), (mw, fh), (255, 100, 0), 2) # ORANGE line at Y=fh (bottom of new frame area) - self.log(f" DEBUG: BLUE line at Y=0 (new row start), ORANGE line at Y={fh} (new frame bottom)") + # BLUE line at transition boundary (where transition ends and old row 1 begins) + cv2.line(self.mosaic, (0, transition_height), (mw, transition_height), (255, 0, 0), 3) + self.log(f" DEBUG: BLUE line at Y={transition_height} (transition/row1 boundary)") vertical_overlap = min(200, fh // 3) min_overlap = 50 @@ -242,79 +258,78 @@ class StitchingScanner: # ============================================= # Step 1: Detect Y alignment # ============================================= - # Compare frame's bottom with the TOP of old row 1 content - # Frame's bottom: frame[fh-overlap:fh] - # Old row 1's top: mosaic[old_row1_start:old_row1_start+overlap] + # Compare frame's TOP with the BOTTOM of transition strips + # Frame's top portion overlaps with mosaic's transition bottom + # + # Transition strips are at Y=0 to Y=transition_height + # So transition bottom is around Y=(transition_height - overlap) to Y=transition_height - if old_row1_start >= 0 and old_row1_start + vertical_overlap <= mh: - expected_x = 0 if direction == ScanDirection.RIGHT else max(0, mw - fw) - x_end = min(expected_x + fw, mw) + transition_compare_y_end = transition_height + transition_compare_y_start = max(0, transition_height - vertical_overlap) + + # Frame's TOP should overlap with transition BOTTOM + frame_top = frame[:vertical_overlap, :] + + # Get the X range - centered on where transition strips are + x_start = x_offset + x_end = min(x_offset + fw, mw) + compare_width = x_end - x_start + + if transition_compare_y_start >= 0 and compare_width >= min_overlap: + # Mosaic region: bottom of transition strips + mosaic_transition_bottom = self.mosaic[transition_compare_y_start:transition_compare_y_end, + x_start:x_end] - # Frame's bottom portion (will overlap with old row 1's top) - frame_bottom = frame[fh - vertical_overlap:fh, :x_end - expected_x] - - # Old row 1's top portion in the mosaic - mosaic_top_of_old = self.mosaic[old_row1_start:old_row1_start + vertical_overlap, - expected_x:x_end] + # Frame region: top portion (what overlaps with transition) + frame_compare = frame_top[:, :compare_width] # ========== DEBUG: Save frame with comparison region marked ========== debug_frame = frame.copy() - # CYAN border on frame showing the region being compared - cv2.rectangle(debug_frame, - (0, fh - vertical_overlap), - (x_end - expected_x, fh), - (255, 255, 0), 2) # CYAN - frame's bottom region used for Y comparison - self.log(f" DEBUG: Frame comparison region: Y={fh - vertical_overlap}:{fh}") - - # Save debug frame + cv2.rectangle(debug_frame, (0, 0), (compare_width, vertical_overlap), + (255, 255, 0), 2) # CYAN - frame's top region used for Y comparison + self.log(f" DEBUG: Frame Y comparison region: X=0:{compare_width}, Y=0:{vertical_overlap}") try: cv2.imwrite('/tmp/debug_frame_row2_start.png', debug_frame) - self.log(f" DEBUG: Saved frame to /tmp/debug_frame_row2_start.png") except: pass - # ========== DEBUG: Draw borders on mosaic for comparison regions ========== - # CYAN border: Mosaic region used for Y alignment comparison + # ========== DEBUG: Draw CYAN border on mosaic for Y comparison region ========== cv2.rectangle(self.mosaic, - (expected_x, old_row1_start), - (x_end, old_row1_start + vertical_overlap), + (x_start, transition_compare_y_start), + (x_end, transition_compare_y_end), (255, 255, 0), 2) # CYAN - mosaic comparison region for Y - self.log(f" DEBUG: CYAN border - mosaic Y comparison region X={expected_x}:{x_end}, Y={old_row1_start}:{old_row1_start + vertical_overlap}") + self.log(f" DEBUG: CYAN border - mosaic Y comparison region X={x_start}:{x_end}, Y={transition_compare_y_start}:{transition_compare_y_end}") - # MAGENTA border: Where frame WOULD be placed (Y=0 to fh) + # MAGENTA border: Where frame is EXPECTED to be placed (at transition position) cv2.rectangle(self.mosaic, - (expected_x, 0), - (min(expected_x + fw, mw), fh), + (x_start, 0), + (x_end, fh), (255, 0, 255), 2) # MAGENTA - expected frame position - self.log(f" DEBUG: MAGENTA border - expected frame position X={expected_x}:{min(expected_x + fw, mw)}, Y=0:{fh}") + self.log(f" DEBUG: MAGENTA border - expected frame position X={x_start}:{x_end}, Y=0:{fh}") - min_w = min(frame_bottom.shape[1], mosaic_top_of_old.shape[1]) - min_h = min(frame_bottom.shape[0], mosaic_top_of_old.shape[0]) + min_w = min(frame_compare.shape[1], mosaic_transition_bottom.shape[1]) + min_h = min(frame_compare.shape[0], mosaic_transition_bottom.shape[0]) if min_w >= min_overlap and min_h >= min_overlap: - frame_bottom = frame_bottom[:min_h, :min_w] - mosaic_top_of_old = mosaic_top_of_old[:min_h, :min_w] + frame_compare = frame_compare[:min_h, :min_w] + mosaic_transition_bottom = mosaic_transition_bottom[:min_h, :min_w] # ========== DEBUG: Save the comparison regions as images ========== try: - cv2.imwrite('/tmp/debug_frame_bottom_region.png', frame_bottom) - cv2.imwrite('/tmp/debug_mosaic_top_region.png', mosaic_top_of_old) + cv2.imwrite('/tmp/debug_frame_top_region.png', frame_compare) + cv2.imwrite('/tmp/debug_mosaic_transition_region.png', mosaic_transition_bottom) self.log(f" DEBUG: Saved comparison regions to /tmp/debug_*.png") except: pass - # Detect displacement: how is frame_bottom shifted relative to mosaic_top_of_old? + # Detect displacement dx_v, dy_v, conf_v = self._detect_displacement_with_confidence( - mosaic_top_of_old, frame_bottom) + mosaic_transition_bottom, frame_compare) self.log(f" Row-start Y alignment: dx={dx_v:.1f}, dy={dy_v:.1f}, conf={conf_v:.3f}") - self.log(f" Compared frame[{fh-vertical_overlap}:{fh}] with mosaic[{old_row1_start}:{old_row1_start+vertical_overlap}]") + self.log(f" Compared frame[0:{vertical_overlap}] with mosaic[{transition_compare_y_start}:{transition_compare_y_end}]") if conf_v > 0.1: - # dy > 0 means frame_bottom is shifted DOWN relative to mosaic_top_of_old - # To correct in placement: move content UP - # blend formula: y_offset = row_start_y - alignment_y - # So positive alignment_y decreases y_offset, moving content UP offset.y_offset = dy_v offset.confidence = conf_v @@ -324,22 +339,26 @@ class StitchingScanner: horizontal_overlap = min(200, fw // 3) if direction == ScanDirection.LEFT: - # For LEFT scan: frame starts at right edge - # Compare frame's right edge with mosaic's right edge at OLD row 1 position - if old_row1_start >= 0 and mw >= horizontal_overlap: - y_start = old_row1_start - y_end = min(old_row1_start + fh, mh) + # For LEFT scan: frame starts at the transition X position + # Compare frame's RIGHT edge with mosaic's transition strip RIGHT edge + + transition_x_end = min(x_offset + fw, mw) + transition_x_start = max(x_offset, transition_x_end - horizontal_overlap) + + # Y range: within the transition strip area + y_start = 0 + y_end = min(transition_height, fh) + + if transition_x_end - transition_x_start >= min_overlap: + mosaic_edge = self.mosaic[y_start:y_end, transition_x_start:transition_x_end] + frame_edge = frame[:y_end, fw - (transition_x_end - transition_x_start):fw] - mosaic_edge = self.mosaic[y_start:y_end, mw - horizontal_overlap:mw] - frame_edge = frame[:mosaic_edge.shape[0], fw - horizontal_overlap:fw] - - # ========== DEBUG: Draw border for X comparison region ========== - # YELLOW border: Mosaic region used for X alignment comparison + # ========== DEBUG: Draw YELLOW border for X comparison region ========== cv2.rectangle(self.mosaic, - (mw - horizontal_overlap, y_start), - (mw, y_end), + (transition_x_start, y_start), + (transition_x_end, y_end), (0, 255, 255), 2) # YELLOW - mosaic comparison region for X - self.log(f" DEBUG: YELLOW border - mosaic X comparison region X={mw - horizontal_overlap}:{mw}, Y={y_start}:{y_end}") + self.log(f" DEBUG: YELLOW border - mosaic X comparison region X={transition_x_start}:{transition_x_end}, Y={y_start}:{y_end}") min_h = min(mosaic_edge.shape[0], frame_edge.shape[0]) min_w = min(mosaic_edge.shape[1], frame_edge.shape[1]) @@ -354,28 +373,26 @@ class StitchingScanner: self.log(f" Row-start X alignment: dx={dx_h:.1f}, dy={dy_h:.1f}, conf={conf_h:.3f}") if conf_h > 0.1: - # dx > 0 means frame shifted RIGHT - # To correct: move LEFT (decrease x_offset) - # blend: x_offset = x_offset + alignment_x - # So negative alignment_x decreases x_offset offset.x_offset = -dx_h if conf_h > offset.confidence: offset.confidence = conf_h else: - # For RIGHT scan at row start - if old_row1_start >= 0 and horizontal_overlap > 0: - y_start = old_row1_start - y_end = min(old_row1_start + fh, mh) + # For RIGHT scan at row start (similar logic but for left edge) + transition_x_start = x_offset + transition_x_end = min(x_offset + horizontal_overlap, mw) + + y_start = 0 + y_end = min(transition_height, fh) + + if transition_x_end - transition_x_start >= min_overlap: + mosaic_edge = self.mosaic[y_start:y_end, transition_x_start:transition_x_end] + frame_edge = frame[:y_end, :transition_x_end - transition_x_start] - mosaic_edge = self.mosaic[y_start:y_end, :horizontal_overlap] - frame_edge = frame[:min(y_end - y_start, fh), :horizontal_overlap] - - # ========== DEBUG: Draw border for X comparison region ========== cv2.rectangle(self.mosaic, - (0, y_start), - (horizontal_overlap, y_end), - (0, 255, 255), 2) # YELLOW - mosaic comparison region for X - self.log(f" DEBUG: YELLOW border - mosaic X comparison region X=0:{horizontal_overlap}, Y={y_start}:{y_end}") + (transition_x_start, y_start), + (transition_x_end, y_end), + (0, 255, 255), 2) # YELLOW + self.log(f" DEBUG: YELLOW border - mosaic X comparison region X={transition_x_start}:{transition_x_end}, Y={y_start}:{y_end}") min_h = min(mosaic_edge.shape[0], frame_edge.shape[0]) min_w = min(mosaic_edge.shape[1], frame_edge.shape[1]) @@ -1049,6 +1066,7 @@ class StitchingScanner: self.log(f"=== Row {row + 1} ===") self.log(f"Row start Y position: {self._row_start_y}") + self.log(f"Row start X position: {self.state.current_x}") self.log(f"Cumulative alignment at row start: X={self._cumulative_align_x:.1f}, Y={self._cumulative_align_y:.1f}") # Serpentine: even rows right, odd rows left @@ -1140,10 +1158,10 @@ class StitchingScanner: 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' + # For LEFT scan: stop when we've reached the left edge (current_x <= 0) + if self.state.current_x <= 0 and direction == ScanDirection.LEFT: + self.log(f"Reached left edge (current_x={self.state.current_x:.1f})") + stop_reason = 'edge' break if abs(self.state.current_x) >= self.config.max_mosaic_width and direction == ScanDirection.RIGHT: @@ -1286,14 +1304,21 @@ class StitchingScanner: # Calculate the Y position in the mosaic where the new row starts # Since we use append_below=False during DOWN movement, new content # is PREPENDED to the TOP of the mosaic. So the new row starts at Y=0 - # (or close to it, accounting for overlap) - overlap_pixels = int(h * self.config.row_overlap) self._row_start_y = 0 # New row is at the TOP after prepending + # Calculate the X position where transition strips were placed + # This is where the camera is now (ready to start row 2) + x_offset = max(0, self.state.mosaic_width - self.state.mosaic_init_width) + self.log(f"New row Y position: {self._row_start_y} (mosaic height: {self.state.mosaic_height})") + self.log(f"New row X position: {x_offset} (transition strip location)") with self._state_lock: self.state.current_y = 0 + # Set current_x to the x_offset where transition strips are + # This is where row 2 will START (then scan LEFT from here) + self.state.current_x = x_offset + self.motion.send_command('s') time.sleep(self.config.settle_time)