diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0bb75f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.onnx filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index a091b50..20dc2af 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ !assets/*.jpeg *.mp4 *.mp3 +*data_collection* # configuration *.toml @@ -31,4 +32,4 @@ venvcompat venv/ **/__pycache__ -stepper_captures \ No newline at end of file +stepper_captures diff --git a/README.md b/README.md index 3eed580..b793330 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,18 @@ Enable homing (if you have sensors). $22=1 ``` +Ensure that positions are given as `WPos` instead of `MPos`. This allows +us to set a (0,0,0) starting point for within grbl itself. Note that this +is optional, but highly recommened, as it's easier to read coordinates +from grbl this way. + Adjust the homing direction invert mask (if you have sensors). Bits 0, 1, and 2 in this value correspond to the X, Y, and Z axes. For each axis, check if the limit switch is reached by moving in the positive direction or the negative direction. If the axis requires negative movement, set the corresponding bit. On CMU's setup, the limit switches are all reached by traveling in the negative direction, -so we use a value of 7 (invert all axes). +so we use a value of 7 (invert all axes). However this value may change +depending on how you also configure setting `$3`. ``` $23=7 @@ -148,6 +154,23 @@ python --version # ensure version is correct (<=3.10) pip install -r requirements.txt ``` +## Loading Alignment Markers (RF_DETR model) + +Due to file size constraints in GitHub, the actual file for the RF_DETR weights (`weights.onnx`) are not stored +in this repository, and are instead replaced by a pointer file on a large file system in git (Git LFS) +In order to extract that file, you have two options: + +```bash +git lfs install +git clone https://github.com/hacker-fab/stepper +git lfs pull +``` + +> If you already cloned without LFS, you can just run `git lfs pull` to fetch the weights, no need to run the second line. + +Alternatively, you may fetch the real file [here](https://github.com/hacker-fab/stepper_attachments/tree/main/model) and replace the current `weights.onnx` file with the one downloaded from the link. + + ### Using a Basler (Pylon) camera To use a Basler camera with the GUI, you will need to install `pylon` from @@ -220,9 +243,13 @@ uv-exposure = 25000.0 enabled = true # Set homing to false if your stage does not have limit sensors homing = false + +# The following features can be enabled when homing is set to true. Note that this assumes the stage +# won't be moved manually, but entirely through the gui + # Enable tiling if user wishes to use tiling features tiling = false -# Set autofocus offset, requires homing to be true. Set 0 for no autofocus +# Set autofocus offset to get an estimated focus position for the z-stage. Set 0 to use the original autofocus algorithm autofocus = 0 @@ -236,8 +263,13 @@ baud-rate = 115200 [alignment] # Enable or disable real-time detection of alignment markers enabled = false -# Path to the YOLO model weights file -model_path = "best.pt" +# Path to the alignment model weights file +# The following paths are supported: "ckpts/best.pt" and "ckpts/weights.onnx" +# The key difference between the two are that best.pt runs a YOLO model trained on developed +# images while weights.onnx runs on RF-DETR, which is trained on both developed and latent images +# that suite it for the tiling feature for the stepper +model_path = "ckpts/best.pt" + # Alignment marker reference coordinates (in pixels) right_marker_x = 1634.0 # x-coordinate for markers on the right side top_marker_y = 117.5 # y-coordinate for markers on the top diff --git a/ckpts/weights.onnx b/ckpts/weights.onnx new file mode 100644 index 0000000..2220b16 --- /dev/null +++ b/ckpts/weights.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ceb7afeb340d46c2fd671f9532db16a6dd23e8fd85a60adec3c1b97cd5042cd8 +size 121571388 diff --git a/default.toml b/default.toml index 315cf88..6d9f99c 100644 --- a/default.toml +++ b/default.toml @@ -21,16 +21,21 @@ uv-exposure = 25000.0 [stage] # Set enabled to false to disable all motion. enabled = true - # Set homing to false if your stage does not have limit sensors homing = false +# The following features can be enabled when homing +# is set to true. Note that this assumes the stage +# won't be moved manually, but entirely through the gui + # Enable tiling if user wishes to use tiling features tiling = false - -# Set autofocus offset, requires homing to be true. Set 0 for no autofocus +# Set autofocus offset to get an estimated focus position +# for the z-stage. Set 0 to use the original autofocus +# algorithm, which may take more time to run. autofocus = 0 + # Select the correct serial port for the device running GRBL. # The correct serial port can be checked with Device Manager on Windows. port = "COM6" @@ -46,8 +51,13 @@ z-max = -1 [alignment] # Enable or disable real-time detection of alignment markers enabled = false -# Path to the YOLO model weights file +# Path to the alignment model weights file +# The following paths are supported: "ckpts/best.pt" and "ckpts/weights.onnx" +# The key difference between the two are that best.pt runs a YOLO model trained on developed +# images while weights.onnx runs on RF-DETR, which is trained on both developed and latent images +# that suite it for the tiling feature for the stepper model_path = "ckpts/best.pt" + # Alignment marker reference coordinates (in pixels) right_marker_x = 1820.0 # x-coordinate for markers on the right side top_marker_y = 269.0 # y-coordinate for markers on the top diff --git a/pyproject.toml b/pyproject.toml index 348ffe7..2a39612 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,8 @@ dependencies = [ "pyserial>=3.5", "toml>=0.10.2", "ultralytics>=8.3.218", + "onnxruntime>=1.19.0,<1.22.0", + "screeninfo>=0.8.1", ] [tool.uv.sources] diff --git a/src/gui.py b/src/gui.py index 2842235..d1315e2 100644 --- a/src/gui.py +++ b/src/gui.py @@ -2,900 +2,77 @@ import os import queue import shutil -import time import toml import tkinter -from dataclasses import dataclass from datetime import datetime -from enum import Enum, auto +from enum import Enum from functools import partial from pathlib import Path -from tkinter import BooleanVar, IntVar, StringVar, Tk, filedialog, messagebox, ttk +from pycpd import RigidRegistration +from tkinter import BooleanVar, IntVar, StringVar, Tk, Toplevel, filedialog, messagebox, ttk from tkinter.ttk import Progressbar -from typing import Callable, List, Optional +from typing import Optional import cv2 import numpy as np import serial import math -from PIL import Image, ImageOps, ImageTk -from ultralytics import YOLO +from PIL import ImageTk from camera.camera_module import CameraModule from camera.webcam import Webcam -from hardware import ImageProcessSettings, Lithographer, ProcessedImage + from lib.gui import IntEntry, Thumbnail, FloatEntry from lib.img import image_to_tk_image +import matplotlib.pyplot as plt from projector import TkProjector from stage_control.grbl_stage import GrblStage from stage_control.omm_stage import OMMStage from stage_control.stage_controller import StageController -# TODO: Don't hardcode -THUMBNAIL_SIZE: tuple[int, int] = (160, 90) -#The values set here are not used and instead come from the config file -DEFAULT_RED_EXPOSURE: float = 4167.0 -DEFAULT_UV_EXPOSURE: float = 25000.0 - -def fetch_focus_score(camera_image, blue_only, ddepth=cv2.CV_64F, kernel_size=5, log=False): - """ fetch_focus_score: computes the laplacian focal score after some - pre-processing of the camera image. The key is to detect the edges better - than other parts of the image that might not be suitable to be focused on. """ - - camera_image = camera_image.copy() - camera_image[:, :, 1] = 0 # green should never be used for focus - if blue_only: - camera_image[:, :, 0] = 0 # disable red - - src = camera_image - src = cv2.cvtColor(src, cv2.COLOR_BGR2GRAY) - # Remove noise by blurring with a Gaussian filter - src = cv2.GaussianBlur(src, (3, 3), 0) - - # Apply Laplace function - src = cv2.Laplacian(src, ddepth, ksize=kernel_size) - - return src.var() - -def compute_focus_score(camera_image, blue_only, save=False): - camera_image = camera_image.copy() - camera_image[:, :, 1] = 0 # green should never be used for focus - if blue_only: - camera_image[:, :, 0] = 0 # disable red - img = cv2.cvtColor(camera_image, cv2.COLOR_RGB2GRAY) - img = cv2.resize(img, (0, 0), fx=0.5, fy=0.5) - mean = np.sum(img) / (img.shape[0] * img.shape[1]) - img_lapl = (np.abs(cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=1)) + np.abs(cv2.Sobel(img, cv2.CV_64F, 0, 1, ksize=1))) / mean - if save: - print('saved focus: ', np.min(img_lapl), np.max(img_lapl)) - cv2.imwrite(save, img_lapl * 255.0 / 5.0) - return img_lapl.var() / mean - - -def detect_alignment_markers(model, image, draw_rectangle=False): - detections = [] - display_image = image.copy() - try: - image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) - original_height, original_width = image_rgb.shape[:2] - resized = cv2.resize(image_rgb, (640, 640)) - results = model(resized) - boxes = results[0].boxes - for box in boxes: - x1, y1, x2, y2 = box.xyxy[0].cpu().numpy() - x1 = int(x1 * original_width / 640) - x2 = int(x2 * original_width / 640) - y1 = int(y1 * original_height / 640) - y2 = int(y2 * original_height / 640) - detections.append(((x1, y1), (x2, y2))) - print('mark at ', (x1 + x2) / 2, (y1 + y2) / 2) - if draw_rectangle: - cv2.rectangle(display_image, (x1, y1), (x2, y2), (0, 255, 0), 5) - except Exception as e: - print(f"Detection failed: {e}") - - return detections, display_image - - -class StrAutoEnum(str, Enum): - """Base class for string-valued enums that use auto()""" - - def _generate_next_value_(name, *_): - return name.lower() - - -class ShownImage(StrAutoEnum): - """The type of image currently being displayed by the projector""" - - CLEAR = auto() - PATTERN = auto() - FLATFIELD = auto() - RED_FOCUS = auto() - UV_FOCUS = auto() - - -class PatterningStatus(StrAutoEnum): - """The current state of the patterning process""" - - IDLE = auto() - PATTERNING = auto() - ABORTING = auto() - - -class Event(StrAutoEnum): - """Events that can be dispatched to listeners""" - - SNAPSHOT = auto() - SHOWN_IMAGE_CHANGED = auto() - STAGE_POSITION_CHANGED = auto() - IMAGE_ADJUST_CHANGED = auto() - PATTERN_IMAGE_CHANGED = auto() - MOVEMENT_LOCK_CHANGED = auto() - EXPOSURE_PATTERN_PROGRESS_CHANGED = auto() - PATTERNING_BUSY_CHANGED = auto() - PATTERNING_FINISHED = auto() - CHIP_CHANGED = auto() - - -class MovementLock(StrAutoEnum): - """Controls whether stage position can be manually adjusted""" - - UNLOCKED = auto() # X, Y, and Z are free to move - XY_LOCKED = auto() # Only Z (focus) is free to move to avoid smearing UV focus pattern - LOCKED = auto() # No positions can move to avoid disrupting patterning - - -class RedFocusSource(StrAutoEnum): - """The source image to use for red focus mode""" - - IMAGE = auto() # Uses the dedicated red focus image - SOLID = auto() # Shows a solid red screen - PATTERN = auto() # Uses the blue channel from the pattern image - INV_PATTERN = auto() # Uses the inverse of the blue channel from the pattern image - - -@dataclass -class AlignmentConfig: - enabled: bool - model_path: str - right_marker_x: float - left_marker_x: float - top_marker_y: float - bottom_marker_y: float - x_scale_factor: float - y_scale_factor: float - - -@dataclass -class LithographerConfig: - stage: StageController - camera: CameraModule - camera_scale: float - red_exposure: float - uv_exposure: float - alignment: AlignmentConfig - - -@dataclass -class ExposureLog: - time: datetime - path: str - coords: tuple[float, float, float] - duration: float # ms - aborted: bool - - def to_disk(self): - return { - "time": str(self.time), - "path": self.path, - "coords": self.coords, - "duration": self.duration, - "aborted": self.aborted, - } - - @classmethod - def from_disk(cls, d): - return cls( - datetime.fromisoformat(d["time"]), - d["path"], - d["coords"], - d["duration"], - d["aborted"], - ) - - -@dataclass -class ChipLayer: - exposures: List[ExposureLog] - - def to_disk(self): - return {"exposures": [ex.to_disk() for ex in self.exposures]} - - @classmethod - def from_disk(cls, d): - return cls([ExposureLog.from_disk(ex) for ex in d["exposures"]]) - - -@dataclass -class Chip: - layers: List[ChipLayer] - - def to_disk(self): - return {"layers": [layer.to_disk() for layer in self.layers]} - - @classmethod - def from_disk(cls, d): - return cls([ChipLayer.from_disk(layer) for layer in d["layers"]]) - - -class EventDispatcher: - hardware: Lithographer - root: Tk - model: Optional[YOLO] - camera: Optional[CameraModule] - red_focus: ProcessedImage - uv_focus: ProcessedImage - pattern: ProcessedImage - pattern_image: Image.Image - red_focus_image: Image.Image - uv_focus_image: Image.Image - solid_red_image: Image.Image - image_adjust_position: tuple[float, float, float] - border_size: float - posterize_strength: Optional[int] - red_focus_source: RedFocusSource - stage_setpoint: tuple[float, float, float] - shown_image: ShownImage - autofocus_busy: bool - patterning_busy: bool - autofocus_on_mode_switch: bool - realtime_detection: bool - first_autofocus: bool - should_abort: bool - exposure_time: int - patterning_progress: float # ranges from 0.0 to 1.0 - red_exposure_time: float - uv_exposure_time: float - exposure_history: List[ExposureLog] - chip: Chip - auto_snapshot_on_uv: bool - snapshot_directory: Path - listeners: dict[Event, List[Callable]] - - def __init__( - self, - stage: StageController, - proj: TkProjector, - root: Tk, - camera: Optional[CameraModule], - red_exposure: float, - uv_exposure: float, - ): - # Hardware components - self.hardware = Lithographer(stage, proj) - self.camera = camera - self.root = root - - # Detection model - self.model = None - - # Image processing objects - self.red_focus = ProcessedImage() - self.uv_focus = ProcessedImage() - self.pattern = ProcessedImage() - - # Source images - self.pattern_image = Image.new("RGB", (1, 1), "black") - self.red_focus_image = Image.new("RGB", (1, 1), "black") - self.uv_focus_image = Image.new("RGB", (1, 1), "black") - self.solid_red_image = Image.new("RGB", (1, 1), "red") - - # Image settings - self.image_adjust_position = (0.0, 0.0, 0.0) - self.border_size = 0.0 - self.posterize_strength = None - self.red_focus_source = RedFocusSource.IMAGE - - # Stage control - self.stage_setpoint = (0.0,0.0,0.0) - - # Status flags - self.shown_image = ShownImage.CLEAR - self.autofocus_busy = False - self.patterning_busy = False - self.autofocus_on_mode_switch = False - self.realtime_detection = False - self.first_autofocus = True - self.should_abort = False - - # Exposure settings and progress - self.exposure_time = 8000 - self.patterning_progress = 0.0 - self.red_exposure_time = red_exposure - self.uv_exposure_time = uv_exposure - - # History and logging - self.exposure_history = [] - self.chip = Chip([ChipLayer([])]) - - # Snapshot settings - self.auto_snapshot_on_uv = True - self.snapshot_directory = Path("stepper_captures") - self.snapshot_directory.mkdir(exist_ok=True) - - # Event handling - self.listeners = dict() - self.add_event_listener(Event.SHOWN_IMAGE_CHANGED, lambda: self._update_projector()) - - def load_chip(self, path: str): - print(f"Loading chip at {path!r}") - with open(path, "r") as f: - d = json.load(f) - self.chip = Chip.from_disk(d) - self.on_event(Event.CHIP_CHANGED) - - def new_chip(self): - # TODO: Prompt user to save old chip?? - self.chip = Chip([ChipLayer([])]) - self.on_event(Event.CHIP_CHANGED) - - def add_chip_layer(self): - self.chip.layers.append(ChipLayer([])) - self.on_event(Event.CHIP_CHANGED) - - def save_chip(self, path: str): - with open(path, "w") as f: - json.dump(self.chip.to_disk(), f) - - def delete_chip_exposure(self, layer: int, ex: int): - self.chip.layers[layer].exposures.pop(ex) - print(f"Deleted exposure {layer} {ex}") - self.on_event(Event.CHIP_CHANGED) - - @property - def current_image(self) -> Optional[Image.Image]: - match self.shown_image: - case ShownImage.CLEAR: - return None - case ShownImage.RED_FOCUS: - return self.red_focus.processed() - case ShownImage.UV_FOCUS: - return self.uv_focus.processed() - case ShownImage.PATTERN: - return self.pattern.processed() - - def _update_projector(self): - img = self.current_image - if img is None: - self.hardware.projector.clear() - else: - self.hardware.projector.show(img) - - def _refresh_pattern(self): - self.pattern.update( - image=self.pattern_image, - settings=ImageProcessSettings( - posterization=self.posterize_strength, - color_channels=(False, False, True), - flatfield=None, - size=self.hardware.projector.size(), - image_adjust=self.image_adjust_position, - border_size=self.border_size, - ), - ) - - if self.red_focus_source in (RedFocusSource.PATTERN, RedFocusSource.INV_PATTERN): - self._refresh_red_focus() - - # TODO: - # Image adjust, resizing, and flatfield correction are performed *AFTER SLICING* - - self.on_event(Event.PATTERN_IMAGE_CHANGED) - - def set_red_focus_source(self, source: RedFocusSource): - self.red_focus_source = source - self._refresh_red_focus() - - def _red_focus_source(self) -> Image.Image: - match self.red_focus_source: - case RedFocusSource.IMAGE: - return self.red_focus_image - case RedFocusSource.SOLID: - return self.solid_red_image - case RedFocusSource.PATTERN: - return self.pattern_image.getchannel("B").convert("RGBA") - case RedFocusSource.INV_PATTERN: - return ImageOps.invert(self.pattern_image.getchannel("B")).convert("RGBA") - - def _refresh_red_focus(self): - if self.hardware.projector.size() != self.solid_red_image.size: - self.solid_red_image = Image.new("RGB", self.hardware.projector.size(), "red") - - img = self._red_focus_source() - - self.red_focus.update( - image=img, - settings=ImageProcessSettings( - posterization=self.posterize_strength, - flatfield=None, - color_channels=(True, False, False), - size=self.hardware.projector.size(), - image_adjust=self.image_adjust_position, - border_size=self.border_size, - ), - ) - - if self.shown_image == ShownImage.RED_FOCUS: - self.on_event(Event.SHOWN_IMAGE_CHANGED) - - def _refresh_uv_focus(self): - self.uv_focus.update( - image=self.uv_focus_image, - settings=ImageProcessSettings( - posterization=self.posterize_strength, - flatfield=None, - color_channels=(False, False, True), - size=self.hardware.projector.size(), - image_adjust=self.image_adjust_position, - border_size=0.0, - ), - ) - - if self.shown_image == ShownImage.UV_FOCUS: - self.on_event(Event.SHOWN_IMAGE_CHANGED) - - def set_posterize_strength(self, strength: Optional[int]): - self.posterize_strength = strength - self._refresh_red_focus() - self._refresh_uv_focus() - self._refresh_pattern() - - def set_border_size(self, border_size: float): - self.border_size = border_size - self._refresh_red_focus() - self._refresh_uv_focus() - self._refresh_pattern() - - def set_shown_image(self, shown_image: ShownImage): - print(f"set_shown_image({shown_image})") - self.shown_image = shown_image - self.on_event(Event.SHOWN_IMAGE_CHANGED) - - def create_warning(self, msg: str): - print(f"Warning: {msg}") - messagebox.showwarning("Warning: ", msg) - - def move_absolute(self, coords: dict[str, float]): - # 0 to -($13X - $27) in WPos space - if(self.hardware.stage.has_homing()): # debugging statements - print(f"Moving to position: {coords}") - print(f"Current position: {self.stage_setpoint[0]}, {self.stage_setpoint[1]}, {self.stage_setpoint[2]}") - - # find new coordinates -> some nuance exists between work and gui positioning - # in work position, the x moves in negative direction (away from home) and y moves in positive direction (away from home) - x = coords.get("x", 0) - y = coords.get("y", 0) - z = coords.get("z", 0) - set_point = (x, y, z) - - if self.hardware.stage.has_homing(): - print(f"Moving to absolute: {set_point}") - ok, msg = self._check_bounds(set_point) - if not ok: - self.create_warning(msg) - return False - - try: - self.hardware.stage.move_absolute(coords) - self.stage_setpoint = set_point - self.on_event(Event.STAGE_POSITION_CHANGED) - return True - - except(RuntimeError) as e: - self.create_warning(f"{str(e)}. Please remove your chip, restart the program.") - return False - - except(Exception) as e: - self.create_warning(f"{str(e)}. Please remove your chip, restart the program.") - self.stage_setpoint = self.hardware.stage.get_position() - self.on_event(Event.STAGE_POSITION_CHANGED) - return False - - - def _check_bounds(self, set_point): - bounds = self.hardware.stage.get_bounds() - - if bounds is None: - return True # no homing, no bounds enforced - - axes = [('x', 0), ('y', 1), ('z', 2)] - for name, i in axes: - lo, hi = bounds[name] - val = set_point[i] - if not (lo <= val <= hi): - return False, (f"Moving {name.upper()} to {val} prohibited. " - f"Boundaries are [{lo}, {hi}]") - return True, None - - def move_relative(self, coords: dict[str, float]): - - if(self.hardware.stage.has_homing()): # debugging statements - print(f"Moving: {coords}") - print(f"Current position: {self.stage_setpoint[0]}, {self.stage_setpoint[1]}, {self.stage_setpoint[2]}") - # find new coordinates -> some nuance exists between work and gui positioning - # in work position, the x moves in negative direction (away from home) and y moves in positive direction (away from home - x = self.stage_setpoint[0] + coords.get("x", 0) - y = self.stage_setpoint[1] + coords.get("y", 0) - z = self.stage_setpoint[2] + coords.get("z", 0) - set_point = (x, y, z) - - # if soft limits and max travel set, then enforce boundaries - if(self.hardware.stage.has_homing()): - ok, msg = self._check_bounds(set_point) - if not ok: - self.create_warning(msg) - return - - try: - self.hardware.stage.move_relative(coords) - self.stage_setpoint = set_point - self.on_event(Event.STAGE_POSITION_CHANGED) - - except(RuntimeError) as e: - self.create_warning(f"{str(e)}. Please remove your chip, restart the program.") - - except(Exception) as e: - self.create_warning(f"{str(e)}. Please remove your chip, restart the program.") - self.stage_setpoint = self.hardware.stage.get_position() - self.on_event(Event.STAGE_POSITION_CHANGED) - - def set_use_solid_red(self, use: bool): - self.use_solid_red = use - self.set_shown_image(ShownImage.RED_FOCUS) - self._refresh_red_focus() - - def set_pattern_image(self, img: Image.Image, path: str): - self.pattern_image = img - self.pattern_image_path = path - self._refresh_pattern() - - def set_red_focus_image(self, img: Image.Image): - self.red_focus_image = img - self._refresh_red_focus() - - def set_uv_focus_image(self, img: Image.Image): - self.uv_focus_image = img - self._refresh_uv_focus() - - def set_patterning_busy(self, busy: bool): - self.patterning_busy = busy - self.on_event(Event.MOVEMENT_LOCK_CHANGED) - self.on_event(Event.PATTERNING_BUSY_CHANGED) - - def set_progress(self, pattern_progress: float, exposure_progress: float): - self.patterning_progress = pattern_progress - self.exposure_progress = exposure_progress - self.on_event(Event.EXPOSURE_PATTERN_PROGRESS_CHANGED) - - def set_latest_image(self, camera_image): - self.camera_image = camera_image - - def set_autofocus_busy(self, busy): - self.autofocus_busy = busy - self.on_event(Event.MOVEMENT_LOCK_CHANGED) - - def abort_patterning(self): - self.should_abort = True - print("Aborting patterning") - - def in_uv(self): - return self.shown_image in (ShownImage.PATTERN, ShownImage.UV_FOCUS) - - def home_stage(self): - """ - Homing stage resets Machine position (Mpos) and sets Work Position (WPos) - of current state post-homing to (0, 0, 0), which means set_point must - be updated to reflect the work position - """ - self.hardware.stage.home() - self.hardware.stage.set_on_start_location() - print(f"Post Homing Location: {self.hardware.stage.get_on_start_location()}") - print("Homing Complete.") - - self.on_event(Event.STAGE_POSITION_CHANGED) - - def query_config(self): - self.hardware.stage.get_position() - print("Query Config Complete.") - - def set_image_position(self, x, y, t): - self.image_adjust_position = (x, y, t) - self._refresh_red_focus() - self._refresh_uv_focus() - self._refresh_pattern() - self.on_event(Event.IMAGE_ADJUST_CHANGED) - - @property - def image_position(self): - return self.image_adjust_position - - @property - def movement_lock(self): - if self.patterning_busy or self.autofocus_busy: - return MovementLock.LOCKED - # elif (self.shown_image == ShownImage.UV_FOCUS or self.shown_image == ShownImage.PATTERN): - # return MovementLock.XY_LOCKED - else: - return MovementLock.UNLOCKED - - def on_event(self, event: Event, *args, **kwargs): - if event not in self.listeners: - return +# importing utilities +from lib.globals import * +from tiling_utils import * +from lib.structs import * - for listener in self.listeners[event]: - listener(*args, **kwargs) - - def on_event_cb(self, event: Event, *args, **kwargs): - return lambda: self.on_event(event, *args, **kwargs) - - def add_event_listener(self, event: Event, listener: Callable): - if event not in self.listeners: - self.listeners[event] = [] - self.listeners[event].append(listener) - - def begin_patterning(self): - # TODO: Update patterning preview +import subprocess +import time +import screeninfo - print("Patterning at ", self.stage_setpoint) - duration = self.exposure_time - print(f"Patterning 1 tiles for {duration}ms\nTotal time: {str(round((duration) / 1000))}s") +def setup_displays(): + subprocess.run(['displayswitch.exe', '/extend']) + time.sleep(2) + print("Done display shenanigins") - # TODO: Image slicing. - # Note that flatfield correction and image adjustment should be applied *after* slicing - img = self.pattern.processed() +def setup_projection_window(proj_window: Toplevel): + monitors = screeninfo.get_monitors() - self.set_patterning_busy(True) - self.hardware.projector.show(img) - end_time = time.time() + duration / 1000.0 - while time.time() < end_time: - progress = 1.0 - ((end_time - time.time()) * 1000 / duration) - self.set_progress(0.0, progress) - self.root.update() - if self.should_abort: - break - self.set_shown_image(ShownImage.CLEAR) - self.root.update() # Force image to stop being displayed ASAP - self.set_progress(1.0, 1.0) - - log = ExposureLog( - datetime.now(), - self.pattern_image_path, - self.stage_setpoint, - duration, - self.should_abort, + if len(monitors) < 2: + messagebox.showwarning( + title="Projector Not Found", + message="Only one display detected — is the projector connected and turned on?" ) - self.exposure_history.append(log) - self.chip.layers[-1].exposures.append(log) - - self.on_event(Event.CHIP_CHANGED) - self.set_patterning_busy(False) - - if self.should_abort: - print("Patterning aborted") - self.should_abort = False - - def non_blocking_delay(self, t: float): - start = time.time() - while time.time() - start < t: - self.root.update() - - def enter_red_mode(self, mode_switch_autofocus=True): - print("enter_red_mode") - self.set_shown_image(ShownImage.RED_FOCUS) - self.camera.setExposureTime(self.red_exposure_time) - if mode_switch_autofocus and self.autofocus_on_mode_switch: - self.autofocus(blue_only=False) - self.on_event(Event.MOVEMENT_LOCK_CHANGED) - - def enter_uv_mode(self, mode_switch_autofocus=True): - if self.auto_snapshot_on_uv: - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - filename = self.snapshot_directory / f"uv_mode_entry_{timestamp}.png" - self.on_event(Event.SNAPSHOT, str(filename)) - - self.camera.setExposureTime(self.uv_exposure_time) - if ( - mode_switch_autofocus - and not self.autofocus_busy - and self.autofocus_on_mode_switch - ): - # UV mode usually needs about -70 to be in focus compared to red mode - #self.move_relative({"z": -85.0}) - pass - - # self.set_shown_image(ShownImage.UV_FOCUS) - self.set_shown_image(ShownImage.CLEAR) # enter uv mode: don't project uv - - if mode_switch_autofocus and self.autofocus_on_mode_switch: - self.non_blocking_delay(2.0) - self.autofocus(blue_only=True) - - self.on_event(Event.MOVEMENT_LOCK_CHANGED) - - def autofocus(self, blue_only, log=False): - if not self.camera: - print("No camera connected, skipping autofocus") - return + return - if self.first_autofocus: - # TODO: Fix this spuriously triggering - self.first_autofocus = False - return + projector = next((m for m in monitors if not m.is_primary), monitors[1]) + print(f"Target: {projector.name} at ({projector.x}, {projector.y})") - if self.autofocus_busy: - print("Skipping nested autofocus!") - return - - if log: - try: - os.mkdir('aftest') - except FileExistsError: - pass - log_file = open('aftest/log.csv', 'w') - - print("Starting autofocus") - - if self.hardware.stage.has_homing(): - - counter = 0 - def sample(): - def one_sample(): - return fetch_focus_score(self.camera_image, blue_only=blue_only, log=True) - focus_score = sum([one_sample() for _ in range(5)])/5 - print("focus average:", focus_score) - nonlocal counter - if log: - log_file.write(f'{counter},{focus_score}\n') - cv2.imwrite(f'aftest/img{counter}.png', self.camera_image, log=True) - counter += 1 - return focus_score - - print("Starting Autofocus...") - best_score = -1.0 - best_z = 0 - z_base = self.hardware.stage.get_autofocus() - - # account for uv mode, where z-focus is different - if blue_only == True: - z_base += 50.0 - if(self.move_absolute({"z": z_base})) == False: - self.create_warning("Failed autofocus, z-stage can't go past boundary limits") - self.set_autofocus_busy(False) - return - self.non_blocking_delay(1.0) + def move_window(): + # Disable real fullscreen — this is what locks the window to a monitor + proj_window.attributes('-fullscreen', False) + proj_window.update() - else: - for i in range(-20, 20, 2): - if not (self.move_absolute({"z": z_base+i})): - self.create_warning("Failed autofocus, z-stage can't go past boundary limits") - self.set_autofocus_busy(False) - return - self.non_blocking_delay(0.5) - new_score = sample() - # always check for optimal scores - if (new_score > best_score): - best_score = new_score - best_z = self.stage_setpoint[2] - - print(f"Fine grain sampling done, best focus is: {best_score}") - self.move_absolute({"z":best_z}) - self.non_blocking_delay(1.0) + # Remove title bar and borders to simulate fullscreen appearance + proj_window.overrideredirect(True) - else: - counter = 0 - def sample_focus(): - def do_thing(): - self.non_blocking_delay(0.1) - return compute_focus_score(self.camera_image, blue_only=blue_only) - focus_score = sorted([do_thing() for _ in range(3)])[1] - nonlocal counter - if log: - log_file.write(f'{counter},{focus_score}\n') - cv2.imwrite(f'aftest/img{counter}.png', self.camera_image) - counter += 1 - return focus_score - - self.set_autofocus_busy(True) - self.non_blocking_delay(1.0) - mid_score = sample_focus() - self.move_relative({"z": -20.0}) - self.non_blocking_delay(1.0) - neg_score = sample_focus() - self.move_relative({"z": 40.0}) - self.non_blocking_delay(1.0) - pos_score = sample_focus() - self.move_relative({"z": -20.0}) - self.non_blocking_delay(1.0) - - last_focus = mid_score - - if neg_score < mid_score < pos_score: - # Improved focus is in the +Z direction - for i in range(30): - self.move_relative({"z": 10.0}) - self.non_blocking_delay(0.5) - new_score = sample_focus() - if last_focus > new_score: - print(f"Successful +Z coarse autofocus {i}") - last_focus = new_score - break - last_focus = new_score - - for i in range(10): - self.move_relative({"z": -2.0}) - self.non_blocking_delay(0.5) - new_score = sample_focus() - if last_focus > new_score: - print(f"Successful -Z fine autofocus {i}") - break - last_focus = new_score - elif neg_score > mid_score > pos_score: - # Improved focus is in the -Z direction - for i in range(30): - self.move_relative({"z": -10.0}) - self.non_blocking_delay(0.5) - new_score = sample_focus() - if last_focus > new_score: - print(f"Successful -Z coarse autofocus {i}") - break - last_focus = new_score - - for i in range(10): - self.move_relative({"z": 2.0}) - self.non_blocking_delay(0.5) - new_score = sample_focus() - if last_focus > new_score: - print(f"Successful +Z fine autofocus {i}") - break - last_focus = new_score - elif neg_score < mid_score and pos_score < mid_score: - # We are very close to already being in focus - print(f"Almost in focus! (neg {neg_score} mid {mid_score} pos {pos_score})") - self.move_relative({"z": -20.0}) - self.non_blocking_delay(0.5) - - for i in range(30): - self.move_relative({"z": 2.0}) - self.non_blocking_delay(0.5) - new_score = sample_focus() - if last_focus > new_score: - print(f"Successful +Z fine autofocus {i}") - break - last_focus = new_score - else: - print("Autofocus is confused!") + # Position and size to exactly cover the projector monitor + proj_window.geometry(f"{projector.width}x{projector.height}+{projector.x}+{projector.y}") + proj_window.update() + proj_window.lift() + print(f"Projection window covering {projector.name} ({projector.width}x{projector.height}) ✓") - print("Autofocus Complete.") - self.set_autofocus_busy(False) - print("Finished autofocus") + proj_window.after(500, move_window) - def initialize_alignment(self, config: LithographerConfig): - self.config = config - self.realtime_detection = config.alignment.enabled - # Attempt loading the model even if detection is off by default - try: - print("loading model") - model_path = config.alignment.model_path - self.model = YOLO(model_path) - print("loaded model") - except Exception as e: - print(f"Failed to load YOLO model: {e}") - - def set_snapshot_directory(self, directory: Path): - self.snapshot_directory = directory - self.snapshot_directory.mkdir(exist_ok=True) - - class SnapshotFrame: """ Presents a frame with a filename entry and a button to save screenshots of the current camera view. @@ -941,7 +118,6 @@ def _next_filename(self): def _refresh_name_preview(self): self.name_preview.configure(text=f"Output File: {self._next_filename()}") - class CameraFrame: def __init__( self, @@ -991,7 +167,6 @@ def _on_new_frame(self): filename = self.snapshots_pending.get_nowait() print(f"Saving image {filename}") fetch_focus_score(image, blue_only=False) - fetch_focus_score(image, blue_only=False) img = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) cv2.imwrite(filename, img) except queue.Empty: @@ -1029,7 +204,7 @@ def cleanup(self): def gui_camera_preview(self, camera_image, dimensions): model = self.event_dispatcher.model if model and self.event_dispatcher.realtime_detection: - _, camera_image = detect_alignment_markers(model, camera_image, draw_rectangle=True) + _, camera_image = detect_markers(model, camera_image, draw_rectangle=True) self.event_dispatcher.set_latest_image(camera_image) resized_img = cv2.resize(camera_image, (0, 0), fx=self.gui_camera_scale, fy=self.gui_camera_scale) gui_img = image_to_tk_image(Image.fromarray(resized_img, mode="RGB")) @@ -1041,9 +216,9 @@ def __init__(self, parent, event_dispatcher: EventDispatcher, uvmode): self.frame = ttk.Frame(parent) self.event_dispatcher = event_dispatcher - # Position display at top - self.position_frame = ttk.LabelFrame(self.frame, text="Current Position (µm)") - self.position_frame.grid(row=0, column=0, columnspan=2, pady=5, sticky="ew") + # # Position display at top + # self.position_frame = ttk.LabelFrame(self.frame, text="Current Position (µm)") + # self.position_frame.grid(row=0, column=0, columnspan=2, pady=5, sticky="ew") self.position_intputs = [] # Track all interactive widgets for locking @@ -1265,6 +440,8 @@ def create_xy_control(self, parent): def _on_xy_click(self, event): """Handle clicks on the XY canvas""" + + # TODO: check for movement lock canvas_size = 255 center = canvas_size // 2 @@ -1400,6 +577,7 @@ def __init__(self, parent, event_dispatcher: EventDispatcher): def callback_set(): x, y, t = self._position() + print(f"setting image position: {x}, {y}, {t}") event_dispatcher.set_image_position(x, y, t) self.set_position_button = ttk.Button(self.absolute_frame, text="Set Image Position", command=callback_set) @@ -1560,6 +738,8 @@ def _upload_marks(self): if dir_path: # User didn't cancel self.predefined_images.append((filename, dir_path)) self.image_dropdown['values'] = [name for name, _ in self.predefined_images] + self.image_dropdown.set(filename) + self._load_image(dir_path) def _load_selected(self): """Called when Load Selected button is clicked""" @@ -1619,7 +799,6 @@ def __init__(self, parent, button_text, import_command, predefined_images=None): self.label = ttk.Label(self.frame, text=button_text) self.label.grid(row=1, column=0) - class PatternDisplayFrame: # read only pattern display in red and uv focusing mode def __init__(self, parent, event_dispatcher: EventDispatcher): self.frame = ttk.Frame(parent) @@ -1668,8 +847,6 @@ def __init__(self, parent, event_dispatcher: EventDispatcher, show_uv_focus=Fals self.frame, "UV Focus", self._on_uv_focus_change, - # lambda t: event_dispatcher.set_uv_focus_image(self.uv_focus_image), - # import_command in ImageSelectFrame --> on_select in PredefinedImageSelector predefined_images=uv_focus_predefined ) self.uv_focus_frame.frame.grid(row=1, column=0, padx=5, pady=5) @@ -1878,7 +1055,6 @@ def refresh_cur_layer(self): pos = f"{ex.coords[0]},{ex.coords[1]},{ex.coords[2]}" self.cur_layer_view.insert("", "end", ex_id, image=self._get_thumbnail(ex.path), values=(pos,)) - class ExposureFrame: def __init__(self, parent, event_dispatcher: EventDispatcher): self.frame = ttk.Frame(parent) @@ -1930,6 +1106,11 @@ def on_posterize_check(): self.posterize_cutoff_entry.widget.grid(row=2, column=2, sticky="nesw") self.posterize_cutoff_entry.widget["state"] = "disabled" + def on_change_exposure(new_exposure): + self.exposure_time_entry.set(new_exposure) + + event_dispatcher.add_event_listener(Event.EXPOSURE_TIME_CHANGED, on_change_exposure) + # returns threshold percentage if posterizing is enabled, else None def _posterize_strength(self) -> Optional[int]: if self.posterize_enable_var.get(): @@ -1937,7 +1118,6 @@ def _posterize_strength(self) -> Optional[int]: else: return None - class PatterningFrame: def __init__(self, parent, event_dispatcher: EventDispatcher): self.frame = ttk.Frame(parent) @@ -1988,7 +1168,7 @@ def set_image(self, img: Image.Image): self.thumb_image = image_to_tk_image(img.resize(THUMBNAIL_SIZE)) self.preview_tile.configure(image=self.thumb_image) # type:ignore - +##################### MODE FRAME CLASSES ########################### class RedModeFrame: def __init__(self, parent, event_dispatcher: EventDispatcher): self.frame = ttk.Frame(parent, name="redmodeframe") @@ -2075,7 +1255,6 @@ def on_radiobutton(*_): def red_focus_image(self): return self.red_focus_frame.thumb.image - class UvModeFrame: def __init__(self, parent, event_dispatcher): self.frame = ttk.Frame(parent, name="uvmodeframe") @@ -2113,6 +1292,81 @@ def __init__(self, parent, event_dispatcher): self.patterning_frame = PatterningFrame(self.right_frame, event_dispatcher) self.patterning_frame.frame.grid(row=1, column=0) +class PreviousPatternUploadFrame: + def __init__(self, parent, event_dispatcher: EventDispatcher): + upload_type = "Previous Layer Pattern" + self.frame = ttk.Frame(parent) + self.event_dispatcher = event_dispatcher + + # Create container frame for centering + container = ttk.Frame(self.frame) + container.grid(row=0, column=0) + + # Main pattern upload section + self.upload_frame = ttk.LabelFrame(container, text=f"{upload_type} Upload") + self.upload_frame.grid(row=0, column=0) + + # Pattern selector (using existing ImageSelectFrame functionality) + self.pattern_selector = ImageSelectFrame( + self.upload_frame, + f"Select {upload_type}", + self._on_pattern_upload + ) + self.pattern_selector.frame.grid(row=0, column=0) + + # Pattern info display + self.info_frame = ttk.LabelFrame(container, text=f"{upload_type} Information") + self.info_frame.grid(row=1, column=0) + + self.pattern_path_var = StringVar(value="No pattern loaded") + ttk.Label(self.info_frame, text=f"{upload_type}:").grid(row=0, column=0, sticky="w") + ttk.Label(self.info_frame, textvariable=self.pattern_path_var, + foreground="blue").grid(row=0, column=1, sticky="w", padx=(10,0)) + + # Pattern preview (larger than thumbnail) + self.preview_frame = ttk.LabelFrame(container, text=f"{upload_type} Preview") + self.preview_frame.grid(row=0, column=1, rowspan=2, padx=10) + + # Center the container + self.frame.grid_columnconfigure(0, weight=1) + self.frame.grid_rowconfigure(0, weight=1) + + # Create larger preview image + preview_size = (320, 240) # Larger than THUMBNAIL_SIZE + placeholder = Image.new("RGB", preview_size, "gray") + self.preview_photo = image_to_tk_image(placeholder) + self.preview_label = ttk.Label(self.preview_frame, image=self.preview_photo) + self.preview_label.grid(row=0, column=0, padx=5, pady=5) + + # Upload instructions + instruction_text = ("Upload your pattern image using the selector above. " + "The previous layer pattern will be used to align with the stitched image") + ttk.Label(self.upload_frame, text=instruction_text, + wraplength=400).grid(row=1, column=0, padx=5, pady=5) + + def _on_pattern_upload(self, _): + """Handle pattern upload""" + if self.pattern_selector.thumb.image: + # Update the event dispatcher with the new pattern + self.event_dispatcher.set_prev_pattern_image( + self.pattern_selector.thumb.image, + self.pattern_selector.thumb.path + ) + + # Update the info display + if self.pattern_selector.thumb.path: + filename = Path(self.pattern_selector.thumb.path).name + self.pattern_path_var.set(filename) + else: + self.pattern_path_var.set("Pattern uploaded") + + # Update preview image + if self.pattern_selector.thumb.image: + preview_img = self.pattern_selector.thumb.image.copy() + preview_img.thumbnail((320, 240), Image.Resampling.LANCZOS) + self.preview_photo = image_to_tk_image(preview_img) + self.preview_label.configure(image=self.preview_photo) + class PatternUploadFrame: def __init__(self, parent, event_dispatcher: EventDispatcher): self.frame = ttk.Frame(parent) @@ -2192,10 +1446,15 @@ def __init__(self, parent, event_dispatcher: EventDispatcher): self.notebook = ttk.Notebook(parent) # Add Pattern Upload tab first + self.previous_layer_upload_frame = PreviousPatternUploadFrame(self.notebook, event_dispatcher) + self.notebook.add(self.previous_layer_upload_frame.frame, text="Previous Layer Upload") + self.pattern_upload_frame = PatternUploadFrame(self.notebook, event_dispatcher) self.notebook.add(self.pattern_upload_frame.frame, text="Pattern Upload") + self.red_mode_frame = RedModeFrame(self.notebook, event_dispatcher) self.notebook.add(self.red_mode_frame.frame, text="Red Light Alignment Mode") + self.uv_mode_frame = UvModeFrame(self.notebook, event_dispatcher) self.notebook.add(self.uv_mode_frame.frame, text="UV Exposure Mode") @@ -2208,20 +1467,17 @@ def on_tab_change(): self.notebook.bind("<>", lambda _: on_tab_change()) - # def on_tab_event(evt): - # self.notebook.select(1 if evt == Event.EnterUvMode else 0) - - # event_dispatcher.add_event_listener(Event.EnterRedMode, lambda: on_tab_event(Event.EnterRedMode)) - # event_dispatcher.add_event_listener(Event.EnterUvMode, lambda: on_tab_event(Event.EnterUvMode)) - def _current_tab(self): selected = self.notebook.select() - if "patternupload" in selected.lower() or self.notebook.index("current") == 0: + if "previouslayer" in selected.lower() or self.notebook.index("current") == 0: + return "prevpattern" + elif "patternupload" in selected.lower() or self.notebook.index("current") == 1: return "pattern" - elif "redmode" in selected or self.notebook.index("current") == 1: - return "red" + elif "redmode" in selected or self.notebook.index("current") == 2: + return "red" else: return "uv" +################## END MODE FRAME CLASSES ########################### class GlobalSettingsFrame: def __init__(self, parent, event_dispatcher: EventDispatcher, enable_detection: bool = False): @@ -2254,22 +1510,23 @@ def set_realtime_detection(*_): def do_align(): h, w, _ = event_dispatcher.camera_image.shape - markers, _ = detect_alignment_markers(event_dispatcher.model, event_dispatcher.camera_image) + + # Replace detect_alignment_markers with your pipeline + img_input, orig_h, orig_w = rf_detr_preprocess(event_dispatcher.camera_image, layer=event_dispatcher.config.layer) + markers = detect_marks_for_slam(img_input, event_dispatcher.model, orig_h, orig_w) + dx, dy = 0, 0 if len(markers) == 0: + event_dispatcher.create_warning("No alignment markers detected. Please manually align.") return - + # Get alignment parameters from config alignment = event_dispatcher.config.alignment - for m in markers: - xy0, xy1 = m - x0, y0 = xy0 - x1, y1 = xy1 - # compute normalized centers of the bounding box - x = (x0 + x1) / 2 / w - y = (y0 + y1) / 2 / h - + cx, cy = m["center"] + x = cx / w + y = cy / h + if x > 0.5: dx += alignment.x_scale_factor * (alignment.right_marker_x / w - x) else: @@ -2278,13 +1535,13 @@ def do_align(): dy += alignment.y_scale_factor * (alignment.bottom_marker_y / h - y) else: dy += alignment.y_scale_factor * (alignment.top_marker_y / h - y) - + dx /= len(markers) dy /= len(markers) - event_dispatcher.move_relative({ 'x': dx, 'y': dy }) + event_dispatcher.move_relative({'x': dx, 'y': dy}) print(markers) - + self.alignbutton = ttk.Button( self.frame, text="Align :)", @@ -2293,7 +1550,6 @@ def do_align(): ) self.alignbutton.grid(row=2, column=1) - # TODO: Fix this self.autofocus_button = ttk.Button(self.frame, text="Autofocus", command=lambda: event_dispatcher.autofocus(blue_only=event_dispatcher.in_uv())) self.autofocus_button.grid(row=2, column=0, sticky="ew") @@ -2373,6 +1629,9 @@ def choose_directory(): # Configure grid weights for proper expansion self.snapshot_frame.columnconfigure(1, weight=1) + # Button for new RF-DETR Model's alignment detection + self.detect_button = ttk.Button(self.frame, text="Detect Fiducials", command=lambda: event_dispatcher.detect_marks_for_slam(event_dispatcher.camera_image, event_dispatcher.model)) + self.detect_button.grid(row=3, column=0, sticky="ew") class ExposureHistoryFrame: def __init__(self, parent, event_dispatcher: EventDispatcher): @@ -2394,8 +1653,11 @@ def _refresh(self): self.text.insert("end", line) self.text["state"] = "disabled" - class OffsetAmountFrame: + # HOW TO USE: + # Subtraction amounts are there to tune the offset for the alignment markers + # self.x_settings = OffsetAmountFrame(self.frame, "X", 1037-54) #Move amount between exposures in X + # self.y_settings = OffsetAmountFrame(self.frame, "Y", 539-27) #Move amount between exposures in y def __init__(self, parent, label, default_offset): self.frame = ttk.LabelFrame(parent, text=label) @@ -2410,140 +1672,385 @@ def __init__(self, parent, label, default_offset): self.amount_spinbox = ttk.Spinbox(self.frame, from_=-20, to=20, textvariable=self.amount_var, width=3) self.amount_spinbox.grid(row=0, column=3) +class ProjectorDisplayFrame: + """Frame to display what the projector is currently showing""" + + def __init__(self, parent, event_dispatcher: EventDispatcher): + self.frame = ttk.Frame(parent) + self.event_dispatcher = event_dispatcher + + # Main label frame + self.display_frame = ttk.LabelFrame(self.frame, text="Projector Output") + self.display_frame.grid(row=0, column=0) + + # Create placeholder image + # Using a similar size to camera preview for consistency + self.display_size = (320, 180) + placeholder = Image.new("RGB", self.display_size, "black") + self.photo = image_to_tk_image(placeholder) + + # Display label + self.label = ttk.Label(self.display_frame, image=self.photo, relief="solid", borderwidth=2) + self.label.grid(row=0, column=0, padx=5, pady=5) + + # Status label showing current mode + self.status_var = StringVar(value="Status: Clear") + self.status_label = ttk.Label(self.display_frame, textvariable=self.status_var) + self.status_label.grid(row=1, column=0, padx=5, pady=5) + + # Listen for projector changes + event_dispatcher.add_event_listener(Event.SHOWN_IMAGE_CHANGED, self._update_display) + event_dispatcher.add_event_listener(Event.PATTERN_IMAGE_CHANGED, self._update_display) + event_dispatcher.add_event_listener(Event.IMAGE_ADJUST_CHANGED, self._update_display) + event_dispatcher.add_event_listener(Event.PATTERNING_BUSY_CHANGED, self._update_display) + + # Force initial update + # self.event_dispatcher.root.after(100, self._update_display) + + def _update_display(self): + """Update the display when projector content changes""" + shown_image = self.event_dispatcher.shown_image + + # Update status text + status_map = { + ShownImage.CLEAR: "Status: Clear (No Output)", + ShownImage.PATTERN: "Status: Pattern (UV Exposure)", + ShownImage.FLATFIELD: "Status: Flatfield Correction", + ShownImage.RED_FOCUS: "Status: Red Focus Mode", + ShownImage.UV_FOCUS: "Status: UV Focus Pattern", + } + self.status_var.set(status_map.get(shown_image, "Status: Unknown")) + + # Get the appropriate processed image based on mode + # Note: When patterning, we check patterning_busy flag as well + img = None + if shown_image == ShownImage.RED_FOCUS: + img = self.event_dispatcher.red_focus.processed() + # pattern case above uv focus case: when set_patterning_busy(True) is called, + # shown_image is never changed to PATTERN during patterning - it stays as UV_FOCUS + elif shown_image == ShownImage.PATTERN or self.event_dispatcher.patterning_busy: + img = self.event_dispatcher.pattern.processed() + elif shown_image == ShownImage.UV_FOCUS: + img = self.event_dispatcher.uv_focus.processed() + elif shown_image == ShownImage.FLATFIELD: + # Flatfield might not be implemented, use pattern as fallback + img = self.event_dispatcher.pattern.processed() + + # Update image + if img is None or (shown_image == ShownImage.CLEAR and not self.event_dispatcher.patterning_busy): + # Show black placeholder when clear + placeholder = Image.new("RGB", self.display_size, "black") + self.photo = image_to_tk_image(placeholder) + else: + display_img = img.copy() + display_img.thumbnail(self.display_size, Image.Resampling.LANCZOS) + self.photo = image_to_tk_image(display_img) + + self.label.configure(image=self.photo) + +############# MULTI-LAYER TILING CLASSES ################## class TilingFrame: def __init__(self, parent, model: EventDispatcher): - self.frame = ttk.LabelFrame(parent, text="Tiling") - self.model = model - self.red_to_uv_offset = -40 + self.params = TilingParameters(None, None, None, None, None, None, None, None, None, None, None) + + # constants that we can tune + self.params.ratio = 0.5 # 1/ratio = number of steps in between current tile to next tile to take --> used to define stride size + self.red_to_uv_offset = 60 # offset between red mode and uv mode for z-stage (steps) + self.red_to_pattern_offset = 10 # offset between red mode and red pattern mode for z-stage (steps) + self.params.px_to_step_x = px_to_step_x # projection pixels to steps conversion (x) + self.params.px_to_step_y = px_to_step_y # projection pixels to steps conversion (y) + self.params.step_error_threshold_x = 10 + self.params.step_error_threshold_y = 10 + self.leeway_w_steps = 10 + self.leeway_h_steps = 10 # steps + self.projection_width_steps = 1037 # steps + self.projection_height_steps = 583 # steps + self.overlay_w_px = 0 + self.overlay_h_px = 0 + + self.segmented = False + self.exposure_time = 20000 + self.model = model - self.overall_pattern_size_w = 0 - self.overall_pattern_size_h = 0 + """ + self.tile_width, self.tile_height = 3840, 2160 # in pixels, defined in TilingFrame + # each snapshot captured is 1920 x 1080 pixels + """ + + self.frame = ttk.LabelFrame(parent, text="Tiling") + + self.layer = tkinter.IntVar(self.frame, value=1) # default is layer 1 - #Defaults set based on DLP471TP and a 10x objective + def on_layer_change(*args): + try: + # Attempt to get the integer value + current_layer = self.layer.get() + if(current_layer == 1): + self.model.on_event(Event.EXPOSURE_TIME_CHANGED, self.exposure_time) + else: + self.model.on_event(Event.EXPOSURE_TIME_CHANGED, 8000) + self.layer.set(current_layer) + print(f"on_layer_change: {current_layer}") + + except tkinter.TclError: + pass + + self.layer.trace_add("write", on_layer_change) + + # Defaults set based on DLP471TP and a 10x objective #5.4 um Pixel Pitch #Width 10.368 mm #Height 5.832 mm - # Move in X = 10.368mm / 10 = 1037um + # Move in X = 10.368 mm / 10 = 1037um # Move in Y = 5.832 mm / 10 = 538.2 um ~ 539 um - #Subtraction amounts are there to tune the offset for the alignment markers - self.x_settings = OffsetAmountFrame(self.frame, "X", 1037-54) #Move amount between exposures in X - self.y_settings = OffsetAmountFrame(self.frame, "Y", 539-27) #Move amount between exposures in y - - #Tiling verisons of alignment - def detect_alignment_markers_tiling(yolo_model, image, draw_rectangle=False, edge=None, edge_fraction=0.25): - #Detects alignment markers and optionally filters detections by image edge(s). - #yolo_model: YOLO model - #image: image to detect on - #draw_rectangle: If True, draw rectangles - #edge: 'left', 'right', 'top', or a list like ['left', 'right'] where markers are expected - #none means that markers are expect on all edges - #edge_fraction: Fraction of width/height considered as edge region + + def snake_pattern_alignment_errors(model, dest_img, src_img, + dir_x=None, dir_y=None, step_x:int=0, step_y:int=0, layer=1, + src_pattern=False, dest_pattern=False) -> tuple[float, float, float]: + """ + Takes in 2 images: + - `src_img`: original image (either former location or pattern image) + - `dest_img`: new image (either new offset or raw red focus) + - `dir_x, dir_y`: x direction, y direction stage moved + - 'step_x, step_y`: in stepper steps, how many taken per axis + - `src_pattern, dest_pattern`: booleans that indicate whether image is digital (False --> camera) + + Preconditions: + - imgs must be of same size (or scaled to same size for convenience) + - step_size is given in stepper steps + + Match camera detections to pattern detections by nearest-neighbor + after normalizing for scale/translation.Then calculates transform + Returns list of tuple(dx, dy, rotation degree) pairs (pixels) + """ + assert src_img is not None and dest_img is not None, "Error: one or both images are None" + assert ((step_x >= 0) and (step_y >= 0)), "Error: step size cannot be negative, use direction args for negatives" + # calculate step size + step_size_x_px = round(step_x / self.params.px_to_step_x) # convert steps to pixels => modified to be other way around + step_size_y_px = round(step_y / self.params.px_to_step_y) # convert steps to pixels + print(f"step size calculations (pixels): dir_x {dir_x}, dir_y {dir_y}, step_size_x_px {step_size_x_px}, step_size_y_px {step_size_y_px}") + + # pre-processing + dest_processed, d_h, d_w = rf_detr_preprocess(dest_img, layer+1 if dest_pattern == True else layer) + src_processed, s_h, s_w = rf_detr_preprocess(src_img, layer+1 if src_pattern == True else layer) + + # intermediate check + assert dest_processed.shape == src_processed.shape, "Error: images are differently sized, exiting function" + + # detect raw alignment marks -> raw marks are in pixels + src_marks_raw = detect_marks_for_slam(src_processed, model, d_h, d_w, CONFIDENCE_THRESHOLD) # assume returning [dict{}] + dest_marks_raw = detect_marks_for_slam(dest_processed, model, s_h, s_w, CONFIDENCE_THRESHOLD) # assume returning [dict{}] + print("detected src mark count: ", len(src_marks_raw), "detected dest mark count: ", len(dest_marks_raw)) + # detection failed: no correction needed, just default to trusting stage steps + if len(dest_marks_raw) == 0 or len(src_marks_raw) == 0: + print("Early exit: No markers detected in one or both images") + return (None, None, None) + + # take centroids of each set of marks + dest_marks = [mark["center"] for mark in dest_marks_raw] + src_marks = [mark["center"] for mark in src_marks_raw] + print(f"raw dest_marks: {dest_marks}") + print(f"raw src_marks: {src_marks}") + + # if direction and step size are defined, then we should offset the src + # img and bring it close to dest markers and match them, this maximizes + # the probability that we find correct matches without needing to run some + # crazy algorithm that takes forever ;) + + step_dx = 0 + step_dy = 0 + src_marks_shifted = src_marks.copy() # default: no shift + + # offset src_img's detections so they can match with current camera img + shift_x = 0 + shift_y = 0 + + if dir_x == 'right': + shift_x = +step_size_x_px + elif dir_x == 'left': + shift_x = -step_size_x_px + + if dir_y == 'up': + shift_y = +step_size_y_px + elif dir_y == 'down': + shift_y = -step_size_y_px + + step_dx = shift_x + step_dy = shift_y + print(f"shift size (px): step_dx {step_dx}, step_dy={step_dy}") + src_marks_shifted = [(x + shift_x, y + shift_y) for x, y in src_marks] + print(f"new src_marks that are shifted: {src_marks_shifted}\n") + + # match coordinates to closest coordinates -> match-finder alg + img_h, img_w = dest_img.shape[1], dest_img.shape[0] + matched_dest, _, matched_src_shifted = match_alignment_markers_by_coordinates(dest_marks, src_marks, src_marks_shifted, img_h, img_w) + print("\nmatched_dest_marks", matched_dest) + print("matched_src_shifted_marks", matched_src_shifted) + # check if we should proceed with error correction + if len(matched_dest) < 1: + print(f"Warning: {len(matched_dest)} valid match(es) found, need at least 2 for rotation. Skipping correction.") + return (0, 0, 0) + + # calculate transform in pixels and rotation + dx, dy, d0 = estimate_transform(np.array(matched_dest), np.array(matched_src_shifted)) + print(f"calculated estimate_transform, {dx}, {dy}, {d0}") + if len(matched_dest) == 1: + d0 = 0.0 + if abs(d0) >= 90.0: + self.model.create_warning(f"Error: chip is rotated from previous layer. Please correct rotation and re-pattern. Skipping errors") + return (0, 0, 0) + + total_dx, total_dy, d0 = (dx, dy, d0) # removed dx+step_x, removed dy+step_y + print(f"error transform result (px): {round(total_dx)}, {round(total_dy)}, {d0}") + + return (round(total_dx), round(total_dy), d0) + + def slam_way_to_target(model, row:int, col:int, num_rows:int, num_cols:int, width:int, height:int, ratio:int, layer, src_img, dest_img): + """ + Takes in 2 images: + - `model`: marker detection model (likely RF-DETR) + - `row, col`: tile row, tile column + - `num_cols`: total number of tile columns in pattern + - `width, height`: steps to take in each direction + - `partial_steps`: number of steps to take to get to next tiling location on stage + - `layer`: layer 1 for first layer, 2, for next, etc. + - `src_img, dest_img`: captures of destination (where you currently are), source (where you came from, or a pattern image for alignment feature) + """ + error_y = 0 # steps + error_x = 0 # steps + h_direction = None + v_direction = None - detections = [] - display_image = image.copy() - try: - image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) - original_height, original_width = image_rgb.shape[:2] - resized = cv2.resize(image_rgb, (640, 640)) - results = yolo_model(resized) - boxes = results[0].boxes - - if isinstance(edge, str): - edge = [edge] # allow single string or list - - for box in boxes: - x1, y1, x2, y2 = box.xyxy[0].cpu().numpy() - x1 = int(x1 * original_width / 640) - x2 = int(x2 * original_width / 640) - y1 = int(y1 * original_height / 640) - y2 = int(y2 * original_height / 640) - x_center = (x1 + x2) / 2 - y_center = (y1 + y2) / 2 - - # If edge filtering is enabled - if edge is not None: - if 'left' in edge and x_center > original_width * edge_fraction: - continue - if 'right' in edge and x_center < original_width * (1 - edge_fraction): - continue - if 'top' in edge and y_center > original_height * edge_fraction: - continue - - detections.append(((x1, y1), (x2, y2))) - if draw_rectangle: - cv2.rectangle(display_image, (x1, y1), (x2, y2), (0, 255, 0), 3) - - print(f"Detected {len(detections)} marker(s)") - except Exception as e: - print(f"Detection failed: {e}") - - return detections, display_image - - def do_align_tiling(edge): - #edge = ['left', 'right', 'top'] - h, w, _ = model.camera_image.shape - - # Detect markers on the left, right, and top edges - markers, _ = detect_alignment_markers_tiling(model.model, model.camera_image, edge) - if len(markers) == 0: - print("No markers detected.") - return - - alignment = model.config.alignment - dx, dy = 0.0, 0.0 - count_x, count_y = 0, 0 + num_iterations = math.ceil(1 / ratio) + # step-alignment + for _ in range(num_iterations): + + # # determine next step direction (and) size + h_direction, v_direction, step_x, step_y = get_next_tile_vector(row, col, width, height, num_rows, num_cols, num_iterations, error_x, error_y) + print(f"\nnext tile vector returned: h_direction={h_direction}, v_direction={v_direction}, step_x={step_x}, step_y={step_y}") + + # calculate relative position and move there + dx_step = math.floor((step_x if h_direction == 'right' else -step_x) if h_direction != None else 0) + dy_step = math.floor((step_y-15 if v_direction == 'down' else -step_y+15) if v_direction != None else 0) # y-axis needs some leeway, it tends to overstep down + + # move the stage to next half-step + error corrected coordiantes + print(f"moving a stride...") + self.model.move_relative({'x': -dx_step, 'y': -dy_step}) + self.model.non_blocking_delay(1.0) + + # detect alignment errors from stage movement + self.model.autofocus(blue_only=False, search=4, start=self.model.stage_setpoint[2]) + wait() + + dest_img = self.model.camera_image.copy() + self.model.non_blocking_delay(0.5) + + # fetch alignment error + dx_step = abs(dx_step) + dy_step = abs(dy_step) + print(f"\nCalling alignment_errors with h={h_direction}, v={v_direction}, dx_step {dx_step} and dy_step {dy_step}, layer {layer}") + dx, dy, dr = snake_pattern_alignment_errors(model, dest_img, src_img, h_direction, v_direction, dx_step, dy_step, layer, + src_pattern=False, dest_pattern=False) + print(f"alignment_errors returned with derivatives (pixels): {dx}, {dy}, {dr}") + + if(dx == None and dy == None and dr == None): + dx, dy, dr = 0,0,0 + self.model.create_warning("Couldn't find alignment markers. Warning: Tiling may fail.") + + # store errors so next movement accomodates for them + error_x = round(dx*self.params.px_to_step_x) # scale pixels to number of steps + error_y = round(dy*self.params.px_to_step_y) # scale pixels to number of steps + print(f"transform is: {error_x}, {error_y}, with rotation {dr} (steps)") + + # set src to dest + src_img = dest_img.copy() + + # fine-alignment + print("\nFine alignment") + src_img = self.model.camera_image.copy() + dest_img = self.params.align_image.copy() + print(f"Calling alignment_errors") + dx, dy, dr = snake_pattern_alignment_errors(model, dest_img, src_img, None, None, 0, 0, layer=layer, src_pattern=False, dest_pattern=True) + print(f"alignment_errors returned with {dx}, {dy}, {dr}") + + if(dx == None and dy == None and dr == None): + dx, dy, dr = 0,0,0 + self.model.create_warning("Couldn't find alignment markers. Warning: Tiling may fail.") + + error_x = round(dx*self.params.px_to_step_x) # scale pixels to number of steps + error_y = round(dy*self.params.px_to_step_y) # scale pixels to number of steps + error_x_img = (dx*self.params.px_to_step_x) * 1.0 / step_to_projection_pixels_x + error_y_img = (dy*self.params.px_to_step_y) * 1.0 / step_to_projection_pixels_y + print(f"fine transform is: {error_x}, {error_y}, with rotation {dr} (steps)") + print(f"fine transform (projection): {error_x_img}, {error_y_img}, with rotation {dr} (steps)") + + tries = 0 + can_exit = False + while tries < 10: + + # if close enough, just move the pattern digitally + if(can_exit and abs(error_x) <= self.params.step_error_threshold_x and abs(error_y) <= self.params.step_error_threshold_y): + print("exiting slam loop and adjusting image position") + + self.model.set_image_position(error_x_img, error_y_img, dr) + self.model.non_blocking_delay(0.5) + dest_img = self.params.align_image.copy() + return (self.model.camera_image.copy(), dest_img) + + # adjust + print(f"\ntry {tries}: move_relative error_x{-error_x}, and error_y={error_y}") + self.model.move_relative({'x': -error_x, 'y': error_y}) + self.model.non_blocking_delay(0.5) + + # capture and detect + src_img = self.model.camera_image.copy() + print(f"Calling snake_pattern_alignment_errors") + dx, dy, dr = snake_pattern_alignment_errors(model, dest_img, src_img, None, None, 0, 0, layer=layer, src_pattern=False, dest_pattern=True) + print(f"snake_pattern_alignment_errors reuturned iwth {dx}, {dy}, {dr}") + + if(dx == None and dy == None and dr == None): + print(f"moving back a stride...") + # move back + self.model.move_relative({'x': error_x, 'y': -error_y}) + self.model.non_blocking_delay(0.5) + error_x+=5 # small adjustment in steps size + error_y-=5 # small adjustment in steps size + tries += 1 + can_exit = False + continue + + # store errors so next movement accomodates for them + error_x = round(dx*self.params.px_to_step_x) # scale pixels to number of steps + error_y = round(dy*self.params.px_to_step_y) # scale pixels to number of steps + error_x_img = (dx*self.params.px_to_step_x) * 1.0 / step_to_projection_pixels_x + error_y_img = (dy*self.params.px_to_step_y) * 1.0 / step_to_projection_pixels_y - for m in markers: - xy0, xy1 = m - x0, y0 = xy0 - x1, y1 = xy1 - x = (x0 + x1) / 2 / w - y = (y0 + y1) / 2 / h - - # Horizontal alignment (left/right markers) - if x < 0.5: - dx += alignment.x_scale_factor * (alignment.left_marker_x / w - x) - count_x += 1 - elif x > 0.5: - dx += alignment.x_scale_factor * (alignment.right_marker_x / w - x) - count_x += 1 + can_exit = True + print(f"error_x{error_x}, and error_y={error_y}") - # Vertical alignment (top markers only) - if y < 0.3: # top region - dy += alignment.y_scale_factor * (alignment.top_marker_y / h - y) - count_y += 1 - - # Average corrections based on detected edges - if count_x > 0: - dx /= count_x - if count_y > 0: - dy /= count_y - - # Move accordingly (if no top markers, dy=0) - #If a small amount of alignment is needed move the image otherwise move the stage since we have far more percision in moving the image than the stage - #The con of this is that large movements of the image result in cropping of the image - #TODO calibrate the stage move threshold - if(dx < 10 or dy < 10): - #move the image instead of the stage - model.set_image_position(dx, dy, t=0) - else: - model.move_relative({'x': dx, 'y': dy}) - print(f"Alignment correction: dx={dx:.5f}, dy={dy:.5f} using {len(markers)} markers.") + tries += 1 + if tries == 10: + print(f"\nfine alignment did not converge at tile ({row},{col})") - #function that takes in an arbitrary sized image composed of 3840x2160 tiles + return (src_img, dest_img) + + # function that takes in an arbitrary sized image composed of 3840x2160 tiles #with shared alignment marks that are 200 pixels from the edge def split_image_with_overlap(image_path, - tile_width=3840, - tile_height=2160, - overlap_x=200, - overlap_y=200, - output_dir="tiles"): + tile_width=3840, tile_height=2160, + overlap_x=200, overlap_y=200, + output_dir="tiles"): + img = Image.open(image_path) img_w, img_h = img.size - self.overall_pattern_size_w = img_w - self.overall_pattern_size_h = img_h os.makedirs(output_dir, exist_ok=True) + + ####################### Rachel Insertion ############################################ + self.overlay_w_px = overlap_x # pixels_digital + self.overlay_h_px = overlap_y # pixels_digital + print(f"tile_width={tile_width}, tile_height={tile_height}, overlay={overlap_x},{overlap_y}") + ####################### Rachel Insertion ############################################ stride_x = tile_width - overlap_x stride_y = tile_height - overlap_y @@ -2574,8 +2081,14 @@ def split_image_with_overlap(image_path, tile_count = 0 #Set amount of tiles for later use when exposing - self.x_settings.amount_var = len(x_positions) - self.y_settings.amount_var = len(y_positions) + # self.x_settings.amount_var = len(x_positions) + # self.y_settings.amount_var = len(y_positions) + + # Rachel Insertions + self.params.num_rows = len(y_positions) + self.params.num_cols = len(x_positions) + self.params.prefix_path = os.path.join(output_dir, f"") + #Crop and Save the tile images for tile_id_y, top in enumerate(y_positions): for tile_id_x, left in enumerate(x_positions): @@ -2586,189 +2099,151 @@ def split_image_with_overlap(image_path, tile = img.crop(box) tile.save(os.path.join(output_dir, f"tile_{tile_id_y},{tile_id_x}.png")) tile_count += 1 - - print("X amount = "+str(self.x_settings.amount_var)) - print("Y amount = "+str(self.y_settings.amount_var)) + + # print("X amount = "+str(self.x_settings.amount_var)) + # print("Y amount = "+str(self.y_settings.amount_var)) print(f"Saved {tile_count} tiles to {output_dir}") + + return (len(y_positions), len(x_positions)) - #function that patterns a single tile - def pattern_for_tile(self, model, x_start, x_dir, x_idx, x_offset, y_start, y_dir, y_idx, y_offset, y_idx_max, x_idx_max, tile_dir="tiles"): - #change image - image_path = tile_dir+"/tile_"+str(y_idx)+","+str(x_idx)+".png" + def segment(): + #create tile directory and segment images + model.num_rows, model.num_cols = split_image_with_overlap(model.pattern_image_path) + #load the first tile for operator placement + model.set_red_focus_source(RedFocusSource.PATTERN) + image_path = "tiles/tile_"+str(0)+","+str(0)+".png" current_tile = Image.open(image_path) model.set_pattern_image(current_tile, image_path) - #move to the next position if not the first tile - #the first tile is exposed where the operator(user of the stepper) places it - if(~(x_idx == 0 & y_idx == 0)): - self.model.move_absolute( - { - "x": x_start + x_dir * x_idx * x_offset, - "y": y_start + y_dir * y_idx * y_offset, - } - ) - #Red autofocus - self.model.autofocus(blue_only=False) - - #align to previous alignment marks if not first tile - if(~(x_idx == 0 & y_idx == 0)): - if(x_idx !=0 & x_idx!=x_idx_max): - if(y_idx % 2 == 0): - do_align_tiling('left') - else: - do_align_tiling('right') + self.segmented = True + + def wait(): + while self.model.autofocus_busy: + self.model.non_blocking_delay(1.0) + + def on_begin(): + + positions = {} + print("Starting Tiling") + print("on_begin: setting red focus to be pattern") + self.model.set_red_focus_source(RedFocusSource.PATTERN) + print("doneset pattern") + + print("on_begin storing align_image") + self.params.align_image = model.camera_image.copy() + print("on_begin done align_image") + + src_img = model.camera_image.copy() # (1200, 1840, 3) + dest_img = None + prev_row = 0 + prev_col = 0 + + if self.layer.get() == 1: + self.model.on_event(Event.EXPOSURE_TIME_CHANGED, self.exposure_time) + + overlay_w_steps = self.overlay_w_px * digital_to_cam_view + self.leeway_w_steps + overlay_h_steps = self.overlay_h_px * digital_to_cam_view + self.leeway_h_steps + print(f"overlay_w_steps: {overlay_w_steps}, overlay_h_steps:{overlay_h_steps}") + + self.params.stride_x = int((self.projection_width_steps - overlay_w_steps) * self.params.ratio) + self.params.stride_y = int((self.projection_height_steps - overlay_h_steps) * self.params.ratio) + + print(f"strides x,y: {self.params.stride_x}, {self.params.stride_y}\n") + + # iterate row in order + for row in range(self.params.num_rows): + + # iterate columns in snake order + range_order = range(self.params.num_cols) + + if self.layer.get() == 1: + if row % 2 == 1: + range_order = range_order[::-1] else: - do_align_tiling('top') - - + if row % 2 == 0: + range_order = range_order[::-1] - #Do automatic offset for UV then autofocus - self.model.move_relative({"z": self.red_to_uv_offset}) - self.model.non_blocking_delay(0.5) - self.model.enter_uv_mode(mode_switch_autofocus=False) - self.model.autofocus(blue_only=True) + for col in range_order: - #expose the image - self.model.begin_patterning() + # if first image, no need to move anywhere or align anywhere + if not (row==0 and col==0): - #TODO Add second exposure of the alignment markers - # I tried doing this with a non blocking delay but didnt have success - # I think that loading a pattern of the alignment marks that is hardcoded into the software might be the best bet + print(f"\nSlam_way_to_target: for {row}, {col} from {prev_row}, {prev_col}") + self.model.autofocus(blue_only=False, search=6, start=self.model.stage_setpoint[2]) + wait() - #Offset back to red mode - self.model.enter_red_mode(mode_switch_autofocus=False) - self.model.move_relative({"z": -1 * self.red_to_uv_offset}) + src_img = self.model.camera_image.copy() + src_img, dest_img = slam_way_to_target(model.model, prev_row, prev_col, self.params.num_rows, self.params.num_cols, + self.params.stride_x, self.params.stride_y, + self.params.ratio, self.layer.get(), + src_img, dest_img) + # store location exposure + prev_x, prev_y, _ = self.model.hardware.stage.get_position() + positions[f"{row}_{col}"] = (prev_x, prev_y) + pattern_path = f"{self.params.prefix_path}tile_{row},{col}.png" + print(f"\nUploading pattern: {self.params.prefix_path}tile_{row},{col}.png") + pattern_img = Image.open(f"{self.params.prefix_path}tile_{row},{col}.png") - def segment(): - #create tile directory and segment images - split_image_with_overlap(model.pattern_image_path) - #load the first tile for operator placement - model.set_red_focus_source(RedFocusSource.PATTERN) - image_path = "tiles/tile_"+str(0)+","+str(0)+".png" - current_tile = Image.open(image_path) - model.set_pattern_image(current_tile, image_path) + # focus and expose + model.set_red_focus_source(RedFocusSource.PATTERN) + model.set_pattern_image(pattern_img, pattern_path) + if row == 0 and col == 0: + model.move_relative({"z": -1 * self.red_to_uv_offset}) # first one starts from pattern + else: + model.move_relative({"z": -1 * (self.red_to_uv_offset - self.red_to_pattern_offset)}) - def on_begin(): - model.set_red_focus_source(RedFocusSource.PATTERN) + model.non_blocking_delay(2.0) + model.enter_uv_mode(mode_switch_autofocus=False) + + # expose the image + model.begin_patterning() + model.on_event(Event.CHIP_CHANGED) + + model.enter_red_mode(mode_switch_autofocus=False) + model.move_relative({"z": (self.red_to_uv_offset)}) + model.non_blocking_delay(2.0) + + model.set_red_focus_source(RedFocusSource.SOLID) + self.model.set_image_position(0, 0, 0) + + print(f"src_img captured, exposed tile # {row}_{col}") + prev_row = row + prev_col = col - x_amount = self.x_settings.amount_var - x_offset = int(self.x_settings.offset_var.get()) - x_dir = 1 if x_amount > 0 else -1 - x_amount = abs(x_amount) - - y_amount = self.y_settings.amount_var - y_offset = int(self.y_settings.offset_var.get()) - y_dir = 1 if y_amount > 0 else -1 - y_amount = abs(y_amount) - - x_start, y_start = self.model.stage_setpoint[0], self.model.stage_setpoint[1] - print(f"x_start {x_start}, y_start = {y_start}") - - #Move in Snake pattern with left to right on even rows and right to left on odd rows - for y_idx in range(y_amount): - if(y_idx %2 == 0): - for x_idx in range(x_amount): - pattern_for_tile(self, model, x_start, -x_dir, x_idx, x_offset, y_start, -y_dir, y_idx, y_offset, y_idx_max=y_amount, x_idx_max=x_amount) - print("Patterned x_idx:" + str(x_idx) + " y_idx: "+str(y_idx)) - else: - for x_idx in range(x_amount - 1, -1, -1): - pattern_for_tile(self, model, x_start, -x_dir, x_idx, x_offset, y_start, -y_dir, y_idx, y_offset, y_idx_max=y_amount, x_idx_max=x_amount) - print("Patterned x_idx:" + str(x_idx) + " y_idx: "+str(y_idx)) + print("Done Tiling") + self.model.on_event(Event.EXPOSURE_TIME_CHANGED, 8000) + self.segmented = False + + print(positions) + return positions # steps #TODO IMPLEMENT ABORT def on_abort(): pass - #Segment Images must be done before begining tiling - #TODO enforce above - #Tiling check must be done before segment images if needed - self.tiling_check_button = TilingCheckFrame(self.frame, model) - self.tiling_check_button.frame.grid(row=0, column = 0) - self.segment_images_button = ttk.Button(self.frame, text="Segement Images", command=segment, state="enabled") - self.segment_images_button.grid(row=1, column=0) + self.segment_images_button = ttk.Button(self.frame, text="Segement Images", command=segment, state="disabled" if self.segmented else "enabled") + self.segment_images_button.grid(row=0, column=0) + + self.layer_value = ttk.Entry(self.frame, textvariable=self.layer, state="normal") + self.layer_value.grid(row=1, column=0) + self.begin_tiling_button = ttk.Button(self.frame, text="Begin Tiling", command=on_begin, state="enabled") self.begin_tiling_button.grid(row=2, column=0) + self.abort_tiling_button = ttk.Button(self.frame, text="Abort Tiling", command=on_abort, state="disabled") self.abort_tiling_button.grid(row=3, column=0) - - -class ProjectorDisplayFrame: - """Frame to display what the projector is currently showing""" - - def __init__(self, parent, event_dispatcher: EventDispatcher): - self.frame = ttk.Frame(parent) - self.event_dispatcher = event_dispatcher - - # Main label frame - self.display_frame = ttk.LabelFrame(self.frame, text="Projector Output") - self.display_frame.grid(row=0, column=0) - - # Create placeholder image - # Using a similar size to camera preview for consistency - self.display_size = (320, 180) - placeholder = Image.new("RGB", self.display_size, "black") - self.photo = image_to_tk_image(placeholder) - - # Display label - self.label = ttk.Label(self.display_frame, image=self.photo, relief="solid", borderwidth=2) - self.label.grid(row=0, column=0, padx=5, pady=5) - # Status label showing current mode - self.status_var = StringVar(value="Status: Clear") - self.status_label = ttk.Label(self.display_frame, textvariable=self.status_var) - self.status_label.grid(row=1, column=0, padx=5, pady=5) - - # Listen for projector changes - event_dispatcher.add_event_listener(Event.SHOWN_IMAGE_CHANGED, self._update_display) - event_dispatcher.add_event_listener(Event.PATTERN_IMAGE_CHANGED, self._update_display) - event_dispatcher.add_event_listener(Event.IMAGE_ADJUST_CHANGED, self._update_display) - event_dispatcher.add_event_listener(Event.PATTERNING_BUSY_CHANGED, self._update_display) + self.image_stitch_button = ImageStitchingFrame(self.frame, model) + self.image_stitch_button.frame.grid(row=4, column=0) - # Force initial update - # self.event_dispatcher.root.after(100, self._update_display) - - def _update_display(self): - """Update the display when projector content changes""" - shown_image = self.event_dispatcher.shown_image - - # Update status text - status_map = { - ShownImage.CLEAR: "Status: Clear (No Output)", - ShownImage.PATTERN: "Status: Pattern (UV Exposure)", - ShownImage.FLATFIELD: "Status: Flatfield Correction", - ShownImage.RED_FOCUS: "Status: Red Focus Mode", - ShownImage.UV_FOCUS: "Status: UV Focus Pattern", - } - self.status_var.set(status_map.get(shown_image, "Status: Unknown")) - - # Get the appropriate processed image based on mode - # Note: When patterning, we check patterning_busy flag as well - img = None - if shown_image == ShownImage.RED_FOCUS: - img = self.event_dispatcher.red_focus.processed() - # pattern case above uv focus case: when set_patterning_busy(True) is called, - # shown_image is never changed to PATTERN during patterning - it stays as UV_FOCUS - elif shown_image == ShownImage.PATTERN or self.event_dispatcher.patterning_busy: - img = self.event_dispatcher.pattern.processed() - elif shown_image == ShownImage.UV_FOCUS: - img = self.event_dispatcher.uv_focus.processed() - elif shown_image == ShownImage.FLATFIELD: - # Flatfield might not be implemented, use pattern as fallback - img = self.event_dispatcher.pattern.processed() - - # Update image - if img is None or (shown_image == ShownImage.CLEAR and not self.event_dispatcher.patterning_busy): - # Show black placeholder when clear - placeholder = Image.new("RGB", self.display_size, "black") - self.photo = image_to_tk_image(placeholder) - else: - display_img = img.copy() - display_img.thumbnail(self.display_size, Image.Resampling.LANCZOS) - self.photo = image_to_tk_image(display_img) - - self.label.configure(image=self.photo) + self.multilayer_alignment_button = MultiLayerAlignFrame(self.frame, model) + self.multilayer_alignment_button.frame.grid(row=5, column=0) + + # TODO: add one please + # self.abort_tiling_button = ttk.Button(self.frame, text="Abort Tiling", command=on_abort, state="disabled") + # self.abort_tiling_button.grid(row=3, column=0) class TilingCheckFrame: def __init__(self, parent, event_dispatcher: EventDispatcher): @@ -2879,7 +2354,7 @@ def takeAndStitchMapImages(self, stride_x, stride_y, crop_x, crop_y): "y": current_y, "z": orig_z }) - self.event_dispatcher.non_blocking_delay(2) + self.event_dispatcher.non_blocking_delay(0.5) # columns in this row for idx, col in enumerate(col_range): @@ -2890,7 +2365,7 @@ def takeAndStitchMapImages(self, stride_x, stride_y, crop_x, crop_y): "y": current_y, "z": orig_z }) - self.event_dispatcher.non_blocking_delay(2.5) + self.event_dispatcher.non_blocking_delay(0.5) captured_image = self.capture_current_image() # crop @@ -2915,6 +2390,7 @@ def takeAndStitchMapImages(self, stride_x, stride_y, crop_x, crop_y): "y": orig_y, "z": orig_z }) + self.event_dispatcher.non_blocking_delay(0.5) # Overlay the pattern image at center with 50% transparency pattern_img = self.img.copy().convert('RGBA') # pattern image @@ -2930,14 +2406,604 @@ def takeAndStitchMapImages(self, stride_x, stride_y, crop_x, crop_y): return stitched_image +class ImageStitchingFrame: + def __init__(self, parent, event_dispatcher: EventDispatcher): + self.frame = ttk.Frame(parent) + self.event_dispatcher = event_dispatcher + + self.capture_button = ttk.Button( + self.frame, + text="Capture & Stitch", + command=self.capture_and_stitch + ) + self.capture_button.grid(row=0, column=0) + + self.preview_label = ttk.Label(self.frame) + self.preview_label.grid(row=1, column=0, padx=5, pady=5, sticky="nsew") + + self.frame.rowconfigure(1, weight=1) + self.frame.columnconfigure(0, weight=1) + + def capture_and_stitch(self): + """ + Outer wrapper function for doing capture & stitch + Calls on capture_helper to do capturing and stitch_helper to do stitching + """ + self.img = self.event_dispatcher.pattern_image + + # projection size + self.projection_width_um, self.projection_height_um = 1037, 583 + + # tile size + self.tile_width_px, self.tile_height_px = 3840, 2160 + + # camera capture size + self.snapshot_width_px, self.snapshot_height_px = 1920, 1080 + + # total image width and height in pixels + self.img_w_px = self.event_dispatcher.num_cols * self.tile_width_px + self.img_h_px = self.event_dispatcher.num_rows * self.tile_height_px + + self.img_w_um = self.event_dispatcher.num_cols * self.projection_width_um + self.img_h_um = self.event_dispatcher.num_rows * self.projection_height_um + + print(f"total image width (px): {self.img_w_px}, image height: {self.img_h_px}") + print(f"total image width (um): {self.img_w_um}, image height: {self.img_h_um}") + + # overlay ratio that is used for alignment + # currently we need to move half-sized width and height + # so that there are enough features for alignment purposes + self.overlay_ratio = 0.5 + self.stride_x_um = self.projection_width_um * (1 - self.overlay_ratio) + self.stride_y_um = self.projection_height_um * (1 - self.overlay_ratio) + + self.capture_button.config(state='disabled', text="Capturing...") + self.frame.update() + + # creates a capture folder to store all data collection and logs in + # logs will store tile -> stage position mapping, this is useful + # for the next step when we have to go to the initial position - alignment marker offset + curr_time = datetime.now().strftime("%Y%m%d_%H%M%S") + capture_folder = f"data_collection_{curr_time}/" + self.event_dispatcher.set_capture_folder(capture_folder) + + # CAPTURE + captures, captured_positions, num_rows, num_cols = self.capture_helper( + settings=ImageCaptureSettings( + stride_x_um=self.stride_x_um, + stride_y_um=self.stride_y_um, + total_x_um=self.img_w_um, + total_y_um=self.img_h_um, + capture_folder=capture_folder + ) + ) + + print(f"number of captured images: {len(captures)} x {len(captures[0])}") + + # preprocess the captured tiles + preprocessed_imgs = self.preprocess_images(captures, settings=TilePreprocessSettings( + gaussian_kernel_size=(7, 7) + )) + + # max difference between expected distance and calculated difference we can tolerate + # before falling back on the default values + threshold = 200 + stitched_image = self.stitch_helper( + preprocessed_imgs, + captured_positions, + settings=ImageStitchSettings( + num_rows=num_rows, + num_cols=num_cols, + output_folder=capture_folder, + resize=0.2, + debug=True, + threshold=threshold + ) + ) + + if stitched_image is not None: + displayed_image = cv2.resize(stitched_image, (self.snapshot_width_px // 10, self.snapshot_height_px // 10)) + displayed_image = Image.fromarray(displayed_image) + self.display_image(displayed_image) + print("stitching complete!") + self.event_dispatcher.on_event(Event.STITCH_COMPLETED, stitched_image) # not used for now + else: + print("failed to stitch images") + + self.capture_button.config(state='normal', text="Capture & Stitch Chip Imges") + + def display_image(self, pil_image): + display_img = pil_image.copy() + photo = ImageTk.PhotoImage(display_img) + self.preview_label.config(image=photo) + self.preview_label.image = photo + + def capture_current_image(self): + # Get the camera view from the event dispatcher + if hasattr(self.event_dispatcher, 'camera_image') and self.event_dispatcher.camera_image is not None: + camera_image = self.event_dispatcher.camera_image + pil_image = Image.fromarray(camera_image) + return pil_image + else: + print("No camera image available") + return None + + def capture_helper(self, settings: ImageCaptureSettings): + """ + Take snapshots num_cols * num_rows times, move in snake pattern + Move in stride_x and y um in distance + Crop margins off to account for dark margins in camera snapshot + """ + + captured_imgs = [] + captured_positions = [] + + num_cols = int(settings.total_x_um // settings.stride_x_um) + num_rows = int(settings.total_y_um // settings.stride_y_um) + if num_cols * settings.stride_x_um < settings.total_x_um: + num_cols += 1 + if num_rows * settings.stride_y_um < settings.total_y_um: + num_rows += 1 + print("starting capture...") + print("num_cols, num_rows: ", num_cols, num_rows) + + # get stage positions (um) + orig_x, orig_y, orig_z = self.event_dispatcher.stage_setpoint + start_x = orig_x + start_y = orig_y + + print(f"stage_set_point: {orig_x}, {orig_y}, {orig_z}") + + # create folder to save captures, info and log file + os.mkdir(settings.capture_folder) + log_file_path = os.path.join(settings.capture_folder, "log.txt") + log_file = open(log_file_path, "w") + log_file.write(json.dumps({"rows": num_rows, "cols": num_cols}) + "\n") + + + # move in snake pattern with left to right on even rows + # and right to left on odd rows + for row in range(num_rows): + row_imgs = [] + row_pos = [] + current_y = start_y - row * settings.stride_y_um + + if row % 2 == 0: + col_range = range(num_cols) + first_x = start_x + else: + col_range = range(num_cols - 1, -1, -1) + first_x = start_x + (num_cols - 1) * settings.stride_x_um + + # move to the next row + self.event_dispatcher.move_absolute({ + "x": first_x, + "y": current_y, + "z": orig_z + }) + self.event_dispatcher.non_blocking_delay(2) + + # columns in this row + for idx, col in enumerate(col_range): + current_x = start_x + col * settings.stride_x_um + if idx > 0: # idx = 0 first one don't need to move + self.event_dispatcher.move_absolute({ + "x": current_x, + "y": current_y, + "z": orig_z + }) + self.event_dispatcher.non_blocking_delay(2.5) + + captured_img = self.capture_current_image() + row_imgs.append(captured_img) + + # save capture and write to log file + tile_file = f"tile_{row}_{col}.png" + tile_path = os.path.join(settings.capture_folder, tile_file) + captured_img.save(tile_path) + log_file.write(f"{tile_file}: x={current_x}, y={current_y}\n") + + self.event_dispatcher.non_blocking_delay(0.5) + self.frame.update() + row_pos.append((current_x, current_y)) + + if row % 2 == 0: + captured_imgs.append(row_imgs) + captured_positions.append(row_pos) + else: + captured_imgs.append(row_imgs[::-1]) + captured_positions.append(row_pos[::-1]) + # Return to starting position + self.event_dispatcher.move_absolute({ + "x": orig_x, + "y": orig_y, + "z": orig_z + }) + self.event_dispatcher.non_blocking_delay(0.5) + + print(captured_positions) + return captured_imgs, captured_positions, num_rows, num_cols + + def preprocess_image(self, img, settings: TilePreprocessSettings): + """ + Converts to grayscale, blurs, and crops weird camera margins + """ + + # extract the projection rectangle from the camera view + img = np.array(img) + img, corners = extract_rectangle(img, display=False) + + img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + img = cv2.GaussianBlur(img, settings.gaussian_kernel_size, 0) + + # remove margins in camera view + h, w = img.shape[:2] + return img + + def preprocess_images(self, imgs, settings: TilePreprocessSettings): + """ + Loops through all tiles and preprocesses them + """ + result = [] + for row in range(0, len(imgs)): + row_imgs = [] + for col in range(0, len(imgs[row])): + row_imgs.append(self.preprocess_image(imgs[row][col], settings)) + result.append(row_imgs) + return result + + def image_alignment(self, dst_img, src_img, display=False): + """ + Image alignment based on SIFT features and RANSAC + Check opencv for more info, based off of example code + Returns the matrix to align src_img to dst_img + """ + sift = cv2.SIFT_create( + contrastThreshold=0.04, + edgeThreshold=10 + ) + src_keypoints, src_descriptors = sift.detectAndCompute(src_img, None) + dst_keypoints, dst_descriptors = sift.detectAndCompute(dst_img, None) + + if src_descriptors is None or dst_descriptors is None: + raise ValueError( + f"SIFT found no keypoints: src={len(src_keypoints) if src_keypoints else 0}, " + f"dst={len(dst_keypoints) if dst_keypoints else 0}. " + f"Check that tile images are not blank or featureless." + ) + + src_descriptors = src_descriptors.astype(np.float32) + dst_descriptors = dst_descriptors.astype(np.float32) + + # Use kdtrees to find nearest neighbors + # trees: neighborhood size + # checks: more checks → searches more of the trees → more accurate matches + FLANN_INDEX_KDTREE = 1 + NUM_TREES=100 + NUM_CHECKS=100 + index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=NUM_TREES) + search_params = dict(checks=NUM_CHECKS) + flann = cv2.FlannBasedMatcher(index_params, search_params) + + # gets the two best matches + matches = flann.knnMatch(src_descriptors, dst_descriptors, k=2) + + good = [] + for m,n in matches: + # only keep the best match if it is significantly better than the 2nd best match + if m.distance < 0.8 * n.distance: + good.append(m) + + # If we get lower than MIN_MATCH_COUNT matches + # it is probably not a good match -> abort + MIN_MATCH_COUNT = 4 + if len(good) < MIN_MATCH_COUNT: + print(f"not enough matches were found: {len(good)} < {MIN_MATCH_COUNT}") + return (None, 0) + + src_pts = np.float32([src_keypoints[m.queryIdx].pt for m in good]).reshape(-1,1,2) + dst_pts = np.float32([dst_keypoints[m.trainIdx].pt for m in good]).reshape(-1,1,2) + + # Find homography M that transforms src_pts to dst_pts + # dst_pts = M * src_pts + # mask: Nx1 array --> 1: inlier, 0: outlier + M, mask = cv2.estimateAffinePartial2D(src_pts, dst_pts, cv2.RANSAC, ransacReprojThreshold=3, maxIters=2000, confidence=0.99, refineIters=10) + + matchesMask = mask.ravel().tolist() + + ################## Evaluation ################## + + M = np.vstack([M, [0, 0, 1]]) + print(M) + + # inlier ratio + num_inliers = sum(matchesMask) + inlier_ratio = num_inliers / len(matchesMask) + print(f"Inlier ratio: {inlier_ratio}") + + # apply homography on src points and calculate distance to dst points + # only considering inliers + src_pts = cv2.perspectiveTransform(src_pts, M) + error = 0 + for i in range(0, len(matchesMask)): + if matchesMask[i] == 1: # inlier + [delta_x, delta_y] = src_pts[i][0] - dst_pts[i][0] + error += (np.pow(delta_x, 2) + np.pow(delta_y, 2)) + error /= num_inliers + error = np.sqrt(error) + print(f"RMS error between inliers={error}") + + if display: + # show boundary of src on dst after homography + h,w = src_img.shape[:2] + pts = np.float32([[0,0],[0,h-1],[w-1,h-1],[w-1,0]]).reshape(-1,1,2) + dst = cv2.perspectiveTransform(pts, M) + dst = np.int32(dst).reshape((-1, 1, 2)) + dst_img = cv2.polylines(dst_img, [np.int32(dst)], True, 255, 8, cv2.LINE_AA) + + # draw matches + draw_params = dict(matchColor = None, # draw matches in green color + singlePointColor = None, + matchesMask = matchesMask, # draw only inliers + flags = cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS) + img3 = cv2.drawMatches(src_img, src_keypoints, dst_img, dst_keypoints, good, None, **draw_params) + plt.imshow(img3, 'gray') + plt.show() + + return (M, error) + + def stitch_helper(self, imgs, stage_positions, settings: ImageStitchSettings): + """ + Stiches images in a snake like pattern + Calls the image alignment function on adjacent tiles and grabs the translation in x and y direction + Pastes into a canvas and saves to output directory + """ + rows = settings.num_rows + cols = settings.num_cols + curr_tile = None + next_tile = None + positions = [[0] * cols for _ in range(rows)] + curr_pos = (0, 0) + + # snake pattern stitching + for row in range(0, rows): + col_range = range(0, cols) if row % 2 == 0 else range(cols-1, -1, -1) + + for col in col_range: + if row == 0 and col == 0: + curr_tile_info = (row, col) + curr_tile = imgs[0][0] + curr_tile_stage_pos = stage_positions[0][0] + positions[row][col] = curr_pos + continue + + next_tile = imgs[row][col] + next_tile_info = (row, col) + next_tile_stage_pos = stage_positions[row][col] + + expected_dx = (next_tile_stage_pos[0] - curr_tile_stage_pos[0]) * 1.668 + expected_dy = (next_tile_stage_pos[1] - curr_tile_stage_pos[1]) * 1.576 + + print(f"curr_tile_info: {curr_tile_info}, next_tile_info: {next_tile_info}") + + try: + M, _ = self.image_alignment(curr_tile, next_tile) + except ValueError as e: + print(f"[stitch] Alignment failed for tiles {curr_tile_info} → {next_tile_info}: {e}") + M = None + + if M is None: + # could not find an alignment use expected values + print(f"alignment algorithm failed, using expected dx and dy") + print(f"expected movement dx={expected_dx}, dy={expected_dy}") + dx = expected_dx + dy = expected_dy + else: + dx = M[0][2] + dy = -M[1][2] # accounting for difference between the stage y positive and canvas y positive + + prediction_error = math.ceil(math.sqrt((expected_dx - dx)**2 + (expected_dy - dy)**2)) + print(f"expected movement dx={expected_dx}, dy={expected_dy}") + print(f"image alignment calculated move as dx={dx}, dy={dy}") + + if prediction_error > settings.threshold: + print("prediction error exceeded threshold, using expected dx and dy") + dx = expected_dx + dy = expected_dy + + curr_pos = (curr_pos[0] + dx, curr_pos[1] + dy) + positions[row][col] = curr_pos + + curr_tile = next_tile + curr_tile_info = next_tile_info + curr_tile_stage_pos = next_tile_stage_pos + + + xs = [] + ys = [] + + for row in range(0, rows): + for col in range(0, cols): + x, y = positions[row][col] + y = -y # account for the difference between y positive for stage and canvas + positions[row][col] = (x, y) + h, w = imgs[row][col].shape[:2] + + if settings.debug: + print(f"row: {row}, col: {col}, position:{(x, y)}") + + xs.append(x + w) + xs.append(x) + ys.append(y + h) + ys.append(y) + + canvas_w = int(max(xs) - min(xs)) + canvas_h = int(max(ys) - min(ys)) + + print(f"canvas_w={canvas_w}, canvas_h={canvas_h}") + + shift_w = int(min(xs)) + shift_h = int(min(ys)) + + print(f"shift_w={shift_w}, shift_h={shift_h}") + canvas = np.zeros((canvas_h, canvas_w), dtype=np.uint8) + for row in range(rows): + for col in range(cols): + img = imgs[row][col] + h, w = img.shape[:2] + x, y = positions[row][col] + x = int(x) - shift_w + y = int(y) - shift_h + # shift into canvas coords + if settings.debug: + print(f"position to paste in canvas: {(y, x)}, {y+h, x+w}") + canvas[y:y+h, x:x+w] = img + + # resize and output + # canvas = cv2.flip(canvas, 1) + self.event_dispatcher.stitched_image = canvas + # output_canvas = cv2.resize(output_canvas, None, fx=settings.resize, fy=settings.resize) + output_path = os.path.join(settings.output_folder, "output.png") + cv2.imwrite(output_path, canvas) + return canvas + +class MultiLayerAlignFrame: + def __init__(self, parent, event_dispatcher: EventDispatcher): + self.frame = ttk.Frame(parent) + self.event_dispatcher = event_dispatcher + + self.capture_button = ttk.Button( + self.frame, + text="Detect Alignment Markers", + command=self.detect_alignment_markers + ) + self.capture_button.grid(row=0, column=0) + + self.preview_label = ttk.Label(self.frame) + self.preview_label.grid(row=1, column=0, padx=5, pady=5, sticky="nsew") + + self.frame.rowconfigure(1, weight=1) + self.frame.columnconfigure(0, weight=1) + + def sort_stitched_alignment_markers(self, matches, stitched): + stitched_sorted = np.zeros(shape=(len(matches), 2)) + for idx in range(len(matches)): + stitched_sorted[matches[idx]] = stitched[idx] + return stitched_sorted + + def detect_alignment_markers(self): + digital_pattern = cv2.flip(np.array(self.event_dispatcher.prev_pattern_image), 1) + stitched_image = self.event_dispatcher.stitched_image + + processed_digital_pattern, orig_h, orig_w = rf_detr_preprocess(digital_pattern, layer=2) + digital_marks = detect_marks_for_slam(processed_digital_pattern, self.event_dispatcher.model, orig_h, orig_w, threshold=STITCHED_CONFIDENCE_THRESHOLD) + + processed_stitched_pattern, orig_h, orig_w = rf_detr_preprocess(stitched_image, layer=2) + stitched_marks = detect_marks_for_slam(processed_stitched_pattern, self.event_dispatcher.model, orig_h, orig_w, threshold=STITCHED_CONFIDENCE_THRESHOLD) + + stitched_marks = np.array([mark["center"] for mark in stitched_marks]).astype(np.float32) + digital_marks = np.array([mark["center"] for mark in digital_marks]).astype(np.float32) + + print(f"digital marks: {digital_marks}") + print(f"stitched marks: {stitched_marks}") + + # sort in row-major order + sorted_digital_marks = np.array(sorted(digital_marks, key=lambda item: (item[1], item[0]))) + + print(f"number of markers detected: {len(stitched_marks)}") + print(f"number of markers detected: {len(digital_marks)}") + + s, R_est, t_est, matches = self.align_stitched_to_digital(stitched_marks, sorted_digital_marks) + print(f"scale: {s}") + print(f"R_est: {R_est}") + print(f"t_est: {t_est}") + print(f"matches: {matches}") + sorted_stitched_marks = self.sort_stitched_alignment_markers(matches, stitched_marks) + print(f"sorted_stitched_marks: {sorted_stitched_marks}") + starting_mark = {"x": sorted_stitched_marks[0][0], "y": sorted_stitched_marks[0][1]} + + x_scale = px_to_step_x + y_scale = px_to_step_y + captured_tile_positions = self.get_captured_tile_positions(self.event_dispatcher.capture_folder) + starting_tile = "tile_0_0.png" + starting_pos = captured_tile_positions[starting_tile] + + # make up for the half width and height of the marker + padding_x = -50 + padding_y = 45 + self.event_dispatcher.move_absolute({"x": starting_pos[0], "y": starting_pos[1]}) + self.event_dispatcher.non_blocking_delay(0.5) + # we should move in +x position and -y position to to put the upper left marker in the upper left corner + self.event_dispatcher.move_relative({"x": x_scale * starting_mark["x"] + padding_x, "y": -y_scale * starting_mark["y"] + padding_y}) + self.event_dispatcher.non_blocking_delay(0.5) + + # set new image position + angle_deg = np.degrees(np.arctan2(R_est[1, 0], R_est[0, 0])) + image_x, image_y, image_t = self.event_dispatcher.image_position + self.event_dispatcher.set_image_position(image_x, image_y, image_t + angle_deg) + self.event_dispatcher.on_event(Event.START_TILING) + + def get_captured_tile_positions(self, folder): + import re + data = {} + pattern = re.compile(r'(\S+): x=([0-9.]+), y=([0-9.]+)') + log_path = os.path.join(folder, "log.txt") + with open(log_path) as f: + for line in f: + match = pattern.search(line) + if match: + filename = match.group(1) + x = float(match.group(2)) + y = float(match.group(3)) + data[filename] = (x, y) + print(data) + return data + + # Align markers in stitched image to those in digital image using CPD registration + def align_stitched_to_digital(self, stitched_marks_centers, digital_marks_centers): + digital = digital_marks_centers # fixed target + stitched = stitched_marks_centers # moving source + print(f"X (fixed, digital): {digital.shape} points") + print(f"Y (moving, stitched): {stitched.shape} points") + + # CPD registration + reg = RigidRegistration(X=digital, Y=stitched) + transformed_stitched, (s, R_est, t_est) = reg.register() + + matches = np.argmax(reg.P, axis=1) # matches[i] = index in X that Y[i] maps to + + # TY == s * Y @ R_est.T + t_est (stitched marks in digital space) + print(f"Estimated scale : {s}") + print(f"Estimated rotation {R_est}") + print(f"Estimated translation: {t_est}") + print(f"Matches: {matches}") + print(transformed_stitched) + + return (s, R_est, t_est, matches) +########## END MULTI-LAYER TILING CLASSES ################## + class MapFrame: def __init__(self, parent, event_dispatcher: EventDispatcher): self.frame = ttk.LabelFrame(parent) self.event_dispatcher = event_dispatcher - # Map dimensions in micrometers - self.map_size_um = 10000.0 # 1 cm * 1 cm - + bounds = event_dispatcher.hardware.stage.get_bounds() + if bounds == None: + # Map dimensions in micrometers + self.x_min_um = 0 + self.x_max_um = 100 + self.y_min_um = 0 + self.y_max_um = 100 + self.width_um = self.x_max_um - self.x_min_um + self.height_um = self.y_max_um - self.y_min_um + else: + self.x_min_um = bounds["x"][0] + self.x_max_um = bounds["x"][1] + self.y_min_um = bounds["y"][0] + self.y_max_um = bounds["y"][1] + self.width_um = self.x_max_um - self.x_min_um + self.height_um = self.y_max_um - self.y_min_um + # Canvas size in pixels self.canvas_size = 350 @@ -2958,28 +3024,27 @@ def __init__(self, parent, event_dispatcher: EventDispatcher): self.pattern_markers = [] event_dispatcher.add_event_listener(Event.STAGE_POSITION_CHANGED, self._on_position_changed) - event_dispatcher.add_event_listener(Event.PATTERNING_FINISHED, self._on_pattern_exposed) event_dispatcher.add_event_listener(Event.CHIP_CHANGED, self._on_chip_changed) self._redraw_all() def _um_to_pixels(self, um_x, um_y): """ - Convert micrometer coordinates to canvas pixel coordinates. - (0, 0) in micrometers is at the center of the canvas. + Convert micrometer stage coordinates to canvas pixel coordinates. + Stage origin (x_min, y_min) maps to canvas (0, canvas_size). """ - scale = self.canvas_size / self.map_size_um - - # Add half map size to shift origin to center - pixel_x = (um_x + self.map_size_um / 2) * scale - pixel_y = (um_y + self.map_size_um / 2) * scale - + scale_x = self.canvas_size / self.width_um + scale_y = self.canvas_size / self.height_um + + pixel_x = (um_x - self.x_min_um) * scale_x + pixel_y = (um_y - self.y_min_um) * scale_y + return pixel_x, pixel_y def _um_size_to_pixels(self, um_width, um_height): - """Convert micrometer dimensions to pixel dimensions""" - scale = self.canvas_size / self.map_size_um - return um_width * scale, um_height * scale + scale_x = self.canvas_size / self.width_um + scale_y = self.canvas_size / self.height_um + return um_width * scale_x, um_height * scale_y def _draw_pattern_marker(self, x_um, y_um): """ Draw a blue rectangle given x and y """ @@ -3053,16 +3118,6 @@ def _redraw_all(self): def _on_position_changed(self): self._redraw_all() # TODO: only update current_position? - - def _on_pattern_exposed(self): - """ Get the most recent exposure from current layer """ - chip = self.event_dispatcher.chip - if chip.layers and chip.layers[-1].exposures: - latest_exposure = chip.layers[-1].exposures[-1] - if not latest_exposure.aborted: - x, y, z = latest_exposure.coords - self.pattern_markers.append((x, y)) - self._redraw_all() def _on_chip_changed(self): """Handle chip changes (load, new chip, etc.) - reload and redraw""" @@ -3087,7 +3142,45 @@ def __init__(self, config: LithographerConfig): self.shown_image = ShownImage.CLEAR - self.top_panel = ttk.Frame(self.root) + # scrollable interface begin ------------------ + self.canvas = tkinter.Canvas(self.root) + self.scrollbar_y = tkinter.Scrollbar(self.root, orient="vertical", command=self.canvas.yview) + self.scrollbar_x = tkinter.Scrollbar(self.root, orient="horizontal", command=self.canvas.xview) + self.canvas.configure(yscrollcommand=self.scrollbar_y.set, xscrollcommand=self.scrollbar_x.set) + + self.scrollbar_y.grid(row=0, column=1, sticky="ns") + self.scrollbar_x.grid(row=1, column=0, sticky="ew") + self.canvas.grid(row=0, column=0, sticky="nsew") + + self.root.grid_rowconfigure(0, weight=1) + self.root.grid_columnconfigure(0, weight=1) + + self.inner_frame = ttk.Frame(self.canvas) + self.canvas_window = self.canvas.create_window((0, 0), window=self.inner_frame, anchor="nw") + + def on_frame_configure(event): + self.canvas.configure(scrollregion=self.canvas.bbox("all")) + + def on_canvas_configure(event): + min_width = self.inner_frame.winfo_reqwidth() + self.canvas.itemconfig(self.canvas_window, width=max(event.width, min_width)) + + self.inner_frame.bind("", on_frame_configure) + self.canvas.bind("", on_canvas_configure) + + # Mouse wheel scrolling + def on_mousewheel_vertical(event): + self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + def on_mousewheel_horizontal(event): + self.canvas.xview_scroll(int(-1 * (event.delta / 120)), "units") + + self.canvas.bind_all("", on_mousewheel_horizontal) + self.canvas.bind_all("", on_mousewheel_vertical) # Windows/macOS + self.canvas.bind_all("", lambda e: self.canvas.yview_scroll(-1, "units")) + self.canvas.bind_all("", lambda e: self.canvas.yview_scroll(1, "units")) + # scrollable interface end --------------------- + + self.top_panel = ttk.Frame(self.inner_frame) self.top_panel.grid(row=0, column=0, sticky='ew') # Map (top) @@ -3107,15 +3200,15 @@ def __init__(self, config: LithographerConfig): self.top_panel.grid_columnconfigure(2, weight=1) # Progress bar - self.pattern_progress = Progressbar(self.root, orient="horizontal", mode="determinate") + self.pattern_progress = Progressbar(self.inner_frame, orient="horizontal", mode="determinate") self.pattern_progress.grid(row=1, column=0, sticky="ew") # Main tab interface (replaces middle_panel) - self.mode_select_frame = ModeSelectFrame(self.root, self.event_dispatcher) + self.mode_select_frame = ModeSelectFrame(self.inner_frame, self.event_dispatcher) self.mode_select_frame.notebook.grid(row=2, column=0, sticky="nsew") # Bottom panel (chip log and image adjustment and tiling) - self.bottom_panel = ttk.Frame(self.root) + self.bottom_panel = ttk.Frame(self.inner_frame) self.bottom_panel.grid(row=3, column=0, sticky="ew") # Chip management @@ -3148,13 +3241,13 @@ def on_start(): self.event_dispatcher.query_config() if self.event_dispatcher.hardware.stage.has_homing(): self.event_dispatcher.home_stage() + self.map._redraw_all() self.event_dispatcher.stage_setpoint = self.event_dispatcher.hardware.stage.get_position() print(f"Current GUI Location: {self.event_dispatcher.stage_setpoint}") - messagebox.showinfo( - message="BEFORE CONTINUING: Ensure that you move the projector window to the correct display! Click on the fullscreen, completely black window, then press Windows Key + Shift + Left Arrow until it no longer is visible!" - ) + setup_displays() + setup_projection_window(self.event_dispatcher.hardware.projector.window) self.root.after(0, on_start) @@ -3167,7 +3260,6 @@ def cleanup(self): # if RUN_WITH_STAGE: # serial_port.close() - def main(): # Open a file selector window @@ -3277,8 +3369,8 @@ def main(): ) lithographer = LithographerGui(lithographer_config) + lithographer.root.mainloop() - if __name__ == "__main__": main() \ No newline at end of file diff --git a/src/lib/globals.py b/src/lib/globals.py new file mode 100644 index 0000000..5c15fa6 --- /dev/null +++ b/src/lib/globals.py @@ -0,0 +1,788 @@ + +# TODO: Don't hardcode +import cv2 +import numpy as np +import json +import os +import time + +import onnxruntime as rt +from PIL import ImageOps +from ultralytics import YOLO +from typing import Callable, List +from hardware import ImageProcessSettings, Lithographer, ProcessedImage + +from datetime import datetime +from pathlib import Path +from tkinter import Tk, messagebox +from typing import Optional + +from camera.camera_module import CameraModule + +from projector import TkProjector +from stage_control.stage_controller import StageController + +# importing utilities +from lib.structs import * + +THUMBNAIL_SIZE: tuple[int, int] = (160, 90) +#The values set here are not used and instead come from the config file +DEFAULT_RED_EXPOSURE: float = 4167.0 +DEFAULT_UV_EXPOSURE: float = 25000.0 + +def fetch_focus_score(camera_image, blue_only, ddepth=cv2.CV_64F, kernel_size=5, log=False): + """ fetch_focus_score: computes the laplacian focal score after some + pre-processing of the camera image. The key is to detect the edges better + than other parts of the image that might not be suitable to be focused on. """ + + camera_image = camera_image.copy() + camera_image[:, :, 1] = 0 # green should never be used for focus + if blue_only: + camera_image[:, :, 0] = 0 # disable red + + src = camera_image + src = cv2.cvtColor(src, cv2.COLOR_BGR2GRAY) + # Remove noise by blurring with a Gaussian filter + src = cv2.GaussianBlur(src, (3, 3), 0) + + # Apply Laplace function + src = cv2.Laplacian(src, ddepth, ksize=kernel_size) + + return src.var() + +def compute_focus_score(camera_image, blue_only, save=False): + camera_image = camera_image.copy() + camera_image[:, :, 1] = 0 # green should never be used for focus + if blue_only: + camera_image[:, :, 0] = 0 # disable red + img = cv2.cvtColor(camera_image, cv2.COLOR_RGB2GRAY) + img = cv2.resize(img, (0, 0), fx=0.5, fy=0.5) + mean = np.sum(img) / (img.shape[0] * img.shape[1]) + img_lapl = (np.abs(cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=1)) + np.abs(cv2.Sobel(img, cv2.CV_64F, 0, 1, ksize=1))) / mean + if save: + print('saved focus: ', np.min(img_lapl), np.max(img_lapl)) + cv2.imwrite(save, img_lapl * 255.0 / 5.0) + return img_lapl.var() / mean + +def detect_markers(model, image, draw_rectangle=False): + detections = [] + display_image = image.copy() + try: + image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + original_height, original_width = image_rgb.shape[:2] + resized = cv2.resize(image_rgb, (640, 640)) + results = model(resized) + boxes = results[0].boxes + for box in boxes: + x1, y1, x2, y2 = box.xyxy[0].cpu().numpy() + x1 = int(x1 * original_width / 640) + x2 = int(x2 * original_width / 640) + y1 = int(y1 * original_height / 640) + y2 = int(y2 * original_height / 640) + detections.append(((x1, y1), (x2, y2))) + print('mark at ', (x1 + x2) / 2, (y1 + y2) / 2) + if draw_rectangle: + cv2.rectangle(display_image, (x1, y1), (x2, y2), (0, 255, 0), 5) + except Exception as e: + print(f"Detection failed: {e}") + + return detections, display_image + + +class EventDispatcher: + hardware: Lithographer + root: Tk + model: Optional[YOLO | rt.InferenceSession] + camera: Optional[CameraModule] + red_focus: ProcessedImage + uv_focus: ProcessedImage + pattern: ProcessedImage + pattern_image: Image.Image + red_focus_image: Image.Image + uv_focus_image: Image.Image + solid_red_image: Image.Image + image_adjust_position: tuple[float, float, float] + border_size: float + posterize_strength: Optional[int] + red_focus_source: RedFocusSource + stage_setpoint: tuple[float, float, float] + shown_image: ShownImage + autofocus_busy: bool + patterning_busy: bool + autofocus_on_mode_switch: bool + realtime_detection: bool + first_autofocus: bool + should_abort: bool + exposure_time: int + patterning_progress: float # ranges from 0.0 to 1.0 + red_exposure_time: float + uv_exposure_time: float + exposure_history: List[ExposureLog] + chip: Chip + auto_snapshot_on_uv: bool + snapshot_directory: Path + listeners: dict[Event, List[Callable]] + + def __init__( + self, + stage: StageController, + proj: TkProjector, + root: Tk, + camera: Optional[CameraModule], + red_exposure: float, + uv_exposure: float, + ): + # Hardware components + self.hardware = Lithographer(stage, proj) + self.camera = camera + self.root = root + + # Detection model + self.model = None + self.num_rows = None + self.num_cols = None + + # Image processing objects + self.red_focus = ProcessedImage() + self.uv_focus = ProcessedImage() + self.pattern = ProcessedImage() + + # Source images + self.pattern_image = Image.new("RGB", (1, 1), "black") + self.red_focus_image = Image.new("RGB", (1, 1), "black") + self.uv_focus_image = Image.new("RGB", (1, 1), "black") + self.solid_red_image = Image.new("RGB", (1, 1), "red") + + # Image settings + self.image_adjust_position = (0.0, 0.0, 0.0) + self.border_size = 0.0 + self.posterize_strength = None + self.red_focus_source = RedFocusSource.IMAGE + + # Stage control + self.stage_setpoint = (0.0,0.0,0.0) + + # Status flags + self.shown_image = ShownImage.CLEAR + self.autofocus_busy = False + self.patterning_busy = False + self.autofocus_on_mode_switch = False + self.realtime_detection = False + self.first_autofocus = True + self.should_abort = False + + # Exposure settings and progress + self.exposure_time = 8000 + self.patterning_progress = 0.0 + self.red_exposure_time = red_exposure + self.uv_exposure_time = uv_exposure + + # History and logging + self.exposure_history = [] + self.chip = Chip([ChipLayer([])]) + + # Snapshot settings + self.auto_snapshot_on_uv = True + self.snapshot_directory = Path("stepper_captures") + self.snapshot_directory.mkdir(exist_ok=True) + + # Event handling + self.listeners = dict() + self.add_event_listener(Event.SHOWN_IMAGE_CHANGED, lambda: self._update_projector()) + + def load_chip(self, path: str): + print(f"Loading chip at {path!r}") + with open(path, "r") as f: + d = json.load(f) + self.chip = Chip.from_disk(d) + self.on_event(Event.CHIP_CHANGED) + + def new_chip(self): + # TODO: Prompt user to save old chip?? + self.chip = Chip([ChipLayer([])]) + self.on_event(Event.CHIP_CHANGED) + + def add_chip_layer(self): + self.chip.layers.append(ChipLayer([])) + self.on_event(Event.CHIP_CHANGED) + + def save_chip(self, path: str): + with open(path, "w") as f: + json.dump(self.chip.to_disk(), f) + + def delete_chip_exposure(self, layer: int, ex: int): + self.chip.layers[layer].exposures.pop(ex) + print(f"Deleted exposure {layer} {ex}") + self.on_event(Event.CHIP_CHANGED) + + @property + def current_image(self) -> Optional[Image.Image]: + match self.shown_image: + case ShownImage.CLEAR: + return None + case ShownImage.RED_FOCUS: + return self.red_focus.processed() + case ShownImage.UV_FOCUS: + return self.uv_focus.processed() + case ShownImage.PATTERN: + return self.pattern.processed() + + def _update_projector(self): + img = self.current_image + if img is None: + self.hardware.projector.clear() + else: + self.hardware.projector.show(img) + + def _refresh_pattern(self): + self.pattern.update( + image=self.pattern_image, + settings=ImageProcessSettings( + posterization=self.posterize_strength, + color_channels=(False, False, True), + flatfield=None, + size=self.hardware.projector.size(), + image_adjust=self.image_adjust_position, + border_size=self.border_size, + ), + ) + + if self.red_focus_source in (RedFocusSource.PATTERN, RedFocusSource.INV_PATTERN): + self._refresh_red_focus() + + # TODO: + # Image adjust, resizing, and flatfield correction are performed *AFTER SLICING* + + self.on_event(Event.PATTERN_IMAGE_CHANGED) + + def set_red_focus_source(self, source: RedFocusSource): + self.red_focus_source = source + self._refresh_red_focus() + + def _red_focus_source(self) -> Image.Image: + match self.red_focus_source: + case RedFocusSource.IMAGE: + return self.red_focus_image + case RedFocusSource.SOLID: + return self.solid_red_image + case RedFocusSource.PATTERN: + return self.pattern_image.getchannel("B").convert("RGBA") + case RedFocusSource.INV_PATTERN: + return ImageOps.invert(self.pattern_image.getchannel("B")).convert("RGBA") + + def _refresh_red_focus(self): + if self.hardware.projector.size() != self.solid_red_image.size: + self.solid_red_image = Image.new("RGB", self.hardware.projector.size(), "red") + + img = self._red_focus_source() + print(f"_refresh_red_focus: size: {self.image_adjust_position}, posterization: {self.posterize_strength}, projector size: {self.hardware.projector.size()}, border size = {self.border_size}") + self.red_focus.update( + image=img, + settings=ImageProcessSettings( + posterization=self.posterize_strength, + flatfield=None, + color_channels=(True, False, False), + size=self.hardware.projector.size(), + image_adjust=self.image_adjust_position, + border_size=self.border_size, + ), + ) + + if self.shown_image == ShownImage.RED_FOCUS: + self.on_event(Event.SHOWN_IMAGE_CHANGED) + + def _refresh_uv_focus(self): + self.uv_focus.update( + image=self.uv_focus_image, + settings=ImageProcessSettings( + posterization=self.posterize_strength, + flatfield=None, + color_channels=(False, False, True), + size=self.hardware.projector.size(), + image_adjust=self.image_adjust_position, + border_size=0.0, + ), + ) + + if self.shown_image == ShownImage.UV_FOCUS: + self.on_event(Event.SHOWN_IMAGE_CHANGED) + + def set_posterize_strength(self, strength: Optional[int]): + self.posterize_strength = strength + self._refresh_red_focus() + self._refresh_uv_focus() + self._refresh_pattern() + + def set_border_size(self, border_size: float): + self.border_size = border_size + self._refresh_red_focus() + self._refresh_uv_focus() + self._refresh_pattern() + + def set_shown_image(self, shown_image: ShownImage): + print(f"set_shown_image({shown_image})") + self.shown_image = shown_image + self.on_event(Event.SHOWN_IMAGE_CHANGED) + + def create_warning(self, msg: str): + print(f"Warning: {msg}") + messagebox.showwarning("Warning: ", msg) + + def move_absolute(self, coords: dict[str, float]): + # 0 to -($13X - $27) in WPos space + if(self.hardware.stage.has_homing()): # debugging statements + print(f"Moving to position: {coords}") + print(f"Current position: {self.stage_setpoint[0]}, {self.stage_setpoint[1]}, {self.stage_setpoint[2]}") + + # find new coordinates -> some nuance exists between work and gui positioning + # in work position, the x moves in negative direction (away from home) and y moves in positive direction (away from home) + x = coords.get("x", self.stage_setpoint[0]) + y = coords.get("y", self.stage_setpoint[1]) + z = coords.get("z", self.stage_setpoint[2]) + set_point = (x, y, z) + + if self.hardware.stage.has_homing(): + ok, msg = self._check_bounds(set_point) + if not ok: + self.create_warning(msg) + return False + + try: + self.hardware.stage.move_absolute(coords) + self.stage_setpoint = set_point + self.on_event(Event.STAGE_POSITION_CHANGED) + return True + + except(RuntimeError) as e: + self.create_warning(f"{str(e)}. Please remove your chip, restart the program.") + return False + + except(Exception) as e: + self.create_warning(f"{str(e)}. Please remove your chip, restart the program.") + self.stage_setpoint = self.hardware.stage.get_position() + self.on_event(Event.STAGE_POSITION_CHANGED) + return False + + + def _check_bounds(self, set_point): + bounds = self.hardware.stage.get_bounds() + + if bounds is None: + return True # no homing, no bounds enforced + + axes = [('x', 0), ('y', 1), ('z', 2)] + for name, i in axes: + lo, hi = bounds[name] + val = set_point[i] + if not (lo <= val <= hi): + return False, (f"Moving {name.upper()} to {val} prohibited. " + f"Boundaries are [{lo}, {hi}]") + return True, None + + def move_relative(self, coords: dict[str, float]): + + if(self.hardware.stage.has_homing()): # debugging statements + print(f"Moving by: {coords} | Current position: {self.stage_setpoint[0]}, {self.stage_setpoint[1]}, {self.stage_setpoint[2]}") + # find new coordinates -> some nuance exists between work and gui positioning + # in work position, the x moves in negative direction (away from home) and y moves in positive direction (away from home + x = self.stage_setpoint[0] + coords.get("x", 0) + y = self.stage_setpoint[1] + coords.get("y", 0) + z = self.stage_setpoint[2] + coords.get("z", 0) + set_point = (x, y, z) + + # if soft limits and max travel set, then enforce boundaries + if(self.hardware.stage.has_homing()): + ok, msg = self._check_bounds(set_point) + if not ok: + self.create_warning(msg) + return + + try: + self.hardware.stage.move_relative(coords) + self.stage_setpoint = set_point + self.on_event(Event.STAGE_POSITION_CHANGED) + + except(RuntimeError) as e: + self.create_warning(f"{str(e)}. Please remove your chip, restart the program.") + + except(Exception) as e: + self.create_warning(f"{str(e)}. Please remove your chip, restart the program.") + self.stage_setpoint = self.hardware.stage.get_position() + self.on_event(Event.STAGE_POSITION_CHANGED) + + def set_use_solid_red(self, use: bool): + self.use_solid_red = use + self.set_shown_image(ShownImage.RED_FOCUS) + self._refresh_red_focus() + + def set_pattern_image(self, img: Image.Image, path: str): + self.pattern_image = img + self.pattern_image_path = path + self._refresh_pattern() + + def set_prev_pattern_image(self, img: Image.Image, path: str): + self.prev_pattern_image = img + self.prev_pattern_image_path = path + + def set_stitched_image(self, img: Image.Image, path: str): + self.stitched_image = img + self.stitched_image_path = path + + def set_capture_folder(self, capture_folder: str): + self.capture_folder = capture_folder + + def set_red_focus_image(self, img: Image.Image): + self.red_focus_image = img + self._refresh_red_focus() + + def set_uv_focus_image(self, img: Image.Image): + self.uv_focus_image = img + self._refresh_uv_focus() + + def set_patterning_busy(self, busy: bool): + self.patterning_busy = busy + self.on_event(Event.MOVEMENT_LOCK_CHANGED) + self.on_event(Event.PATTERNING_BUSY_CHANGED) + + def set_progress(self, pattern_progress: float, exposure_progress: float): + self.patterning_progress = pattern_progress + self.exposure_progress = exposure_progress + self.on_event(Event.EXPOSURE_PATTERN_PROGRESS_CHANGED) + + def set_latest_image(self, camera_image): + self.camera_image = camera_image + + def set_autofocus_busy(self, busy): + self.autofocus_busy = busy + self.on_event(Event.MOVEMENT_LOCK_CHANGED) + + def abort_patterning(self): + self.should_abort = True + print("Aborting patterning") + + def in_uv(self): + return self.shown_image in (ShownImage.PATTERN, ShownImage.UV_FOCUS) + + def home_stage(self): + """ + Homing stage resets Machine position (Mpos) and sets Work Position (WPos) + of current state post-homing to (0, 0, 0), which means set_point must + be updated to reflect the work position + """ + self.hardware.stage.home() + self.hardware.stage.set_on_start_location() + print(f"Post Homing Location: {self.hardware.stage.get_on_start_location()}") + print("Homing Complete.") + + self.on_event(Event.STAGE_POSITION_CHANGED) + + def query_config(self): + self.hardware.stage.get_position() + print("Query Config Complete.") + + def set_image_position(self, x, y, t): + print("invoked: set_image_position") + self.image_adjust_position = (x, y, t) + self._refresh_red_focus() + self._refresh_uv_focus() + self._refresh_pattern() + self.on_event(Event.IMAGE_ADJUST_CHANGED) + + @property + def image_position(self): + return self.image_adjust_position + + @property + def movement_lock(self): + if self.patterning_busy or self.autofocus_busy: + return MovementLock.LOCKED + # elif (self.shown_image == ShownImage.UV_FOCUS or self.shown_image == ShownImage.PATTERN): + # return MovementLock.XY_LOCKED + else: + return MovementLock.UNLOCKED + + def on_event(self, event: Event, *args, **kwargs): + if event not in self.listeners: + return + + for listener in self.listeners[event]: + listener(*args, **kwargs) + + def on_event_cb(self, event: Event, *args, **kwargs): + return lambda: self.on_event(event, *args, **kwargs) + + def add_event_listener(self, event: Event, listener: Callable): + if event not in self.listeners: + self.listeners[event] = [] + self.listeners[event].append(listener) + + def begin_patterning(self): + # TODO: Update patterning preview + + print("Patterning at ", self.stage_setpoint) + duration = self.exposure_time + print(f"Patterning 1 tiles for {duration}ms\nTotal time: {str(round((duration) / 1000))}s") + + # TODO: Image slicing. + # Note that flatfield correction and image adjustment should be applied *after* slicing + img = self.pattern.processed() + + self.set_patterning_busy(True) + self.hardware.projector.show(img) + end_time = time.time() + duration / 1000.0 + while time.time() < end_time: + progress = 1.0 - ((end_time - time.time()) * 1000 / duration) + self.set_progress(0.0, progress) + self.root.update() + if self.should_abort: + break + self.set_shown_image(ShownImage.CLEAR) + self.root.update() # Force image to stop being displayed ASAP + self.set_progress(1.0, 1.0) + + log = ExposureLog( + datetime.now(), + self.pattern_image_path, + self.stage_setpoint, + duration, + self.should_abort, + ) + self.exposure_history.append(log) + self.chip.layers[-1].exposures.append(log) + + self.on_event(Event.CHIP_CHANGED) + self.set_patterning_busy(False) + + if self.should_abort: + print("Patterning aborted") + self.should_abort = False + + def non_blocking_delay(self, t: float): + start = time.time() + while time.time() - start < t: + self.root.update() + + def enter_red_mode(self, mode_switch_autofocus=True): + print("enter_red_mode") + self.set_shown_image(ShownImage.RED_FOCUS) + self.camera.setExposureTime(self.red_exposure_time) + if mode_switch_autofocus and self.autofocus_on_mode_switch: + self.autofocus(blue_only=False) + self.on_event(Event.MOVEMENT_LOCK_CHANGED) + + def enter_uv_mode(self, mode_switch_autofocus=True): + if self.auto_snapshot_on_uv: + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = self.snapshot_directory / f"uv_mode_entry_{timestamp}.png" + self.on_event(Event.SNAPSHOT, str(filename)) + + self.camera.setExposureTime(self.uv_exposure_time) + if ( + mode_switch_autofocus + and not self.autofocus_busy + and self.autofocus_on_mode_switch + ): + # UV mode usually needs about -70 to be in focus compared to red mode + #self.move_relative({"z": -85.0}) + pass + + # self.set_shown_image(ShownImage.UV_FOCUS) + self.set_shown_image(ShownImage.CLEAR) # enter uv mode: don't project uv + + if mode_switch_autofocus and self.autofocus_on_mode_switch: + self.non_blocking_delay(2.0) + self.autofocus(blue_only=True) + + self.on_event(Event.MOVEMENT_LOCK_CHANGED) + + def autofocus(self, blue_only, log=False, search=20, start=None): + if not self.camera: + print("No camera connected, skipping autofocus") + return + + if self.first_autofocus: + # TODO: Fix this spuriously triggering + self.first_autofocus = False + return + + if self.autofocus_busy: + print("Skipping nested autofocus!") + return + + if log: + try: + os.mkdir('aftest') + except FileExistsError: + pass + log_file = open('aftest/log.csv', 'w') + + if self.hardware.stage.has_homing(): + + counter = 0 + def sample(): + def one_sample(): + return fetch_focus_score(self.camera_image, blue_only=blue_only, log=True) + focus_score = sum([one_sample() for _ in range(3)])/3 + print("focus average:", focus_score) + nonlocal counter + if log: + log_file.write(f'{counter},{focus_score}\n') + cv2.imwrite(f'aftest/img{counter}.png', self.camera_image, log=True) + counter += 1 + return focus_score + + print("Starting Autofocus...") + best_score = -1.0 + best_z = 0 + if start == None: + z_base = self.hardware.stage.get_autofocus() + else: + z_base = start + + # account for uv mode, where z-focus is different + if blue_only == True: + z_base -= 50.0 + if(self.move_absolute({"z": z_base})) == False: + self.create_warning("Failed autofocus, z-stage can't go past boundary limits") + self.set_autofocus_busy(False) + return + self.non_blocking_delay(1.0) + + else: + for i in range(-search, search, 2): + if not (self.move_absolute({"z": (z_base+i)})): + self.create_warning("Failed autofocus, z-stage can't go past boundary limits") + self.set_autofocus_busy(False) + return + self.non_blocking_delay(0.5) + new_score = sample() + # always check for optimal scores + if (new_score > best_score): + best_score = new_score + best_z = self.stage_setpoint[2] + + print(f"Fine grain sampling done, best focus is: {best_score}") + self.move_absolute({"z":best_z}) + self.non_blocking_delay(1.0) + + else: + counter = 0 + def sample_focus(): + def do_thing(): + self.non_blocking_delay(0.1) + return compute_focus_score(self.camera_image, blue_only=blue_only) + focus_score = sorted([do_thing() for _ in range(3)])[1] + nonlocal counter + if log: + log_file.write(f'{counter},{focus_score}\n') + cv2.imwrite(f'aftest/img{counter}.png', self.camera_image) + counter += 1 + return focus_score + + self.set_autofocus_busy(True) + self.non_blocking_delay(1.0) + mid_score = sample_focus() + self.move_relative({"z": -20.0}) + self.non_blocking_delay(1.0) + neg_score = sample_focus() + self.move_relative({"z": 40.0}) + self.non_blocking_delay(1.0) + pos_score = sample_focus() + self.move_relative({"z": -20.0}) + self.non_blocking_delay(1.0) + + last_focus = mid_score + + if neg_score < mid_score < pos_score: + # Improved focus is in the +Z direction + for i in range(30): + self.move_relative({"z": 10.0}) + self.non_blocking_delay(0.5) + new_score = sample_focus() + if last_focus > new_score: + print(f"Successful +Z coarse autofocus {i}") + last_focus = new_score + break + last_focus = new_score + + for i in range(10): + self.move_relative({"z": -2.0}) + self.non_blocking_delay(0.5) + new_score = sample_focus() + if last_focus > new_score: + print(f"Successful -Z fine autofocus {i}") + break + last_focus = new_score + elif neg_score > mid_score > pos_score: + # Improved focus is in the -Z direction + for i in range(30): + self.move_relative({"z": -10.0}) + self.non_blocking_delay(0.5) + new_score = sample_focus() + if last_focus > new_score: + print(f"Successful -Z coarse autofocus {i}") + break + last_focus = new_score + + for i in range(10): + self.move_relative({"z": 2.0}) + self.non_blocking_delay(0.5) + new_score = sample_focus() + if last_focus > new_score: + print(f"Successful +Z fine autofocus {i}") + break + last_focus = new_score + elif neg_score < mid_score and pos_score < mid_score: + # We are very close to already being in focus + print(f"Almost in focus! (neg {neg_score} mid {mid_score} pos {pos_score})") + self.move_relative({"z": -20.0}) + self.non_blocking_delay(0.5) + + for i in range(30): + self.move_relative({"z": 2.0}) + self.non_blocking_delay(0.5) + new_score = sample_focus() + if last_focus > new_score: + print(f"Successful +Z fine autofocus {i}") + break + last_focus = new_score + else: + print("Autofocus is confused!") + + print("Autofocus Complete.") + self.set_autofocus_busy(False) + print("Finished autofocus") + + def get_model(self, path: str): + """ + Based on which model we're using, we will feed the model + a different session. 'best.pt' indicates YOLO while the onx + file indicates RF-DETR + + Note to developers: RF-DETR was trained on latent and developed patterns + while YOLO model was trained on developed patterns only + """ + if "best" in path: + print("Using YOLO model") + self.model = YOLO(path) + else: + print("Using RF_DETR model") + session = rt.InferenceSession(path) + self.model = session + return True + + def initialize_alignment(self, config: LithographerConfig): + self.config = config + self.realtime_detection = config.alignment.enabled + # Attempt loading the model even if detection is off by default + try: + print("loading model") + model_path = config.alignment.model_path + self.get_model(model_path) + print("loaded model") + except Exception as e: + print(f"Failed to load alignment model: {e}") + + def set_snapshot_directory(self, directory: Path): + self.snapshot_directory = directory + self.snapshot_directory.mkdir(exist_ok=True) diff --git a/src/lib/gui.py b/src/lib/gui.py index 0305b82..0cdc894 100644 --- a/src/lib/gui.py +++ b/src/lib/gui.py @@ -236,20 +236,20 @@ def __init__( def _round_display(self, event=None): try: value = float(self.widget.get()) - value = round(value, 1) - self._var.set(f"{value:.1f}") + value = round(value, 5) + self._var.set(f"{value:.5f}") except ValueError: - self._var.set(f"{self.default:.1f}") + self._var.set(f"{self.default:.5f}") def get(self) -> float: if self.widget.get() == "": - self.default = round(self.default, 1) + self.default = round(self.default, 5) return self.default else: - return round(self._var.get(), 1) + return round(self._var.get(), 5) def set(self, value: float): - value = round(value, 1) + value = round(value, 5) self._var.set(value) # TODO: diff --git a/src/lib/structs.py b/src/lib/structs.py new file mode 100644 index 0000000..242939b --- /dev/null +++ b/src/lib/structs.py @@ -0,0 +1,168 @@ +# This serves as configurations for the Tiling Feature +from dataclasses import dataclass +from datetime import datetime +from enum import Enum, auto +from typing import List +from PIL import Image + +from camera.camera_module import CameraModule +from stage_control.stage_controller import StageController + +@dataclass +class ExposureLog: + time: datetime + path: str + coords: tuple[float, float, float] + duration: float # ms + aborted: bool + + def to_disk(self): + return { + "time": str(self.time), + "path": self.path, + "coords": self.coords, + "duration": self.duration, + "aborted": self.aborted, + } + + @classmethod + def from_disk(cls, d): + return cls( + datetime.fromisoformat(d["time"]), + d["path"], + d["coords"], + d["duration"], + d["aborted"], + ) + +@dataclass +class ChipLayer: + exposures: List[ExposureLog] + + def to_disk(self): + return {"exposures": [ex.to_disk() for ex in self.exposures]} + + @classmethod + def from_disk(cls, d): + return cls([ExposureLog.from_disk(ex) for ex in d["exposures"]]) + +@dataclass +class Chip: + layers: List[ChipLayer] + + def to_disk(self): + return {"layers": [layer.to_disk() for layer in self.layers]} + + @classmethod + def from_disk(cls, d): + return cls([ChipLayer.from_disk(layer) for layer in d["layers"]]) + +class StrAutoEnum(str, Enum): + """Base class for string-valued enums that use auto()""" + + def _generate_next_value_(name, *_): + return name.lower() + +class ShownImage(StrAutoEnum): + """The type of image currently being displayed by the projector""" + + CLEAR = auto() + PATTERN = auto() + FLATFIELD = auto() + RED_FOCUS = auto() + UV_FOCUS = auto() + +class PatterningStatus(StrAutoEnum): + """The current state of the patterning process""" + + IDLE = auto() + PATTERNING = auto() + ABORTING = auto() + +class Event(StrAutoEnum): + """Events that can be dispatched to listeners""" + + SNAPSHOT = auto() + SHOWN_IMAGE_CHANGED = auto() + STAGE_POSITION_CHANGED = auto() + IMAGE_ADJUST_CHANGED = auto() + PATTERN_IMAGE_CHANGED = auto() + MOVEMENT_LOCK_CHANGED = auto() + EXPOSURE_TIME_CHANGED = auto() + EXPOSURE_PATTERN_PROGRESS_CHANGED = auto() + PATTERNING_BUSY_CHANGED = auto() + PATTERNING_FINISHED = auto() + CHIP_CHANGED = auto() + STITCH_COMPLETED = auto() + START_TILING = auto() + +class MovementLock(StrAutoEnum): + """Controls whether stage position can be manually adjusted""" + + UNLOCKED = auto() # X, Y, and Z are free to move + XY_LOCKED = auto() # Only Z (focus) is free to move to avoid smearing UV focus pattern + LOCKED = auto() # No positions can move to avoid disrupting patterning + +class RedFocusSource(StrAutoEnum): + """The source image to use for red focus mode""" + + IMAGE = auto() # Uses the dedicated red focus image + SOLID = auto() # Shows a solid red screen + PATTERN = auto() # Uses the blue channel from the pattern image + INV_PATTERN = auto() # Uses the inverse of the blue channel from the pattern image + +@dataclass +class AlignmentConfig: + enabled: bool + model_path: str + right_marker_x: float + left_marker_x: float + top_marker_y: float + bottom_marker_y: float + x_scale_factor: float + y_scale_factor: float + +@dataclass +class LithographerConfig: + stage: StageController + camera: CameraModule + camera_scale: float + red_exposure: float + uv_exposure: float + alignment: AlignmentConfig + + +@dataclass +class TilingParameters: + align_image: Image + ratio: float + stride_x: int + stride_y: int + num_rows: int + num_cols: int + prefix_path: str + px_to_step_x: float + px_to_step_y: float + step_error_threshold_x: int + step_error_threshold_y: int + +@dataclass +class ImageCaptureSettings: + stride_x_um: int # x direction stride in um(steps) for stage movement during image capture + stride_y_um: int # y direction stride in um(steps) for stage movement during image capture + total_x_um: int # total steps in x direction we need to move + total_y_um: int # total steps in y direction we need to move + capture_folder: str # capture folder where we store all data + logs + +@dataclass +class ImageStitchSettings: + num_rows: int # number of tile rows during pattern segmentation + num_cols: int # number of tile col during pattern segmentation + output_folder: str # output folder where we store the stitched image (set the same as capture_folder) + resize: float # resize factor of the stitched image before we save to the output folder (we do this to prevent it from being massive) + debug: bool # flag for debug print + threshold: int # error margin we allow before defaulting to expected_dx and expected_dy during stitching + +@dataclass +class TilePreprocessSettings: + gaussian_kernel_size: tuple[int, int] diff --git a/src/stage_control/grbl_stage.py b/src/stage_control/grbl_stage.py index 7c937f1..bd56a01 100644 --- a/src/stage_control/grbl_stage.py +++ b/src/stage_control/grbl_stage.py @@ -49,31 +49,6 @@ def __init__(self, controller_target, enable_homing, enable_tiling, autofocus_of def _fill_resp_buffer(self): self.resp_buffer += self.controller_target.read_all() - def _wait_for_idle(self, timeout=10): - """ - To ensure we are accurantely moving the stage, we'll only send - GRBL the next move command when it is ready to take in more commands - This means we will return if we're no longer idle - """ - deadline = time.time() + timeout - - while time.time() < deadline: - while b"\r\n" not in self.resp_buffer: - self._fill_resp_buffer() - - raw, self.resp_buffer = self.resp_buffer.split(b"\r\n", 1) - line = raw.decode("ascii", errors="replace").strip() - self.resp_buffer = b"" - - if not line: - continue - - if line.startswith("<") and "Idle" in line: - return - - # ignore everything else (but don't lose it if you need it!) - raise TimeoutError("Stage did not reach idle") - def _handle_alarms(self, response): """ GRBL Alarm codes: @@ -170,7 +145,7 @@ def _send_msg(self, msg: bytes): - <>: Status reports are sent in chevrons. """ self.controller_target.write(msg) # write gcode command - deadline = time.time() + 30.0 + deadline = time.time() + 120.0 while True: while b"\r\n" not in self.resp_buffer: # sometimes grbl may take time to respond, so we wait until self._fill_resp_buffer() # the response actually arrives. This is really important. @@ -178,9 +153,9 @@ def _send_msg(self, msg: bytes): raise TimeoutError("No response from GRBL") time.sleep(0.01) # yield the CPU - resp = self.resp_buffer.split(b"\r\n") - self.resp_buffer = b"" - response = [res.decode("ascii", errors="replace").strip() for res in resp] + lines = self.resp_buffer.split(b"\r\n") + self.resp_buffer = lines[-1] + response = [res.decode("ascii", errors="replace").strip() for res in lines[:-1]] for item in response: if not item: @@ -189,7 +164,7 @@ def _send_msg(self, msg: bytes): print(f"[GRBL feedback]: {item}") continue # keep waiting for ok/error if "ok" in item: - print("Received OK") + # print("Received OK") return # happy path, command completed, successful elif item.startswith("error:"): print(f"[GRBL error]: {item}") @@ -247,7 +222,7 @@ def _query_state(self): x, y, z = part.removeprefix("WPos:").split(",") work_position = (float(x), float(y), float(z)) - resolved_position = position or work_position + resolved_position = work_position or position print(f"resolved position: {resolved_position}, Idle: {idle}") if resolved_position is None: raise ValueError(f"GRBL status response contained no position data: {buff!r}") @@ -321,7 +296,8 @@ def _query_config(self): if "=" in part: key, value = part.split("=", 1) key = int(key.strip()) - value = value.strip() + # value = value.strip() + value = value.split("(")[0].strip() if value == "": value = None elif "." in value: diff --git a/src/tiling_utils.py b/src/tiling_utils.py new file mode 100644 index 0000000..637c423 --- /dev/null +++ b/src/tiling_utils.py @@ -0,0 +1,532 @@ +from PIL import Image +import cv2 as cv +import math +import numpy as np +import onnxruntime as rt +from scipy.optimize import linear_sum_assignment +from scipy.spatial.distance import cdist +import matplotlib.pyplot as plt + +RF_DETR_IMPUT_SIZE = 704 +CONFIDENCE_THRESHOLD = 0.75 +STITCHED_CONFIDENCE_THRESHOLD = 0.4 + +# digital pattern pixels to step size scalar +px_to_step_x = 1.0/1.668 # was 1.0/1.576 +px_to_step_y = 1.0/1.576 # was 1.0/1.668 +digital_to_cam_view = 0.5 + +# image set position scale (steps to pixels scalar) +step_to_projection_pixels_x = 100.0/220.0 +step_to_projection_pixels_y = 100.0/160.0 + +# auto_align scale (digital to projection scalar) +digital_to_cam_scale_w = 2.0469 +digital_to_cam_scale_h = 1.8 + +################## Alignment and Detection Utility Functions ################## +def rf_detr_preprocess(img, layer: int = 1): + """ + pre-processes any type of image and prepares it + for alignment marker detection. + + Returns the pre-processed image, the original image + width and the original image height in pixels + """ + def clahe(img_cleaned): + clahe_obj = cv.createCLAHE(clipLimit=30.0, tileGridSize=(15, 18)) + + if len(img_cleaned.shape) == 3: + gray = cv.cvtColor(img_cleaned, cv.COLOR_RGB2GRAY) + else: + gray = img_cleaned + + return clahe_obj.apply(gray) + + if img is None: + raise Exception("Error: image is None") + + # Convert PIL to numpy + if isinstance(img, Image.Image): + img = img.convert('RGB') + img = np.array(img) + + processed = img.copy() + if processed.ndim == 4: + processed = processed[0].transpose(1, 2, 0) + elif processed.ndim == 2: + processed = cv.cvtColor(processed, cv.COLOR_GRAY2BGR) + + # Strip alpha channel if present (RGBA -> RGB) + if processed.ndim == 3 and processed.shape[2] == 4: + processed = processed[:, :, :3] + + if layer == 1: + print("Latent image! Must be pre-processed") + processed = clahe(processed) # -> grayscale (H, W) + processed = cv.cvtColor(processed, cv.COLOR_GRAY2BGR) # -> (H, W, 3) + processed = np.array(processed) + + orig_h, orig_w = processed.shape[:2] + img_resized = cv.resize(processed, (RF_DETR_IMPUT_SIZE, RF_DETR_IMPUT_SIZE)) + img_input = img_resized.transpose(2, 0, 1) + img_input = np.expand_dims(img_input, 0).astype(np.float32) / 255.0 + return img_input, orig_h, orig_w + +# def estimate_transform(dest: np.ndarray, src: np.ndarray) -> tuple[float, float, float]: +# """ +# TODO: make this better +# Given matched point pairs, estimate (dx, dy, rotation_degrees) +# using least-squares rigid body fit. + +# Precondition: dest and src are of same size +# Assumption made: user does not tilt the chip by more than 90 degrees +# because sth is wrong with chip placement if that's the case and +# user should benefit from reloading the chip on the stage + +# dst_pts: (K, 2) — camera-detected positions (after step shift) +# src_pts: (K, 2) — pattern expected positions +# """ + +# # --- Translation: avg x,y offset --- +# assert dest.shape == src.shape, "dest and src sized differently" + +# delta = dest - src +# dx = float(np.mean(delta[:, 0])) +# dy = float(np.mean(delta[:, 1])) + +# dest_centroid = dest - dest.mean(axis=0) +# src_centroid = src - src.mean(axis=0) + +# angles = [] +# for s, d in zip(dest_centroid, src_centroid): +# cross = s[0]*d[1] - s[1]*d[0] +# dot = s[0]*d[0] + s[1]*d[1] +# angle = np.arctan2(cross, dot) +# angles.append(angle) +# angles_arr = np.array(angles) + +# # rotation_deg = float(np.degrees(np.mean(angles))) +# rotation_deg = float(np.degrees(np.arctan2( +# np.mean(np.sin(angles_arr)), +# np.mean(np.cos(angles_arr)) +# ))) +# return (dx, dy, rotation_deg) + +def estimate_transform(dest: np.ndarray, src: np.ndarray) -> tuple[float, float, float]: + """ + Estimate (dx, dy, rotation_degrees) between matched point pairs + using least-squares rigid body fit (SVD method). + + dest: (K, 2) — where points are now (camera detections) + src: (K, 2) — where points should be (pattern expected) + Returns (dx, dy, rotation_deg) where: + - dx, dy are in pixels, representing stage correction needed + - rotation_deg is the chip rotation relative to pattern + """ + assert dest.shape == src.shape and dest.shape[0] >= 1 + + dest_centroid = dest.mean(axis=0) + src_centroid = src.mean(axis=0) + + dest_c = dest - dest_centroid + src_c = src - src_centroid + + # Only meaningful with 2+ points; fall back to zero rotation for single point + if dest.shape[0] >= 2: + H = src_c.T @ dest_c # (2, 2) cross-covariance + U, S, Vt = np.linalg.svd(H) + R = Vt.T @ U.T + + # Correct for reflection (det should be +1 for rotation, -1 for reflection) + if np.linalg.det(R) < 0: + Vt[-1, :] *= -1 + R = Vt.T @ U.T + + rotation_rad = np.arctan2(R[1, 0], R[0, 0]) + rotation_deg = float(np.degrees(rotation_rad)) + else: + R = np.eye(2) + rotation_deg = 0.0 + + # Rotate src centroid by R, then find offset to dest centroid + # This is the correct order: rotation is about src centroid, + # then translate to align centroids + src_centroid_rotated = R @ src_centroid + dx = float(dest_centroid[0] - src_centroid_rotated[0]) + dy = float(dest_centroid[1] - src_centroid_rotated[1]) + + # --- 5. Sanity check --- + if abs(rotation_deg) >= 90.0: + raise ValueError( + f"Estimated rotation {rotation_deg:.1f}° exceeds 90° — " + "chip is likely misloaded or detection failed." + ) + + return (dx, dy, rotation_deg) + +def detect_marks_for_slam(img, session, orig_h, orig_w, threshold=0.77) -> list[dict]: + """ + Detects alignment markers for tiling SLAM algorithm + Returns an array of dictionary coordinates such that + - `arr[i] = {"center": (x,y), "left":(x,y), "right":(x,y), "top":(x,y), "bottom":(x,y)}` + """ + vis = img.copy() + assert ((vis.shape[1]) <= 3), "bad image color dimensions" + + # Run inference with our weights + input_name = session.get_inputs()[0].name + boxes, scores = session.run(None, {input_name: img}) + + # collect good matches + boxes = boxes[0] + scores = scores[0] + + markers = [] + for i in range(len(boxes)): + class_id = np.argmax(scores[i]) + confidence = 1 / (1 + np.exp(-scores[i][class_id])) + if confidence > threshold: + x, y, w, h = boxes[i] + + # fetch centers, and scale back to orig_img size + cx = int(x * orig_w) + cy = int(y * orig_h) + bw = int(w * orig_w) + bh = int(h * orig_h) + + marker = { + "center": (cx, cy), + "left": (cx - bw // 2, cy), + "right": (cx + bw // 2, cy), + "top": (cx, cy - bh // 2), + "bottom": (cx, cy + bh // 2) + } + markers.append(marker) + return markers + +################## Snake Pattern Tiling Functions ################## +def get_next_tile_vector(row:int, col:int, width:int, height:int, num_rows:int, num_cols:int, num_steps:int, error_x:int=0, error_y:int = 0): + """ + determine next step direction and size (in steps) + Arguments: + - row: current row + - col: current column + - width: total amount of steps to take horizontally (steps) + - height: total amount of steps to take vertically (steps) + - num_rows, num_cols: number of columns + - num_steps: how many steps of movement + - error_x=0: x step-errors from previous step (steps) + - error_y=0: y step-errors from previous step (steps) + + Returns: tuple(h_direction, v_direction, step_x, step_y) --> units: (steps) + """ + is_row_transition = ((col == 0 and row % 2 == 1) or (col == num_cols-1 and row % 2 == 0)) + if is_row_transition == True: # moving to different row + v_direction = 'down' + h_direction = None + else: # moving to different column + h_direction = 'right' if (row % 2 == 0) else 'left' + v_direction = None + print(f"width={width}, error_x={error_x}, num_steps={num_steps}, height={height}, error_y={error_y}") + step_x = math.floor(width - error_x) if h_direction is not None else 0 + # for some reason moving in this direction causes overstep? + if h_direction == 'left': + step_x -= 10 + step_y = math.floor(height + error_y) if v_direction is not None else 0 + print(f"h_direction={h_direction}, v_direction={v_direction}, step_x={step_x}, step_y={step_y}") + return (h_direction, v_direction, step_x, step_y) + +def match_alignment_markers_by_coordinates(dest_marks, src_marks, src_marks_shifted, img_h, img_w): + img_diagonal = np.sqrt(img_h**2 + img_w**2) + match_threshold = 0.1 * img_diagonal + dists = cdist(dest_marks, src_marks_shifted) + row_ind, col_ind = linear_sum_assignment(dists) + + matched_dest = [] + matched_src_shifted = [] # shifted — for transform calculation + matched_src_original = [] # original — for graphing/visualization purposes + for r, c in zip(row_ind, col_ind): + dist = dists[r, c] + status = "✓" if dist < match_threshold else "✗ REJECTED" + print(f" cam[{r}]={dest_marks[r]} → pat[{c}]={src_marks_shifted[c]} dist={dist:.1f}px {status}") + if dist < match_threshold: + matched_dest.append(dest_marks[r]) + matched_src_shifted.append(src_marks_shifted[c]) + matched_src_original.append(src_marks[c]) + + return (matched_dest, matched_src_original, matched_src_shifted) + +########## Functions for Extracting Projection Rectangle ########## + +def order_points(pts): + """Order corner points as: top-left, top-right, bottom-right, bottom-left.""" + pts = pts.reshape(4, 2).astype(np.float32) + rect = np.zeros((4, 2), dtype=np.float32) + s = pts.sum(axis=1) + rect[0] = pts[np.argmin(s)] # top-left + rect[2] = pts[np.argmax(s)] # bottom-right + diff = np.diff(pts, axis=1) + rect[1] = pts[np.argmin(diff)] # top-right + rect[3] = pts[np.argmax(diff)] # bottom-left + return rect.astype(int) + +def find_rectangle_contour(binary, img_shape): + """ + Find the contour that best represents a rectangle in the scene. + Returns the approximated 4-point contour or None. + """ + h, w = img_shape[:2] + min_area = 0.02 * h * w + max_area = 0.98 * h * w + + contours, _ = cv.findContours(binary, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE) + if not contours: + return None + + # Sort by area descending; try to find a 4-sided approximation + contours = sorted(contours, key=cv.contourArea, reverse=True) + + for contour in contours[:5]: # only inspect top-5 largest + area = cv.contourArea(contour) + if not (min_area < area < max_area): + continue + + arc = cv.arcLength(contour, True) + # Try a range of epsilon values for robustness + for eps_factor in [0.02, 0.03, 0.04, 0.05, 0.07]: + approx = cv.approxPolyDP(contour, eps_factor * arc, True) + if len(approx) == 4: + # Verify it's convex and large enough + if cv.isContourConvex(approx): + return approx + + # If we can't get exactly 4 points, use convex hull and fit a quadrilateral + hull = cv.convexHull(contour) + arc = cv.arcLength(hull, True) + for eps_factor in [0.02, 0.03, 0.05, 0.08, 0.12]: + approx = cv.approxPolyDP(hull, eps_factor * arc, True) + if len(approx) == 4: + return approx + + return None + +def extract_rectangle(img, display=False): + """ + Extract the bright rectangular projection area from a camera image. + Uses brightness thresholding to isolate the lit projection region. + + Returns + ------- + extracted : np.ndarray Simple crop of the detected rectangle (no dewarping) + corners : np.ndarray The 4 corner points [[x,y], ...] in top-left, top-right, + bottom-right, bottom-left order. None if not found. + """ + orig = img.copy() + h, w = img.shape[:2] + + # --- 1. Isolate the bright region --- + # Convert to grayscale and aggressively blur to ignore inner content + gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY) if len(img.shape) == 3 else img.copy() + blurred = cv.GaussianBlur(gray, (51, 51), 0) + + # Otsu threshold — bright projection vs dark camera margins + _, bright_mask = cv.threshold(blurred, 0, 255, cv.THRESH_BINARY + cv.THRESH_OTSU) + + # Also try a fixed high-brightness threshold as a fallback candidate + _, bright_fixed = cv.threshold(blurred, 180, 255, cv.THRESH_BINARY) + + # Pick whichever gives a larger foreground region (more likely to be the projection) + mask = bright_mask if cv.countNonZero(bright_mask) > cv.countNonZero(bright_fixed) else bright_fixed + + # --- 2. Clean up the mask --- + kernel = cv.getStructuringElement(cv.MORPH_RECT, (15, 15)) + mask = cv.morphologyEx(mask, cv.MORPH_CLOSE, kernel, iterations=3) + mask = cv.morphologyEx(mask, cv.MORPH_OPEN, kernel, iterations=2) + + # --- 3. Find the largest bright contour --- + contours, _ = cv.findContours(mask, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE) + if not contours: + print("No bright region found.") + return None, None + + # Ignore contours that are too small or suspiciously fill the whole frame + valid = [c for c in contours + if 0.02 * h * w < cv.contourArea(c) < 0.97 * h * w] + if not valid: + valid = contours # fall back to all contours + + best = max(valid, key=cv.contourArea) + + # --- 4. Approximate as a quadrilateral --- + approx = None + arc = cv.arcLength(best, True) + for eps in [0.02, 0.03, 0.04, 0.05, 0.07, 0.10]: + candidate = cv.approxPolyDP(best, eps * arc, True) + if len(candidate) == 4 and cv.isContourConvex(candidate): + approx = candidate + break + + if approx is None: + # Fall back: use bounding rect of the convex hull + hull = cv.convexHull(best) + x, y, bw, bh = cv.boundingRect(hull) + approx = np.array([[[x, y]], [[x+bw, y]], [[x+bw, y+bh]], [[x, y+bh]]]) + + corners = order_points(approx) # tl, tr, br, bl + tl, tr, br, bl = corners + + # --- 5. Crop (axis-aligned bounding box of the 4 corners) --- + x1 = max(0, min(tl[0], bl[0])) + y1 = max(0, min(tl[1], tr[1])) + x2 = min(w, max(tr[0], br[0])) + y2 = min(h, max(bl[1], br[1])) + extracted = orig[y1:y2, x1:x2] + + # --- 6. Optional display --- + if display: + vis = cv.cvtColor(orig, cv.COLOR_BGR2RGB) + cv.drawContours(vis, [approx], -1, (0, 255, 0), 3) + for pt in corners: + cv.circle(vis, tuple(pt), 8, (0, 0, 255), -1) + # Draw crop box + cv.rectangle(vis, (x1, y1), (x2, y2), (255, 165, 0), 2) + + fig, axes = plt.subplots(1, 2, figsize=(14, 6)) + axes[0].imshow(vis) + axes[0].set_title("Detected rectangle (green=contour, orange=crop)") + axes[0].axis("off") + axes[1].imshow(cv.cvtColor(extracted, cv.COLOR_BGR2RGB)) + axes[1].set_title("Extracted") + axes[1].axis("off") + plt.tight_layout() + plt.show() + + return extracted, corners + +""" +def do_align() function + x_amount = self.x_settings.amount_var + x_offset = int(self.x_settings.offset_var.get()) + x_dir = 1 if x_amount > 0 else -1 + x_amount = abs(x_amount) + + y_amount = self.y_settings.amount_var + y_offset = int(self.y_settings.offset_var.get()) + y_dir = 1 if y_amount > 0 else -1 + y_amount = abs(y_amount) + + x_start, y_start = self.model.stage_setpoint[0], self.model.stage_setpoint[1] + print(f"x_start {x_start}, y_start = {y_start}") + + # Move in Snake pattern with left to right on even rows and right to left on odd rows + for y_idx in range(y_amount): + if(y_idx %2 == 0): + for x_idx in range(x_amount): + pattern_for_tile(self, model, x_start, -x_dir, x_idx, x_offset, y_start, -y_dir, y_idx, y_offset, y_idx_max=y_amount, x_idx_max=x_amount) + print("Patterned x_idx:" + str(x_idx) + " y_idx: "+str(y_idx)) + else: + for x_idx in range(x_amount - 1, -1, -1): + pattern_for_tile(self, model, x_start, -x_dir, x_idx, x_offset, y_start, -y_dir, y_idx, y_offset, y_idx_max=y_amount, x_idx_max=x_amount) + print("Patterned x_idx:" + str(x_idx) + " y_idx: "+str(y_idx)) + +def detect_alignment_markers_yolo(yolo_model, image, draw_rectangle=False, edge=None, edge_fraction=0.25): + #Detects alignment markers and optionally filters detections by image edge(s). + #yolo_model: YOLO model + #image: image to detect on + #draw_rectangle: If True, draw rectangles + #edge: 'left', 'right', 'top', or a list like ['left', 'right'] where markers are expected + #none means that markers are expect on all edges + #edge_fraction: Fraction of width/height considered as edge region + + detections = [] + display_image = image.copy() + try: + image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + original_height, original_width = image_rgb.shape[:2] + resized = cv2.resize(image_rgb, (640, 640)) + results = yolo_model(resized) + boxes = results[0].boxes + + if isinstance(edge, str): + edge = [edge] # allow single string or list + + for box in boxes: + x1, y1, x2, y2 = box.xyxy[0].cpu().numpy() + x1 = int(x1 * original_width / 640) + x2 = int(x2 * original_width / 640) + y1 = int(y1 * original_height / 640) + y2 = int(y2 * original_height / 640) + x_center = (x1 + x2) / 2 + y_center = (y1 + y2) / 2 + + # If edge filtering is enabled + if edge is not None: + if 'left' in edge and x_center > original_width * edge_fraction: + continue + if 'right' in edge and x_center < original_width * (1 - edge_fraction): + continue + if 'top' in edge and y_center > original_height * edge_fraction: + continue + + detections.append(((x1, y1), (x2, y2))) + if draw_rectangle: + cv2.rectangle(display_image, (x1, y1), (x2, y2), (0, 255, 0), 3) + + print(f"Detected {len(detections)} marker(s)") + except Exception as e: + print(f"Detection failed: {e}") + + return detections, display_image + +def do_align_tiling(edge): + #edge = ['left', 'right', 'top'] + h, w, _ = model.camera_image.shape + + # Detect markers on the left, right, and top edges + markers, _ = detect_alignment_markers_yolo(model.model, model.camera_image, edge) + if len(markers) == 0: + print("No markers detected.") + return + + alignment = model.config.alignment + dx, dy = 0.0, 0.0 + count_x, count_y = 0, 0 + + for m in markers: + xy0, xy1 = m + x0, y0 = xy0 + x1, y1 = xy1 + x = (x0 + x1) / 2 / w + y = (y0 + y1) / 2 / h + + # Horizontal alignment (left/right markers) + if x < 0.5: + dx += alignment.x_scale_factor * (alignment.left_marker_x / w - x) + count_x += 1 + elif x > 0.5: + dx += alignment.x_scale_factor * (alignment.right_marker_x / w - x) + count_x += 1 + + # Vertical alignment (top markers only) + if y < 0.3: # top region + dy += alignment.y_scale_factor * (alignment.top_marker_y / h - y) + count_y += 1 + + # Average corrections based on detected edges + if count_x > 0: + dx /= count_x + if count_y > 0: + dy /= count_y + + # Move accordingly (if no top markers, dy=0) + #If a small amount of alignment is needed move the image otherwise move the stage since we have far more percision in moving the image than the stage + #The con of this is that large movements of the image result in cropping of the image + #TODO calibrate the stage move threshold + if(dx < 10 or dy < 10): + #move the image instead of the stage + model.set_image_position(dx, dy, t=0) + else: + model.move_relative({'x': dx, 'y': dy}) + print(f"Alignment correction: dx={dx:.5f}, dy={dy:.5f} using {len(markers)} markers.") +""" diff --git a/uv.lock b/uv.lock index 3a2850c..f321253 100644 --- a/uv.lock +++ b/uv.lock @@ -123,6 +123,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coloredlogs" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "humanfriendly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, +] + [[package]] name = "contourpy" version = "1.3.2" @@ -297,6 +309,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "cython" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/3b/ebd94c8b85f8e41b5015a9ed94ee3df866024d480d05cd08b774684fb81d/cython-3.2.5.tar.gz", hash = "sha256:3dd42e4cf36ad15f265bdfec2337cc00c688c8eb6d374ffd13bb19437c27bba1", size = 3286381, upload-time = "2026-05-23T19:34:08.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/b1/0240e3b04fb3c8744bc22dac830284fac1821a44d1afa7da6dceba307e87/cython-3.2.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:220e8b160b2a4ddc362ad8a8c2ab885aa7156099702cdc48f6518a5de921b553", size = 2969751, upload-time = "2026-05-23T19:34:24.065Z" }, + { url = "https://files.pythonhosted.org/packages/29/d6/f300e5ff4569f706f174ca0eeaadff33c81f4191fe9829c54f261abeb405/cython-3.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5887c24ebd19604b7a76d8ea57446cb562a590f7f2557e5954a69aae38b3195e", size = 2962591, upload-time = "2026-05-23T19:34:32.497Z" }, + { url = "https://files.pythonhosted.org/packages/20/a6/efc97000fdb2f34e2431eb09a6ab4de9fbd3bcdb73a8f9d224afa4a9abd3/cython-3.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eb38b89e5a8eb2508a1a0832063826b0703dfb02be84e4aa34b8818ce0ca50fe", size = 2979670, upload-time = "2026-05-23T19:34:41.281Z" }, + { url = "https://files.pythonhosted.org/packages/7e/30/f648409de61fd74ae63090071061145059664cc9b9ff8578197601a3beb6/cython-3.2.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6e5d7a60835345a8bd29d3aa57070880cc3ce017ea0ade7b9f771ce4bf539b1f", size = 2968935, upload-time = "2026-05-23T19:34:49Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/668ef887621f68255feddd482dbcdcf5788b6c91227dd35bd17f128f827b/cython-3.2.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a636c8b7824f3cb587eb2fdde59d8f4a14d433565508081cc290198e37567910", size = 2981525, upload-time = "2026-05-23T19:34:58.445Z" }, + { url = "https://files.pythonhosted.org/packages/a3/de/e3e0cf5704fe569d54b8cd5dc316c9fbf08b1b74728732f86e90168b7a3f/cython-3.2.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:224149d18d980e6ea5001b70fc7ce096c1891d59035dfa9cc5ede50f55804913", size = 2879054, upload-time = "2026-05-23T19:35:18.265Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5c/9cd909e6a8bb178e4e0f9a2a9227c8201a2be38abe45ada4a4c3e9154277/cython-3.2.5-py3-none-any.whl", hash = "sha256:dc1c8cebb7df5bce37f5f8dc1e5bf04313272a5973d50a55c0ec76c83812911b", size = 1257622, upload-time = "2026-05-23T19:34:05.163Z" }, +] + [[package]] name = "filelock" version = "3.20.0" @@ -306,6 +333,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, ] +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + [[package]] name = "fonttools" version = "4.60.1" @@ -372,6 +407,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl", hash = "sha256:530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7", size = 199289, upload-time = "2025-09-02T19:10:47.708Z" }, ] +[[package]] +name = "humanfriendly" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -899,6 +946,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, ] +[[package]] +name = "onnxruntime" +version = "1.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/72/09d8f206402cd91805828354ad1d7473b1bace60fc54a11971012906d9b7/onnxruntime-1.21.1-cp310-cp310-macosx_13_0_universal2.whl", hash = "sha256:daedb5d33d8963062a25f4a3c788262074587f685a19478ef759a911b4b12c25", size = 33639134, upload-time = "2025-04-18T12:01:11.442Z" }, + { url = "https://files.pythonhosted.org/packages/1f/66/31384dc7beea89f21ec7d1582c1b50e9d047d505db38f32cf49693fad1b4/onnxruntime-1.21.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a402f9bda0b1cc791d9cf31d23c471e8189a55369b49ef2b9d0854eb11d22c4", size = 14162243, upload-time = "2025-04-18T12:01:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/fb/76597b77785b2012317ffdd817101ccfab784e2c125645d002c4c9cd377b/onnxruntime-1.21.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15656a2d0126f4f66295381e39c8812a6d845ccb1bb1f7bf6dd0a46d7d602e7f", size = 16000498, upload-time = "2025-04-18T12:01:36.797Z" }, + { url = "https://files.pythonhosted.org/packages/91/83/c7287845f22f2e1d37a54b5997e9589b6931e264cc0f16250d1706eadf79/onnxruntime-1.21.1-cp310-cp310-win_amd64.whl", hash = "sha256:79bbedfd1263065532967a2132fb365a27ffe5f7ed962e16fec55cca741f72aa", size = 12300918, upload-time = "2025-04-18T12:01:14.902Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/13c46c22fb52d8fea53575da163399a7d75fe61223aba685370f047a0882/onnxruntime-1.21.1-cp311-cp311-macosx_13_0_universal2.whl", hash = "sha256:8bee9b5ba7b88ae7bfccb4f97bbe1b4bae801b0fb05d686b28a722cb27c89931", size = 33643424, upload-time = "2025-04-18T12:01:17.445Z" }, + { url = "https://files.pythonhosted.org/packages/18/4f/68985138c507b6ad34061aa4f330b8fbd30b0c5c299be53f0c829420528e/onnxruntime-1.21.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b6a29a1767b92d543091349f5397a1c7619eaca746cd1bc47f8b4ec5a9f1a6c", size = 14162437, upload-time = "2025-04-18T12:01:39.412Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/7dfa4b63f95a17eaf881c9c464feaa59a25bbfb578db204fc22d522b5199/onnxruntime-1.21.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982dcc04a6688e1af9e3da1d4ef2bdeb11417cf3f8dde81f8f721043c1919a4f", size = 16002403, upload-time = "2025-04-18T12:01:41.645Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/397406e758d6c30fb6d0d0152041c6b9ee835c3584765837ce54230c8bc9/onnxruntime-1.21.1-cp311-cp311-win_amd64.whl", hash = "sha256:2b6052c04b9125319293abb9bdcce40e806db3e097f15b82242d4cd72d81fd0c", size = 12301824, upload-time = "2025-04-18T12:01:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/274438bbc259439fa1606d0d6d2eef4171cdbd2d7a1c3b249b4ba440424b/onnxruntime-1.21.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:f615c05869a523a94d0a4de1f0936d0199a473cf104d630fc26174bebd5759bd", size = 33658457, upload-time = "2025-04-18T12:01:22.937Z" }, + { url = "https://files.pythonhosted.org/packages/9c/93/76f629d4f22571b0b3a29a9d375204faae2bd2b07d557043b56df5848779/onnxruntime-1.21.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79dfb1f47386c4edd115b21015354b2f05f5566c40c98606251f15a64add3cbe", size = 14164881, upload-time = "2025-04-18T12:01:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/1b/86/75cbaa4058758fa8ef912dfebba2d5a4e4fd6738615c15b6a2262d076198/onnxruntime-1.21.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2742935d6610fe0f58e1995018d9db7e8239d0201d9ebbdb7964a61386b5390a", size = 16019966, upload-time = "2025-04-18T12:01:47.366Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9d/fb8895b2cb38c9965d4b4e0a9aa1398f3e3f16c4acb75cf3b61689780a65/onnxruntime-1.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:a7afdb3fcb162f5536225e13c2b245018068964b1d0eee05303ea6823ca6785e", size = 12302925, upload-time = "2025-04-18T12:01:26.147Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7e/8445eb44ba9fe0ce0bc77c4b569d79f7e3efd6da2dd87c5a04347e6c134e/onnxruntime-1.21.1-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:ed4f9771233a92edcab9f11f537702371d450fe6cd79a727b672d37b9dab0cde", size = 33658643, upload-time = "2025-04-18T12:01:28.73Z" }, + { url = "https://files.pythonhosted.org/packages/ce/46/9c4026d302f1c7e8427bf9fa3da2d7526d9c5200242bde6adee7928ef1c9/onnxruntime-1.21.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bc100fd1f4f95258e7d0f7068ec69dec2a47cc693f745eec9cf4561ee8d952a", size = 14165205, upload-time = "2025-04-18T12:01:50.117Z" }, + { url = "https://files.pythonhosted.org/packages/44/b2/4e4c6b5c03be752d74cb20937961c76f53fe87a9760d5b7345629d35bb31/onnxruntime-1.21.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fea0d2b98eecf4bebe01f7ce9a265a5d72b3050e9098063bfe65fa2b0633a8e", size = 16019529, upload-time = "2025-04-18T12:01:52.995Z" }, + { url = "https://files.pythonhosted.org/packages/ec/1d/afca646af339cc6735f3fb7fafb9ca94b578c5b6a0ebd63a312468767bdb/onnxruntime-1.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:da606061b9ed1b05b63a37be38c2014679a3e725903f58036ffd626df45c0e47", size = 12303603, upload-time = "2025-04-18T12:01:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/a5/12/a01e38c9a6b8d7c28e04d9eb83ad9143d568b961474ba49f0f18a3eeec82/onnxruntime-1.21.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94674315d40d521952bfc28007ce9b6728e87753e1f18d243c8cd953f25903b8", size = 14176329, upload-time = "2025-04-18T12:01:55.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/72/5ff85c540fd6a465610ce47e4cee8fccb472952fc1d589112f51ae2520a5/onnxruntime-1.21.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c9e4571ff5b2a5d377d414bc85cd9450ba233a9a92f766493874f1093976453", size = 15990556, upload-time = "2025-04-18T12:01:57.979Z" }, +] + [[package]] name = "open-micro-stage-api" version = "0.1.0" @@ -1028,6 +1108,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/31/84efa27aa3478c8670bac1a720c8b1aee5c58c9c657c980e5e5c47fde883/polars_runtime_32-1.34.0-cp39-abi3-win_arm64.whl", hash = "sha256:f9ed1765378dfe0bcd1ac5ec570dd9eab27ea728bbc980cc9a76eebc55586559", size = 35873216, upload-time = "2025-10-02T18:30:17.439Z" }, ] +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + [[package]] name = "psutil" version = "7.1.1" @@ -1044,6 +1139,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/8d/8a9a45c8b655851f216c1d44f68e3533dc8d2c752ccd0f61f1aa73be4893/psutil-7.1.1-cp37-abi3-win_arm64.whl", hash = "sha256:5457cf741ca13da54624126cd5d333871b454ab133999a9a103fb097a7d7d21a", size = 243944, upload-time = "2025-10-19T15:44:20.666Z" }, ] +[[package]] +name = "pyobjc-core" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/bf/3dbb1783388da54e650f8a6b88bde03c101d9ba93dfe8ab1b1873f1cd999/pyobjc_core-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:93418e79c1655f66b4352168f8c85c942707cb1d3ea13a1da3e6f6a143bacda7", size = 676748, upload-time = "2025-11-14T09:30:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/aa/2b2d7ec3ac4b112a605e9bd5c5e5e4fd31d60a8a4b610ab19cc4838aa92a/pyobjc_framework_cocoa-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9b880d3bdcd102809d704b6d8e14e31611443aa892d9f60e8491e457182fdd48", size = 383825, upload-time = "2025-11-14T09:40:28.354Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" }, + { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" }, + { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, +] + [[package]] name = "pyparsing" version = "3.2.5" @@ -1065,6 +1193,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/67/5484d6df0ddeb72e7898082b217a687f8a1343b29c069495f8d36e828146/pypylon-4.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:d8ef3d5ca6b272490a390b103c73c7c4bb995d6957f70d1179ac6a56bc2adfee", size = 89477818, upload-time = "2024-11-25T13:53:24.113Z" }, ] +[[package]] +name = "pyreadline3" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, +] + [[package]] name = "pyserial" version = "3.5" @@ -1308,6 +1445,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/30/2f9a5243008f76dfc5dee9a53dfb939d9b31e16ce4bd4f2e628bfc5d89d2/scipy-1.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d2a4472c231328d4de38d5f1f68fdd6d28a615138f842580a8a321b5845cf779", size = 26448374, upload-time = "2025-09-11T17:45:03.45Z" }, ] +[[package]] +name = "screeninfo" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cython", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/bb/e69e5e628d43f118e0af4fc063c20058faa8635c95a1296764acc8167e27/screeninfo-0.8.1.tar.gz", hash = "sha256:9983076bcc7e34402a1a9e4d7dabf3729411fd2abb3f3b4be7eba73519cd2ed1", size = 10666, upload-time = "2022-09-09T11:35:23.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/bf/c5205d480307bef660e56544b9e3d7ff687da776abb30c9cb3f330887570/screeninfo-0.8.1-py3-none-any.whl", hash = "sha256:e97d6b173856edcfa3bd282f81deb528188aff14b11ec3e195584e7641be733c", size = 12907, upload-time = "2022-09-09T11:35:21.351Z" }, +] + [[package]] name = "setuptools" version = "80.9.0" @@ -1331,22 +1481,26 @@ name = "stepper" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "onnxruntime" }, { name = "open-micro-stage-api" }, { name = "opencv-python" }, { name = "pillow" }, { name = "pypylon" }, { name = "pyserial" }, + { name = "screeninfo" }, { name = "toml" }, { name = "ultralytics" }, ] [package.metadata] requires-dist = [ + { name = "onnxruntime", specifier = ">=1.19.0,<1.22.0" }, { name = "open-micro-stage-api", git = "https://github.com/hacker-fab/MicroManipulatorStepper/?subdirectory=software%2FPythonAPI" }, { name = "opencv-python", specifier = ">=4.11.0.86" }, { name = "pillow", specifier = ">=11.1.0" }, { name = "pypylon", specifier = ">=4.1.0" }, { name = "pyserial", specifier = ">=3.5" }, + { name = "screeninfo", specifier = ">=0.8.1" }, { name = "toml", specifier = ">=0.10.2" }, { name = "ultralytics", specifier = ">=8.3.218" }, ]