zoomable mosiac

This commit is contained in:
2ManyProjects 2026-01-10 12:36:27 -06:00
parent d7321c5ef4
commit a7c2d89cc0
2 changed files with 215 additions and 14 deletions

View file

@ -353,14 +353,14 @@ class AppGUI:
# Max Width # Max Width
ttk.Label(inner2, text="Max W:").pack(side=tk.LEFT) 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 = 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.pack(side=tk.LEFT, padx=(2, 5))
self.max_width_entry.bind('<Return>', lambda e: self._update_stitch_config()) self.max_width_entry.bind('<Return>', lambda e: self._update_stitch_config())
# Max Height # Max Height
ttk.Label(inner2, text="Max H:").pack(side=tk.LEFT) 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 = 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.pack(side=tk.LEFT, padx=(2, 5))
self.max_height_entry.bind('<Return>', lambda e: self._update_stitch_config()) self.max_height_entry.bind('<Return>', lambda e: self._update_stitch_config())
@ -856,6 +856,10 @@ class AppGUI:
# Mosaic Window # Mosaic Window
# ========================================================================= # =========================================================================
# =========================================================================
# Mosaic Window
# =========================================================================
def _show_mosaic_window(self): def _show_mosaic_window(self):
if self.mosaic_window is not None: if self.mosaic_window is not None:
self.mosaic_window.lift() self.mosaic_window.lift()
@ -867,9 +871,30 @@ class AppGUI:
self.mosaic_window.geometry("600x800") self.mosaic_window.geometry("600x800")
self.mosaic_window.protocol("WM_DELETE_WINDOW", self._close_mosaic_window) self.mosaic_window.protocol("WM_DELETE_WINDOW", self._close_mosaic_window)
self.mosaic_label = ttk.Label(self.mosaic_window, text="No mosaic yet") # Canvas for zoomable/pannable mosaic
self.mosaic_label.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) 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('<MouseWheel>', self._on_mosaic_scroll) # Windows
self.mosaic_canvas.bind('<Button-4>', self._on_mosaic_scroll) # Linux scroll up
self.mosaic_canvas.bind('<Button-5>', self._on_mosaic_scroll) # Linux scroll down
self.mosaic_canvas.bind('<ButtonPress-1>', self._on_mosaic_drag_start)
self.mosaic_canvas.bind('<B1-Motion>', self._on_mosaic_drag)
self.mosaic_canvas.bind('<ButtonRelease-1>', self._on_mosaic_drag_end)
self.mosaic_canvas.bind('<Configure>', lambda e: self._render_mosaic())
# Double-click to reset view
self.mosaic_canvas.bind('<Double-Button-1>', self._reset_mosaic_view)
# Button frame
btn_frame = ttk.Frame(self.mosaic_window) btn_frame = ttk.Frame(self.mosaic_window)
btn_frame.pack(fill=tk.X, padx=10, pady=(0, 10)) 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) command=self._save_mosaic).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="Refresh", ttk.Button(btn_frame, text="Refresh",
command=self._update_mosaic_window).pack(side=tk.LEFT, padx=5) 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", ttk.Button(btn_frame, text="Clear",
command=self._clear_mosaic).pack(side=tk.LEFT, padx=5) 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() 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): def _update_mosaic_window(self):
if self.mosaic_window is None: if self.mosaic_window is None:
return return
# Get mosaic from appropriate scanner # Get mosaic from appropriate scanner (full resolution for zooming)
mosaic = None mosaic = None
if self.scanner_mode == 'stitch' and self.stitch_scanner: 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: 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: 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 return
mosaic_rgb = cv2.cvtColor(mosaic, cv2.COLOR_BGR2RGB) # Store full image and render
img = Image.fromarray(mosaic_rgb) self.mosaic_full_image = mosaic.copy()
imgtk = ImageTk.PhotoImage(image=img)
self.mosaic_label.imgtk = imgtk # If first time or image size changed significantly, reset view
self.mosaic_label.config(image=imgtk, text="") 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 # Update size label
if self.stitch_scanner: if self.stitch_scanner:
@ -912,6 +1110,9 @@ class AppGUI:
if self.mosaic_window: if self.mosaic_window:
self.mosaic_window.destroy() self.mosaic_window.destroy()
self.mosaic_window = None self.mosaic_window = None
self.mosaic_full_image = None
if hasattr(self, '_last_mosaic_size'):
del self._last_mosaic_size
def _save_mosaic(self): def _save_mosaic(self):
filename = filedialog.asksaveasfilename( filename = filedialog.asksaveasfilename(

View file

@ -170,8 +170,8 @@ class StitchingScanner:
blend_w = min(blend_width, w_strip, w_base) blend_w = min(blend_width, w_strip, w_base)
DEBUG_SHIFT_RIGHT = 0 # Positive = shift strip right DEBUG_SHIFT_RIGHT = -10 # Positive = shift strip right
DEBUG_SHIFT_UP = 0 # Positive = shift strip up DEBUG_SHIFT_UP = -50 # Positive = shift strip up
if append_right: if append_right:
# Expand mosaic to the right # Expand mosaic to the right