""" AutoScope GUI - Updated with Stitching Scanner Integration Includes both tile-based scanner and new stitching scanner modes. """ import tkinter as tk from tkinter import ttk, scrolledtext, filedialog from PIL import Image, ImageTk import cv2 import numpy as np import threading import queue from motion_controller import MotionController from autofocus import AutofocusController from scanner import Scanner, ScanConfig, ScanDirection, Tile from stitching_scanner import StitchingScanner, StitchConfig class AppGUI: def __init__(self, camera, arduino): self.camera = camera self.arduino = arduino self.running = True self._updating_slider = False # Initialize controllers self.motion = MotionController(arduino, on_command_sent=self._on_command_sent) self.autofocus = AutofocusController( camera, self.motion, on_log=self.log_message, on_focus_update=self._update_focus_display_threadsafe ) # Scanners - both tile-based and stitching self.scanner = None # Tile-based scanner self.stitch_scanner = None # Stitching scanner self.scan_config = ScanConfig() self.stitch_config = StitchConfig() # Scanner mode: 'tile' or 'stitch' self.scanner_mode = 'stitch' # Default to new stitching mode # Queues for thread-safe updates self.serial_queue = queue.Queue() self.tile_queue = queue.Queue() # Mosaic window self.mosaic_window = None # Build the window self.root = tk.Tk() self.root.title("AutoScope") self.root.geometry("720x1280") self.root.minsize(640, 900) self.root.protocol("WM_DELETE_WINDOW", self.on_close) self._build_ui() self._init_scanners() # Start serial reader thread self.serial_thread = threading.Thread(target=self._serial_reader, daemon=True) self.serial_thread.start() def _init_scanners(self): """Initialize both scanner types""" # Tile-based scanner self.scanner = Scanner( camera=self.camera, motion_controller=self.motion, autofocus_controller=self.autofocus, config=self.scan_config, on_tile_captured=self._on_tile_captured, on_log=self.log_message, on_progress=self._on_scan_progress ) # Stitching scanner self.stitch_scanner = StitchingScanner( camera=self.camera, motion_controller=self.motion, autofocus_controller=self.autofocus, config=self.stitch_config, on_log=self.log_message, on_progress=self._on_stitch_progress, on_mosaic_updated=self._on_mosaic_updated ) # Update initial memory estimate self._update_memory_estimate() def _update_memory_estimate(self): """Update memory estimate label""" bytes_per_pixel = 3 mem_mb = (self.stitch_config.max_mosaic_width * self.stitch_config.max_mosaic_height * bytes_per_pixel) / (1024 * 1024) if hasattr(self, 'memory_label'): self.memory_label.config(text=f"~{mem_mb:.0f} MB") def _on_command_sent(self, cmd): self.log_message(f"> {cmd}") def _update_focus_display_threadsafe(self, score): self.root.after(0, lambda: self.focus_score_label.config(text=f"Focus: {score:.1f}")) def _on_tile_captured(self, tile: Tile): self.tile_queue.put(tile) def _on_scan_progress(self, current: int, total: int): self.root.after(0, lambda: self._update_progress(current, total)) def _on_stitch_progress(self, appends: int, total: int): self.root.after(0, lambda: self._update_stitch_progress(appends)) def _on_mosaic_updated(self): """Called when stitching scanner updates mosaic""" self.root.after(0, self._update_mosaic_window) # ========================================================================= # UI Building - Vertical Layout # ========================================================================= def _build_ui(self): main_frame = ttk.Frame(self.root) main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # === TOP: Camera View (large, full width) === camera_frame = ttk.LabelFrame(main_frame, text="Camera") camera_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 5)) self.camera_label = ttk.Label(camera_frame) self.camera_label.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # Camera options row 1: overlays cam_opts1 = ttk.Frame(camera_frame) cam_opts1.pack(fill=tk.X, padx=5, pady=(0, 2)) self.show_tile_overlay_var = tk.BooleanVar(value=True) ttk.Checkbutton(cam_opts1, text="Bounds", variable=self.show_tile_overlay_var).pack(side=tk.LEFT) self.show_displacement_var = tk.BooleanVar(value=True) ttk.Checkbutton(cam_opts1, text="Displacement", variable=self.show_displacement_var).pack(side=tk.LEFT, padx=(10, 0)) self.live_focus_var = tk.BooleanVar(value=True) ttk.Checkbutton(cam_opts1, text="Live focus", variable=self.live_focus_var).pack(side=tk.LEFT, padx=(10, 0)) # Resolution selector ttk.Separator(cam_opts1, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10) ttk.Label(cam_opts1, text="Res:").pack(side=tk.LEFT) self.resolution_var = tk.StringVar(value=self.camera.get_mode()) self.resolution_combo = ttk.Combobox( cam_opts1, textvariable=self.resolution_var, values=list(self.camera.get_mode_labels().values()), state='readonly', width=18 ) # Set display to label mode_labels = self.camera.get_mode_labels() self.resolution_combo.set(mode_labels[self.camera.get_mode()]) self.resolution_combo.pack(side=tk.LEFT, padx=2) self.resolution_combo.bind('<>', self._on_resolution_change) self.focus_score_label = ttk.Label(cam_opts1, text="Focus: --", font=('Arial', 11, 'bold')) self.focus_score_label.pack(side=tk.RIGHT) # Camera options row 2: displacement info cam_opts2 = ttk.Frame(camera_frame) cam_opts2.pack(fill=tk.X, padx=5, pady=(0, 5)) ttk.Label(cam_opts2, text="Displacement:").pack(side=tk.LEFT) self.displacement_label = ttk.Label(cam_opts2, text="X: -- Y: --", font=('Arial', 10)) self.displacement_label.pack(side=tk.LEFT, padx=5) ttk.Separator(cam_opts2, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10) ttk.Label(cam_opts2, text="Mosaic:").pack(side=tk.LEFT) self.mosaic_size_label = ttk.Label(cam_opts2, text="--", font=('Arial', 10)) self.mosaic_size_label.pack(side=tk.LEFT, padx=5) ttk.Separator(cam_opts2, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10) ttk.Label(cam_opts2, text="Cam:").pack(side=tk.LEFT) self.cam_res_label = ttk.Label(cam_opts2, text="--", font=('Arial', 10)) self.cam_res_label.pack(side=tk.LEFT, padx=5) self._update_cam_res_label() # === BOTTOM: Control Panel === control_frame = ttk.Frame(main_frame) control_frame.pack(fill=tk.X) # Row 1: Emergency Stop + Scanner Mode + Controls self._build_row1_scanner(control_frame) # Row 2: Stitching Settings self._build_row2_stitch_settings(control_frame) # Row 3: Movement Controls self._build_row3_movement(control_frame) # Row 4: Speed + Autofocus self._build_row4_speed_autofocus(control_frame) # Row 5: Status + Log self._build_row5_status_log(control_frame) def _build_row1_scanner(self, parent): """Row 1: Emergency stop and scanner controls""" row = ttk.Frame(parent) row.pack(fill=tk.X, pady=(0, 3)) # Emergency stop button self.emergency_btn = tk.Button( row, text="⚠ STOP", command=self._emergency_stop, bg='#d32f2f', fg='white', font=('Arial', 11, 'bold'), width=7, height=1, relief=tk.RAISED, bd=2 ) self.emergency_btn.pack(side=tk.LEFT, padx=(0, 8)) # Scanner frame scanner_frame = ttk.LabelFrame(row, text="Stitching Scanner") scanner_frame.pack(side=tk.LEFT, fill=tk.X, expand=True) sf = ttk.Frame(scanner_frame) sf.pack(fill=tk.X, padx=5, pady=3) # Scanner mode toggle self.scanner_mode_var = tk.StringVar(value='stitch') ttk.Radiobutton(sf, text="Stitch", variable=self.scanner_mode_var, value='stitch', command=self._on_mode_change).pack(side=tk.LEFT) ttk.Radiobutton(sf, text="Tile", variable=self.scanner_mode_var, value='tile', command=self._on_mode_change).pack(side=tk.LEFT, padx=(5, 10)) # Status indicator self.scan_status_label = ttk.Label(sf, text="Idle", font=('Arial', 9, 'bold'), width=8) self.scan_status_label.pack(side=tk.LEFT) # Progress self.scan_progress_bar = ttk.Progressbar(sf, mode='indeterminate', length=60) self.scan_progress_bar.pack(side=tk.LEFT, padx=3) self.scan_progress_label = ttk.Label(sf, text="0 appends", width=10) self.scan_progress_label.pack(side=tk.LEFT) # Separator ttk.Separator(sf, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) # Control buttons btn_frame = ttk.Frame(sf) btn_frame.pack(side=tk.LEFT) self.scan_start_btn = tk.Button( btn_frame, text="▶ Start", width=7, bg='#4CAF50', fg='white', font=('Arial', 9, 'bold'), activebackground='#45a049', relief=tk.RAISED, bd=2, command=self._start_scan ) self.scan_start_btn.pack(side=tk.LEFT, padx=2) self.scan_pause_btn = tk.Button( btn_frame, text="⏸", width=3, bg='#2196F3', fg='white', font=('Arial', 10, 'bold'), activebackground='#1976D2', relief=tk.RAISED, bd=2, command=self._pause_scan, state='disabled' ) self.scan_pause_btn.pack(side=tk.LEFT, padx=2) self.scan_stop_btn = tk.Button( btn_frame, text="⏹", width=3, bg='#FF9800', fg='white', font=('Arial', 10, 'bold'), activebackground='#F57C00', relief=tk.RAISED, bd=2, command=self._stop_scan, state='disabled' ) self.scan_stop_btn.pack(side=tk.LEFT, padx=2) # Test button ttk.Button(btn_frame, text="Test", width=4, command=self._test_displacement).pack(side=tk.LEFT, padx=2) # Mosaic button ttk.Button(sf, text="Mosaic", width=6, command=self._show_mosaic_window).pack(side=tk.RIGHT, padx=2) def _build_row2_stitch_settings(self, parent): """Row 2: Stitching settings with max dimensions""" row = ttk.LabelFrame(parent, text="Stitch Settings") row.pack(fill=tk.X, pady=(0, 3)) # First row: Threshold, Speed, Overlap inner1 = ttk.Frame(row) inner1.pack(fill=tk.X, padx=5, pady=3) # Displacement threshold ttk.Label(inner1, text="Threshold:").pack(side=tk.LEFT) self.disp_threshold_var = tk.DoubleVar(value=0.10) self.disp_threshold_spinbox = ttk.Spinbox( inner1, from_=0.05, to=0.30, increment=0.01, width=5, textvariable=self.disp_threshold_var, command=self._update_stitch_config ) self.disp_threshold_spinbox.pack(side=tk.LEFT, padx=(2, 10)) # Scan speed ttk.Label(inner1, text="Speed:").pack(side=tk.LEFT) self.scan_speed_var = tk.IntVar(value=3) self.scan_speed_spinbox = ttk.Spinbox( inner1, from_=1, to=6, width=3, textvariable=self.scan_speed_var, command=self._update_stitch_config ) self.scan_speed_spinbox.pack(side=tk.LEFT, padx=(2, 10)) # Row overlap ttk.Label(inner1, text="Overlap:").pack(side=tk.LEFT) self.row_overlap_var = tk.DoubleVar(value=0.15) self.row_overlap_spinbox = ttk.Spinbox( inner1, from_=0.05, to=0.50, increment=0.05, width=5, textvariable=self.row_overlap_var, command=self._update_stitch_config ) self.row_overlap_spinbox.pack(side=tk.LEFT, padx=(2, 10)) # Autofocus toggle self.af_every_row_var = tk.BooleanVar(value=True) ttk.Checkbutton( inner1, text="AF each row", variable=self.af_every_row_var, command=self._update_stitch_config ).pack(side=tk.LEFT, padx=(10, 0)) # Row/Direction status on right status_frame = ttk.Frame(inner1) status_frame.pack(side=tk.RIGHT) ttk.Label(status_frame, text="Row:").pack(side=tk.LEFT) self.row_label = ttk.Label(status_frame, text="--", width=5, font=('Arial', 9)) self.row_label.pack(side=tk.LEFT) ttk.Label(status_frame, text="Dir:").pack(side=tk.LEFT, padx=(8, 0)) self.direction_label = ttk.Label(status_frame, text="--", width=6, font=('Arial', 9)) self.direction_label.pack(side=tk.LEFT) # Second row: Max dimensions and test buttons inner2 = ttk.Frame(row) inner2.pack(fill=tk.X, padx=5, pady=(0, 3)) # Max Width ttk.Label(inner2, text="Max W:").pack(side=tk.LEFT) self.max_width_var = tk.IntVar(value=960) 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=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()) # Apply button ttk.Button(inner2, text="Apply", width=5, command=self._update_stitch_config).pack(side=tk.LEFT, padx=(5, 10)) ttk.Separator(inner2, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) # Test buttons ttk.Label(inner2, text="Test:").pack(side=tk.LEFT) ttk.Button(inner2, text="Row↓", width=5, command=self._test_row_transition).pack(side=tk.LEFT, padx=2) ttk.Button(inner2, text="Scan→", width=5, command=lambda: self._test_horizontal('right')).pack(side=tk.LEFT, padx=2) ttk.Button(inner2, text="Scan←", width=5, command=lambda: self._test_horizontal('left')).pack(side=tk.LEFT, padx=2) ttk.Button(inner2, text="Est", width=4, command=self._show_scan_estimate).pack(side=tk.LEFT, padx=2) # Memory estimate label self.memory_label = ttk.Label(inner2, text="~-- MB", font=('Arial', 9)) self.memory_label.pack(side=tk.RIGHT) def _build_row3_movement(self, parent): """Row 3: Movement controls for all axes""" row = ttk.LabelFrame(parent, text="Movement") row.pack(fill=tk.X, pady=(0, 3)) inner = ttk.Frame(row) inner.pack(fill=tk.X, padx=5, pady=3) self.dir_labels = {} self.move_buttons = {} for axis in ["X", "Y", "Z"]: af = ttk.Frame(inner) af.pack(side=tk.LEFT, padx=(0, 15)) ttk.Label(af, text=f"{axis}:", font=('Arial', 10, 'bold')).pack(side=tk.LEFT) dir_btn = ttk.Button(af, text="+→", width=4, command=lambda a=axis: self._toggle_direction(a)) dir_btn.pack(side=tk.LEFT, padx=2) self.dir_labels[axis] = dir_btn move_btn = tk.Button( af, text="Move", width=6, bg='#4CAF50', fg='white', font=('Arial', 9), activebackground='#45a049', relief=tk.RAISED, command=lambda a=axis: self._toggle_movement(a) ) move_btn.pack(side=tk.LEFT, padx=2) self.move_buttons[axis] = move_btn ttk.Button(af, text="⏹", width=2, command=lambda a=axis: self._stop_axis(a)).pack(side=tk.LEFT) # Stop all button tk.Button( inner, text="STOP ALL", width=10, bg='#f44336', fg='white', font=('Arial', 9, 'bold'), activebackground='#d32f2f', relief=tk.RAISED, bd=2, command=self.motion.stop_all ).pack(side=tk.RIGHT) def _build_row4_speed_autofocus(self, parent): """Row 4: Speed and Autofocus controls""" row = ttk.Frame(parent) row.pack(fill=tk.X, pady=(0, 3)) # Speed controls speed_frame = ttk.LabelFrame(row, text="Speed") speed_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 5)) sf = ttk.Frame(speed_frame) sf.pack(fill=tk.X, padx=5, pady=3) self.speed_var = tk.StringVar(value="Medium") for text, val, preset in [("S", "Slow", "slow"), ("M", "Medium", "medium"), ("F", "Fast", "fast")]: ttk.Radiobutton(sf, text=text, variable=self.speed_var, value=val, command=lambda p=preset: self.motion.set_speed_preset(p)).pack(side=tk.LEFT, padx=2) ttk.Separator(sf, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) self.fine_speed_label = ttk.Label(sf, text="1", width=3) self.fine_speed_label.pack(side=tk.LEFT) self.speed_slider = ttk.Scale(sf, from_=0, to=6, orient=tk.HORIZONTAL, length=80) self.speed_slider.set(1) self.speed_slider.config(command=self._on_speed_slider_change) self._on_speed_slider_change(1) self.speed_slider.pack(side=tk.LEFT, padx=3) # Autofocus controls af_frame = ttk.LabelFrame(row, text="Autofocus") af_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) af = ttk.Frame(af_frame) af.pack(fill=tk.X, padx=5, pady=3) self.af_button = ttk.Button(af, text="Auto", width=6, command=self._start_autofocus) self.af_button.pack(side=tk.LEFT, padx=2) ttk.Button(af, text="Coarse", width=6, command=lambda: self._start_autofocus(coarse=True)).pack(side=tk.LEFT, padx=2) ttk.Button(af, text="Fine", width=5, command=lambda: self._start_autofocus(fine=True)).pack(side=tk.LEFT, padx=2) ttk.Button(af, text="Stop", width=5, command=self._stop_autofocus).pack(side=tk.LEFT, padx=2) def _build_row5_status_log(self, parent): """Row 5: Status and Log""" row = ttk.Frame(parent) row.pack(fill=tk.X, pady=(0, 3)) # Status controls status_frame = ttk.LabelFrame(row, text="Status") status_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 5)) stf = ttk.Frame(status_frame) stf.pack(fill=tk.X, padx=5, pady=3) ttk.Button(stf, text="Status", width=6, command=self.motion.request_status).pack(side=tk.LEFT, padx=2) self.mode_label = ttk.Label(stf, text="Serial", width=8) self.mode_label.pack(side=tk.LEFT, padx=(10, 0)) ttk.Button(stf, text="Mode", width=5, command=self.motion.toggle_mode).pack(side=tk.LEFT, padx=2) # Log log_frame = ttk.LabelFrame(row, text="Log") log_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self.serial_log = scrolledtext.ScrolledText(log_frame, height=4, state=tk.DISABLED) self.serial_log.pack(fill=tk.BOTH, expand=True, padx=5, pady=(5, 3)) cmd_frame = ttk.Frame(log_frame) cmd_frame.pack(fill=tk.X, padx=5, pady=(0, 5)) self.cmd_entry = ttk.Entry(cmd_frame) self.cmd_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) self.cmd_entry.bind("", lambda e: self._send_custom_command()) ttk.Button(cmd_frame, text="Send", width=6, command=self._send_custom_command).pack(side=tk.RIGHT, padx=(5, 0)) # ========================================================================= # Scanner Mode Switching # ========================================================================= def _on_mode_change(self): """Handle scanner mode change""" self.scanner_mode = self.scanner_mode_var.get() self.log_message(f"Scanner mode: {self.scanner_mode}") def _on_resolution_change(self, event=None): """Handle camera resolution change""" selected_label = self.resolution_combo.get() # Find mode name from label mode_labels = self.camera.get_mode_labels() mode_name = None for name, label in mode_labels.items(): if label == selected_label: mode_name = name break if mode_name is None: self.log_message(f"Unknown resolution: {selected_label}") return self.log_message(f"Changing resolution to: {selected_label}") # Stop any active scanning if self.stitch_scanner and self.stitch_scanner.running: self.stitch_scanner.stop() self._scan_finished() # Change resolution actual_w, actual_h, actual_fps = self.camera.set_mode(mode_name) self.log_message(f"Resolution set: {actual_w}x{actual_h} @ {actual_fps:.1f}fps") self._update_cam_res_label() def _update_cam_res_label(self): """Update camera resolution display""" w, h = self.camera.get_resolution() fps = self.camera.get_fps() self.cam_res_label.config(text=f"{w}x{h} @ {fps:.1f}fps") def _update_stitch_config(self): """Update stitching scanner config from GUI values""" self.stitch_config.displacement_threshold = self.disp_threshold_var.get() self.stitch_config.max_mosaic_width = self.max_width_var.get() self.stitch_config.max_mosaic_height = self.max_height_var.get() self.stitch_config.row_overlap = self.row_overlap_var.get() self.stitch_config.scan_speed_index = self.scan_speed_var.get() self.stitch_config.autofocus_every_row = self.af_every_row_var.get() # Update memory estimate self._update_memory_estimate() # Reinitialize stitching scanner with new config self.stitch_scanner = StitchingScanner( camera=self.camera, motion_controller=self.motion, autofocus_controller=self.autofocus, config=self.stitch_config, on_log=self.log_message, on_progress=self._on_stitch_progress, on_mosaic_updated=self._on_mosaic_updated ) self.log_message(f"Config updated: {self.stitch_config.max_mosaic_width}x{self.stitch_config.max_mosaic_height}") def _test_row_transition(self): """Test row transition without full scan""" if not self.stitch_scanner: self._update_stitch_config() self.log_message("Testing row transition...") def run_test(): result = self.stitch_scanner.test_row_transition() self.root.after(0, lambda: self.log_message( f"Row transition: {'SUCCESS' if result['success'] else 'FAILED'}, " f"Y moved: {result['y_moved']:.1f}px")) self.root.after(0, self._update_mosaic_window) threading.Thread(target=run_test, daemon=True).start() def _test_horizontal(self, direction: str): """Test single row scan""" if not self.stitch_scanner: self._update_stitch_config() self.log_message(f"Testing single row scan ({direction})...") def run_test(): result = self.stitch_scanner.test_single_row(direction) self.root.after(0, lambda: self.log_message( f"Row scan: {result['stop_reason']}, {result['appends']} appends, " f"mosaic: {result['mosaic_after'][0]}x{result['mosaic_after'][1]}")) self.root.after(0, self._update_mosaic_window) threading.Thread(target=run_test, daemon=True).start() def _show_scan_estimate(self): """Show memory estimate based on current settings""" if not self.stitch_scanner: self._update_stitch_config() est = self.stitch_scanner.get_memory_estimate() msg = (f"Memory Estimate:\n" f" Current: {est['current_size'][0]}x{est['current_size'][1]} = {est['current_mb']:.1f} MB\n" f" Max: {est['max_size'][0]}x{est['max_size'][1]} = {est['max_mb']:.0f} MB") self.log_message(msg) self.memory_label.config(text=f"~{est['max_mb']:.0f} MB") # ========================================================================= # Overlay Drawing # ========================================================================= def _draw_overlays(self, frame): """Draw overlays on camera frame""" frame = self._draw_crosshair(frame) if self.show_tile_overlay_var.get(): frame = self._draw_threshold_box(frame) if self.show_displacement_var.get(): frame = self._draw_displacement_overlay(frame) return frame def _draw_crosshair(self, frame, color=(0, 0, 255), thickness=1, size=40): h, w = frame.shape[:2] cx, cy = w // 2, h // 2 cv2.line(frame, (cx - size, cy), (cx + size, cy), color, thickness) cv2.line(frame, (cx, cy - size), (cx, cy + size), color, thickness) return frame def _draw_threshold_box(self, frame, color=(0, 255, 0), thickness=2): """Draw box showing 10% threshold region""" h, w = frame.shape[:2] threshold = self.stitch_config.displacement_threshold bh, bw = int(h * threshold), int(w * threshold) cv2.rectangle(frame, (bw, bh), (w - bw, h - bh), color, thickness) # Label cv2.putText(frame, f"{threshold:.0%}", (bw + 5, bh + 20), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1) return frame def _draw_displacement_overlay(self, frame): """Draw displacement indicator when scanning""" if not self.stitch_scanner: return frame state = self.stitch_scanner.get_state() if not state.is_scanning: return frame h, w = frame.shape[:2] threshold = self.stitch_config.displacement_threshold # Calculate fill percentages threshold_x = w * threshold threshold_y = h * threshold fill_x = min(abs(state.cumulative_x) / threshold_x, 1.0) if threshold_x > 0 else 0 fill_y = min(abs(state.cumulative_y) / threshold_y, 1.0) if threshold_y > 0 else 0 # Draw horizontal progress bar at top bar_h = 20 bar_w = int(w * 0.8) bar_x = (w - bar_w) // 2 bar_y = 10 # Background cv2.rectangle(frame, (bar_x, bar_y), (bar_x + bar_w, bar_y + bar_h), (50, 50, 50), -1) # Fill fill_w = int(bar_w * fill_x) color = (0, 255, 0) if fill_x >= 1.0 else (0, 165, 255) cv2.rectangle(frame, (bar_x, bar_y), (bar_x + fill_w, bar_y + bar_h), color, -1) # Border cv2.rectangle(frame, (bar_x, bar_y), (bar_x + bar_w, bar_y + bar_h), (200, 200, 200), 1) # Text text = f"X: {state.cumulative_x:.1f}px ({fill_x:.0%})" cv2.putText(frame, text, (bar_x + 5, bar_y + 15), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1) # Direction indicator dir_text = f"Dir: {state.direction}" cv2.putText(frame, dir_text, (bar_x + bar_w - 80, bar_y + 15), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1) return frame # ========================================================================= # Scanner Handlers # ========================================================================= def _start_scan(self): """Start the appropriate scanner based on mode""" if self.scanner_mode == 'stitch': self._update_stitch_config() if self.stitch_scanner.start(): self._scan_started() else: # Tile mode if self.scanner.start(): self._scan_started() def _scan_started(self): """Update UI when scan starts""" self.scan_start_btn.config(state='disabled', bg='#888888') self.scan_pause_btn.config(state='normal') self.scan_stop_btn.config(state='normal') self.scan_status_label.config(text="Scanning") self.scan_progress_bar.start(10) def _pause_scan(self): """Pause/resume the scan""" scanner = self.stitch_scanner if self.scanner_mode == 'stitch' else self.scanner if scanner.paused: scanner.resume() self.scan_pause_btn.config(text="⏸", bg='#2196F3') self.scan_status_label.config(text="Scanning") self.scan_progress_bar.start(10) else: scanner.pause() self.scan_pause_btn.config(text="▶", bg='#4CAF50') self.scan_status_label.config(text="Paused") self.scan_progress_bar.stop() def _stop_scan(self): """Stop the scan""" if self.scanner_mode == 'stitch': self.stitch_scanner.stop() else: self.scanner.stop() self._scan_finished() def _scan_finished(self): """Update UI when scan finishes""" self.scan_start_btn.config(state='normal', bg='#4CAF50') self.scan_pause_btn.config(state='disabled', text="⏸", bg='#2196F3') self.scan_stop_btn.config(state='disabled') self.scan_status_label.config(text="Idle") self.scan_progress_bar.stop() def _update_progress(self, current: int, total: int): """Update tile scanner progress""" if total > 0: self.scan_progress_label.config(text=f"{current}/{total}") else: self.scan_progress_label.config(text=f"{current} tiles") def _update_stitch_progress(self, appends: int): """Update stitching scanner progress""" self.scan_progress_label.config(text=f"{appends} appends") def _test_displacement(self): """Test displacement detection""" if not self.stitch_scanner: return self.log_message("Testing displacement (10 frames)...") def run_test(): results = self.stitch_scanner.test_displacement(num_frames=10) self.root.after(0, lambda: self._show_displacement_results(results)) threading.Thread(target=run_test, daemon=True).start() def _show_displacement_results(self, results): """Display displacement test results""" self.log_message(f"Total displacement: X={results['total_dx']:.1f}, Y={results['total_dy']:.1f}") for fr in results['frames'][:5]: # Show first 5 self.log_message(f" Frame {fr['frame']}: dx={fr['dx']:.2f}, dy={fr['dy']:.2f}") def _emergency_stop(self): """Emergency stop everything""" self.motion.stop_all() if self.stitch_scanner and self.stitch_scanner.running: self.stitch_scanner.stop() if self.scanner and self.scanner.running: self.scanner.stop() self._scan_finished() if self.autofocus.is_running(): self.autofocus.stop() # ========================================================================= # Movement Handlers # ========================================================================= def _toggle_direction(self, axis): direction = self.motion.toggle_direction(axis) arrow = "→" if direction == 1 else "←" sign = "+" if direction == 1 else "-" self.dir_labels[axis].config(text=f"{sign}{arrow}") if self.motion.is_moving(axis): self.motion.stop_axis(axis) self.motion.start_movement(axis) self.move_buttons[axis].config(text="Moving", bg='#FF9800') def _toggle_movement(self, axis): if self.motion.is_moving(axis): self._stop_axis(axis) else: self.motion.start_movement(axis) self.move_buttons[axis].config(text="Moving", bg='#FF9800') def _stop_axis(self, axis): self.motion.stop_axis(axis) self.move_buttons[axis].config(text="Move", bg='#4CAF50') def _on_speed_slider_change(self, value): if self._updating_slider: return index = round(float(value)) self._set_fine_speed(index) def _set_fine_speed(self, index): self._updating_slider = True self.speed_slider.set(index) speed_val = self.motion.get_speed_value(index) self.fine_speed_label.config(text=str(speed_val)) self._updating_slider = False self.motion.set_speed(index) def _start_autofocus(self, coarse=False, fine=False): if self.autofocus.start(coarse=coarse, fine=fine): self.af_button.config(state='disabled') def _stop_autofocus(self): self.autofocus.stop() self.af_button.config(state='normal') def _send_custom_command(self): cmd = self.cmd_entry.get().strip() if cmd: self.motion.send_command(cmd) self.cmd_entry.delete(0, tk.END) # ========================================================================= # Mosaic Window # ========================================================================= # ========================================================================= # Mosaic Window # ========================================================================= def _show_mosaic_window(self): if self.mosaic_window is not None: self.mosaic_window.lift() self._update_mosaic_window() return self.mosaic_window = tk.Toplevel(self.root) self.mosaic_window.title("Mosaic") self.mosaic_window.geometry("600x800") self.mosaic_window.protocol("WM_DELETE_WINDOW", self._close_mosaic_window) # 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)) ttk.Button(btn_frame, text="Save Full Resolution", 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 (full resolution for zooming) mosaic = None if self.scanner_mode == 'stitch' and self.stitch_scanner: 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.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 # Store full image and render self.mosaic_full_image = mosaic.copy() # 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: state = self.stitch_scanner.get_state() self.mosaic_size_label.config(text=f"{state.mosaic_width}x{state.mosaic_height}") def _close_mosaic_window(self): 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( defaultextension=".png", filetypes=[("PNG", "*.png"), ("JPEG", "*.jpg"), ("All", "*.*")] ) if not filename: return if self.scanner_mode == 'stitch' and self.stitch_scanner: if self.stitch_scanner.save_mosaic(filename): self.log_message(f"Saved: {filename}") elif self.scanner: mosaic = self.scanner.build_mosaic(scale=1.0) if mosaic is not None: cv2.imwrite(filename, mosaic) self.log_message(f"Saved: {filename}") def _clear_mosaic(self): """Clear the current mosaic""" if self.stitch_scanner: self.stitch_scanner.mosaic = None self.stitch_scanner.state.mosaic_width = 0 self.stitch_scanner.state.mosaic_height = 0 self._update_mosaic_window() self.log_message("Mosaic cleared") # ========================================================================= # Logging # ========================================================================= def log_message(self, msg): if not hasattr(self, 'serial_log'): return self.serial_log.config(state=tk.NORMAL) self.serial_log.insert(tk.END, msg + "\n") self.serial_log.see(tk.END) self.serial_log.config(state=tk.DISABLED) if msg.startswith("< MODE:"): self.mode_label.config(text=msg.split(":")[-1].capitalize()) # ========================================================================= # Serial & Camera # ========================================================================= def _serial_reader(self): while self.running: if self.arduino and self.arduino.ser.in_waiting: try: line = self.arduino.ser.readline().decode().strip() if line: self.serial_queue.put(line) except Exception as e: self.serial_queue.put(f"[Error: {e}]") threading.Event().wait(0.05) def _process_serial_queue(self): while not self.serial_queue.empty(): self.log_message(f"< {self.serial_queue.get_nowait()}") def _process_tile_queue(self): updated = False while not self.tile_queue.empty(): self.tile_queue.get_nowait() updated = True if updated and self.mosaic_window: self._update_mosaic_window() def update_camera(self): try: frame = self.camera.capture_frame() frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) if self.live_focus_var.get() and not self.autofocus.is_running(): score = self.autofocus.get_focus_score() self.focus_score_label.config(text=f"Focus: {score:.1f}") # Apply overlays frame = self._draw_overlays(frame) frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # Scale to fit h, w = frame.shape[:2] max_h = 700 max_w = 680 scale = min(max_h / h, max_w / w) if scale < 1: frame = cv2.resize(frame, (int(w * scale), int(h * scale))) img = Image.fromarray(frame) imgtk = ImageTk.PhotoImage(image=img) self.camera_label.imgtk = imgtk self.camera_label.configure(image=imgtk) except Exception as e: print(f"Camera error: {e}") def _update_stitch_state_display(self): """Update stitch scanner state in UI""" if not self.stitch_scanner: return state = self.stitch_scanner.get_state() # Update displacement label self.displacement_label.config( text=f"X: {state.cumulative_x:.1f} Y: {state.cumulative_y:.1f}" ) # Update row/direction and size if state.is_scanning: # Show current row and progress percentage width_pct = min(100, (state.mosaic_width / self.stitch_config.max_mosaic_width) * 100) height_pct = min(100, (state.mosaic_height / self.stitch_config.max_mosaic_height) * 100) self.row_label.config(text=f"R{state.current_row + 1}") self.direction_label.config(text=state.direction) # Update mosaic size label with progress self.mosaic_size_label.config( text=f"{state.mosaic_width}x{state.mosaic_height} ({height_pct:.0f}%)") else: self.row_label.config(text="--") self.direction_label.config(text="--") # Check if scan finished if not state.is_scanning and self.scan_start_btn['state'] == 'disabled': self._scan_finished() # ========================================================================= # Main Loop # ========================================================================= def run(self): def update(): if self.running: self.update_camera() self._process_serial_queue() self._process_tile_queue() self._update_stitch_state_display() if not self.autofocus.is_running(): self.af_button.config(state='normal') self.root.after(33, update) update() self.root.mainloop() def on_close(self): self.running = False if self.scanner: self.scanner.stop() if self.stitch_scanner: self.stitch_scanner.stop() self.autofocus.stop() self._close_mosaic_window() self.root.destroy()