diff --git a/src/gui.py b/src/gui.py index 5f324c6..a42f7e4 100644 --- a/src/gui.py +++ b/src/gui.py @@ -353,14 +353,14 @@ class AppGUI: # Max Width ttk.Label(inner2, text="Max W:").pack(side=tk.LEFT) - self.max_width_var = tk.IntVar(value=1000) + self.max_width_var = tk.IntVar(value=840) self.max_width_entry = ttk.Entry(inner2, textvariable=self.max_width_var, width=7) self.max_width_entry.pack(side=tk.LEFT, padx=(2, 5)) self.max_width_entry.bind('', lambda e: self._update_stitch_config()) # Max Height ttk.Label(inner2, text="Max H:").pack(side=tk.LEFT) - self.max_height_var = tk.IntVar(value=1000) + self.max_height_var = tk.IntVar(value=1280) self.max_height_entry = ttk.Entry(inner2, textvariable=self.max_height_var, width=7) self.max_height_entry.pack(side=tk.LEFT, padx=(2, 5)) self.max_height_entry.bind('', lambda e: self._update_stitch_config()) @@ -856,6 +856,10 @@ class AppGUI: # Mosaic Window # ========================================================================= + # ========================================================================= + # Mosaic Window + # ========================================================================= + def _show_mosaic_window(self): if self.mosaic_window is not None: self.mosaic_window.lift() @@ -867,9 +871,30 @@ class AppGUI: self.mosaic_window.geometry("600x800") self.mosaic_window.protocol("WM_DELETE_WINDOW", self._close_mosaic_window) - self.mosaic_label = ttk.Label(self.mosaic_window, text="No mosaic yet") - self.mosaic_label.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + # Canvas for zoomable/pannable mosaic + self.mosaic_canvas = tk.Canvas(self.mosaic_window, bg='#333333', highlightthickness=0) + self.mosaic_canvas.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + # Zoom and pan state + self.mosaic_zoom = 1.0 + self.mosaic_pan_x = 0 + self.mosaic_pan_y = 0 + self.mosaic_drag_start = None + self.mosaic_full_image = None # Store full resolution for zooming + + # Bind mouse events + self.mosaic_canvas.bind('', self._on_mosaic_scroll) # Windows + self.mosaic_canvas.bind('', self._on_mosaic_scroll) # Linux scroll up + self.mosaic_canvas.bind('', self._on_mosaic_scroll) # Linux scroll down + self.mosaic_canvas.bind('', self._on_mosaic_drag_start) + self.mosaic_canvas.bind('', self._on_mosaic_drag) + self.mosaic_canvas.bind('', self._on_mosaic_drag_end) + self.mosaic_canvas.bind('', lambda e: self._render_mosaic()) + + # Double-click to reset view + self.mosaic_canvas.bind('', self._reset_mosaic_view) + + # Button frame btn_frame = ttk.Frame(self.mosaic_window) btn_frame.pack(fill=tk.X, padx=10, pady=(0, 10)) @@ -877,31 +902,204 @@ class AppGUI: command=self._save_mosaic).pack(side=tk.LEFT, padx=5) ttk.Button(btn_frame, text="Refresh", command=self._update_mosaic_window).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="Fit", + command=self._reset_mosaic_view).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="1:1", + command=self._mosaic_zoom_100).pack(side=tk.LEFT, padx=5) ttk.Button(btn_frame, text="Clear", command=self._clear_mosaic).pack(side=tk.LEFT, padx=5) + # Zoom label + self.mosaic_zoom_label = ttk.Label(btn_frame, text="100%", width=6) + self.mosaic_zoom_label.pack(side=tk.RIGHT, padx=5) + ttk.Label(btn_frame, text="Zoom:").pack(side=tk.RIGHT) + self._update_mosaic_window() + def _on_mosaic_scroll(self, event): + """Handle mouse wheel zoom""" + if self.mosaic_full_image is None: + return + + # Get mouse position relative to canvas + canvas_x = self.mosaic_canvas.canvasx(event.x) + canvas_y = self.mosaic_canvas.canvasy(event.y) + + # Determine zoom direction + if event.num == 4 or (hasattr(event, 'delta') and event.delta > 0): + scale_factor = 1.15 + elif event.num == 5 or (hasattr(event, 'delta') and event.delta < 0): + scale_factor = 1 / 1.15 + else: + return + + old_zoom = self.mosaic_zoom + self.mosaic_zoom *= scale_factor + + # Clamp zoom + self.mosaic_zoom = max(0.1, min(10.0, self.mosaic_zoom)) + + # Adjust pan to zoom toward mouse position + if old_zoom != self.mosaic_zoom: + # Calculate the point in image space under the mouse + img_x = (canvas_x - self.mosaic_pan_x) / old_zoom + img_y = (canvas_y - self.mosaic_pan_y) / old_zoom + + # Adjust pan so that same image point stays under mouse + self.mosaic_pan_x = canvas_x - img_x * self.mosaic_zoom + self.mosaic_pan_y = canvas_y - img_y * self.mosaic_zoom + + self._render_mosaic() + + def _on_mosaic_drag_start(self, event): + """Start panning""" + self.mosaic_drag_start = (event.x, event.y) + self.mosaic_canvas.config(cursor='fleur') + + def _on_mosaic_drag(self, event): + """Handle panning""" + if self.mosaic_drag_start is None: + return + + dx = event.x - self.mosaic_drag_start[0] + dy = event.y - self.mosaic_drag_start[1] + + self.mosaic_pan_x += dx + self.mosaic_pan_y += dy + + self.mosaic_drag_start = (event.x, event.y) + self._render_mosaic() + + def _on_mosaic_drag_end(self, event): + """End panning""" + self.mosaic_drag_start = None + self.mosaic_canvas.config(cursor='') + + def _reset_mosaic_view(self, event=None): + """Reset zoom and pan to fit image in canvas""" + if self.mosaic_full_image is None: + return + + canvas_w = self.mosaic_canvas.winfo_width() + canvas_h = self.mosaic_canvas.winfo_height() + img_h, img_w = self.mosaic_full_image.shape[:2] + + if img_w == 0 or img_h == 0: + return + + # Calculate zoom to fit + self.mosaic_zoom = min(canvas_w / img_w, canvas_h / img_h) + + # Center the image + scaled_w = img_w * self.mosaic_zoom + scaled_h = img_h * self.mosaic_zoom + self.mosaic_pan_x = (canvas_w - scaled_w) / 2 + self.mosaic_pan_y = (canvas_h - scaled_h) / 2 + + self._render_mosaic() + + def _mosaic_zoom_100(self): + """Set zoom to 100% (1:1 pixels)""" + if self.mosaic_full_image is None: + return + + canvas_w = self.mosaic_canvas.winfo_width() + canvas_h = self.mosaic_canvas.winfo_height() + img_h, img_w = self.mosaic_full_image.shape[:2] + + self.mosaic_zoom = 1.0 + + # Center the image + self.mosaic_pan_x = (canvas_w - img_w) / 2 + self.mosaic_pan_y = (canvas_h - img_h) / 2 + + self._render_mosaic() + + def _render_mosaic(self): + """Render the mosaic at current zoom/pan""" + if self.mosaic_window is None or self.mosaic_full_image is None: + return + + canvas_w = self.mosaic_canvas.winfo_width() + canvas_h = self.mosaic_canvas.winfo_height() + + if canvas_w <= 1 or canvas_h <= 1: + return + + img_h, img_w = self.mosaic_full_image.shape[:2] + + # Calculate visible region in image space + view_x1 = max(0, int(-self.mosaic_pan_x / self.mosaic_zoom)) + view_y1 = max(0, int(-self.mosaic_pan_y / self.mosaic_zoom)) + view_x2 = min(img_w, int((canvas_w - self.mosaic_pan_x) / self.mosaic_zoom)) + view_y2 = min(img_h, int((canvas_h - self.mosaic_pan_y) / self.mosaic_zoom)) + + if view_x2 <= view_x1 or view_y2 <= view_y1: + self.mosaic_canvas.delete('all') + return + + # Extract visible region + visible = self.mosaic_full_image[view_y1:view_y2, view_x1:view_x2] + + # Scale the visible region + new_w = int((view_x2 - view_x1) * self.mosaic_zoom) + new_h = int((view_y2 - view_y1) * self.mosaic_zoom) + + if new_w <= 0 or new_h <= 0: + return + + # Use appropriate interpolation + interp = cv2.INTER_NEAREST if self.mosaic_zoom > 1 else cv2.INTER_AREA + scaled = cv2.resize(visible, (new_w, new_h), interpolation=interp) + + # Convert to PhotoImage + rgb = cv2.cvtColor(scaled, cv2.COLOR_BGR2RGB) + img = Image.fromarray(rgb) + self.mosaic_photo = ImageTk.PhotoImage(image=img) + + # Calculate position on canvas + pos_x = max(0, self.mosaic_pan_x + view_x1 * self.mosaic_zoom) + pos_y = max(0, self.mosaic_pan_y + view_y1 * self.mosaic_zoom) + + # Update canvas + self.mosaic_canvas.delete('all') + self.mosaic_canvas.create_image(pos_x, pos_y, anchor=tk.NW, image=self.mosaic_photo) + + # Update zoom label + self.mosaic_zoom_label.config(text=f"{self.mosaic_zoom * 100:.0f}%") + def _update_mosaic_window(self): if self.mosaic_window is None: return - # Get mosaic from appropriate scanner + # Get mosaic from appropriate scanner (full resolution for zooming) mosaic = None if self.scanner_mode == 'stitch' and self.stitch_scanner: - mosaic = self.stitch_scanner.get_mosaic_preview(max_size=580) + mosaic = self.stitch_scanner.mosaic # Get full resolution + if mosaic is None: + # Try preview if no full mosaic + mosaic = self.stitch_scanner.get_mosaic_preview(max_size=2000) elif self.scanner and self.scanner.tiles: - mosaic = self.scanner.get_mosaic_preview(max_size=580) + mosaic = self.scanner.build_mosaic(scale=1.0) if mosaic is None: + self.mosaic_canvas.delete('all') + self.mosaic_canvas.create_text( + self.mosaic_canvas.winfo_width() // 2, + self.mosaic_canvas.winfo_height() // 2, + text="No mosaic yet", fill='white', font=('Arial', 14) + ) return - mosaic_rgb = cv2.cvtColor(mosaic, cv2.COLOR_BGR2RGB) - img = Image.fromarray(mosaic_rgb) - imgtk = ImageTk.PhotoImage(image=img) + # Store full image and render + self.mosaic_full_image = mosaic.copy() - self.mosaic_label.imgtk = imgtk - self.mosaic_label.config(image=imgtk, text="") + # If first time or image size changed significantly, reset view + if not hasattr(self, '_last_mosaic_size') or self._last_mosaic_size != mosaic.shape[:2]: + self._last_mosaic_size = mosaic.shape[:2] + self.mosaic_window.after(10, self._reset_mosaic_view) + else: + self._render_mosaic() # Update size label if self.stitch_scanner: @@ -912,6 +1110,9 @@ class AppGUI: if self.mosaic_window: self.mosaic_window.destroy() self.mosaic_window = None + self.mosaic_full_image = None + if hasattr(self, '_last_mosaic_size'): + del self._last_mosaic_size def _save_mosaic(self): filename = filedialog.asksaveasfilename( diff --git a/src/stitching_scanner.py b/src/stitching_scanner.py index f688606..dfb61ec 100644 --- a/src/stitching_scanner.py +++ b/src/stitching_scanner.py @@ -170,8 +170,8 @@ class StitchingScanner: blend_w = min(blend_width, w_strip, w_base) - DEBUG_SHIFT_RIGHT = 0 # Positive = shift strip right - DEBUG_SHIFT_UP = 0 # Positive = shift strip up + DEBUG_SHIFT_RIGHT = -10 # Positive = shift strip right + DEBUG_SHIFT_UP = -50 # Positive = shift strip up if append_right: # Expand mosaic to the right