Autoscope-Controller/src/gui.py
2026-01-10 12:42:34 -06:00

1274 lines
No EOL
50 KiB
Python

"""
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('<<ComboboxSelected>>', 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('<Return>', 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('<Return>', 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("<Return>", 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('<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.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()