1274 lines
No EOL
50 KiB
Python
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() |