diff --git a/.github/ISSUE_TEMPLATE/camera-mount-request.md b/.github/ISSUE_TEMPLATE/camera-mount-request.md
deleted file mode 100644
index 542166d..0000000
--- a/.github/ISSUE_TEMPLATE/camera-mount-request.md
+++ /dev/null
@@ -1,44 +0,0 @@
----
-name: Camera Mount Request
-about: Request a new camera mount design for a specific 3D printer.
-title: "[Mount Request] Camera Model : Printer Model"
-labels: enhancement, hardware
-assignees: ''
-
----
-
-## Printer Information
-Note : Non bedslinger printers are not supported.
-
-**Printer Make & Model:**
-(e.g., Creality Ender 3 v1)
-
-**Build Volume (in mm):**
-(e.g., 220x220x250)
-
-**Firmware Type:**
-(e.g., Marlin, Klipper, unknown)
-
-### Where did you buy the printer?
-
-Provide a link to the store or product page if possible. This helps confirm the exact hardware revision.
-
-## Camera Information
-Camera Make and Model
-
-**Camera Mount Type:**
-(e.g., 1/4-inch-20 UNC screw thread)
-
-- [ ] I added the camera and 3D printer name to the title
-- [ ] This camera is already supported
-
-## Reference Images, Technical Drawings or CAD Files
-
-Clear photos with the print head removed taken from straight on. Include ruler or measurements if possible. Links to STLs or mechanical drawings are greatly preferred. This will increase the chances of getting a model made.
-
-*(drag-and-drop your photos below.)*
-
-
-## Additional Notes
-
-Include any additional details about mounting constraints, screw hole locations, or modifications you've already made.
diff --git a/.github/ISSUE_TEMPLATE/mount-request.md b/.github/ISSUE_TEMPLATE/mount-request.md
deleted file mode 100644
index 2bb3c87..0000000
--- a/.github/ISSUE_TEMPLATE/mount-request.md
+++ /dev/null
@@ -1,67 +0,0 @@
-name: 🛠️ Camera Mount Request
-description: Request a new camera mount design for a specific 3D printer.
-title: "[Mount Request] Printer Model: "
-labels: [enhancement, hardware]
-assignees: ''
-
-body:
- - type: input
- id: printer-model
- attributes:
- label: Printer Make and Model
- description: Specify the full model name of the printer (e.g., Creality Ender 3 v1).
- placeholder: e.g., "Creality Ender 3 v1"
- validations:
- required: true
-
- - type: dropdown
- id: firmware
- attributes:
- label: Firmware Type
- description: Which firmware is the printer running?
- options:
- - Marlin
- - Klipper
- - Other / Unknown
- validations:
- required: true
-
- - type: input
- id: build-volume
- attributes:
- label: Build Volume
- placeholder: e.g., "250 × 210 × 210 mm"
- validations:
- required: false
-
- - type: textarea
- id: dimensions
- attributes:
- label: Bed and Carriage Dimensions
- description: Include bed size, carriage width, and any relevant measurements.
- validations:
- required: false
-
- - type: textarea
- id: photos
- attributes:
- label: Photos / Drawings of the Print Head Carriage
- description: Upload clear photos with the print head removed. Include ruler or measurements if possible.
- validations:
- required: false
-
- - type: input
- id: technical-resources
- attributes:
- label: Technical Drawings or CAD Files (optional)
- placeholder: Paste any links to STLs, DXFs, or mechanical drawings. This will increase the chances of getting a model made.
- validations:
- required: false
-
- - type: textarea
- id: other-notes
- attributes:
- label: Additional Notes
- description: Any other information we should know?
- validations:
- required: false
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 4424d7d..29b6c74 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,9 @@
./input/
output/
+misc/test_img/
+misc/input/
+
config/cameras/amscope/backups/
config/cameras/amscope/settings.yaml
@@ -10,8 +13,8 @@ config/printers/Ender3/settings.yaml
config/automation/backups/
config/automation/settings.yaml
-config/forge/backups/
-config/forge/settings.yaml
+config/fieldweave/backups/
+config/fieldweave/settings.yaml
# Libraries
focus-stack/
diff --git a/README.md b/README.md
index aee529c..14adf98 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,11 @@
-# Forge - Low Cost Gigapixel Scanner
+# FieldWeave - Low Cost Gigapixel Scanner
[](#)
[](#)

-Forge is an opensource, gigapixel imaging system designed to scan tree core samples with high precision. Built upon a modified off the shelf 3D printer, it automates the imaging of multiple samples, producing high resolution images suitable for dendrochronology and related research.
+FieldWeave is an opensource, gigapixel imaging system designed to scan tree core samples with high precision. Built upon a modified off the shelf 3D printer, it automates the imaging of multiple samples, producing high resolution images suitable for dendrochronology and related research.
@@ -24,7 +24,7 @@ Forge is an opensource, gigapixel imaging system designed to scan tree core samp
-
+
|
@@ -34,31 +34,31 @@ Forge is an opensource, gigapixel imaging system designed to scan tree core samp
|
- | Forge on a heavily modded Ender 3 3D printer. |
- Forge's GUI |
+ FieldWeave on a heavily modded Ender 3 3D printer. |
+ FieldWeave's GUI |
-
+
|
-
+
|
| The end of a tree core sample taken using a MU1000 HS camera |
- A M2 Heatset Insert Tip taken using a MU500 Camera. Typical Forge outputs for tree core samples are significantly larger. Click to view full resolution |
+ A M2 Heatset Insert Tip taken using a MU500 Camera. Typical FieldWeave outputs for tree core samples are significantly larger. Click to view full resolution |
-## Forge for Reflected Light Microscopy
+## FieldWeave for Reflected Light Microscopy
> **Reflected Light Microscopy (alternate lens configuration)**
- These images were captured using Forge with a different lens and illumination setup. The same capture pipeline was used, with image stacking and stitching currently performed using external tools.
- Forge does not officially support high magnification imaging yet, but it is being worked on. See github issue [#45](https://github.com/AnthonyvW/FORGE/issues/45)
+ These images were captured using FieldWeave with a different lens and illumination setup. The same capture pipeline was used, with image stacking and stitching currently performed using external tools.
+ FieldWeave does not officially support high magnification imaging yet, but it is being worked on. See github issue [#45](https://github.com/AnthonyvW/FORGE/issues/45)
|
@@ -87,7 +87,7 @@ Forge is an opensource, gigapixel imaging system designed to scan tree core samp
|
-
+
|
@@ -109,7 +109,7 @@ Forge is an opensource, gigapixel imaging system designed to scan tree core samp
## Printer Modification
-Before using Forge, your 3D printer must be modified to mount the camera system in place of the print head.
+Before using FieldWeave, your 3D printer must be modified to mount the camera system in place of the print head.
### Required Printed Parts
@@ -151,7 +151,7 @@ Before modifying your printer, you must 3D print the following components:
- Screw on the imaging lens securely.
7. **Install Light**
- Install the light you will be using with Forge.
+ Install the light you will be using with FieldWeave.
> If using the Amscope ring light, place the light pads onto the metal tips of the screws that hold the light in place before putting the light on the lens.
8. **Plug Everything in**
@@ -187,7 +187,7 @@ Prerequisites\. Ensure you have the latest version of python installed, and you
3\.2\. Move the downloaded zipped folder into 3rd_party_imports
-4\. Configure the camera settings using `amscope_camera_configuration.yaml`. For now, you can copy settings from TRIM until I get around to properly implementing this functionality into Forge.
+4\. Configure the camera settings using `amscope_camera_configuration.yaml`. For now, you can copy settings from TRIM until I get around to properly implementing this functionality into FieldWeave.
5\. Run the main application:
@@ -197,7 +197,7 @@ Prerequisites\. Ensure you have the latest version of python installed, and you
---
## Confirmed Compatible Cameras
-Forge supports USB cameras through a modular driver architecture.
+FieldWeave supports USB cameras through a modular driver architecture.
| Camera Model | Notes |
|-------------------------|-----------------------------|
@@ -211,7 +211,7 @@ Forge supports USB cameras through a modular driver architecture.
### Adding Support for New Cameras
-Users are encouraged to contribute new camera interfaces by implementing the Forge camera interface and submitting them as pull requests.
+Users are encouraged to contribute new camera interfaces by implementing the FieldWeave camera interface and submitting them as pull requests.
If your camera is not currently supported or you would like to contribute an interfaces, please open an issue or submit a pull request.
@@ -222,9 +222,9 @@ Alternatively, contributions of driver implementations with thorough documentati
## 3D Printer Compatibility
-Forge is designed to run on 3D printers using **Marlin firmware**, which supports standard G-code over USB serial. Compatibility with other firmware types varies and may require additional configuration or is not currently supported.
+FieldWeave is designed to run on 3D printers using **Marlin firmware**, which supports standard G-code over USB serial. Compatibility with other firmware types varies and may require additional configuration or is not currently supported.
-> Not sure if your 3D printer will work? Plug your printer into your computer via USB, and then start Forge. If the printer homes then it is compatible with Forge.
+> Not sure if your 3D printer will work? Plug your printer into your computer via USB, and then start FieldWeave. If the printer homes then it is compatible with FieldWeave.
## Confirmed Compatible Printers
@@ -240,10 +240,10 @@ Forge is designed to run on 3D printers using **Marlin firmware**, which support
| Printer / Firmware | Status | Reason |
|----------------------------------|---------------|------------------------------------------------------------------------|
| **Klipper-based printers** | ❓ Unverified | Serial responses (e.g., `ok`, `M400`) may differ. Needs testing. |
-| **RepRapFirmware (e.g., Duet)** | ❌ Incompatible | Different G-code syntax; not supported by Forge |
+| **RepRapFirmware (e.g., Duet)** | ❌ Incompatible | Different G-code syntax; not supported by FieldWeave |
| **Sailfish Firmware (e.g., FlashForge)** | ❌ Incompatible | Proprietary, non-standard G-code |
| **Proprietary OEM firmware** | ❌ Incompatible | Often locked or limited (e.g., XYZprinting); lacks serial G-code input |
-| **Non-G-code motion platforms** | ❌ Incompatible | Forge requires G-code over USB for motion control |
+| **Non-G-code motion platforms** | ❌ Incompatible | FieldWeave requires G-code over USB for motion control |
> Want to help verify compatibility with other printers, firmware, or cameras?
> [Open an issue](https://github.com/AnthonyvW/FORGE/issues) with your setup details and test results!
diff --git a/UI/camera_view.py b/UI/camera_view.py
deleted file mode 100644
index 91863b1..0000000
--- a/UI/camera_view.py
+++ /dev/null
@@ -1,142 +0,0 @@
-from typing import Optional
-
-import pygame
-import numpy as np
-
-from UI.frame import Frame
-from UI.text import Text, TextStyle
-
-
-class CameraView(Frame):
- """
- A Frame that renders a camera feed inside itself, respecting pixel margins.
- - Maintains aspect ratio with letterboxing
- - Reacts to parent resize automatically
- - Can be put behind everything via z_index
- """
- def __init__(
- self,
- camera,
- parent: Optional[Frame] = None,
- *,
- x: float = 0,
- y: float = 0,
- width: float = 1.0,
- height: float = 1.0,
- x_is_percent: bool = True,
- y_is_percent: bool = True,
- width_is_percent: bool = True,
- height_is_percent: bool = True,
- z_index: int = -100, # keep it behind panels/modals
- x_align: str = "left",
- y_align: str = "top",
- background_color: Optional[pygame.Color] = None,
- mouse_passthrough: bool = True,
- left_margin_px: int = 0,
- right_margin_px: int = 0,
- top_margin_px: int = 0,
- bottom_margin_px: int = 0,
- ):
- self.camera = camera
- self.mouse_passthrough = mouse_passthrough
- self.background_color = background_color
-
- # margins (in pixels) to reserve for other UI
- self.left_margin_px = left_margin_px
- self.right_margin_px = right_margin_px
- self.top_margin_px = top_margin_px
- self.bottom_margin_px = bottom_margin_px
-
- # track last applied size to avoid redundant resizes
- self._last_draw_w = None
- self._last_draw_h = None
- self._last_frame_rect = None # (dx, dy, fw, fh)
-
- super().__init__(
- parent=parent,
- x=x, y=y, width=width, height=height,
- x_is_percent=x_is_percent, y_is_percent=y_is_percent,
- width_is_percent=width_is_percent, height_is_percent=height_is_percent,
- z_index=z_index, x_align=x_align, y_align=y_align,
- background_color=None, # we fill manually to keep margins clean
- )
-
- self.no_camera_text = Text(
- text="No Camera Detected",
- parent=self,
- x=0.5, y=0.5,
- x_is_percent=True, y_is_percent=True,
- x_align="center", y_align="center",
- style=TextStyle(font_size=32),
- )
-
- # Show/hide based on current init state
- if self.camera.initialized:
- self.no_camera_text.add_hidden_reason("SYSTEM")
-
- def _compute_inner_rect(self):
- # Base geometry from normal frame rules
- abs_x, abs_y, abs_w, abs_h = super().get_absolute_geometry()
- # Apply pixel margins to shrink usable area
- ix = abs_x + self.left_margin_px
- iy = abs_y + self.top_margin_px
- iw = max(0, abs_w - self.left_margin_px - self.right_margin_px)
- ih = max(0, abs_h - self.top_margin_px - self.bottom_margin_px)
- return ix, iy, iw, ih
-
- def get_frame_rect(self):
- """
- Returns (x, y, w, h) of the currently drawn camera frame within the surface,
- accounting for letterboxing. May be None if nothing drawn yet.
- """
- return self._last_frame_rect
-
- def get_absolute_geometry(self):
- # Expose the *inner* rect as the geometry of this view
- return self._compute_inner_rect()
-
- def draw(self, surface: pygame.Surface) -> None:
- if self.is_effectively_hidden:
- return
-
- ix, iy, iw, ih = self._compute_inner_rect()
- if self.background_color:
- pygame.draw.rect(surface, self.background_color, (ix, iy, iw, ih))
-
- # --- fetch NumPy frame (prefer still, fallback to last live stream) ---
- arr = self.camera.get_last_frame(prefer="latest", wait_for_still=False)
-
- if arr is None or iw <= 0 or ih <= 0:
- self._last_frame_rect = (ix, iy, 0, 0)
- # (Optionally draw a subtle "no signal" background here)
- for child in reversed(self.children):
- child.draw(surface)
- return
-
- # Ensure contiguous uint8 RGB
- if arr.dtype != np.uint8:
- arr = np.clip(arr, 0, 255).astype(np.uint8)
- if arr.ndim == 2:
- arr = np.stack([arr]*3, axis=-1)
- h, w, c = arr.shape
- assert c in (3, 4)
-
- # --- fit to (iw, ih) with letterboxing ---
- scale = min(iw / w, ih / h)
- tw, th = max(1, int(round(w * scale))), max(1, int(round(h * scale)))
-
- # Convert NumPy → Surface then scale
- # Use frombuffer on a contiguous copy to avoid strides issues
- buf = arr[:, :, :3].copy(order="C").tobytes() # RGB only for pygame
- frame_surf = pygame.image.frombuffer(buf, (w, h), "RGB")
- if (tw, th) != (w, h):
- frame_surf = pygame.transform.smoothscale(frame_surf, (tw, th))
-
- dx = ix + (iw - tw) // 2
- dy = iy + (ih - th) // 2
- self._last_frame_rect = (dx, dy, tw, th)
- surface.blit(frame_surf, (dx, dy))
-
- # Draw overlays/children
- for child in reversed(self.children):
- child.draw(surface)
diff --git a/UI/flex_frame.py b/UI/flex_frame.py
deleted file mode 100644
index f7f1bc5..0000000
--- a/UI/flex_frame.py
+++ /dev/null
@@ -1,119 +0,0 @@
-import pygame
-from typing import Tuple
-from UI.frame import Frame
-
-class FlexFrame(Frame):
- """
- Simple flex-like layout container.
-
- Direction: column only (for now).
- - Packs visible children from top to bottom.
- - Uses each child's *current* height (so collapsed Sections shrink naturally).
- - Optionally fills child widths to container width.
- """
-
- def __init__(
- self,
- *,
- parent=None,
- x=0, y=0, width=100, height=100,
- x_is_percent=False, y_is_percent=False,
- width_is_percent=False, height_is_percent=False,
- z_index=0,
- background_color: pygame.Color | None = None,
-
- # Flex options
- padding: Tuple[int, int, int, int] = (0, 0, 0, 0), # (left, top, right, bottom)
- gap: int = 8,
- fill_child_width: bool = True,
- align_horizontal: str = "left", # "left" | "center" | "right"
- auto_height_to_content: bool = False, # If True, grow/shrink this frame's height to fit children
- **kwargs
- ):
- super().__init__(
- parent=parent, x=x, y=y, width=width, height=height,
- x_is_percent=x_is_percent, y_is_percent=y_is_percent,
- width_is_percent=width_is_percent, height_is_percent=height_is_percent,
- z_index=z_index, background_color=background_color,
- padding=padding,
- **kwargs
- )
- self.gap = gap
- self.fill_child_width = fill_child_width
- self.align_horizontal = align_horizontal
- self.auto_height_to_content = auto_height_to_content
-
- self._layout_dirty = True
-
- # --- Public: you can call this if you change padding/gap/etc dynamically
- def request_layout(self) -> None:
- self._layout_dirty = True
-
- # --- Core layout: compute child positions/sizes in absolute container space
- def _layout(self) -> None:
-
- y_cursor = 0 # content-local pixels (0 is top of content box)
- visible_children = [ch for ch in self.children if not ch.is_effectively_hidden]
-
- for i, child in enumerate(visible_children):
- # --- Fill width if requested: since base Frame uses the content box,
- # percent widths are now relative to content width. So 1.0 == fill.
- if self.fill_child_width:
- child.width_is_percent = True
- child.width = 1.0
- # else: leave child's width props as-is
-
- # Horizontal alignment inside content box
- if self.align_horizontal == "left":
- child.x_align = "left"
- elif self.align_horizontal == "center":
- child.x_align = "center"
- elif self.align_horizontal == "right":
- child.x_align = "right"
- else:
- child.x_align = "left"
-
- # Position vertically (content-local); base Frame will offset by content origin
- child.y_is_percent = False
- child.y = y_cursor
-
- # For left/center/right we want x as an offset from alignment anchor
- child.x_is_percent = False
- child.x = 0 # use alignment only
-
- # After setting width/position hints, read child's computed height
- # (this uses current width/height props relative to content box)
- _, _, ch_w, ch_h = child.get_absolute_geometry()
-
- # Advance cursor
- y_cursor += ch_h
- if i != len(visible_children) - 1:
- y_cursor += self.gap
-
- # Auto-size this FlexFrame’s OUTER height to fit children + padding
- if self.auto_height_to_content and not self.height_is_percent:
- pt, pr, pb, pl = self.padding # base Frame’s padding (top,right,bottom,left)
- self.height = max(0, y_cursor + pt + pb)
-
- self._layout_dirty = False
-
- # Ensure layout is up-to-date in all the usual passes
- def draw(self, surface: pygame.Surface) -> None:
- self._layout()
- super().draw(surface)
-
- def process_mouse_move(self, px, py):
- self._layout()
- super().process_mouse_move(px, py)
-
- def process_mouse_press(self, px, py, button):
- self._layout()
- super().process_mouse_press(px, py, button)
-
- def process_mouse_release(self, px, py, button):
- self._layout()
- super().process_mouse_release(px, py, button)
-
- def process_mouse_wheel(self, px: int, py: int, *, dx: int, dy: int) -> bool:
- self._layout()
- return super().process_mouse_wheel(px, py, dx=dx, dy=dy)
diff --git a/UI/focus_overlay.py b/UI/focus_overlay.py
deleted file mode 100644
index e40e3f9..0000000
--- a/UI/focus_overlay.py
+++ /dev/null
@@ -1,227 +0,0 @@
-import pygame
-from typing import Tuple, Set
-
-from UI.frame import Frame
-from UI.camera_view import CameraView
-
-from image_processing.machine_vision import MachineVision
-
-
-class FocusOverlay(Frame):
- """
- UI overlay that renders the focused tiles and (optionally) the invalid/hot tiles
- produced by MachineVision.
- """
- def __init__(
- self,
- camera_view: "CameraView",
- mv: MachineVision, # Injected machine vision instance (optional)
- visible: bool = False,
-
- # Visual styles (overlay-only concerns)
- alpha_hard: int = 100,
- border_alpha_hard: int = 200,
- alpha_soft: int = 50,
- border_alpha_soft: int = 120,
- invalid_alpha_fill: int = 90,
- invalid_alpha_border: int = 180,
- invalid_border_w: int = 2,
- soft_border_w: int = 1,
- hard_border_w: int = 2,
- draw_invalid: bool = True,
- ):
- super().__init__(parent=camera_view, x=0, y=0, width=1, height=1,
- x_is_percent=True, y_is_percent=True,
- width_is_percent=True, height_is_percent=True,
- z_index=camera_view.z_index + 1,
- background_color=None)
-
- self.camera_view = camera_view
- self.mv = mv
-
- # Visuals
- self.visible = visible
- self.draw_invalid = draw_invalid
-
- self.soft_fill = pygame.Color(255, 180, 0, alpha_soft)
- self.soft_border = pygame.Color(255, 180, 0, border_alpha_soft)
- self.soft_border_w = soft_border_w
-
- self.hard_fill = pygame.Color(0, 200, 255, alpha_hard)
- self.hard_border = pygame.Color(0, 200, 255, border_alpha_hard)
- self.hard_border_w = hard_border_w
-
- self.invalid_fill = pygame.Color(255, 0, 0, invalid_alpha_fill)
- self.invalid_border = pygame.Color(255, 0, 0, invalid_alpha_border)
- self.invalid_border_w = invalid_border_w
-
- # Edge margin overlays (translucent red)
- self.draw_edge_margins = True
- self.edge_fill = pygame.Color(255, 0, 0, 80) # translucent red fill
- self.edge_border = pygame.Color(255, 0, 0, 160) # slightly less translucent border
- self.edge_border_w = 1
-
- # cache overlay to avoid realloc each frame
- self._overlay = None
- self._overlay_size = None
-
- # ---------- convenience pass-throughs for callers ----------
- def toggle_overlay(self) -> None:
- self.visible = not self.visible
-
- def set_enabled(self, value: bool) -> None:
- self.enabled = bool(value)
-
- def clear_hot_pixel_map(self) -> None:
- self.mv.clear_hot_pixel_map()
-
- def build_hot_pixel_map(
- self,
- duration_sec: float = 1.0,
- *,
- dilate: int = 0,
- min_hits: int = 1,
- max_fps: int = 30,
- include_soft: bool = True,
- ):
- return self.mv.build_hot_pixel_map(
- duration_sec=duration_sec,
- dilate=dilate,
- min_hits=min_hits,
- max_fps=max_fps,
- include_soft=include_soft,
- )
-
- def is_tile_invalid(self, col: int, row: int) -> bool:
- return self.mv.is_tile_invalid(col, row)
-
-
- def _get_overlay(self, surface_size: tuple[int, int]) -> pygame.Surface:
- """Return an RGBA overlay the size of the target surface (recreate on resize)."""
- if self._overlay is None or self._overlay_size != surface_size:
- self._overlay_size = surface_size
- self._overlay = pygame.Surface(surface_size, flags=pygame.SRCALPHA)
- else:
- # Clear with fully transparent color
- self._overlay.fill((0, 0, 0, 0))
- return self._overlay
-
- # ------------------------------- draw -------------------------------
- def draw(self, surface: pygame.Surface) -> None:
- if not self.visible:
- return
-
- # Build/resize overlay and clear it fully transparent
- overlay = self._get_overlay(surface.get_size())
- overlay.fill((0, 0, 0, 0))
-
- fr = self.camera_view.get_frame_rect()
- if not fr:
- return
-
- fx, fy, fw, fh = fr
-
- # Get raw frame shape so we can map RAW → GUI coordinates
- raw = self.mv.capture_current_frame(color="rgb", source="latest")
- if raw is None:
- return
- h, w = raw.shape[:2]
-
- # Scale factors from RAW pixel space to the drawn frame rectangle
- sx = float(fw) / float(w) if w else 1.0
- sy = float(fh) / float(h) if h else 1.0
-
- # Compute focused tiles once in RAW
- res = self.mv.compute_focused_tiles(include_soft=True, filter_invalid=True)
- soft_tiles = res["soft"]
- hard_tiles = res["hard"]
-
- # All blits/draws go to overlay (supports alpha)
- def _blit_rect(fill_color, border_color, border_w, rect):
- if rect.width <= 0 or rect.height <= 0:
- return
- pygame.draw.rect(overlay, fill_color, rect)
- pygame.draw.rect(overlay, border_color, rect, border_w)
-
- def rect_from_raw(tile, fx, fy, sx, sy):
- # Snap both edges (shared boundaries) and derive size from them
- left = fx + int(round(tile.x * sx))
- top = fy + int(round(tile.y * sy))
- right = fx + int(round((tile.x + tile.w) * sx))
- bottom = fy + int(round((tile.y + tile.h) * sy))
- w = max(0, right - left)
- h = max(0, bottom - top)
- return pygame.Rect(left, top, w, h)
-
- # --- draw edge margin overlays (percent insets from each edge) ---
- if self.draw_edge_margins and self.mv is not None:
- l_pct, r_pct, t_pct, b_pct = self.mv.get_edge_margins()
-
- # Clamp
- l_pct = max(0.0, min(1.0, float(l_pct)))
- r_pct = max(0.0, min(1.0, float(r_pct)))
- t_pct = max(0.0, min(1.0, float(t_pct)))
- b_pct = max(0.0, min(1.0, float(b_pct)))
-
- # Sizes in screen pixels
- left_w = int(round(fw * l_pct))
- right_w = int(round(fw * r_pct))
- top_h = int(round(fh * t_pct))
- bottom_h = int(round(fh * b_pct))
-
- # Interior (safe) rect
- inner_x = fx + left_w
- inner_y = fy + top_h
- inner_w = max(0, fw - left_w - right_w)
- inner_h = max(0, fh - top_h - bottom_h)
-
- # TOP (owns corners)
- if top_h > 0:
- pygame.draw.rect(overlay, self.edge_fill, (fx, fy, fw, top_h), 0)
-
- # BOTTOM (owns corners)
- if bottom_h > 0:
- pygame.draw.rect(overlay, self.edge_fill, (fx, fy + fh - bottom_h, fw, bottom_h), 0)
-
- # LEFT (trimmed to avoid overlap)
- usable_h = max(0, fh - top_h - bottom_h)
- if left_w > 0 and usable_h > 0:
- pygame.draw.rect(overlay, self.edge_fill, (fx, fy + top_h, left_w, usable_h), 0)
-
- # RIGHT (trimmed)
- if right_w > 0 and usable_h > 0:
- pygame.draw.rect(overlay, self.edge_fill, (fx + fw - right_w, fy + top_h, right_w, usable_h), 0)
-
- # Single border around the central (non-red) region
- if inner_w > 0 and inner_h > 0 and self.edge_border_w > 0:
- pygame.draw.rect(
- overlay,
- self.edge_border,
- pygame.Rect(inner_x, inner_y, inner_w, inner_h),
- width=self.edge_border_w
- )
-
- # Soft tiles
- for t in soft_tiles:
- r = rect_from_raw(t, fx, fy, sx, sy)
- _blit_rect(self.soft_fill, self.soft_border, self.soft_border_w, r)
-
- for t in hard_tiles:
- r = rect_from_raw(t, fx, fy, sx, sy)
- _blit_rect(self.hard_fill, self.hard_border, self.hard_border_w, r)
-
- # Invalid (hot) tiles
- if self.draw_invalid and self.mv.invalid_tiles:
- interior_raw = self.mv.get_interior_rect_pixels(w, h) # RAW coords
- for (col, row) in self.mv.invalid_tiles:
- t = self.mv.tile_rect_from_index(col, row) # RAW rect
- # only draw if fully inside the interior
- if (t.left >= interior_raw.left and
- t.top >= interior_raw.top and
- t.right <= interior_raw.right and
- t.bottom <= interior_raw.bottom):
- r = rect_from_raw(t, fx, fy, sx, sy)
- _blit_rect(self.invalid_fill, self.invalid_border, self.invalid_border_w, r)
-
- # Composite overlay (with alpha) onto the actual screen surface
- surface.blit(overlay, (0, 0))
\ No newline at end of file
diff --git a/UI/frame.py b/UI/frame.py
deleted file mode 100644
index 527340f..0000000
--- a/UI/frame.py
+++ /dev/null
@@ -1,421 +0,0 @@
-import pygame
-from typing import Callable, Optional, Tuple, Type, TypeVar, Iterator, Optional, List, Any
-
-T = TypeVar("T")
-
-def default_frame_background() -> Optional[pygame.Color]:
- return None
-
-class Frame():
- def __init__(
- self, parent=None, x=0, y=0, width=100, height=100,
- x_is_percent=False, y_is_percent=False,
- width_is_percent=False, height_is_percent=False,
- z_index=0, x_align: str = 'left', y_align: str = 'top',
- background_color: Optional[pygame.Color] = None,
- fill_remaining_height: bool = False,
- padding: Tuple[int, int, int, int] = (0, 0, 0, 0),
- ):
- self.parent = parent
- self.children = []
-
- self.x = x
- self.y = y
- self.width = width
- self.height = height
-
- self.background_color = background_color
-
- self.x_is_percent = x_is_percent
- self.y_is_percent = y_is_percent
- self.width_is_percent = width_is_percent
- self.height_is_percent = height_is_percent
- self.fill_remaining_height = fill_remaining_height
- self.padding = padding
-
- self.z_index = z_index
- self.x_align = x_align
- self.y_align = y_align
-
- self.is_hovered = False
- self.is_pressed = False
- self.mouse_passthrough = False
- self.hidden_reasons: set[str] = set()
-
- # Automatically add parent if its passed as an argument
- if parent is not None:
- parent.add_child(self)
-
- @property
- def debug_outline_color(self) -> pygame.Color:
- return pygame.Color(255, 0, 0) # Default: red
-
- @property
- def position(self) -> Tuple[int, int]:
- return (self.x, self.y)
-
- def update_position(self, x_offset: int, y_offset: int) -> None:
- """Move this frame and all children by the given pixel offset, regardless of percent/absolute mode."""
- self._apply_offset(x_offset, y_offset)
-
- for child in self.children:
- _, _, parent_width, parent_height = self.get_content_geometry()
- percent_dx = x_offset / parent_width if parent_width else 0
- percent_dy = y_offset / parent_height if parent_height else 0
- child._apply_offset(x_offset, y_offset, percent_dx, percent_dy)
-
- def _apply_offset(self, dx: float, dy: float, dx_percent: float = 0.0, dy_percent: float = 0.0) -> None:
- """Shift this frame by the given amounts, using percent or absolute logic as needed."""
- if self.x_is_percent:
- self.x += dx_percent
- else:
- self.x += dx
-
- if self.y_is_percent:
- self.y += dy_percent
- else:
- self.y += dy
-
- def update_size(self, width_offset: int, height_offset: int) -> None:
- """Resize this frame and all children by pixel delta, adjusting percent-based children proportionally."""
- self._apply_size_change(width_offset, height_offset)
-
- for child in self.children:
- _, _, parent_width, parent_height = self.get_content_geometry()
- percent_dw = width_offset / parent_width if parent_width else 0
- percent_dh = height_offset / parent_height if parent_height else 0
- child._apply_size_change(width_offset, height_offset, percent_dw, percent_dh)
-
- def _apply_size_change(self, dw: float, dh: float, dw_percent: float = 0.0, dh_percent: float = 0.0) -> None:
- """Apply size delta to this frame, supporting both pixel and percent-based width/height."""
- if self.width_is_percent:
- self.width += dw_percent
- # else: don't modify absolute width
-
- # If we're filling the remaining height, ignore direct height resizes
- if not self.fill_remaining_height:
- if self.height_is_percent:
- self.height += dh_percent
- # else: don't modify absolute height
-
- def iter_descendants(self) -> Iterator["Frame"]:
- """Depth-first traversal over all descendants (not including self)."""
- stack: List["Frame"] = list(getattr(self, "children", []))
- while stack:
- node = stack.pop()
- yield node
- stack.extend(getattr(node, "children", []))
-
- def find_child_of_type(self, cls: Type[T], *, include_self: bool=False) -> Optional[T]:
- """Return the first descendant (optionally self) that is an instance of `cls`."""
- if include_self and isinstance(self, cls): # type: ignore[arg-type]
- return self # type: ignore[return-value]
- for node in self.iter_descendants():
- if isinstance(node, cls):
- return node # type: ignore[return-value]
- return None
-
- def find_children_of_type(self, cls: Type[T], *, include_self: bool=False) -> List[T]:
- """Return all descendants (optionally self) that are instances of `cls`."""
- out: List[T] = []
- if include_self and isinstance(self, cls): # type: ignore[arg-type]
- out.append(self) # type: ignore[arg-type]
- for node in self.iter_descendants():
- if isinstance(node, cls):
- out.append(node) # type: ignore[arg-type]
- return out
-
- def find_first(self, predicate: Callable[[Any], bool], *, include_self: bool=False) -> Optional[Any]:
- """Generic: return first node for which predicate(node) is True."""
- if include_self and predicate(self):
- return self
- for node in self.iter_descendants():
- if predicate(node):
- return node
- return None
-
- @property
- def absolute_position(self) -> Tuple[int, int]:
- abs_width = self.width * parent_width if self.width_is_percent else self.width
- abs_height = self.height * parent_height if self.height_is_percent else self.height
- return (abs_width, abs_height)
-
- @property
- def size(self) -> Tuple[int, int]:
- return (self.width, self.height)
-
- def get_absolute_geometry(self):
- """Returns absolute screen coordinates"""
- if self.parent:
- parent_x, parent_y, parent_width, parent_height = self.parent.get_content_geometry()
- else:
- parent_x, parent_y = 0, 0
- parent_width, parent_height = pygame.display.get_surface().get_size()
-
- # Raw (pre-alignment) values
- raw_x = self.x * parent_width if self.x_is_percent else self.x
- raw_y = self.y * parent_height if self.y_is_percent else self.y
-
- abs_width = self.width * parent_width if self.width_is_percent else self.width
-
- if self.fill_remaining_height:
- # Pin to top and stretch to parent's bottom
- abs_y = parent_y + raw_y
- abs_height = max(0, (parent_y + parent_height) - abs_y)
- else:
- # Normal vertical alignment path (uses declared height)
- abs_height = self.height * parent_height if self.height_is_percent else self.height
-
- if self.y_align == 'top':
- abs_y = parent_y + raw_y
- elif self.y_align == 'center':
- if self.y_is_percent:
- abs_y = parent_y + raw_y - (abs_height // 2)
- else:
- abs_y = parent_y + (parent_height // 2) + raw_y - (abs_height // 2)
- elif self.y_align == 'bottom':
- abs_y = parent_y + parent_height - raw_y - abs_height
- else:
- raise ValueError(f"Invalid y_align: {self.y_align}")
-
- # Horizontal alignment (unchanged)
- if self.x_align == 'left':
- abs_x = parent_x + raw_x
- elif self.x_align == 'center':
- if self.x_is_percent:
- abs_x = parent_x + raw_x - (abs_width // 2)
- else:
- abs_x = parent_x + (parent_width // 2) + raw_x - (abs_width // 2)
- elif self.x_align == 'right':
- abs_x = parent_x + parent_width - raw_x - abs_width
- else:
- raise ValueError(f"Invalid x_align: {self.x_align}")
-
- return abs_x, abs_y, abs_width, abs_height
-
- def get_content_geometry(self) -> Tuple[int, int, int, int]:
- """Inner (padded) rectangle children should layout inside."""
- abs_x, abs_y, abs_w, abs_h = self.get_absolute_geometry()
- pad_top, pad_right, pad_bottom, pad_left = self.padding
- inner_x = abs_x + pad_left
- inner_y = abs_y + pad_top
- inner_w = max(0, abs_w - pad_left - pad_right)
- inner_h = max(0, abs_h - pad_top - pad_bottom)
- return inner_x, inner_y, inner_w, inner_h
-
- def add_child(self, child):
- child.parent = self
- self.children.append(child)
- self.children.sort(key=lambda c: c.z_index, reverse=True) # front-to-back order
-
- def for_each_descendant(self, fn):
- stack = list(self.children)
- while stack:
- node = stack.pop()
- fn(node)
- stack.extend(node.children)
-
- def contains_point(self, px, py):
- if self.is_effectively_hidden:
- return False
- abs_x, abs_y, abs_width, abs_height = self.get_absolute_geometry()
- return abs_x <= px <= abs_x + abs_width and abs_y <= py <= abs_y + abs_height
-
- def handle_click(self, px, py):
- if self.is_effectively_hidden:
- return False
- # First check children, then self
- for child in (self.children):
- if child.mouse_passthrough:
- continue
- if child.contains_point(px, py):
- child.handle_click(px, py)
- return # Only send to first child that contains it (or remove this if you want overlapping elements to handle too)
-
- self.on_click()
-
- def handle_hover(self, px, py):
- if self.is_effectively_hidden:
- return
-
- for child in (self.children):
- if child.mouse_passthrough:
- continue
- if child.contains_point(px, py):
- child.handle_hover(px, py)
- return
- if self.contains_point(px, py):
- self.on_hover()
-
- def _clear_hover_recursive(self):
- if self.is_hovered:
- self.is_hovered = False
- self.on_hover_leave()
- for ch in self.children:
- ch._clear_hover_recursive()
-
- def process_mouse_move(self, px, py):
- """Hover handling with z occlusion"""
- if self.is_effectively_hidden:
- return
-
- # First propagate to children front-to-back
- top_hit = None
- for child in (self.children):
- if child.mouse_passthrough:
- continue
- if child.contains_point(px, py):
- top_hit = child
- break
-
- for child in self.children:
- if child is top_hit:
- child.process_mouse_move(px, py)
- else:
- child._clear_hover_recursive()
-
- # Now check self hover state
- inside = self.contains_point(px, py)
- if inside and not self.is_hovered:
- self.is_hovered = True
- self.on_hover_enter()
- elif not inside and self.is_hovered:
- self.is_hovered = False
- self.on_hover_leave()
-
- def process_mouse_press(self, px, py, button):
- if self.is_effectively_hidden:
- return
-
- for child in self.children:
- if child.mouse_passthrough:
- continue
- if child.contains_point(px, py):
- child.process_mouse_press(px, py, button)
- return
-
- if self.contains_point(px, py):
- self.is_pressed = True
- self.on_mouse_press(button)
-
- def process_mouse_release(self, px, py, button):
- if self.is_effectively_hidden:
- return
-
- for child in self.children:
- if child.mouse_passthrough:
- continue
- if child.contains_point(px, py):
- child.process_mouse_release(px, py, button)
- return
-
- if self.is_pressed:
- self.is_pressed = False
- self.on_mouse_release(button)
- if self.contains_point(px, py):
- self.on_click(button)
-
- def process_mouse_wheel(self, px: int, py: int, *, dx: int, dy: int) -> bool:
- # Route to topmost eligible child under the cursor first
- for child in reversed(self.children): # assume later children are drawn on top
- if child.contains_point(px, py):
- if child.process_mouse_wheel(px, py, dx=dx, dy=dy):
- return True
-
- # If no child handled it, let THIS frame handle it (if it wants)
- return bool(self.on_wheel(dx, dy, px, py))
-
-
- def broadcast_mouse_wheel(self, px: int, py: int, *, dx: int = 0, dy: int = 0) -> None:
- """Give every widget a chance to react to wheel (e.g., global zoom, tooltips)."""
- if self.is_effectively_hidden:
- return
- for child in self.children:
- child.broadcast_mouse_wheel(px, py, dx=dx, dy=dy)
- self.on_wheel(dx, dy, px, py)
-
- # ---- visibility core ----
- def add_hidden_reason(self, reason: str):
- if reason not in self.hidden_reasons:
- self.hidden_reasons.add(reason)
-
- def remove_hidden_reason(self, reason: str):
- self.hidden_reasons.discard(reason)
-
- @property
- def is_effectively_hidden(self) -> bool:
- return bool(self.hidden_reasons)
-
- def hide(self, recursive: bool = False):
- self.add_hidden_reason("USER")
- if recursive:
- for ch in self.children:
- ch.hide(True)
-
- def show(self, recursive: bool = False):
- self.remove_hidden_reason("USER")
- if recursive:
- for ch in self.children:
- ch.show(True)
-
- def draw(self, surface: pygame.Surface) -> None:
- if self.is_effectively_hidden:
- return
-
- abs_x, abs_y, abs_w, abs_h = self.get_absolute_geometry()
-
- if self.background_color:
- pygame.draw.rect(surface, self.background_color, (abs_x, abs_y, abs_w, abs_h))
-
- for child in reversed(self.children):
- child.draw(surface)
-
- # --- Override these ---
- def on_click(self, button=None):
- pass
-
- def on_mouse_press(self, button):
- pass
-
- def on_mouse_release(self, button):
- pass
-
- def on_wheel(self, dx: int, dy: int, px: int, py: int) -> None:
- """Override in widgets that want wheel input. (dx/dy match pygame.MOUSEWHEEL)"""
- return False
-
- def on_hover(self):
- pass
-
- def on_hover_enter(self):
- pass
-
- def on_hover_leave(self):
- pass
-
- def broadcast_mouse_press(self, px, py, button):
- """Give every widget a chance to react to a global mouse press (e.g., focus/unfocus)."""
- if self.is_effectively_hidden:
- return
-
- for child in self.children:
- child.broadcast_mouse_press(px, py, button)
- self.on_global_mouse_press(px, py, button)
-
- def on_global_mouse_press(self, px, py, button):
- """Override in widgets that need to react even if the click was outside them."""
- pass
-
- def broadcast_key_event(self, event):
- """Bubble key events to all widgets; inactive widgets can ignore them."""
- if self.is_effectively_hidden:
- return
-
- for child in self.children:
- child.broadcast_key_event(event)
-
- self.on_key_event(event)
-
- def on_key_event(self, event):
- """Override in widgets that want keyboard input."""
- pass
diff --git a/UI/input/button.py b/UI/input/button.py
deleted file mode 100644
index 371d3af..0000000
--- a/UI/input/button.py
+++ /dev/null
@@ -1,186 +0,0 @@
-import pygame
-from typing import Callable, Optional, Tuple
-from dataclasses import dataclass, field
-from enum import Enum
-from UI.text import Text, TextStyle
-from UI.frame import Frame
-
-class ButtonShape(Enum):
- RECTANGLE = "rectangle"
- DIAMOND = "diamond"
-
-def default_background() -> pygame.Color:
- return pygame.Color("#dbdbdb")
-
-def default_foreground() -> pygame.Color: # used for BORDER only
- return pygame.Color("#b3b4b6")
-
-def default_hover_background() -> pygame.Color:
- return pygame.Color("#b3b4b6")
-
-def default_disabled_background() -> pygame.Color:
- return pygame.Color(128, 128, 128)
-
-def default_disabled_foreground() -> pygame.Color:
- return pygame.Color(192, 192, 192)
-
-@dataclass
-class ButtonColors:
- # Backgrounds
- background: pygame.Color = field(default_factory=default_background)
- hover_background: pygame.Color = field(default_factory=default_hover_background)
- disabled_background: pygame.Color = field(default_factory=default_disabled_background)
-
- # Borders ("foreground")
- foreground: pygame.Color = field(default_factory=default_foreground)
- hover_foreground: Optional[pygame.Color] = None
- disabled_foreground: pygame.Color = field(default_factory=default_disabled_foreground)
-
-
-class Button(Frame):
- def __init__(
- self,
- function_to_call: Callable,
- x: int,
- y: int,
- width: int,
- height: int,
- text: str = "",
- colors: Optional[ButtonColors] = None,
- text_style: Optional[TextStyle] = None,
- args: Optional[Tuple] = None,
- args_provider: Optional[Callable[[], Tuple]] = None,
- shape: ButtonShape = ButtonShape.RECTANGLE,
- **frame_kwargs
- ):
- super().__init__(x=x, y=y, width=width, height=height, **frame_kwargs)
-
- self.function_to_call = function_to_call
- self.args = args or ()
- self.args_provider = args_provider
- self.shape = shape
-
- self.is_hover = False
- self.is_enabled = True
- self.colors = colors or ButtonColors()
- self.text_style = text_style or TextStyle(font_size=min(height - 4, 32))
-
- # If hover border isn't given, keep border consistent with normal foreground.
- if self.colors.hover_foreground is None:
- self.colors.hover_foreground = self.colors.foreground
-
- # Create text child if provided
- if text:
- self.text = Text(
- text,
- x=0.5, y=0.5,
- x_is_percent=True,
- y_is_percent=True,
- x_align="center",
- y_align="center",
- style=self.text_style
- )
- # Inherit initial state
- self.text.set_is_enabled(self.is_enabled)
- self.text.set_is_hover(self.is_hover)
- self.add_child(self.text)
- else:
- self.text = None
-
- @property
- def debug_outline_color(self) -> pygame.Color:
- return pygame.Color(0, 255, 0)
-
- def _point_in_diamond(self, x: int, y: int) -> bool:
- abs_x, abs_y, abs_w, abs_h = self.get_absolute_geometry()
- rel_x = x - abs_x
- rel_y = y - abs_y
- cx = abs_w / 2
- cy = abs_h / 2
- return (abs(rel_x - cx) / cx + abs(rel_y - cy) / cy) <= 1.0
-
- def _get_diamond_points(self) -> list:
- abs_x, abs_y, abs_w, abs_h = self.get_absolute_geometry()
- cx = abs_x + abs_w / 2
- cy = abs_y + abs_h / 2
- return [
- (cx, abs_y), # top
- (abs_x + abs_w, cy), # right
- (cx, abs_y + abs_h), # bottom
- (abs_x, cy) # left
- ]
-
- def contains_point(self, x: int, y: int) -> bool:
- if self.shape == ButtonShape.DIAMOND:
- return self._point_in_diamond(x, y)
- return super().contains_point(x, y)
-
- def on_click(self, button=None):
- if not self.is_enabled:
- return
- args = self.args_provider() if self.args_provider else self.args
- self.function_to_call(*args)
-
- def on_hover_enter(self):
- self.is_hover = True
- if self.text:
- self.text.set_is_hover(True)
-
- def on_hover_leave(self):
- self.is_hover = False
- if self.text:
- self.text.set_is_hover(False)
-
- def set_enabled(self, enabled: bool) -> None:
- if self.is_enabled != enabled:
- self.is_enabled = enabled
- if self.text:
- self.text.set_is_enabled(enabled)
-
- def set_text(self, text: str) -> None:
- if self.text:
- self.text.set_text(text)
- elif text:
- self.text = Text(
- text,
- x=0.5, y=0.5,
- x_is_percent=True,
- y_is_percent=True,
- x_align="center",
- y_align="center",
- style=self.text_style,
- )
- self.text.set_is_enabled(self.is_enabled)
- self.text.set_is_hover(self.is_hover)
- self.add_child(self.text)
-
- def set_shape(self, shape: ButtonShape) -> None:
- self.shape = shape
-
- def _resolve_colors(self):
- """Compute bg and border colors based on state (text handled by Text)."""
- if not self.is_enabled:
- return self.colors.disabled_background, self.colors.disabled_foreground
- if self.is_hover:
- return self.colors.hover_background, (self.colors.hover_foreground or self.colors.foreground)
- return self.colors.background, self.colors.foreground
-
- def draw(self, surface: pygame.Surface) -> None:
- if self.is_effectively_hidden:
- return
-
- abs_x, abs_y, abs_w, abs_h = self.get_absolute_geometry()
- bg_color, border_color = self._resolve_colors()
-
- # Draw geometry
- if self.shape == ButtonShape.DIAMOND:
- points = self._get_diamond_points()
- pygame.draw.polygon(surface, bg_color, points)
- pygame.draw.polygon(surface, border_color, points, 2)
- else:
- pygame.draw.rect(surface, bg_color, (abs_x, abs_y, abs_w, abs_h))
- pygame.draw.rect(surface, border_color, (abs_x, abs_y, abs_w, abs_h), 2)
-
- # Draw children
- for child in self.children:
- child.draw(surface)
diff --git a/UI/input/button_icon.py b/UI/input/button_icon.py
deleted file mode 100644
index e6c8900..0000000
--- a/UI/input/button_icon.py
+++ /dev/null
@@ -1,106 +0,0 @@
-import pygame
-from UI.frame import Frame
-
-
-def _load_surface(img_or_path: pygame.Surface | str) -> pygame.Surface:
- """Load and convert a surface from either a path or an existing Surface."""
- if isinstance(img_or_path, pygame.Surface):
- return img_or_path.convert_alpha()
- return pygame.image.load(img_or_path).convert_alpha()
-
-
-def _recolor_by_alpha_mask(src: pygame.Surface,
- fill_rgba: tuple[int, int, int, int]) -> pygame.Surface:
- """
- Recolor a monochrome/black symbol using ONLY its alpha as a mask.
- All nontransparent pixels become `fill_rgba` with their original per-pixel alpha.
- """
- out = pygame.Surface(src.get_size(), pygame.SRCALPHA)
- out.fill(fill_rgba)
-
- try:
- # Fast path with NumPy/surfarray
- import pygame.surfarray as sarr # numpy-backed
- alpha_src = sarr.pixels_alpha(src).copy() # copy to avoid locking issues
- sarr.pixels_alpha(out)[:, :] = alpha_src
- except Exception:
- # Fallback: per-pixel (a bit slower, but fine for icons)
- w, h = src.get_size()
- try:
- for y in range(h):
- for x in range(w):
- a = src.get_at((x, y)).a
- if a: # only where alpha > 0
- r, g, b, _ = fill_rgba
- out.set_at((x, y), pygame.Color(r, g, b, a))
- finally:
- out.unlock()
- src.unlock()
- return out
-
-
-
-class ButtonIcon(Frame):
- """
- A child widget that displays an image on top of a Button, recoloring a
- specific color depending on hover state.
- """
-
- def __init__(
- self,
- parent_button, # your Button instance
- image: pygame.Surface | str, # path or loaded surface
- *,
- normal_replace: tuple[int, int, int, int],
- hover_replace: tuple[int, int, int, int],
- size: tuple[int, int] | None = None, # (width, height) in px
- inset_px: int = 0, # optional inset padding
- z_index: int = 10 # draw order
- ):
- # Fills parent’s area by default
- super().__init__(parent=parent_button,
- x=0, y=0, width=1.0, height=1.0,
- x_is_percent=True, y_is_percent=True,
- width_is_percent=True, height_is_percent=True,
- z_index=z_index,
- padding=(inset_px, inset_px, inset_px, inset_px))
-
- self.mouse_passthrough = True
- self._size = size
- base = _load_surface(image)
- self._img_normal = _recolor_by_alpha_mask(base, normal_replace)
- self._img_hover = _recolor_by_alpha_mask(base, hover_replace)
- self._img_disabled = self._make_disabled(self._img_normal)
-
- def _make_disabled(self, surf: pygame.Surface) -> pygame.Surface:
- """Dim the image for disabled state."""
- out = surf.copy()
- out.fill((255, 255, 255, 153), special_flags=pygame.BLEND_RGBA_MULT)
- return out
-
- def draw(self, surface: pygame.Surface) -> None:
- if self.is_effectively_hidden:
- return
-
- parent = self.parent
- if not parent.is_enabled:
- img = self._img_disabled
- elif hasattr(parent, "is_hover") and parent.is_hover:
- img = self._img_hover
- else:
- img = self._img_normal
-
- inner_x, inner_y, inner_w, inner_h = self.get_content_geometry()
-
- if self._size:
- tw, th = self._size
- blit_img = pygame.transform.smoothscale(img, (tw, th))
- else:
- # default: fit inside content area
- blit_img = pygame.transform.smoothscale(
- img, (inner_w, inner_h)
- )
-
- bx = inner_x + (inner_w - blit_img.get_width()) // 2
- by = inner_y + (inner_h - blit_img.get_height()) // 2
- surface.blit(blit_img, (bx, by))
diff --git a/UI/input/radio.py b/UI/input/radio.py
deleted file mode 100644
index 8b2c761..0000000
--- a/UI/input/radio.py
+++ /dev/null
@@ -1,185 +0,0 @@
-import pygame
-from typing import Callable, Optional, Any, List
-from dataclasses import dataclass, field
-
-from UI.input.button import Button, ButtonColors
-from UI.text import TextStyle
-
-@dataclass
-class SelectedColors:
- """Optional override palette when a RadioButton is selected."""
- background: Optional[pygame.Color] = None
- hover_background: Optional[pygame.Color] = None
- foreground: Optional[pygame.Color] = None # border color when selected
- hover_foreground: Optional[pygame.Color] = None
-
-class RadioGroup:
- """
- Manages an exclusive group of RadioButtons.
- - Only one RadioButton can be selected at a time.
- - Optionally allow deselection by clicking the already-selected button.
- """
- def __init__(
- self,
- allow_deselect: bool = False,
- on_change: Optional[Callable[[Optional["RadioButton"]], None]] = None
- ):
- self._buttons: List["RadioButton"] = []
- self._selected: Optional["RadioButton"] = None
- self.allow_deselect = allow_deselect
- self.on_change = on_change
-
- def add(self, btn: "RadioButton") -> None:
- if btn not in self._buttons:
- self._buttons.append(btn)
- btn._group = self
- if btn.is_selected:
- self.select(btn, fire=False)
-
- def remove(self, btn: "RadioButton") -> None:
- if btn in self._buttons:
- was_selected = (btn is self._selected)
- self._buttons.remove(btn)
- btn._group = None
- if was_selected:
- self._selected = None
- if self.on_change:
- self.on_change(None)
-
- def select(self, btn: Optional["RadioButton"], fire: bool = True) -> None:
- if btn is None:
- if self._selected:
- self._selected._set_selected(False)
- self._selected = None
- if fire and self.on_change:
- self.on_change(None)
- return
-
- if btn not in self._buttons:
- self.add(btn)
-
- if self._selected is btn:
- if self.allow_deselect:
- # Deselect current
- btn._set_selected(False)
- self._selected = None
- if fire and self.on_change:
- self.on_change(None)
- # else: clicking again does nothing
- return
-
- # Deselect previous
- if self._selected:
- self._selected._set_selected(False)
-
- # Select new
- btn._set_selected(True)
- self._selected = btn
- if fire and self.on_change:
- self.on_change(btn)
-
- def get_selected(self) -> Optional["RadioButton"]:
- return self._selected
-
- def get_value(self) -> Optional[Any]:
- return self._selected.value if self._selected else None
-
- def set_value(self, value: Any, fire: bool = True) -> None:
- for b in self._buttons:
- if b.value == value:
- self.select(b, fire=fire)
- return
- # If value not found, deselect all
- self.select(None, fire=fire)
-
-
-class RadioButton(Button):
- """
- A Button that participates in a RadioGroup.
- - Clicking selects this button in its group (exclusive).
- - Appearance changes when selected (via SelectedColors).
- - You can still pass a per-button callback (function_to_call).
- """
- def __init__(
- self,
- function_to_call,
- x, y, width, height,
- text: str = "",
- *,
- value: Any = None,
- group: Optional[RadioGroup] = None,
- selected: bool = False,
- colors: Optional[ButtonColors] = None,
- selected_colors: Optional[SelectedColors] = None,
- text_style: Optional[TextStyle] = None,
- **frame_kwargs
- ):
- super().__init__(
- function_to_call=function_to_call,
- x=x, y=y, width=width, height=height,
- text=text, colors=colors, text_style=text_style, **frame_kwargs
- )
- self.value = value if value is not None else text
- self._group: Optional[RadioGroup] = None
- self._is_selected: bool = False
- self._selected_colors = selected_colors or SelectedColors()
-
- if group:
- group.add(self)
- if selected:
- # defer to group to enforce exclusivity
- (group or self._group).select(self, fire=False)
-
- # --- Public API ---
- @property
- def is_selected(self) -> bool:
- return self._is_selected
-
- def set_selected(self, selected: bool, fire: bool = True) -> None:
- """
- Ask the group to set selection (preferred).
- If no group, sets locally.
- """
- if self._group:
- self._group.select(self if selected else None, fire=fire)
- else:
- self._set_selected(selected)
-
- # --- Internal state change (no group notifications) ---
- def _set_selected(self, selected: bool) -> None:
- if self._is_selected == selected:
- return
- self._is_selected = selected
- # If you want to adjust child Text style on selection, you can do it here.
-
- # --- Button overrides ---
- def on_click(self, button=None):
- if not self.is_enabled:
- return
- # Selection logic first (so callbacks see the new state)
- if self._group:
- self._group.select(self)
- else:
- self._set_selected(True)
-
- # Optional per-button callback
- super().on_click(button)
-
- def _resolve_colors(self):
- """
- Extend Button color resolution to account for selected state.
- We only override backgrounds/borders—your Text color stays independent.
- """
- base_bg, base_border = super()._resolve_colors()
- if not self._is_selected:
- return base_bg, base_border
-
- # Selected palette with graceful fallback to base colors
- bg = self._selected_colors.background or base_bg
- fg = self._selected_colors.foreground or base_border
-
- if self.is_hover:
- bg = self._selected_colors.hover_background or bg
- fg = self._selected_colors.hover_foreground or fg
-
- return bg, fg
diff --git a/UI/input/scroll_frame.py b/UI/input/scroll_frame.py
deleted file mode 100644
index 11f5ccb..0000000
--- a/UI/input/scroll_frame.py
+++ /dev/null
@@ -1,306 +0,0 @@
-# UI/input/scroll_frame.py
-import pygame
-from UI.frame import Frame
-
-class ScrollbarV(Frame):
- """Vertical scrollbar child for a ScrollFrame (right-side strip)."""
- def __init__(
- self,
- *,
- parent: "ScrollFrame",
- width: int = 12,
- track_color: pygame.Color = pygame.Color("#d7d7d7"),
- thumb_color: pygame.Color = pygame.Color("#9a9a9a"),
- thumb_min_px: int = 24,
- z_index: int = 1000,
- ):
- super().__init__(
- parent=parent,
- x=0, y=0,
- width=width, height=1.0,
- width_is_percent=False, height_is_percent=True,
- x_align="right", y_align="top",
- z_index=z_index,
- background_color=None,
- )
- self._dragging = False
- self._drag_offset = 0
- self.track_color = track_color
- self.thumb_color = thumb_color
- self.thumb_min_px = thumb_min_px
-
- # --- helpers that query parent metrics ---
- @property
- def _sf(self) -> "ScrollFrame":
- return self.parent # type: ignore[return-value]
-
- def _viewport_h(self) -> int:
- return self._sf._viewport_height()
-
- def _content_h(self) -> int:
- return self._sf._content_height()
-
- def _track_rect(self) -> pygame.Rect:
- x, y, w, h = self.get_absolute_geometry()
- return pygame.Rect(x, y, w, h)
-
- def _thumb_h(self) -> int:
- vh = self._viewport_h()
- ch = self._content_h()
- if ch <= vh:
- return vh
- return max(int(vh * (vh / ch)), self.thumb_min_px)
-
- def _thumb_rect(self) -> pygame.Rect:
- track = self._track_rect()
- vh = self._viewport_h()
- ch = self._content_h()
-
- if ch <= vh:
- # No overflow: fill track (thumb == track)
- return pygame.Rect(track.x, track.y, track.w, track.h)
-
- thumb_h = self._thumb_h()
- track_h = track.h - thumb_h
- ratio = self._sf.scroll_y / (ch - vh) if ch > vh else 0.0
- thumb_y = track.y + int(ratio * track_h)
- return pygame.Rect(track.x, thumb_y, track.w, thumb_h)
-
- def _set_scroll_from_thumb_y(self, thumb_y: int):
- track = self._track_rect()
- vh = self._viewport_h()
- ch = self._content_h()
- if ch <= vh:
- self._sf._set_scroll(0)
- return
- thumb_h = self._thumb_h()
- track_h = track.h - thumb_h
- ratio = (thumb_y - track.y) / track_h if track_h > 0 else 0.0
- self._sf._set_scroll(ratio * (ch - vh))
-
- # --- input handling ---
- def process_mouse_press(self, px, py, button):
- if self.is_effectively_hidden:
- return
- if button == "left":
- thumb = self._thumb_rect()
- track = self._track_rect()
- if thumb.collidepoint(px, py):
- self._dragging = True
- self._drag_offset = py - thumb.y
- return
- if track.collidepoint(px, py):
- # Jump to click position and begin drag
- new_y = py - self._thumb_h() // 2
- new_y = max(track.y, min(new_y, track.bottom - self._thumb_h()))
- self._set_scroll_from_thumb_y(new_y)
- self._dragging = True
- self._drag_offset = py - self._thumb_rect().y
- return
- # Not on the scrollbar; no need to route further because ScrollFrame will.
-
- def process_mouse_move(self, px, py):
- if self._dragging:
- track = self._track_rect()
- th = self._thumb_h()
- new_y = max(track.y, min(py - self._drag_offset, track.bottom - th))
- self._set_scroll_from_thumb_y(new_y)
-
- def process_mouse_release(self, px, py, button):
- self._dragging = False
-
- # --- drawing ---
- def draw(self, surface: pygame.Surface) -> None:
- if self.is_effectively_hidden:
- return
-
- # track
- track = self._track_rect()
- pygame.draw.rect(surface, self.track_color, track)
-
- # thumb
- thumb = self._thumb_rect()
- pygame.draw.rect(surface, self.thumb_color, thumb)
- pygame.draw.rect(surface, pygame.Color(0, 0, 0), thumb, 1)
-
-
-class ScrollFrame(Frame):
- """Vertical-only scrollable container with a dedicated right-side ScrollbarV child."""
- def __init__(
- self,
- *,
- parent,
- x=0, y=0, width=100, height=100,
- scroll_speed=30,
- scrollbar_width=12,
- background_color=pygame.Color("#f5f5f5"),
- track_color=pygame.Color("#d7d7d7"),
- thumb_color=pygame.Color("#9a9a9a"),
- thumb_min_px=24,
- bottom_padding = 10,
- z_index=0,
- **kwargs
- ):
- # Prevent overridden add_child from running before content exists
- self._initializing = True
- super().__init__(
- parent=parent, x=x, y=y, width=width, height=height,
- background_color=background_color, z_index=z_index,
- **kwargs
- )
-
- self.scroll_y = 0
- self.scroll_speed = scroll_speed
- self.scrollbar_width = scrollbar_width
- self.bottom_padding = bottom_padding
-
- # Content container (everything the user adds goes here)
- self.content = Frame(
- parent=None,
- x=0, y=0,
- width=1.0, height=1.0,
- width_is_percent=True, height_is_percent=True,
- z_index=z_index # below scrollbar
- )
- # Attach directly then sort
- self.content.parent = self
- self.children.append(self.content)
-
- # Scrollbar child (high z so it wins hit-testing)
- self.scrollbar = ScrollbarV(
- parent=self,
- width=scrollbar_width,
- track_color=track_color,
- thumb_color=thumb_color,
- thumb_min_px=thumb_min_px,
- z_index=z_index + 999 # ensure on top
- )
-
- self._initializing = False
-
- # Route user children into the content frame
- def add_child(self, child):
- if self._initializing:
- return super().add_child(child)
- return self.content.add_child(child)
-
- # --- geometry + layout ---
- def _viewport_rect(self) -> pygame.Rect:
- x, y, w, h = self.get_absolute_geometry()
- # If scrollbar is hidden, content gets full width
- width = w if getattr(self.scrollbar, "is_hidden", False) else max(0, w - self.scrollbar_width)
- return pygame.Rect(x, y, width, h)
-
- def _viewport_height(self) -> int:
- return self.get_absolute_geometry()[3]
-
- def _layout(self):
- """Two-pass layout so resizes clamp scroll and scrollbar hides/shows instantly."""
- abs_x, abs_y, abs_w, abs_h = self.get_absolute_geometry()
-
- # 1) Assume no scrollbar first
- self.scrollbar.is_hidden = True
- self._apply_content_geometry(abs_w, abs_h)
-
- # 2) Check if we actually need scrolling
- need_scroll = self._content_height() > abs_h
- if need_scroll:
- # With scrollbar shown, viewport gets narrower
- self.scrollbar.is_hidden = False
- self._apply_content_geometry(max(0, abs_w - self.scrollbar_width), abs_h)
-
- # 3) Clamp scroll after any size change
- self._clamp_scroll()
-
- def _content_height(self) -> int:
- """Total vertical extent of content children relative to content's top."""
- if not self.content.children:
- return self._viewport_height()
-
- _, content_abs_top, _, _ = self.content.get_absolute_geometry()
- max_bottom_rel = 0
- for ch in self.content.children:
- _, ch_abs_y, _, ch_h = ch.get_absolute_geometry()
- bottom_rel = (ch_abs_y + ch_h) - content_abs_top
- if bottom_rel > max_bottom_rel:
- max_bottom_rel = bottom_rel
-
- return max(max_bottom_rel + self.bottom_padding, self._viewport_height())
-
-
- def _max_scroll(self) -> int:
- return max(0, self._content_height() - self._viewport_height())
-
- def _clamp_scroll(self) -> None:
- """Keep scroll_y valid after size changes (e.g., window expand)."""
- max_scroll = self._max_scroll()
- new_y = min(max(self.scroll_y, 0), max_scroll)
- if new_y != self.scroll_y:
- self.scroll_y = new_y
- self.content.y = -self.scroll_y
-
- def _apply_content_geometry(self, viewport_w: int, viewport_h: int) -> None:
- self.content.width_is_percent = False
- self.content.height_is_percent = False
- self.content.x = 0
- self.content.y = -self.scroll_y
- self.content.width = viewport_w
- self.content.height = max(viewport_h, self._content_height())
-
- # --- scroll core ---
- def _set_scroll(self, value: int | float):
- max_scroll = max(0, self._content_height() - self._viewport_height())
- self.scroll_y = max(0, min(int(value), max_scroll))
- self.content.y = -self.scroll_y
-
- # --- input routing: run layout first so geometry is up-to-date ---
- def process_mouse_press(self, px, py, button):
- self._layout()
- # Children (including scrollbar) handle their own input via z-index ordering
- super().process_mouse_press(px, py, button)
-
- def process_mouse_move(self, px, py):
- self._layout()
- # If the scrollbar is dragging, capture the move
- if self.scrollbar._dragging:
- self.scrollbar.process_mouse_move(px, py)
- return
- super().process_mouse_move(px, py)
-
- def process_mouse_release(self, px, py, button):
- self._layout()
- # If the scrollbar started a drag, ensure it gets the release
- if self.scrollbar._dragging:
- self.scrollbar.process_mouse_release(px, py, button)
- return
- super().process_mouse_release(px, py, button)
-
- def on_wheel(self, dx: int, dy: int, px: int, py: int) -> None:
- # Keep layout fresh for correct hit-tests
- self._layout()
-
- # Only react if the mouse is over the visible viewport or the scrollbar track
- if not (self._viewport_rect().collidepoint(px, py) or self.scrollbar._track_rect().collidepoint(px, py)):
- return
-
- # Pygame: positive dy == wheel up
- self._set_scroll(self.scroll_y - dy * self.scroll_speed)
-
- # --- drawing ---
- def draw(self, surface: pygame.Surface) -> None:
- if self.is_effectively_hidden:
- return
-
- self._layout()
- abs_x, abs_y, abs_w, abs_h = self.get_absolute_geometry()
-
- if self.background_color:
- pygame.draw.rect(surface, self.background_color, (abs_x, abs_y, abs_w, abs_h))
-
- old_clip = surface.get_clip()
- surface.set_clip(self._viewport_rect())
- self.content.draw(surface)
- surface.set_clip(old_clip)
-
- if not self.scrollbar.is_hidden:
- self.scrollbar.draw(surface)
diff --git a/UI/input/slider.py b/UI/input/slider.py
deleted file mode 100644
index e6df16e..0000000
--- a/UI/input/slider.py
+++ /dev/null
@@ -1,244 +0,0 @@
-# slider.py
-import pygame
-from typing import Callable, Optional
-from UI.frame import Frame
-from UI.text import TextStyle
-from UI.input.button import Button, ButtonColors
-
-class Slider(Frame):
- def __init__(
- self,
- min_value: float,
- max_value: float,
- x: int,
- y: int,
- width: int,
- height: int,
- initial_value: Optional[float] = None,
- on_change: Optional[Callable[[float], None]] = None,
- tick_count: int = 2, # 0 or >=2; 1 coerced to 2
- track_color: Optional[pygame.Color] = None,
- tick_color: Optional[pygame.Color] = None,
- knob_fill: Optional[pygame.Color] = None,
- knob_border_color: Optional[pygame.Color] = None,
- knob_hover_fill: Optional[pygame.Color] = None,
- knob_hover_border_color: Optional[pygame.Color] = None,
- with_buttons: bool = False,
- step: float = 1.0,
- **frame_kwargs
- ):
- super().__init__(x=x, y=y, width=width, height=height, **frame_kwargs)
-
- # --- values / callback ---
- self.min_value = min_value
- self.max_value = max_value
- self.value = min_value if initial_value is None else max(min_value, min(max_value, initial_value))
- self.on_change = on_change
-
- # --- ticks ---
- if tick_count < 0:
- tick_count = 0
- if tick_count == 1:
- tick_count = 2
- self.tick_count = tick_count
-
- # --- visuals ---
- self.track_height = 4
- self.knob_width = 10
- self.knob_border = 2
-
- self.track_color = track_color or pygame.Color("#b3b4b6")
- self.tick_color = tick_color or self.track_color
- self.knob_fill = knob_fill or pygame.Color("#b3b4b6")
- self.knob_border_color = pygame.Color(knob_border_color) if knob_border_color else pygame.Color("#5a5a5a")
-
- # hover styles
- self.knob_hover_fill = knob_hover_fill or pygame.Color("#5a5a5a")
- self.knob_hover_border_color = pygame.Color(knob_hover_border_color) if knob_hover_border_color else pygame.Color("#5a5a5a")
-
- # --- interaction ---
- self.dragging = False
- self.knob_hover = False
-
- # --- optional +/- buttons (fixed layout, no resize adjustments) ---
- self.with_buttons = with_buttons
- self.step = step
- self.btn_w = 0
- self.btn_margin = 4
- self.left_button: Optional[Button] = None
- self.right_button: Optional[Button] = None
-
- if self.with_buttons:
- self.btn_w = min(height, 16)
- text_style = TextStyle(
- hover_color=pygame.Color("#5a5a5a"),
- font_size=24
- )
-
- # White background & border, keep text black
- btn_colors = ButtonColors(
- background=pygame.Color("#f5f5f5"),
- hover_background=pygame.Color("#f5f5f5"),
- disabled_background=pygame.Color("#f5f5f5"),
-
- foreground=pygame.Color("#f5f5f5"),
- hover_foreground=pygame.Color("#f5f5f5"),
- disabled_foreground=pygame.Color("#f5f5f5")
- )
-
- self.left_button = Button(
- self._decrement, 0, 0, self.btn_w, height, text_style=text_style,
- text="-", colors=btn_colors, parent=self
- )
- self.right_button = Button(
- self._increment, width - self.btn_w, 0, self.btn_w, height, text_style=text_style,
- text="+", colors=btn_colors, parent=self
- )
-
- # ===== Public helpers (so external buttons can also drive the slider) =====
- def increment(self, amount: Optional[float] = None):
- self._bump(+ (amount if amount is not None else self.step))
-
- def decrement(self, amount: Optional[float] = None):
- self._bump(- (amount if amount is not None else self.step))
-
- def set_value(self, v: float, *, notify: bool = False) -> None:
- """
- Programmatically set the slider's value, clamped to [min_value, max_value].
- If notify=True, fire on_change if the value actually changed.
- """
- v = max(self.min_value, min(self.max_value, float(v)))
- old = self.value
- self.value = v
- if notify and self.on_change and self.value != old:
- self.on_change(self.value)
-
- # ===== Internal helpers =====
- def _bump(self, delta: float):
- old = self.value
- self.value = max(self.min_value, min(self.max_value, self.value + delta))
- if self.on_change and self.value != old:
- self.on_change(self.value)
-
- def _increment(self):
- self._bump(self.step)
-
- def _decrement(self):
- self._bump(-self.step)
-
- def _track_rect(self):
- """Return (track_x, track_y, track_w, abs_h) for the inner track.
- If buttons are enabled, we reserve left/right gutters."""
- abs_x, abs_y, abs_w, abs_h = self.get_absolute_geometry()
- if self.with_buttons:
- track_x = abs_x + self.btn_w + self.btn_margin
- track_w = max(1, abs_w - 2 * (self.btn_w + self.btn_margin))
- else:
- track_x = abs_x
- track_w = max(1, abs_w)
- track_y = abs_y + abs_h // 2 - self.track_height // 2
- return track_x, track_y, track_w, abs_h
-
- # --- value <-> position mapped to inner track ---
- def _value_to_pos(self) -> int:
- track_x, _, track_w, _ = self._track_rect()
- if self.max_value == self.min_value:
- return track_x
- ratio = (self.value - self.min_value) / (self.max_value - self.min_value)
- return track_x + int(ratio * track_w)
-
- def _pos_to_value(self, px: int) -> float:
- track_x, _, track_w, _ = self._track_rect()
- if track_w <= 0 or self.max_value == self.min_value:
- return self.min_value
- ratio = (px - track_x) / track_w
- ratio = max(0.0, min(1.0, ratio))
- return self.min_value + ratio * (self.max_value - self.min_value)
-
- # ===== Events =====
- def on_mouse_press(self, button):
- if button == "left":
- self.dragging = True
-
- def on_mouse_release(self, button):
- if button == "left":
- self.dragging = False
-
- def process_mouse_move(self, px, py):
- # Keep your existing hover bookkeeping
- super().process_mouse_move(px, py)
-
- # Purely visual hover for the knob
- if self.is_hovered:
- track_x, _, _, abs_h = self._track_rect()
- knob_center = self._value_to_pos()
- knob_x = knob_center - self.knob_width // 2
- abs_x, abs_y, _, _ = self.get_absolute_geometry()
- self.knob_hover = (knob_x <= px <= knob_x + self.knob_width) and (abs_y <= py <= abs_y + abs_h)
- else:
- self.knob_hover = False
-
- def on_hover_leave(self):
- # Only clear highlight; do not cancel a live drag
- self.knob_hover = False
- if not pygame.mouse.get_pressed()[0]:
- self.dragging = False
-
- # ===== Drawing =====
- def draw(self, surface: pygame.Surface) -> None:
- if self.is_effectively_hidden:
- return
-
- # Keep dragging even when mouse leaves the slider, stop on global button up
- if self.dragging:
- left_down = pygame.mouse.get_pressed()[0]
- if not left_down:
- self.dragging = False
- else:
- mx, _ = pygame.mouse.get_pos()
- old = self.value
- self.value = self._pos_to_value(mx)
- if self.on_change and self.value != old:
- self.on_change(self.value)
-
- # Draw track
- track_x, track_y, track_w, abs_h = self._track_rect()
- pygame.draw.rect(surface, self.track_color, (track_x, track_y, track_w, self.track_height))
-
- # Ticks
- if self.tick_count >= 2:
- base_tick_h = max(6, min(12, abs_h // 2))
- end_tick_h = int(base_tick_h * 1.5)
- for i in range(self.tick_count):
- t = i / (self.tick_count - 1)
- tx = track_x + int(t * track_w)
- tick_h = end_tick_h if i in (0, self.tick_count - 1) else base_tick_h
- top = track_y - tick_h // 2
- bottom = top + tick_h
- pygame.draw.line(
- surface,
- self.tick_color,
- (tx, top),
- (tx, bottom + self.track_height),
- int(self.track_height * 0.75)
- )
-
- # Knob (rectangle) with hover style
- knob_x_center = self._value_to_pos()
- knob_x = knob_x_center - self.knob_width // 2
- abs_x, abs_y, _, _ = self.get_absolute_geometry()
- knob_y = abs_y
-
- if self.knob_hover:
- fill_color = self.knob_hover_fill
- border_color = self.knob_hover_border_color
- else:
- fill_color = self.knob_fill
- border_color = self.knob_border_color
-
- # Draw children (the +/- buttons)
- for child in self.children:
- child.draw(surface)
-
- pygame.draw.rect(surface, fill_color, (knob_x, knob_y, self.knob_width, abs_h))
- pygame.draw.rect(surface, border_color, (knob_x, knob_y, self.knob_width, abs_h), self.knob_border)
diff --git a/UI/input/text_field.py b/UI/input/text_field.py
deleted file mode 100644
index fb80028..0000000
--- a/UI/input/text_field.py
+++ /dev/null
@@ -1,425 +0,0 @@
-import unicodedata
-import re
-
-import pygame
-from UI.frame import Frame
-from UI.text import Text, TextStyle
-
-class TextField(Frame):
- def __init__(self, parent=None, x=0, y=0, width=200, height=30,
- placeholder="", style=None,
- background_color=pygame.Color("white"),
- text_color=pygame.Color("black"),
- border_color=pygame.Color("black"),
- padding=5,
- on_text_change=None, allowed_pattern: str = None, on_commit=None,
- **kwargs):
- super().__init__(parent=parent, x=x, y=y, width=width, height=height,
- background_color=background_color, **kwargs)
-
- self.active = False
- self.placeholder = placeholder
- self.text = ""
- self.style = style or TextStyle(color=text_color, font_size=18)
- self.border_color = border_color
-
- # Callbacks
- self.on_text_change = on_text_change
- self.allowed_pattern = re.compile(allowed_pattern) if allowed_pattern else None
- self.on_commit = on_commit
-
- # Rendered text element inside the field
- self._text = Text(self.placeholder, parent=self, x=padding, y=height // 2,
- x_align="left", y_align="center", style=self.style)
-
- # Caret state
- self._padding = padding
- self._caret_index = 0
- self._caret_visible = True
- self._blink_interval_ms = 500
- self._last_blink_ms = pygame.time.get_ticks()
- self._scroll_px = 0
-
- # --- key repeat state ---
- self._repeat_key = None
- self._repeat_delay_ms = 350 # first repeat delay (ms)
- self._repeat_interval_ms = 40 # subsequent repeats (ms)
- self._next_repeat_ms = 0
-
- # --- focus management: respond to global clicks anywhere ---
- def on_global_mouse_press(self, px, py, button):
- if self.is_effectively_hidden:
- return
-
- was_active = self.active
- self.active = self.contains_point(px, py)
-
- if self.active and not was_active:
- self._caret_index = len(self.text)
- self._reset_blink()
- elif not self.active and was_active:
- if self.on_commit:
- self.on_commit(self.text)
- self._caret_visible = False
- self._repeat_key = None
-
-
- def _decode_clip_bytes(self, raw: bytes) -> str:
- """Best-effort decode for clipboard bytes from pygame.scrap/SDL."""
- # BOMs first
- if raw.startswith(b'\xff\xfe') or raw.startswith(b'\xfe\xff'):
- try:
- return raw.decode('utf-16')
- except Exception:
- pass
- # Heuristic: lots of NULs => UTF-16 without BOM (Windows CF_UNICODETEXT)
- if raw and raw[1:2] == b'\x00' or raw.count(b'\x00') >= max(1, len(raw)//4):
- for enc in ('utf-16-le', 'utf-16-be'):
- try:
- return raw.decode(enc)
- except Exception:
- continue
- # Try utf-8, then latin1 as last resort
- for enc in ('utf-8', 'latin1'):
- try:
- return raw.decode(enc)
- except Exception:
- continue
- # Fallback replace to avoid exceptions
- return raw.decode('utf-8', errors='replace')
-
- def _sanitize_paste_text(self, s: str) -> str:
- """Normalize, strip invisibles, and make it safe for a single-line field."""
- # Normalize
- s = unicodedata.normalize('NFC', s)
-
- # Replace non-breaking spaces and other common whitespace oddities with space
- s = s.replace('\u00A0', ' ') # nbsp
- s = s.replace('\u2007', ' ') # figure space
- s = s.replace('\u202F', ' ') # narrow nbsp
-
- # Drop zero-width and format chars (keep newline handling for now)
- # Cf (format), Cc (control) — but allow \n and \t to survive for next step
- s = ''.join(ch for ch in s if not (
- (unicodedata.category(ch) in ('Cf', 'Cc')) and ch not in ('\n', '\t')
- ))
-
- # Optional: map curly quotes if your font lacks them
- # Comment these out if your font supports U+2018/2019 properly
- s = s.replace('‘', "'").replace('’', "'").replace('“', '"').replace('”', '"')
-
- # Normalize line endings and collapse to single line for this widget
- s = s.replace('\r\n', '\n').replace('\r', '\n').replace('\t', ' ')
- s = " ".join(s.splitlines()) # turns any newlines into single spaces
-
- # Trim trailing exotic whitespace that might still linger
- s = re.sub(r'\s+$', ' ', s) # keep one space if user had one at end
- return s
-
- # --- Paste functionality ---
- def _insert_text(self, s: str):
- if not s:
- return
- s = self._sanitize_paste_text(s)
- if not s:
- return
-
- # Validate pasted text
- candidate = self.text[:self._caret_index] + s + self.text[self._caret_index:]
- if self.allowed_pattern and not self.allowed_pattern.fullmatch(candidate):
- return # reject paste if it violates the pattern
-
- self.text = self.text[:self._caret_index] + s + self.text[self._caret_index:]
- self._caret_index += len(s)
- self._refresh()
- self._reset_blink()
- self._ensure_caret_visible()
- if getattr(self, "_repeat_key", None) == pygame.K_BACKSPACE:
- self._repeat_key = None
-
- def _get_clipboard_text(self) -> str:
- """Clipboard : try pygame.scrap, then pygame.clipboard; robust decoding."""
- # 1) pygame.scrap
- try:
- if hasattr(pygame, "scrap"):
- if not pygame.scrap.get_init():
- pygame.scrap.init()
- raw = (
- pygame.scrap.get("text/plain;charset=utf-8")
- or pygame.scrap.get("text/plain")
- or pygame.scrap.get(getattr(pygame, "SCRAP_TEXT", "text/plain"))
- )
- if raw:
- if isinstance(raw, bytes):
- return self._decode_clip_bytes(raw)
- return str(raw)
- except Exception:
- pass
-
- # 2) SDL clipboard
- try:
- if hasattr(pygame, "clipboard"):
- txt = pygame.clipboard.get_text()
- if isinstance(txt, bytes):
- return self._decode_clip_bytes(txt)
- return txt or ""
- except Exception:
- pass
-
- return ""
-
- # --- typing: react to KEYDOWN + KEYUP ---
- def on_key_event(self, event):
- if not self.active or self.is_effectively_hidden:
- return
-
- # handle KEYUP to stop repeat
- if event.type == pygame.KEYUP:
- if event.key == self._repeat_key:
- self._repeat_key = None
- return
-
- if event.type != pygame.KEYDOWN:
- return
-
- mods = getattr(event, "mod", 0)
- is_ctrl_or_cmd = bool(mods & (pygame.KMOD_CTRL | pygame.KMOD_META))
- is_shift = bool(mods & pygame.KMOD_SHIFT)
-
- # Paste: Ctrl/Cmd+V or Shift+Insert
- if (event.key == pygame.K_v and is_ctrl_or_cmd) or (event.key == pygame.K_INSERT and is_shift):
- pasted = self._get_clipboard_text()
- if pasted:
- self._insert_text(pasted)
- return
-
- if event.key == pygame.K_RETURN:
- self.active = False
- self._caret_visible = False
- self._repeat_key = None
- return
-
- if event.key == pygame.K_BACKSPACE:
- self._do_backspace()
- # start repeat timing
- now = pygame.time.get_ticks()
- self._repeat_key = pygame.K_BACKSPACE
- self._next_repeat_ms = now + self._repeat_delay_ms
- return
-
- if event.key == pygame.K_DELETE:
- if self._caret_index < len(self.text):
- self.text = self.text[:self._caret_index] + self.text[self._caret_index + 1:]
- self._refresh(); self._reset_blink()
- return
-
- if event.key == pygame.K_LEFT:
- self._move_left()
- now = pygame.time.get_ticks()
- self._repeat_key = pygame.K_LEFT
- self._next_repeat_ms = now + self._repeat_delay_ms
- self._ensure_caret_visible()
- return
-
- if event.key == pygame.K_RIGHT:
- self._move_right()
- now = pygame.time.get_ticks()
- self._repeat_key = pygame.K_RIGHT
- self._next_repeat_ms = now + self._repeat_delay_ms
- self._ensure_caret_visible()
- return
-
- if event.key == pygame.K_HOME:
- self._caret_index = 0
- self._reset_blink()
- self._ensure_caret_visible()
- return
-
- if event.key == pygame.K_END:
- self._caret_index = len(self.text)
- self._reset_blink()
- self._ensure_caret_visible()
- return
-
- if event.unicode and event.unicode.isprintable():
- candidate = self.text[:self._caret_index] + event.unicode + self.text[self._caret_index:]
- if self.allowed_pattern and not self.allowed_pattern.fullmatch(candidate):
- return # reject this character
- self.text = candidate
- self._caret_index += 1
- self._refresh()
- self._reset_blink()
- self._ensure_caret_visible()
- if self._repeat_key == pygame.K_BACKSPACE:
- self._repeat_key = None
-
- def set_text(self, new_text: str, *, emit: bool = True):
- self.text = str(new_text)
- self._caret_index = len(self.text)
- if emit:
- self._refresh()
- else:
- cb = self.on_text_change
- self.on_text_change = None
- self._refresh()
- self.on_text_change = cb
-
- # --- Carat Movement ---
-
-
- # --- helper for a single backspace action ---
- def _do_backspace(self):
- if self._caret_index > 0:
- self.text = self.text[:self._caret_index - 1] + self.text[self._caret_index:]
- self._caret_index -= 1
- self._refresh()
- self._reset_blink()
- self._ensure_caret_visible()
-
- # --- per-frame repeat tick ---
- def _update_key_repeat(self):
- if not self.active or self._repeat_key is None:
- return
- now = pygame.time.get_ticks()
- while now >= self._next_repeat_ms:
- if self._repeat_key == pygame.K_BACKSPACE:
- self._do_backspace()
- elif self._repeat_key == pygame.K_LEFT:
- self._move_left()
- elif self._repeat_key == pygame.K_RIGHT:
- self._move_right()
- self._next_repeat_ms += self._repeat_interval_ms
-
- def _text_inner_width(self) -> int:
- # Available width for text inside padding and border
- return max(0, self.width - 2 * self._padding - 4) # -4 for the 2px border on each side
-
- def _text_width(self) -> int:
- return self._measure_text_prefix_width(self.text)
-
- def _prefix_width(self, upto: int) -> int:
- return self._measure_text_prefix_width(self.text[:upto])
-
- def _ensure_caret_visible(self):
- """Adjust self._scroll_px so the caret x (prefix width) stays inside the inner view."""
- inner_w = self._text_inner_width()
- if inner_w <= 0:
- self._scroll_px = 0
- return
-
- total_w = self._text_width()
- caret_px = self._prefix_width(self._caret_index)
-
- # left edge
- if caret_px < self._scroll_px:
- self._scroll_px = caret_px
- # right edge
- elif caret_px > self._scroll_px + inner_w:
- self._scroll_px = caret_px - inner_w
-
- # clamp scroll to content
- max_scroll = max(0, total_w - inner_w)
- self._scroll_px = max(0, min(self._scroll_px, max_scroll))
-
- def _move_left(self):
- if self._caret_index > 0:
- self._caret_index -= 1
- self._reset_blink()
- self._ensure_caret_visible()
-
- def _move_right(self):
- if self._caret_index < len(self.text):
- self._caret_index += 1
- self._reset_blink()
- self._ensure_caret_visible()
-
- def _refresh(self):
- if self.text:
- self._text.set_text(self.text)
- else:
- self._text.set_text(self.placeholder)
- self._caret_index = max(0, min(self._caret_index, len(self.text)))
- # If content shrank, ensure scroll isn't past the end
- inner_w = self._text_inner_width()
- max_scroll = max(0, self._text_width() - inner_w)
- self._scroll_px = max(0, min(self._scroll_px, max_scroll))
-
- if self.on_text_change:
- self.on_text_change(self.text)
-
- def _reset_blink(self):
- self._last_blink_ms = pygame.time.get_ticks()
- self._caret_visible = True
-
- def _update_blink(self):
- now = pygame.time.get_ticks()
- if now - self._last_blink_ms >= self._blink_interval_ms:
- self._caret_visible = not self._caret_visible
- self._last_blink_ms = now
-
- def _measure_text_prefix_width(self, prefix: str) -> int:
- """
- Measure pixel width of a substring using the Text's FreeType font.
- Falls back gracefully if font unavailable.
- """
- font = getattr(self._text, "_font", None)
- if not font:
- # crude fallback: render via Text then read width
- temp = Text(prefix, parent=self, x=0, y=0, style=self.style)
- w, _ = temp.size
- # don't add temp to tree permanently
- self.children.remove(temp)
- return w
- # With pygame.freetype, render() returns (surface, rect)
- surf, _ = font.render(prefix, fgcolor=self.style.color, size=self.style.font_size)
- return surf.get_width()
-
- def draw(self, surface):
- if self.is_effectively_hidden:
- return
-
- self._update_key_repeat()
-
- # Background + border
- abs_x, abs_y, abs_w, abs_h = self.get_absolute_geometry()
- pygame.draw.rect(surface, self.background_color, (abs_x, abs_y, abs_w, abs_h))
- border = pygame.Color("dodgerblue") if self.active else self.border_color
- pygame.draw.rect(surface, border, (abs_x, abs_y, abs_w, abs_h), 2)
-
- # Clip text to inner rect
- clip_rect = pygame.Rect(abs_x + 2, abs_y + 2, max(0, abs_w - 4), max(0, abs_h - 4))
- prev_clip = surface.get_clip()
- if prev_clip: # prev_clip can be a Rect or None
- clip_rect = clip_rect.clip(prev_clip)
-
- surface.set_clip(clip_rect)
-
- # Calculate horizontal scroll if text exceeds field width
- text_width = self._measure_text_prefix_width(self.text)
- inner_width = abs_w - 2 * self._padding - 4 # inside padding and 2px border
- if inner_width < 0:
- inner_width = 0
-
- # When text is shorter than view, no scroll; otherwise use self._scroll_px
- total_w = self._text_width()
- max_scroll = max(0, total_w - inner_width)
- scroll = max(0, min(self._scroll_px, max_scroll))
-
- # Draw the text with horizontal offset
- text_abs_x = abs_x + self._padding + 2 - scroll # +2 for left border
- self._text.x = text_abs_x - abs_x
- self._text.draw(surface)
-
- # Caret
- if self.active:
- self._update_blink()
- if self._caret_visible:
- prefix_w = self._prefix_width(self._caret_index)
- text_x, text_y, text_w, text_h = self._text.get_absolute_geometry()
- caret_x = (abs_x + self._padding + 2) + (prefix_w - scroll)
- pygame.draw.line(surface, pygame.Color("black"),
- (caret_x, text_y),
- (caret_x, text_y + text_h), 1)
-
- surface.set_clip(prev_clip)
-
diff --git a/UI/input/toggle_button.py b/UI/input/toggle_button.py
deleted file mode 100644
index 8f5dc45..0000000
--- a/UI/input/toggle_button.py
+++ /dev/null
@@ -1,78 +0,0 @@
-import pygame
-from dataclasses import dataclass
-from typing import Callable, Optional
-
-from UI.input.button import Button, ButtonColors
-from UI.text import TextStyle
-
-@dataclass
-class ToggledColors:
- background: Optional[pygame.Color] = None
- hover_background: Optional[pygame.Color] = None
- foreground: Optional[pygame.Color] = None
- hover_foreground: Optional[pygame.Color] = None
-
-class ToggleButton(Button):
- """
- Two-state button (ON/OFF).
- - `on_click` is optional.
- - `on_change` receives (state: bool, button: ToggleButton).
- - Color palette can be overridden when ON via ToggledColors.
- """
- def __init__(
- self,
- function_to_call: Optional[Callable] = None, # optional click callback
- x: int = 0, y: int = 0, width: int = 100, height: int = 30,
- text: str = "",
- *,
- toggled: bool = False,
- on_change: Optional[Callable[[bool, "ToggleButton"], None]] = None,
- colors: Optional[ButtonColors] = None,
- toggled_colors: Optional[ToggledColors] = None,
- text_style: Optional[TextStyle] = None,
- **frame_kwargs
- ):
- super().__init__(
- function_to_call=function_to_call,
- x=x, y=y, width=width, height=height,
- text=text, colors=colors, text_style=text_style,
- **frame_kwargs
- )
- self._is_on = bool(toggled)
- self._on_change = on_change
- self._toggled_colors = toggled_colors or ToggledColors()
-
- @property
- def is_on(self) -> bool:
- return self._is_on
-
- def set_toggled(self, on: bool, fire: bool = True) -> None:
- if self._is_on == on:
- return
- self._is_on = on
- if fire and self._on_change:
- self._on_change(self._is_on, self)
-
- def toggle(self, fire: bool = True) -> None:
- self.set_toggled(not self._is_on, fire=fire)
-
- def on_click(self, button=None):
- if not self.is_enabled:
- return
- # Flip state first
- self.toggle(fire=True)
- # Only call per-click handler if provided
- if self.function_to_call:
- super().on_click(button)
-
- def _resolve_colors(self):
- base_bg, base_border = super()._resolve_colors()
- if not self._is_on:
- return base_bg, base_border
-
- bg = self._toggled_colors.background or base_bg
- fg = self._toggled_colors.foreground or base_border
- if self.is_hover:
- bg = self._toggled_colors.hover_background or bg
- fg = self._toggled_colors.hover_foreground or fg
- return bg, fg
diff --git a/UI/list_frame.py b/UI/list_frame.py
deleted file mode 100644
index eabd93f..0000000
--- a/UI/list_frame.py
+++ /dev/null
@@ -1,186 +0,0 @@
-# list_frame.py
-from typing import Callable, Iterable, Iterator, List, Optional, Sequence
-import pygame
-from UI.frame import Frame
-
-RowBuilder = Callable[[int, Frame], None]
-ElementFactory = Callable[[Frame, int], Frame]
-
-
-class RowContainer(Frame):
- """A thin container for one row inside a ListFrame."""
- def __init__(self, parent: Frame, index: int, row_height: int, **kwargs):
- # Position rows at (0, index*row_height) within the ListFrame
- super().__init__(parent=parent, x=0, y=index * row_height,
- width=1.0, height=row_height,
- width_is_percent=True, height_is_percent=False,
- **kwargs)
- self.index = index
- self.row_height = row_height
-
- def set_index_and_y(self, index: int) -> None:
- self.index = index
- self.y = index * self.row_height
-
-
-class ListFrame(Frame):
- """
- A vertical list container that repeats a row blueprint N times.
-
- Build options:
- A) row_builder(index, row_parent): create row contents for each index
- B) element_factories: sequence of callables (parent, index) -> Frame
-
- Public API:
- - set_count(n), set_row_height(h), rebuild()
- - get_row(i) -> RowContainer
- - __len__, __iter__, __getitem__
- - update_row(i, rebuild: bool = False, fn: Optional[RowBuilder] = None)
-
- Notes:
- - All child element positions are relative to their RowContainer,
- which itself is positioned inside the ListFrame at y = index * row_height.
- - Z-ordering and input handling inherit from Frame.
- """
- def __init__(
- self,
- parent: Optional[Frame] = None,
- *,
- x: float = 0,
- y: float = 0,
- width: float = 100,
- height: float = 100,
- x_is_percent: bool = False,
- y_is_percent: bool = False,
- width_is_percent: bool = False,
- height_is_percent: bool = False,
- z_index: int = 0,
- x_align: str = "left",
- y_align: str = "top",
- background_color: Optional[pygame.Color] = None,
- row_height: int = 24,
- count: int = 0,
- row_builder: Optional[RowBuilder] = None,
- element_factories: Optional[Sequence[ElementFactory]] = None,
- ):
- super().__init__(
- parent=parent, x=x, y=y, width=width, height=height,
- x_is_percent=x_is_percent, y_is_percent=y_is_percent,
- width_is_percent=width_is_percent, height_is_percent=height_is_percent,
- z_index=z_index, x_align=x_align, y_align=y_align,
- background_color=background_color
- )
- self._row_height = int(row_height)
- self._count = int(count)
- self._row_builder: Optional[RowBuilder] = row_builder
- self._factories: Optional[List[ElementFactory]] = list(element_factories) if element_factories else None
-
- self._rows: List[RowContainer] = []
- if self._count > 0:
- self._materialize_rows()
-
- # ------------ public API ------------
-
- def set_count(self, n: int) -> None:
- n = max(0, int(n))
- if n == self._count:
- return
- self._count = n
- self._resize_rows()
-
- def set_row_height(self, h: int) -> None:
- h = max(1, int(h))
- if h == self._row_height:
- return
- self._row_height = h
- # Reposition rows and update their heights
- for i, row in enumerate(self._rows):
- row.row_height = h
- row.height = h
- row.set_index_and_y(i)
-
- def get_row(self, index: int) -> RowContainer:
- return self._rows[index]
-
- def rebuild(self) -> None:
- """Fully rebuild all rows (e.g., after changing builder/factories)."""
- self._clear_rows()
- self._materialize_rows()
-
- def update_row(self, index: int, rebuild: bool = False, fn: Optional[RowBuilder] = None) -> None:
- """Optionally clear and rebuild a single row with a temporary or new builder."""
- row = self._rows[index]
- if rebuild:
- # Clear children of this row
- for ch in list(row.children):
- row.children.remove(ch)
- builder = fn or self._row_builder
- if builder is not None:
- builder(index, row)
- elif self._factories:
- for f in self._factories:
- f(row, index)
-
- # --- Pythonic container behavior ---
- def __len__(self) -> int:
- return self._count
-
- def __iter__(self) -> Iterator[RowContainer]:
- return iter(self._rows)
-
- def __getitem__(self, i: int) -> RowContainer:
- return self.get_row(i)
-
- # ------------ internals ------------
-
- def _clear_rows(self) -> None:
- # Detach row containers from our children list
- for row in self._rows:
- if row in self.children:
- self.children.remove(row)
- self._rows.clear()
-
- def _materialize_rows(self) -> None:
- for i in range(self._count):
- row = RowContainer(parent=self, index=i, row_height=self._row_height)
- self._rows.append(row)
- # Build row contents
- if self._row_builder is not None:
- self._row_builder(i, row)
- elif self._factories:
- for f in self._factories:
- f(row, i)
-
- # Keep our overall children z-sorted (your Frame.add_child sorts on insert,
- # but we added directly via RowContainer(parent=self), so re-sort here)
- self.children.sort(key=lambda c: c.z_index, reverse=True)
-
- def _resize_rows(self) -> None:
- """Grow/shrink rows to match current count; re-use existing when possible."""
- cur = len(self._rows)
- if self._count == cur:
- return
-
- if self._count < cur:
- # Remove extras from the end
- to_remove = self._rows[self._count:]
- for row in to_remove:
- if row in self.children:
- self.children.remove(row)
- self._rows = self._rows[:self._count]
- else:
- # Add new rows
- for i in range(cur, self._count):
- row = RowContainer(parent=self, index=i, row_height=self._row_height)
- self._rows.append(row)
- if self._row_builder is not None:
- self._row_builder(i, row)
- elif self._factories:
- for f in self._factories:
- f(row, i)
-
- # Reposition all rows to keep indices consistent
- for i, row in enumerate(self._rows):
- row.set_index_and_y(i)
-
- self.children.sort(key=lambda c: c.z_index, reverse=True)
diff --git a/UI/main_window.py b/UI/main_window.py
new file mode 100644
index 0000000..eb98a16
--- /dev/null
+++ b/UI/main_window.py
@@ -0,0 +1,155 @@
+from __future__ import annotations
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QMainWindow,
+ QProgressBar,
+ QSizePolicy,
+ QTabWidget,
+ QWidget,
+)
+
+from .tabs.navigate_tab import NavigateTab
+from .tabs.project_tab import ProjectTab
+from .tabs.calibration_tab import CalibrationTab
+from .tabs.logs_tab import LogsTab
+
+from common.state import State
+from .settings.settings_main import SettingsButton, SettingsDialog
+
+from common.app_context import get_app_context
+
+
+class MainWindow(QMainWindow):
+ def __init__(self) -> None:
+ super().__init__()
+
+ # Get app context
+ self.app_context = get_app_context()
+
+ # Register this main window with app context (initializes toast manager)
+ self.app_context.register_main_window(self)
+
+ # Set window title with version
+ self.setWindowTitle(f"FieldWeave - v{self.app_context.current_version}")
+ self.resize(1920, 1080)
+ self.move(500,200) # Move window to a more convenient position.
+ self._state = State()
+
+ # Create and register settings dialog
+ self.settings_dialog = SettingsDialog(self)
+ self.app_context.register_settings_dialog(self.settings_dialog)
+
+ # Header Bar
+ self.tabs = QTabWidget()
+ self.tabs.setDocumentMode(True)
+
+ # Create tabs
+ self.navigate_tab = NavigateTab()
+ self.tabs.addTab(self.navigate_tab, "Navigate")
+ self.tabs.addTab(ProjectTab(), "Project")
+ self.tabs.addTab(CalibrationTab(), "Calibration")
+ self.tabs.addTab(LogsTab(), "Logs")
+
+ self._setup_header_right()
+ self.setCentralWidget(self.tabs)
+
+ def resizeEvent(self, event) -> None:
+ super().resizeEvent(event)
+ # Toast manager now tracks moves/resizes via event filter
+
+ def _setup_header_right(self) -> None:
+ header_edge = QWidget()
+ header_edge.setObjectName("TabCorner")
+
+ layout = QHBoxLayout(header_edge)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(6)
+
+ # Status
+ self.status_bar = self._build_status_bar()
+
+ # Settings Button
+ self.settingsButton = SettingsButton("Settings")
+ self.settingsButton.clicked.connect(lambda: self._open_settings("Camera"))
+
+ layout.addWidget(self.status_bar)
+ layout.addWidget(self.settingsButton)
+
+ self.tabs.setCornerWidget(header_edge, Qt.Corner.TopRightCorner)
+
+ # Get the width and height of the settings button match the height of the header bar
+ h = self.tabs.tabBar().sizeHint().height()
+
+ self.settingsButton.setFixedHeight(h)
+ self.settingsButton.setFixedWidth(max(34, int(h * 0.95)))
+
+ self._apply_status()
+
+ def _build_status_bar(self) -> QWidget:
+ status_bar = QFrame()
+ status_bar.setObjectName("StatusBar")
+ status_bar.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+
+ row = QHBoxLayout(status_bar)
+ row.setContentsMargins(10, 0, 10, 0)
+ row.setSpacing(10)
+
+ # Status Text
+ self.status_line = QLabel("-")
+ self.status_line.setObjectName("StatusLine")
+ self.status_line.setWordWrap(False)
+ self.status_line.setSizePolicy(
+ QSizePolicy.Policy.MinimumExpanding,
+ QSizePolicy.Policy.Fixed,
+ )
+
+ # Progress Bar | Optional
+ self.progress = QProgressBar()
+ self.progress.setObjectName("StatusProgress")
+ self.progress.setRange(0, 100)
+ self.progress.setFixedWidth(120)
+ self.progress.setTextVisible(True)
+ self.progress.setFormat("%p%")
+ self.progress.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.progress.setSizePolicy(
+ QSizePolicy.Policy.Fixed,
+ QSizePolicy.Policy.Fixed
+ )
+
+ row.addWidget(self.status_line, stretch=1)
+ row.addWidget(self.progress, stretch=0)
+
+ return status_bar
+
+ def _open_settings(self, category: str) -> None:
+ self.app_context.open_settings(category)
+
+ def _apply_status(self) -> None:
+ self.status_line.setText(self._state.format_status_text())
+
+ show_progress = self._state.progress_total > 0
+ self.progress.setVisible(show_progress)
+
+ if show_progress:
+ percent = int(round(100.0 * self._state.progress_current / max(1, self._state.progress_total)))
+ self.progress.setValue(max(0, min(100, percent)))
+
+ self.status_bar.setProperty("kind", self._state.automation_state)
+ self.status_bar.style().unpolish(self.status_bar)
+ self.status_bar.style().polish(self.status_bar)
+
+ def closeEvent(self, event):
+ """Handle application close - cleanup resources"""
+ # Cleanup camera preview
+ if hasattr(self.navigate_tab, 'camera_preview'):
+ self.navigate_tab.camera_preview.cleanup()
+
+ # Cleanup app context
+ ctx = get_app_context()
+ ctx.cleanup()
+
+ super().closeEvent(event)
\ No newline at end of file
diff --git a/UI/modal.py b/UI/modal.py
deleted file mode 100644
index 53f3358..0000000
--- a/UI/modal.py
+++ /dev/null
@@ -1,249 +0,0 @@
-import pygame
-from UI.frame import Frame
-from UI.section_frame import Section
-from UI.text import TextStyle
-
-from UI.input.button import Button, ButtonColors
-
-class _Scrim(Frame):
- """Full-screen overlay that blocks interaction with underlying UI."""
- def __init__(self, *, parent, z_index=10_000, alpha=160):
- # Cover the whole root; percent sizing + centered at (0,0) top-left.
- super().__init__(
- parent=parent, x=0, y=0, width=1.0, height=1.0,
- x_is_percent=True, y_is_percent=True,
- width_is_percent=True, height_is_percent=True,
- z_index=z_index
- )
- self._alpha = alpha
- self._capture_drag = None # set by Modal while dragging
- self._capture_release = None # set by Modal while dragging
- self.hide() # start hidden
-
- def draw(self, surface: pygame.Surface) -> None:
- if self.is_effectively_hidden:
- return
- abs_x, abs_y, abs_w, abs_h = self.get_absolute_geometry()
- # Semi-transparent scrim
- scrim = pygame.Surface((abs_w, abs_h), pygame.SRCALPHA)
- scrim.fill((0, 0, 0, self._alpha))
- surface.blit(scrim, (abs_x, abs_y))
-
- # Draw children (e.g., the modal panel)
- for ch in reversed(self.children):
- ch.draw(surface)
-
- # Clicking the scrim closes the modal (if no child handled the click)
- def on_click(self, button=None):
- # Delegate: the Modal sets this from its constructor
- if hasattr(self, "_request_close"):
- self._request_close()
-
- def process_mouse_move(self, px, py):
- # If a modal is dragging, forward move regardless of where the mouse is.
- if callable(self._capture_drag):
- self._capture_drag(px, py)
- super().process_mouse_move(px, py)
-
- def process_mouse_release(self, px, py, button):
- # If a modal is dragging, ensure the release reaches it.
- if callable(self._capture_release):
- self._capture_release(px, py, button)
- super().process_mouse_release(px, py, button)
-
-class _DragCapture(Frame):
- """Full-screen invisible mouse-capture layer used during modal drag in floating mode."""
- def __init__(self, *, parent, on_move, on_release, z_index):
- super().__init__(
- parent=parent, x=0, y=0, width=1.0, height=1.0,
- x_is_percent=True, y_is_percent=True,
- width_is_percent=True, height_is_percent=True,
- z_index=z_index
- )
- self._on_move = on_move
- self._on_release = on_release
- # No background; purely for event capture
-
- def process_mouse_move(self, px, py):
- if callable(self._on_move):
- self._on_move(px, py)
- super().process_mouse_move(px, py)
-
- def process_mouse_release(self, px, py, button):
- if callable(self._on_release):
- self._on_release(px, py, button)
- super().process_mouse_release(px, py, button)
-
-class Modal(Section):
- """
- Modal with two modes:
- - overlay=True : uses a full-screen scrim (blocks clicks behind)
- - overlay=False : no scrim; modal floats and does NOT block other buttons
- """
- def __init__(self, *, parent, title: str, width: int = 480, height: int = 320, header_height: int = 32,
- on_close=None, z_index: int = 10_001,
- header_bg: pygame.Color = pygame.Color("#dbdbdb"),
- background_color: pygame.Color = pygame.Color("#ffffff"),
- title_style: TextStyle | None = None,
- overlay: bool = True,
- scrim_alpha: int = 160,
- **kwargs
- ):
- super().__init__(
- parent=(parent if not overlay else _Scrim(parent=parent, z_index=z_index - 1, alpha=scrim_alpha)),
- title=title,
- width=width, height=height,
- header_height=header_height,
- header_bg=header_bg,
- background_color=background_color,
- title_style=title_style,
- z_index=z_index,
- x=0.5, y=0.5,
- x_is_percent=True, y_is_percent=True,
- **kwargs
- )
-
- self.x_align = "center"
- self.y_align = "center"
-
- self._on_close = on_close
- self._overlay = overlay
- self._dragging = False
- self._drag_offset = (0, 0)
- self._drag_capture_layer = None # only used when overlay == False
-
- if overlay:
- self._scrim = self.parent
- self._scrim.hide(True)
- self._scrim._request_close = self.close
- else:
- self._scrim = None
- self.hide(True)
-
- close_btn_style = TextStyle(
- color=pygame.Color("#4a4a4a"),
- font_size=min(20, header_height - 8),
- )
- self._close_btn = Button(
- self.close,
- x=8, y=(header_height - 24) // 2,
- width=24, height=24,
- text="X",
- text_style=close_btn_style,
- x_align="right",
- parent=self.header,
- colors=ButtonColors(
- background=header_bg,
- foreground=header_bg,
- hover_background=pygame.Color("#b3b4b6"),
- disabled_background=header_bg,
- disabled_foreground=header_bg
- ),
- )
-
- # Public API
- def open(self):
- if self._scrim is not None:
- self._scrim.show(True) # shows panel via scrim
- else:
- self.show(True) # floating mode: just show panel
-
- def close(self):
- if self._scrim is not None:
- self._scrim.hide(True)
- else:
- self.hide(True)
- if callable(self._on_close):
- self._on_close()
-
- # ESC to close (works in both modes)
- def on_key_event(self, event):
- try:
- if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
- self.close()
- except Exception:
- pass
-
- # --- Dragging support ---
-
- def _begin_drag(self, px, py):
- # Anchor the pointer offset from the modal's top-left corner
- abs_x, abs_y, _, _ = self.get_absolute_geometry()
- self._dragging = True
- self._drag_offset = (px - abs_x, py - abs_y)
-
- # While dragging, switch to absolute pixel positioning
- self.x_is_percent = False
- self.y_is_percent = False
- self.x_align = "left"
- self.y_align = "top"
-
- if self._scrim is not None:
- # Overlay mode: use scrim as move/release forwarder
- self._scrim._capture_drag = self._on_drag_move
- self._scrim._capture_release = self._on_drag_release
- else:
- # Floating mode: create a temporary full-screen capture layer
- self._drag_capture_layer = _DragCapture(
- parent=self.parent,
- on_move=self._on_drag_move,
- on_release=self._on_drag_release,
- z_index=self.z_index + 10_000
- )
-
- def _end_drag(self):
- self._dragging = False
- if self._scrim is not None:
- self._scrim._capture_drag = None
- self._scrim._capture_release = None
- if self._drag_capture_layer is not None:
- # Remove the capture layer
- try:
- self.parent.children.remove(self._drag_capture_layer)
- except ValueError:
- pass
- self._drag_capture_layer = None
-
- def _on_drag_move(self, px, py):
- if not self._dragging:
- return
- parent_x, parent_y, parent_w, parent_h = self.parent.get_absolute_geometry()
-
- new_x = px - self._drag_offset[0] - parent_x
- new_y = py - self._drag_offset[1] - parent_y
-
- # Optional: keep modal inside parent bounds (comment out if you don’t want clamping)
- w, h = self.size
- new_x = max(0, min(new_x, max(0, parent_w - w)))
- new_y = max(0, min(new_y, max(0, parent_h - h)))
-
- self.x = new_x
- self.y = new_y
-
- def _on_drag_release(self, px, py, button):
- if button == "left":
- self._end_drag()
-
- # Hook into your existing event methods
-
- def process_mouse_press(self, px, py, button):
- super().process_mouse_press(px, py, button)
-
- if button != "left":
- return
-
- # Start dragging if press was on the header (but not on the close button)
- if self.header.contains_point(px, py) and not self._close_btn.contains_point(px, py):
- self._begin_drag(px, py)
-
- def process_mouse_move(self, px, py):
- # If dragging, update first so the UI feels snappy
- if self._dragging:
- self._on_drag_move(px, py)
- super().process_mouse_move(px, py)
-
- def process_mouse_release(self, px, py, button):
- # Ensure we end dragging even if the release happens off the modal
- if self._dragging:
- self._on_drag_release(px, py, button)
- super().process_mouse_release(px, py, button)
\ No newline at end of file
diff --git a/UI/modals/automation_settings_modal.py b/UI/modals/automation_settings_modal.py
deleted file mode 100644
index 4f35116..0000000
--- a/UI/modals/automation_settings_modal.py
+++ /dev/null
@@ -1,346 +0,0 @@
-import pygame
-
-from UI.modal import Modal
-from printer.automated_controller import AutomatedPrinter
-from printer.automation_config import AutomationSettingsManager
-
-from UI.focus_overlay import FocusOverlay
-
-from UI.input.text_field import TextField
-from UI.input.button import Button, ButtonShape, ButtonColors
-from UI.input.scroll_frame import ScrollFrame
-from UI.input.slider import Slider
-from UI.input.radio import RadioButton, RadioGroup
-
-from UI.tooltip import Tooltip
-from UI.text import Text, TextStyle
-
-from UI.styles import (
- make_button_text_style,
- make_display_text_style,
- make_settings_text_style,
- BASE_BUTTON_COLORS,
- SELECTED_RADIO_COLORS,
- RADIO_TEXT_STYLE,
-)
-
-
-class _Layout:
- """Simple vertical slot layout using a fixed section offset."""
- def __init__(self, offset: int = 60):
- self.offset = offset
- self._i = 0
-
- def next_y(self) -> int:
- y = self.offset * self._i
- self._i += 1
- return y
-
-
-def _add_pct_slider(
- *,
- parent,
- x: int,
- y: int,
- title: str,
- initial_pct_0to1: float,
- on_change_pct_0to1
-):
- """
- Render a labeled 0..100% slider with 0.1% resolution.
- The underlying callback receives a float in [0.0, 1.0].
- Returns a setter: set_pct_0to1(float) -> None for external syncing.
- """
- Text(title, parent=parent, x=x, y=y + 6, style=make_button_text_style())
-
- ui_value = max(0.0, min(100.0, float(initial_pct_0to1) * 100.0))
-
- slider = Slider(
- parent=parent, x=x + 125, y=y, width=230, height=32,
- min_value=0.0, max_value=100.0, initial_value=ui_value,
- step=0.1, tick_count=0, with_buttons=True,
- )
-
- value_text = Text(f"{ui_value:.1f}%", parent=parent, x=x + 360, y=y + 8, style=make_button_text_style())
-
- def _on_slider(val: float):
- val = max(0.0, min(100.0, float(val)))
- value_text.set_text(f"{val:.1f}%")
- try:
- on_change_pct_0to1(val / 100.0)
- except Exception as e:
- print(f"[Automation Settings] Failed to apply '{title}' = {val}% → {e}")
-
- slider.on_change = _on_slider
-
- def set_pct_0to1(p: float):
- p = max(0.0, min(1.0, float(p)))
- slider.set_value(p * 100.0, notify=False)
- value_text.set_text(f"{p * 100.0:.1f}%")
-
- return set_pct_0to1
-
-
-def add_save_load_reset_section(modal, automated_controller: AutomatedPrinter, sync_modal_from_automation, y: int, x: int = 8) -> None:
- import tkinter as tk
- from tkinter import filedialog
- btn_w, btn_h = 88, 28
- spacing = 12
- y += 8
-
- # Save
- def on_save():
- try:
- automated_controller.save_automation_settings() # persists to active file (with backups)
- except Exception as e:
- print(f"[Automation Save] Failed: {e}")
- print("Saved Settings")
-
- Button(
- on_save,
- x=x, y=y, width=btn_w, height=btn_h,
- text="Save", parent=modal,
- colors=BASE_BUTTON_COLORS, text_style=RADIO_TEXT_STYLE,
- )
-
- # Load (from arbitrary YAML)
- def on_load():
- root = tk.Tk(); root.withdraw()
- cfg_dir = automated_controller.get_automation_config_dir()
- filepath = filedialog.askopenfilename(
- initialdir=str(cfg_dir),
- title="Select Automation Config File",
- filetypes=[("YAML files", "*.yaml"), ("All files", "*.*")],
- )
- root.destroy()
- if not filepath:
- return
- try:
- # Parity with camera settings loader
- loaded = AutomationSettingsManager.load_from_file(filepath)
- automated_controller.set_automation_settings(loaded, persist=False) # apply immediately
- sync_modal_from_automation(modal, automated_controller) # refresh widgets
- except Exception as e:
- print(f"[Automation Load] Failed to load/apply '{filepath}': {e}")
- print("Loaded Settings")
-
- Button(
- on_load,
- x=x + (btn_w + spacing), y=y, width=btn_w, height=btn_h,
- text="Load", parent=modal,
- colors=BASE_BUTTON_COLORS, text_style=RADIO_TEXT_STYLE,
- )
-
- # Reset → restore defaults into active (with backup), apply, and persist
- def on_reset():
- try:
- automated_controller.restore_default_automation_settings(persist=True)
- sync_modal_from_automation(modal, automated_controller)
- except Exception as e:
- print(f"[Automation Reset] Failed to restore defaults: {e}")
- print("Reset Settings")
-
- Button(
- on_reset,
- x=x + 2*(btn_w + spacing), y=y, width=btn_w, height=btn_h,
- text="Reset", parent=modal,
- colors=BASE_BUTTON_COLORS, text_style=RADIO_TEXT_STYLE,
- )
-
-
-def build_automation_settings_modal(modal: Modal, automated_controller: AutomatedPrinter):
-
- scroll_area = ScrollFrame(parent=modal, x=0, y=0, width=modal.width, height=365)
- layout = _Layout(offset=40)
- s = automated_controller.automation_settings
-
- # Image name format
- image_name_format_height = 8 + layout.next_y()
- Text("Image Name Format: ", parent=scroll_area, x=8, y=image_name_format_height + 5, style=make_button_text_style())
- format_field = TextField(
- parent=scroll_area,
- x=220, y=image_name_format_height, width=250,
- allowed_pattern=r'^[^\\/:*?"<>|\x00-\x1F]+$',
- border_color=pygame.Color("#b3b4b6"), text_color=pygame.Color("#5a5a5a")
- )
- format_field.set_text(s.image_name_template)
-
- def _on_template_change(text: str):
- # empty -> fallback to existing template so we don't persist a blank
- tpl = text.strip() or automated_controller.automation_settings.image_name_template
- automated_controller.update_automation_settings(persist=False, image_name_template=tpl)
-
- format_field.on_change = _on_template_change
- Tooltip.attach(format_field,
- "Format Options\n\
-{x}, {y}, {z} - Position coordinates (supports zero-padding and custom decimal delimiter)\n\
-{i} - Image index\n\
-{f} - Focus score\n\
-{d:%Y%m%d} - Date/time (customizable with standard strftime format codes)\n\
-\n\
-Example:\n\
-{d:%Y%m%d}_X={x}_Y={y}_Image_{i} -> 20251021_X=010.40_Y=000.04_Image_1"
- )
-
-
- row_y = layout.next_y()
- Text("Zero-Pad Coordinates", parent=scroll_area, x=8, y=row_y + 5, style=make_button_text_style())
-
- def on_zero_pad_change(selected_val):
- # Accept "true"/"false" (string) or button.value
- value = True if (selected_val == "true" or getattr(selected_val, "value", None) == "true") else False
- automated_controller.update_automation_settings(persist=False, zero_pad=value)
-
- zero_group = RadioGroup(allow_deselect=False, on_change=on_zero_pad_change)
- RadioButton(lambda: None, x=220, y=row_y, width=56, height=32, text="True",
- value="true", group=zero_group, parent=scroll_area,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
- RadioButton(lambda: None, x=280, y=row_y, width=64, height=32, text="False",
- value="false", group=zero_group, parent=scroll_area,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
-
- # Initialize from current settings (default True if missing)
- zero_group.set_value("true" if automated_controller.automation_settings.zero_pad else "false")
-
- # Decimal delimiter (mutually exclusive)
- row_y = layout.next_y()
- Text("Decimal Delimiter", parent=scroll_area, x=8, y=row_y + 5, style=make_button_text_style())
-
- def on_delim_change(selected_btn):
- val = None if selected_btn is None else selected_btn.value
- # Only accept the four allowed delimiters
- if val in {"_", "-", "=", "."}:
- automated_controller.update_automation_settings(persist=False, delimiter=val)
-
- delim_group = RadioGroup(allow_deselect=False, on_change=on_delim_change)
-
- # Buttons: "_", "-", "=", "."
- RadioButton(lambda: None, x=220, y=row_y, width=36, height=32, text="_",
- value="_", group=delim_group, parent=scroll_area,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
- RadioButton(lambda: None, x=260, y=row_y, width=36, height=32, text="-",
- value="-", group=delim_group, parent=scroll_area,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
- RadioButton(lambda: None, x=300, y=row_y, width=36, height=32, text="=",
- value="=", group=delim_group, parent=scroll_area,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
- RadioButton(lambda: None, x=340, y=row_y, width=36, height=32, text=".",
- value=".", group=delim_group, parent=scroll_area,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
-
- # Initialize from current settings (default ".")
- delim_group.set_value(automated_controller.automation_settings.delimiter)
-
-
- # Focus Scale
- row_y = layout.next_y()
- Text("Focus Scale", parent=scroll_area, x=8, y=row_y + 5, style=make_button_text_style())
-
- focus_scale_slider = Slider(
- parent=scroll_area, x=153, y=row_y, width=230, height=32,
- min_value=0.0, max_value=1.0, initial_value=getattr(automated_controller.machine_vision, "scale_factor", 1.0),
- step=0.001, tick_count=0, with_buttons=True,
- )
-
- focus_scale_value = Text(
- f"{getattr(automated_controller.machine_vision, 'scale_factor', 1.0):.3f}",
- parent=scroll_area, x=390, y=row_y + 8, style=make_button_text_style()
- )
-
- def on_focus_scale_change(val: float):
- try:
- val = max(0.0, min(1.0, float(val)))
- automated_controller.update_automation_settings(persist=False, scale_factor=val)
- automated_controller.machine_vision.scale_factor = val
- focus_scale_value.set_text(f"{val:.3f}")
- except Exception as e:
- print(f"[Automation Settings] Failed to set focus scale: {e}")
-
- focus_scale_slider.on_change = on_focus_scale_change
-
-
- # Machine Vision Exclusion Zones
- Text("Machine Vision Exclusion Zones", parent=scroll_area, x=8, y=8 + layout.next_y(), style=make_button_text_style())
-
- slider_setters = {}
-
- row_y = layout.next_y()
- slider_setters["top"] = _add_pct_slider(
- parent=scroll_area, x=28, y=row_y,
- title="Top Side:",
- initial_pct_0to1=getattr(s, "inset_top_pct", 0.0),
- on_change_pct_0to1=lambda v: automated_controller.update_automation_settings(
- persist=False, inset_top_pct=float(v))
- )
-
- row_y = layout.next_y()
- slider_setters["left"] = _add_pct_slider(
- parent=scroll_area, x=28, y=row_y,
- title="Left Side:",
- initial_pct_0to1=getattr(s, "inset_left_pct", 0.0),
- on_change_pct_0to1=lambda v: automated_controller.update_automation_settings(
- persist=False, inset_left_pct=float(v))
- )
-
- row_y = layout.next_y()
- slider_setters["bottom"] = _add_pct_slider(
- parent=scroll_area, x=28, y=row_y,
- title="Bottom Side:",
- initial_pct_0to1=getattr(s, "inset_bottom_pct", 0.0),
- on_change_pct_0to1=lambda v: automated_controller.update_automation_settings(
- persist=False, inset_bottom_pct=float(v))
- )
-
- row_y = layout.next_y()
- slider_setters["right"] = _add_pct_slider(
- parent=scroll_area, x=28, y=row_y,
- title="Right Side:",
- initial_pct_0to1=getattr(s, "inset_right_pct", 0.0),
- on_change_pct_0to1=lambda v: automated_controller.update_automation_settings(
- persist=False, inset_right_pct=float(v))
- )
-
- def sync_modal_from_automation(modal_obj, controller):
- st = controller.automation_settings
-
- # template
- try:
- format_field.set_text(st.image_name_template or "")
- except Exception:
- pass
-
- # zero-pad
- try:
- zero_group.set_value("true" if st.zero_pad else "false")
- except Exception:
- pass
-
- # delimiter
- try:
- delim_group.set_value(st.delimiter if st.delimiter in {"_", "-", "=", "."} else ".")
- except Exception:
- pass
-
- # focus scale
- try:
- fs = float(st.scale_factor)
- focus_scale_slider.set_value(fs, notify=False)
- focus_scale_value.set_text(f"{fs:.3f}")
- except Exception:
- pass
-
- # sliders
- try:
- slider_setters["top"](float(getattr(st, "inset_top_pct", 0.0)))
- slider_setters["left"](float(getattr(st, "inset_left_pct", 0.0)))
- slider_setters["bottom"](float(getattr(st, "inset_bottom_pct", 0.0)))
- slider_setters["right"](float(getattr(st, "inset_right_pct", 0.0)))
- except Exception:
- pass
-
- add_save_load_reset_section(
- modal,
- automated_controller,
- sync_modal_from_automation,
- y=modal.height - 80
- )
\ No newline at end of file
diff --git a/UI/modals/camera_settings_modal.py b/UI/modals/camera_settings_modal.py
deleted file mode 100644
index 89d712e..0000000
--- a/UI/modals/camera_settings_modal.py
+++ /dev/null
@@ -1,566 +0,0 @@
-from __future__ import annotations
-import pygame
-import tkinter as tk
-from tkinter import filedialog
-from pathlib import Path
-
-from UI.text import Text
-from UI.input.text_field import TextField
-from UI.input.slider import Slider
-from UI.input.radio import RadioButton, RadioGroup
-from UI.input.button import Button, ButtonColors
-from UI.input.scroll_frame import ScrollFrame
-
-from UI.styles import (
- make_settings_text_style,
- BASE_BUTTON_COLORS,
- SELECTED_RADIO_COLORS,
- RADIO_TEXT_STYLE,
-)
-
-from camera.camera_settings import CameraSettingsManager # adjust import if needed
-
-# ---------------------------------------------------------------------------
-# Constants / patterns
-# ---------------------------------------------------------------------------
-NUMERIC_PATTERN = r"^-?\d*\.?\d*$" # existing for slider text fields
-DIGITS_SIGNED = r"^-?\d{0,5}$" # allow up to 5 digits while typing; clamp on commit
-
-# ---- Sync helpers ----
-def _ensure_sync_registry(modal):
- if not hasattr(modal, "_settings_syncers"):
- modal._settings_syncers = []
-
-def _register_syncer(modal, fn):
- _ensure_sync_registry(modal)
- modal._settings_syncers.append(fn)
-
-def sync_modal_from_camera(modal, camera):
- # Run all registered syncers to update the UI from camera.settings
- for fn in getattr(modal, "_settings_syncers", []):
- try:
- fn()
- except Exception as e:
- print(f"[Modal Sync] syncer failed: {e}")
-
-# ---------------------------------------------------------------------------
-# Layout helper
-# ---------------------------------------------------------------------------
-class _Layout:
- """Simple vertical slot layout using a fixed section offset."""
- def __init__(self, offset: int = 60):
- self.offset = offset
- self._i = 0
-
- def next_y(self) -> int:
- y = self.offset * self._i
- self._i += 1
- return y
-
-
-# ---------------------------------------------------------------------------
-# Low-level builders (reusable widgets)
-# ---------------------------------------------------------------------------
-
-def _fmt_value(v, value_type: type, decimals: int | None) -> str:
- if value_type is int:
- try:
- return str(int(float(v)))
- except Exception:
- return "0"
- if decimals is not None:
- try:
- return f"{float(v):.{decimals}f}"
- except Exception:
- return f"{0.0:.{decimals}f}"
- # Default float-ish formatting but hide trailing .0
- try:
- fv = float(v)
- return str(int(fv)) if fv.is_integer() else str(fv)
- except Exception:
- return "0"
-
-
-def create_numeric_setting(
- *,
- modal,
- camera,
- settings,
- title: str,
- y: int,
- attr: str, # e.g. "exposure"
- min_value: float,
- max_value: float,
- value_type: type = int,
- tick_count: int = 8,
- decimals: int | None = None,
- x: int = 8,
-) -> None:
- """Build a labeled slider + numeric text field bound to a single settings attr."""
- Text(title, parent=modal, x=x, y=y + 8, style=make_settings_text_style())
-
- cur = getattr(settings, attr)
- slider = Slider(
- parent=modal, x=x, y=y + 28, width=200, height=32,
- min_value=min_value, max_value=max_value, initial_value=cur,
- tick_count=tick_count, with_buttons=True,
- )
-
- text_field = TextField(
- parent=modal, x=x + 208, y=y + 28, width=65, height=32,
- placeholder=str(cur), allowed_pattern=NUMERIC_PATTERN,
- border_color=pygame.Color("#b3b4b6"), text_color=pygame.Color("#5a5a5a"),
- )
-
- last_applied = [None]
-
- def apply_value(v):
- try:
- fv = float(v)
- except (TypeError, ValueError):
- fv = float(getattr(settings, attr))
- clamped = max(min_value, min(max_value, fv))
- if last_applied[0] is not None and clamped == last_applied[0]:
- return
- camera.update_settings(
- persist=False,
- **{attr: int(clamped) if value_type is int else float(clamped)},
- )
- text_field.set_text(_fmt_value(clamped, value_type, decimals), emit=False)
- last_applied[0] = clamped
-
- def on_slider(val: float):
- apply_value(val)
-
- slider.on_change = on_slider
-
- def on_text_change(txt: str):
- # Update the slider visual while typing, but do not apply until commit
- if txt in ("", "-", ".", "-."):
- return
- try:
- v = float(txt)
- except ValueError:
- return
- v = max(min_value, min(max_value, v))
- slider.value = v
-
- text_field.on_text_change = on_text_change
-
- def on_commit(txt: str):
- if txt in ("", "-", ".", "-."):
- text_field.set_text(_fmt_value(slider.value, value_type, decimals), emit=False)
- return
- apply_value(txt)
- if last_applied[0] is not None:
- slider.value = last_applied[0]
-
- text_field.on_commit = on_commit
- text_field.set_text(_fmt_value(cur, value_type, decimals), emit=False)
-
- def _sync_from_camera():
- cur_val = getattr(camera.settings, attr)
- slider.value = float(cur_val)
- text_field.set_text(_fmt_value(cur_val, value_type, decimals), emit=False)
-
- _register_syncer(modal, _sync_from_camera)
-
-
-
-def create_rgb_triplet_setting(
- *,
- modal,
- camera,
- title: str,
- y: int,
- get_vals, # () -> tuple[int, int, int]
- set_field_name: str, # name of settings field to update via update_settings(...)
- per_channel_bounds: list[tuple[int, int]] | None = None,
- x: int = 8,
-) -> None:
- """Build three numeric fields (R/G/B) for an RGB-like tuple setting."""
- Text(title, parent=modal, x=x, y=y + 8, style=make_settings_text_style())
-
- Text("R", parent=modal, x=x, y=y + 34, style=make_settings_text_style())
- Text("G", parent=modal, x=x + 86, y=y + 34, style=make_settings_text_style())
- Text("B", parent=modal, x=x + 172, y=y + 34, style=make_settings_text_style())
-
- current = list(get_vals())
- bounds = per_channel_bounds or [(0, 255), (0, 255), (0, 255)]
-
- def make_commit(idx: int, tf: TextField):
- lo, hi = bounds[idx]
- def _commit(txt: str):
- try:
- v = int(txt)
- except (TypeError, ValueError):
- v = lo
- v = max(lo, min(hi, v))
- current[idx] = v
- camera.update_settings(persist=False, **{set_field_name: tuple(current)})
- tf.set_text(str(v), emit=False)
- return _commit
-
- r_field = TextField(
- parent=modal, x=x + 16, y=y + 28, width=64, height=32,
- placeholder=str(current[0]), allowed_pattern=DIGITS_SIGNED,
- border_color=pygame.Color("#b3b4b6"), text_color=pygame.Color("#5a5a5a"),
- )
- r_field.on_commit = make_commit(0, r_field)
- r_field.set_text(str(current[0]), emit=False)
-
- g_field = TextField(
- parent=modal, x=x + 102, y=y + 28, width=64, height=32,
- placeholder=str(current[1]), allowed_pattern=DIGITS_SIGNED,
- border_color=pygame.Color("#b3b4b6"), text_color=pygame.Color("#5a5a5a"),
- )
- g_field.on_commit = make_commit(1, g_field)
- g_field.set_text(str(current[1]), emit=False)
-
- b_field = TextField(
- parent=modal, x=x + 188, y=y + 28, width=64, height=32,
- placeholder=str(current[2]), allowed_pattern=DIGITS_SIGNED,
- border_color=pygame.Color("#b3b4b6"), text_color=pygame.Color("#5a5a5a"),
- )
- b_field.on_commit = make_commit(2, b_field)
- b_field.set_text(str(current[2]), emit=False)
-
- def _sync_from_camera():
- vals = list(get_vals()) # this already reads from settings
- r_field.set_text(str(vals[0]), emit=False)
- g_field.set_text(str(vals[1]), emit=False)
- b_field.set_text(str(vals[2]), emit=False)
-
- _register_syncer(modal, _sync_from_camera)
-
-# ---------------------------------------------------------------------------
-# Individual setting sections
-# ---------------------------------------------------------------------------
-
-def add_file_format_section(modal, camera, settings, *, y: int, x: int = 8) -> None:
- Text("File Format", parent=modal, x=x, y=y + 8, style=make_settings_text_style())
-
- def on_image_format_change(selected_btn):
- camera.update_settings(persist=False, fformat=(None if selected_btn is None else selected_btn.value))
-
- base_y = y + 28
- image_format = RadioGroup(allow_deselect=False, on_change=on_image_format_change)
- RadioButton(lambda: None, x=x, y=base_y, width=48, height=32, text="png",
- value="png", group=image_format, parent=modal,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
- RadioButton(lambda: None, x=x + 56, y=base_y, width=56, height=32, text="jpeg",
- value="jpeg", group=image_format, parent=modal,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
- RadioButton(lambda: None, x=x + 120, y=base_y, width=56, height=32, text="jpg",
- value="jpg", group=image_format, parent=modal,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
- RadioButton(lambda: None, x=x + 184, y=base_y, width=56, height=32, text="tiff",
- value="tiff", group=image_format, parent=modal,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
-
- # Initialize from current settings
- image_format.set_value(settings.fformat)
-
- _register_syncer(modal, lambda: image_format.set_value(camera.settings.fformat))
-
-
-def add_camera_temperature_setting(modal, camera, settings, *, y: int, x: int = 8) -> None:
- create_numeric_setting(
- modal=modal, camera=camera, settings=settings,
- title="Camera Temperature", y=y,
- min_value=settings.temp_min, max_value=settings.temp_max,
- attr="temp", value_type=int, x=x,
- )
-
-
-def add_auto_exposure_section(modal, camera, settings, *, y: int, x: int = 8) -> None:
- Text("Use Auto Exposure", parent=modal, x=x, y=y + 8, style=make_settings_text_style())
-
- def on_auto_expo_change(selected_val):
- value = True if (selected_val == "true" or getattr(selected_val, "value", None) == "true") else False
- camera.update_settings(persist=False, auto_expo=value)
-
- base_y = y + 28
- group = RadioGroup(allow_deselect=False, on_change=on_auto_expo_change)
- RadioButton(lambda: None, x=x, y=base_y, width=48, height=32, text="True",
- value="true", group=group, parent=modal,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
- RadioButton(lambda: None, x=x + 56, y=base_y, width=56, height=32, text="False",
- value="false", group=group, parent=modal,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
-
- group.set_value("true" if settings.auto_expo else "false")
-
- _register_syncer(
- modal,
- lambda g=group: g.set_value("true" if camera.settings.auto_expo else "false")
- )
-
-
-
-# Scalar slider wrappers (one function per setting)
-
-def add_exposure_setting(modal, camera, settings, *, y: int, x: int = 8) -> None:
- create_numeric_setting(modal=modal, camera=camera, settings=settings,
- title="Exposure", y=y, attr="exposure",
- min_value=settings.exposure_min, max_value=settings.exposure_max,
- value_type=int, x=x)
-
-
-
-def add_tint_setting(modal, camera, settings, *, y: int, x: int = 8) -> None:
- create_numeric_setting(modal=modal, camera=camera, settings=settings,
- title="Tint", y=y, attr="tint",
- min_value=settings.tint_min, max_value=settings.tint_max,
- value_type=int, x=x)
-
-
-def add_contrast_setting(modal, camera, settings, *, y: int, x: int = 8) -> None:
- create_numeric_setting(modal=modal, camera=camera, settings=settings,
- title="Contrast", y=y, attr="contrast",
- min_value=settings.contrast_min, max_value=settings.contrast_max,
- value_type=int, x=x)
-
-
-def add_hue_setting(modal, camera, settings, *, y: int, x: int = 8) -> None:
- create_numeric_setting(modal=modal, camera=camera, settings=settings,
- title="Hue", y=y, attr="hue",
- min_value=settings.hue_min, max_value=settings.hue_max,
- value_type=int, x=x)
-
-
-def add_saturation_setting(modal, camera, settings, *, y: int, x: int = 8) -> None:
- create_numeric_setting(modal=modal, camera=camera, settings=settings,
- title="Saturation", y=y, attr="saturation",
- min_value=settings.saturation_min, max_value=settings.saturation_max,
- value_type=int, x=x)
-
-
-def add_brightness_setting(modal, camera, settings, *, y: int, x: int = 8) -> None:
- create_numeric_setting(modal=modal, camera=camera, settings=settings,
- title="Brightness", y=y, attr="brightness",
- min_value=settings.brightness_min, max_value=settings.brightness_max,
- value_type=int, x=x)
-
-
-def add_gamma_setting(modal, camera, settings, *, y: int, x: int = 8) -> None:
- create_numeric_setting(modal=modal, camera=camera, settings=settings,
- title="Gamma", y=y, attr="gamma",
- min_value=settings.gamma_min, max_value=settings.gamma_max,
- value_type=int, x=x)
-
-
-def add_sharpening_setting(modal, camera, settings, *, y: int, x: int = 8) -> None:
- create_numeric_setting(modal=modal, camera=camera, settings=settings,
- title="Sharpening", y=y, attr="sharpening",
- min_value=settings.sharpening_min, max_value=settings.sharpening_max,
- value_type=int, x=x)
-
-
-def add_linear_tone_mapping_section(modal, camera, settings, *, y: int, x: int = 8) -> None:
- Text("Use Linear Tone Mapping", parent=modal, x=x, y=y + 8, style=make_settings_text_style())
-
- def on_linear_change(selected_val):
- value = 1 if (selected_val == "true" or getattr(selected_val, "value", None) == "true") else 0
- camera.update_settings(persist=False, linear=value)
-
- base_y = y + 28
- group = RadioGroup(allow_deselect=False, on_change=on_linear_change)
- RadioButton(lambda: None, x=x, y=base_y, width=48, height=32, text="True",
- value="true", group=group, parent=modal,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
- RadioButton(lambda: None, x=x + 56, y=base_y, width=56, height=32, text="False",
- value="false", group=group, parent=modal,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
-
- group.set_value("true" if settings.linear == 1 else "false")
-
- _register_syncer(
- modal,
- lambda g=group: g.set_value("true" if camera.settings.linear == 1 else "false")
- )
-
-
-
-def add_curved_tone_mapping_section(modal, camera, settings, *, y: int, x: int = 8) -> None:
- Text("Curved Tone Mapping", parent=modal, x=x, y=y + 8, style=make_settings_text_style())
-
- def on_curved_change(selected_btn):
- camera.update_settings(persist=False, curve=(None if selected_btn is None else selected_btn.value))
-
- base_y = y + 28
- group = RadioGroup(allow_deselect=False, on_change=on_curved_change)
- RadioButton(lambda: None, x=x, y=base_y, width=104, height=32, text="Logarithmic",
- value="Logarithmic", group=group, parent=modal,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
- RadioButton(lambda: None, x=x + 112, y=base_y, width=104, height=32, text="Polynomial",
- value="Polynomial", group=group, parent=modal,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
- RadioButton(lambda: None, x=x + 224, y=base_y, width=48, height=32, text="Off",
- value="Off", group=group, parent=modal,
- colors=BASE_BUTTON_COLORS, selected_colors=SELECTED_RADIO_COLORS, text_style=RADIO_TEXT_STYLE)
-
- group.set_value(settings.curve)
-
- _register_syncer(
- modal,
- lambda g=group: g.set_value(camera.settings.curve)
- )
-
-
-
-
-
-def add_level_range_low_setting(modal, camera, settings, *, y: int, x: int = 8) -> None:
- lr_min = settings.levelrange_min
- lr_max = settings.levelrange_max
- lr_bounds = [(lr_min, lr_max), (lr_min, lr_max), (lr_min, lr_max)]
-
- def get_level_low_rgb():
- lr = settings.levelrange_low
- return (lr[0], lr[1], lr[2])
-
- create_rgb_triplet_setting(
- modal=modal, camera=camera, title="Level Range Low", y=y,
- get_vals=get_level_low_rgb, set_field_name="levelrange_low",
- per_channel_bounds=lr_bounds, x=x,
- )
-
-
-def add_level_range_high_setting(modal, camera, settings, *, y: int, x: int = 8) -> None:
- lr_min = settings.levelrange_min
- lr_max = settings.levelrange_max
- lr_bounds = [(lr_min, lr_max), (lr_min, lr_max), (lr_min, lr_max)]
-
- def get_level_high_rgb():
- lr = settings.levelrange_high
- return (lr[0], lr[1], lr[2])
-
- create_rgb_triplet_setting(
- modal=modal, camera=camera, title="Level Range High", y=y,
- get_vals=get_level_high_rgb, set_field_name="levelrange_high",
- per_channel_bounds=lr_bounds, x=x,
- )
-
-
-def add_white_balance_gain_setting(modal, camera, settings, *, y: int, x: int = 8) -> None:
- wb_min = settings.wbgain_min
- wb_max = settings.wbgain_max
- wb_bounds = [(wb_min, wb_max), (wb_min, wb_max), (wb_min, wb_max)]
-
- def get_wbgain():
- return settings.wbgain
-
- create_rgb_triplet_setting(
- modal=modal, camera=camera, title="White Balance Gain", y=y,
- get_vals=get_wbgain, set_field_name="wbgain",
- per_channel_bounds=wb_bounds, x=x,
- )
-
-
-def add_save_load_reset_section(modal, camera, *, y: int, x: int = 8) -> None:
- btn_w, btn_h = 88, 28
- spacing = 12
- y += 8
-
- # Save
- Button(
- lambda: camera.save_settings(),
- x=x, y=y, width=btn_w, height=btn_h,
- text="Save", parent=modal,
- colors=BASE_BUTTON_COLORS, text_style=RADIO_TEXT_STYLE,
- )
-
- # Load
- def on_load():
- root = tk.Tk(); root.withdraw()
- cfg_dir = camera.get_config_dir()
- filepath = filedialog.askopenfilename(
- initialdir=str(cfg_dir),
- title="Select Camera Config File",
- filetypes=[("YAML files", "*.yaml"), ("All files", "*.*")],
- )
- root.destroy()
- if not filepath:
- return
- try:
- loaded = CameraSettingsManager.load_from_file(filepath)
- camera.set_settings(loaded, persist=False) # applies immediately
- sync_modal_from_camera(modal, camera) # <-- refresh widgets in-place
- except Exception as e:
- print(f"[Load Settings] Failed to load/apply '{filepath}': {e}")
-
- Button(
- on_load,
- x=x + (btn_w + spacing), y=y, width=btn_w, height=btn_h,
- text="Load", parent=modal,
- colors=BASE_BUTTON_COLORS, text_style=RADIO_TEXT_STYLE,
- )
-
- # Reset
- def on_reset():
- try:
- camera.restore_default_settings(persist=True) # applies + saves active
- sync_modal_from_camera(modal, camera) # <-- refresh widgets in-place
- except Exception as e:
- print(f"[Reset Settings] Failed to restore defaults: {e}")
-
- Button(
- on_reset,
- x=x + 2*(btn_w + spacing), y=y, width=btn_w, height=btn_h,
- text="Reset", parent=modal,
- colors=BASE_BUTTON_COLORS, text_style=RADIO_TEXT_STYLE,
- )
-
-# ---------------------------------------------------------------------------
-# Orchestrator
-# ---------------------------------------------------------------------------
-
-def build_camera_settings_modal(modal, camera) -> None:
- """Populate the provided Modal with camera setting controls.
- This applies values live via camera.update_settings(persist=False).
-
- Layout is organized into vertically stacked sections. Each section is
- built by a dedicated function to keep this module modular and easy to
- extend or rearrange.
- """
- _ensure_sync_registry(modal)
- modal._settings_syncers.clear()
- settings = camera.settings
-
- scroll_area = ScrollFrame(parent=modal, x=0, y= 0, width=modal.width, height=580)
- layout = _Layout(offset=60)
-
-
- # File format
- add_file_format_section(scroll_area, camera, settings, y=layout.next_y())
-
- # Camera temperature
- add_camera_temperature_setting(scroll_area, camera, settings, y=layout.next_y())
-
- # Auto exposure toggle
- add_auto_exposure_section(scroll_area, camera, settings, y=layout.next_y())
-
- # Core scalar sliders
- add_exposure_setting(scroll_area, camera, settings, y=layout.next_y())
- add_tint_setting(scroll_area, camera, settings, y=layout.next_y())
- add_contrast_setting(scroll_area, camera, settings, y=layout.next_y())
- add_hue_setting(scroll_area, camera, settings, y=layout.next_y())
- add_saturation_setting(scroll_area, camera, settings, y=layout.next_y())
- add_brightness_setting(scroll_area, camera, settings, y=layout.next_y())
- add_gamma_setting(scroll_area, camera, settings, y=layout.next_y())
- add_sharpening_setting(scroll_area, camera, settings, y=layout.next_y())
-
- # Tone mapping toggles
- add_linear_tone_mapping_section(scroll_area, camera, settings, y=layout.next_y())
- add_curved_tone_mapping_section(scroll_area, camera, settings, y=layout.next_y())
-
- # Level ranges + WB
- add_level_range_low_setting(scroll_area, camera, settings, y=layout.next_y())
- add_level_range_high_setting(scroll_area, camera, settings, y=layout.next_y())
- add_white_balance_gain_setting(scroll_area, camera, settings, y=layout.next_y())
-
- add_save_load_reset_section(
- modal, camera,
- y=modal.height-80
- )
diff --git a/UI/section_frame.py b/UI/section_frame.py
deleted file mode 100644
index a2864f2..0000000
--- a/UI/section_frame.py
+++ /dev/null
@@ -1,255 +0,0 @@
-import pygame
-
-from UI.frame import Frame
-from UI.text import Text, TextStyle
-
-from UI.input.button import Button, ButtonColors
-
-class Section(Frame):
- def __init__(
- self, *,
- parent,
- title: str,
- x=0, y=0, width=100, height=100,
- x_is_percent=False, y_is_percent=False,
- width_is_percent=False, height_is_percent=False,
- z_index=0,
- background_color=pygame.Color("#ffffff"),
- header_height=32,
- header_bg=pygame.Color("#dbdbdb"),
- title_style: TextStyle | None = None,
- title_align: str = "left",
- collapsible: bool = False,
- **kwargs
- ):
- self._initializing = True
- self.collapsible = collapsible
- self.collapsed = False
-
- self._header_buttons: list[Frame] = []
- self.header_action_gap: int = 0
-
- body_padding = kwargs.pop("padding", (0, 0, 0, 0))
-
- super().__init__(
- parent=parent, x=x, y=y, width=width, height=height,
- x_is_percent=x_is_percent, y_is_percent=y_is_percent,
- width_is_percent=width_is_percent, height_is_percent=height_is_percent,
- z_index=z_index, background_color=background_color,
- padding=(0, 0, 0, 0),
- **kwargs
- )
-
- # Save original (expanded) height config so we can restore it
- self._saved_height = height
- self._saved_height_is_percent = height_is_percent
-
- # Header bar
- self.header = Frame(
- parent=self,
- x=0, y=0,
- width=1.0, height=header_height,
- width_is_percent=True,
- background_color=header_bg,
- z_index=z_index + 1
- )
-
- # Title text
- if title_style is None:
- title_style = TextStyle(
- color=pygame.Color("#7a7a7a"),
- font_size=24,
- font_name="assets/fonts/SofiaSans-Regular.ttf",
- )
- self.title = Text(
- text=title,
- parent=self.header,
- x=(0.5 if title_align == "center" else 8),
- y=self.header.height // 2,
- x_is_percent=(title_align == "center"),
- y_is_percent=False,
- x_align=title_align,
- y_align="center",
- style=title_style,
- )
-
- # Collapse toggle button
- if self.collapsible:
- self.toggle_btn = Button(
- self.toggle_collapse,
- x=0, y=0,
- width=(header_height / 3) * 2, height=header_height,
- text="-",
- parent=self.header,
- x_align="right", y_align="top",
- colors=ButtonColors(
- background=header_bg,
- foreground=header_bg,
- hover_background=pygame.Color("#b3b4b6"),
- disabled_background=header_bg,
- disabled_foreground=header_bg
- ),
- text_style=TextStyle(
- color=pygame.Color("#7a7a7a"),
- font_size=min(20, header_height - 8),
- )
- )
- else:
- self.toggle_btn = None
-
- # Body (content area)
- self.body = Frame(
- parent=self,
- x=0, y=self.header.height,
- width=1.0,
- width_is_percent=True,
- height=max(0, height - header_height) if not height_is_percent else 0,
- height_is_percent=not height_is_percent,
- z_index=z_index,
- padding=body_padding
- )
-
- self._initializing = False
- self._layout_header_actions()
-
- def _layout_header_actions(self):
- """
- Packs the collapse toggle (if any) at the far right, then the custom
- header buttons to its left with a fixed gap.
- """
- offset = 0
-
- # Keep collapse toggle pinned at the far right
- if self.toggle_btn is not None:
- self.toggle_btn.x_align = "right"
- self.toggle_btn.y_align = "top"
- self.toggle_btn.x = 0 # zero offset from the right edge
- self.toggle_btn.y = 0 # it already fills header height
- offset = self.toggle_btn.width + self.header_action_gap
-
- # Pack custom buttons right-to-left in insertion order
- for btn in self._header_buttons:
- # x is an offset from the right edge because x_align="right"
- btn.x = offset
- # re-center vertically in case header height changed
- btn.y = max(0, (self.header.height - btn.height) // 2)
- offset += btn.width + self.header_action_gap
-
- # --- public helper (optional) ---
- def set_collapsed(self, value: bool):
- if value == self.collapsed:
- return
-
- self.collapsed = value
- if self.toggle_btn:
- self.toggle_btn.set_text("+" if self.collapsed else "-")
-
- if self.collapsed:
- # Save current size/fill config
- self._saved_height = self.height
- self._saved_height_is_percent = self.height_is_percent
- self._saved_fill_remaining_height = getattr(self, "_saved_fill_remaining_height", self.fill_remaining_height)
-
- # Clamp Section to header-only and stop filling parent
- self.fill_remaining_height = False
- self.height_is_percent = False
- self.height = self.header.height
-
- # Hide only the body subtree
- self._for_each_in_body(lambda f: f.add_hidden_reason("COLLAPSED"))
-
- else:
- # Restore original size/fill config
- self.height_is_percent = self._saved_height_is_percent
- self.height = self._saved_height
- self.fill_remaining_height = getattr(self, "_saved_fill_remaining_height", self.fill_remaining_height)
-
- # Unhide the body subtree
- self._for_each_in_body(lambda f: f.remove_hidden_reason("COLLAPSED"))
-
- self._layout_header_actions()
-
- def add_header_button(self, button: Frame):
- """
- Add a control to the header, aligned to the right. Items are packed
- right-to-left with self.header_action_gap spacing.
- """
- # Ensure the button lives under the header
- button.parent = self.header
- # Horizontal alignment from the right, we'll position via x offset
- button.x_align = "right"
- # Vertically center in the header
- button.y_align = "top"
- button.y = max(0, (self.header.height - button.height) // 2)
-
- # Make sure it's tracked and rendered above header background
- if button not in self._header_buttons:
- self._header_buttons.append(button)
-
- self._layout_header_actions()
-
- def add_to_header(self, child):
- # Keep existing API working; route through right-side packer if it's a button-like thing
- # Otherwise, just drop it in at (0,0) and let caller manage it.
- try:
- return self.add_header_button(child)
- except Exception:
- self.header.add_child(child)
-
-
- def toggle_collapse(self):
- self.set_collapsed(not self.collapsed)
-
- def add_child(self, child):
- if getattr(self, "_initializing", False) or not hasattr(self, "body"):
- return super().add_child(child)
- return self.body.add_child(child)
-
- def _for_each_in_body(self, fn):
- stack = [self.body]
- while stack:
- node = stack.pop()
- fn(node)
- stack.extend(node.children)
-
- def add_to_header(self, child):
- try:
- return self.add_header_button(child)
- except Exception:
- self.header.add_child(child)
-
- def get_content_geometry(self):
- # Use the section's outer rect (no header offset). Padding lives on body now.
- abs_x, abs_y, abs_w, abs_h = Frame.get_absolute_geometry(self)
- # Section keeps zero padding; body owns padding. If you ever want chrome padding, set it here.
- pad_top, pad_right, pad_bottom, pad_left = self.padding
- inner_x = abs_x + pad_left
- inner_y = abs_y + pad_top
- inner_w = max(0, abs_w - pad_left - pad_right)
- inner_h = max(0, abs_h - pad_top - pad_bottom)
- return inner_x, inner_y, inner_w, inner_h
-
- def _layout(self):
- _, _, sec_w, sec_h = self.get_absolute_geometry()
- self.body.y = self.header.height
- self.body.height_is_percent = False
- # With the section height now clamped to header.height when collapsed,
- # this naturally becomes 0. Otherwise, it's the remaining space.
- self.body.height = max(0, sec_h - self.header.height)
- self._layout_header_actions()
-
- def draw(self, surface: pygame.Surface) -> None:
- self._layout()
- super().draw(surface)
-
- def process_mouse_move(self, px, py):
- self._layout()
- super().process_mouse_move(px, py)
-
- def process_mouse_press(self, px, py, button):
- self._layout()
- super().process_mouse_press(px, py, button)
-
- def process_mouse_release(self, px, py, button):
- self._layout()
- super().process_mouse_release(px, py, button)
diff --git a/UI/settings/pages/about_settings.py b/UI/settings/pages/about_settings.py
new file mode 100644
index 0000000..a29559c
--- /dev/null
+++ b/UI/settings/pages/about_settings.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from PySide6.QtWidgets import (
+ QVBoxLayout,
+ QWidget,
+ QFormLayout,
+ QGroupBox,
+)
+
+def about_page() ->QWidget:
+ w = QWidget()
+ layout = QVBoxLayout(w)
+
+ top = QGroupBox("About FieldWeave")
+ form = QFormLayout(top)
+ layout.addWidget(top)
+
+ return w
\ No newline at end of file
diff --git a/UI/settings/pages/automation_settings.py b/UI/settings/pages/automation_settings.py
new file mode 100644
index 0000000..34fac61
--- /dev/null
+++ b/UI/settings/pages/automation_settings.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from PySide6.QtWidgets import (
+ QVBoxLayout,
+ QWidget,
+ QFormLayout,
+ QGroupBox,
+)
+
+def automation_page() ->QWidget:
+ w = QWidget()
+ layout = QVBoxLayout(w)
+
+ top = QGroupBox("Automation Settings")
+ form = QFormLayout(top)
+ layout.addWidget(top)
+
+ return w
\ No newline at end of file
diff --git a/UI/settings/pages/camera_settings.py b/UI/settings/pages/camera_settings.py
new file mode 100644
index 0000000..6fec457
--- /dev/null
+++ b/UI/settings/pages/camera_settings.py
@@ -0,0 +1,1451 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from PySide6.QtWidgets import (
+ QVBoxLayout,
+ QWidget,
+ QFormLayout,
+ QGroupBox,
+ QComboBox,
+ QSlider,
+ QCheckBox,
+ QSpinBox,
+ QDoubleSpinBox,
+ QLabel,
+ QHBoxLayout,
+ QPushButton,
+ QFileDialog,
+ QScrollArea,
+ QFrame,
+ QMessageBox,
+)
+from PySide6.QtCore import Qt, Signal, Slot, QTimer
+
+from common.app_context import get_app_context
+from common.logger import info, error, warning, debug
+from common.setting_types import SettingMetadata
+
+# Interval (ms) between live-value polls for hardware-controlled fields.
+_LIVE_POLL_INTERVAL_MS = 500
+
+
+class CameraSettingsWidget(QWidget):
+ """Widget for displaying and editing camera settings"""
+
+ settings_loaded = Signal(bool, object) # success, result
+ modifications_changed = Signal(bool) # has_modifications
+ external_setting_changed = Signal(str, object) # field_name, value
+
+ def __init__(self, parent_dialog=None, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+
+ self.parent_dialog = parent_dialog
+ self.ctx = get_app_context()
+ self._settings_widgets: dict[str, QWidget] = {}
+ self._updating_from_camera = False
+ self._modified_settings: set[str] = set() # Track which settings have been modified
+ self._saved_values: dict[str, any] = {} # Store saved values for comparison
+ self._default_values: dict[str, any] = {} # Store default values for reset
+ self._group_names: list[str] = [] # Track group names in order
+ self._group_widgets: dict[str, QGroupBox] = {} # Map group names to widgets
+
+ # Maps field_name -> (controller_field_name, controlled_when) for all fields
+ # with controlled_by set. Populated in _refresh_settings_display().
+ self._controlled_fields: dict[str, tuple[str, bool]] = {}
+
+ # Live-value polling timer — fires when at least one controller is True.
+ self._live_poll_timer = QTimer(self)
+ self._live_poll_timer.setInterval(_LIVE_POLL_INTERVAL_MS)
+ self._live_poll_timer.timeout.connect(self._poll_live_values)
+
+ self._setup_ui()
+ self._connect_signals()
+ self._populate_camera_list()
+
+ def _setup_ui(self) -> None:
+ """Setup the user interface"""
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ # Scrollable content area
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
+
+ # Content widget inside scroll area with white background
+ content = QWidget()
+ content.setObjectName("CameraSettingsContent")
+ content.setStyleSheet("QWidget#CameraSettingsContent { background: white; }")
+ content_layout = QVBoxLayout(content)
+ content_layout.setContentsMargins(10, 10, 10, 10)
+ content_layout.setSpacing(10)
+
+ # Camera title with larger font
+ camera_title = QLabel("Camera")
+ camera_title.setStyleSheet("font-size: 24px; font-weight: bold; color: #5f6368;")
+ content_layout.addWidget(camera_title)
+
+ # Camera selection group
+ camera_group = QGroupBox("Camera Device")
+ camera_layout = QFormLayout(camera_group)
+
+ # Camera combo with refresh button on same line
+ camera_select_layout = QHBoxLayout()
+ self.camera_combo = QComboBox()
+ self.camera_combo.setMinimumWidth(300)
+ camera_select_layout.addWidget(self.camera_combo)
+
+ self.refresh_btn = QPushButton("Refresh")
+ self.refresh_btn.setMaximumWidth(80)
+ camera_select_layout.addWidget(self.refresh_btn)
+
+ camera_layout.addRow("Select Camera:", camera_select_layout)
+
+ content_layout.addWidget(camera_group)
+
+ # Camera settings groups (will be populated dynamically)
+ self.settings_container = QWidget()
+ self.settings_layout = QVBoxLayout(self.settings_container)
+ self.settings_layout.setContentsMargins(0, 0, 0, 0)
+ self.settings_layout.setSpacing(10)
+
+ content_layout.addWidget(self.settings_container)
+ content_layout.addStretch()
+
+ # Reset and Load buttons at bottom
+ button_layout = QHBoxLayout()
+ self.reset_btn = QPushButton("Reset to Defaults")
+ self.reset_btn.setEnabled(False)
+ self.load_btn = QPushButton("Load Settings")
+ self.load_btn.setEnabled(False)
+
+ button_layout.addWidget(self.reset_btn)
+ button_layout.addWidget(self.load_btn)
+ button_layout.addStretch()
+
+ content_layout.addLayout(button_layout)
+
+ scroll.setWidget(content)
+ layout.addWidget(scroll)
+
+ def _connect_signals(self) -> None:
+ """Connect signals and slots"""
+ self.camera_combo.currentIndexChanged.connect(self._on_camera_changed)
+ self.refresh_btn.clicked.connect(lambda: self._populate_camera_list(force_enumerate=True))
+ self.reset_btn.clicked.connect(self._reset_settings)
+ self.load_btn.clicked.connect(self._load_settings)
+ self.settings_loaded.connect(self._on_settings_loaded)
+ self.external_setting_changed.connect(self._handle_external_setting_change)
+
+ # Connect to parent dialog's save button if available
+ if self.parent_dialog:
+ if hasattr(self.parent_dialog, 'save_btn'):
+ self.parent_dialog.save_btn.clicked.connect(self._save_settings)
+ # Connect modifications_changed signal to enable/disable save button
+ self.modifications_changed.connect(self.parent_dialog.save_btn.setEnabled)
+ if hasattr(self.parent_dialog, 'save_camera_settings'):
+ self.parent_dialog.save_camera_settings.connect(self._save_settings)
+
+ # Connect to camera manager signals
+ if self.ctx.camera_manager:
+ self.ctx.camera_manager.camera_list_changed.connect(
+ self._on_camera_list_changed
+ )
+ self.ctx.camera_manager.active_camera_changed.connect(
+ self._on_active_camera_changed
+ )
+
+ def _populate_camera_list(self, force_enumerate: bool = False) -> None:
+ """Populate the camera dropdown with available cameras
+
+ Args:
+ force_enumerate: If True, force re-enumeration. Otherwise use cached list.
+ """
+ self.camera_combo.blockSignals(True)
+ self.camera_combo.clear()
+
+ if not self.ctx.camera_manager:
+ self.camera_combo.addItem("No camera manager available")
+ self.camera_combo.setEnabled(False)
+ self.camera_combo.blockSignals(False)
+ return
+
+ # Use cached list unless forced to enumerate
+ if force_enumerate:
+ cameras = self.ctx.camera_manager.enumerate_cameras()
+ else:
+ cameras = self.ctx.camera_manager.available_cameras
+ # If no cached cameras, enumerate once
+ if not cameras:
+ cameras = self.ctx.camera_manager.enumerate_cameras()
+
+ if not cameras:
+ self.camera_combo.addItem("No cameras detected")
+ self.camera_combo.setEnabled(False)
+ else:
+ self.camera_combo.setEnabled(True)
+
+ # Add cameras to dropdown
+ for camera_info in cameras:
+ display_text = f"{camera_info.display_name} ({camera_info.model})"
+ self.camera_combo.addItem(display_text, camera_info)
+
+ # Select the active camera if any
+ active_info = self.ctx.camera_manager.active_camera_info
+ if active_info:
+ for i in range(self.camera_combo.count()):
+ info_at_index = self.camera_combo.itemData(i)
+ if info_at_index and info_at_index.device_id == active_info.device_id:
+ self.camera_combo.setCurrentIndex(i)
+ break
+
+ self.camera_combo.blockSignals(False)
+
+ # Only refresh settings if we have an active camera
+ if self.ctx.camera and self.ctx.camera.underlying_camera.is_open:
+ self._refresh_settings_display()
+
+ @Slot(int)
+ def _on_camera_changed(self, index: int) -> None:
+ """Handle camera selection change"""
+ if index < 0:
+ return
+
+ camera_info = self.camera_combo.itemData(index)
+ if not camera_info:
+ return
+
+ # Switch to the selected camera
+ info(f"Switching to camera: {camera_info.display_name}")
+ success = self.ctx.camera_manager.switch_camera(camera_info)
+
+ if success:
+ self._refresh_settings_display()
+ else:
+ error(f"Failed to switch to camera: {camera_info.display_name}")
+
+ @Slot()
+ def _on_camera_list_changed(self) -> None:
+ """Handle camera list changes from camera manager"""
+ # Use cached list since camera_list_changed is emitted after enumeration
+ self._populate_camera_list(force_enumerate=False)
+
+ @Slot(object)
+ def _on_active_camera_changed(self, camera_info) -> None:
+ """Handle active camera changes from camera manager"""
+ self._refresh_settings_display()
+
+ # Update combo box selection
+ if camera_info:
+ self.camera_combo.blockSignals(True)
+ for i in range(self.camera_combo.count()):
+ info_at_index = self.camera_combo.itemData(i)
+ if info_at_index and info_at_index.device_id == camera_info.device_id:
+ self.camera_combo.setCurrentIndex(i)
+ break
+ self.camera_combo.blockSignals(False)
+
+ def _refresh_settings_display(self) -> None:
+ """Refresh the settings display based on current camera"""
+ self._live_poll_timer.stop()
+ self._controlled_fields.clear()
+
+ # Clear existing settings widgets
+ self._clear_settings_display()
+
+ camera = self.ctx.camera
+ if not camera:
+ self._show_no_camera_message()
+ return
+
+ # Check if camera is open
+ if not camera.underlying_camera.is_open:
+ self._show_camera_not_open_message()
+ return
+
+ # Get settings metadata
+ try:
+ settings = camera.settings
+ metadata_list = settings.get_metadata()
+
+ # Store current values as "saved" baseline and also as defaults
+ self._saved_values.clear()
+ self._default_values.clear()
+ self._modified_settings.clear()
+ for meta in metadata_list:
+ current_value = getattr(settings, meta.name, None)
+ self._saved_values[meta.name] = current_value
+ # Only set default values if not already set (preserve first load)
+ if meta.name not in self._default_values:
+ self._default_values[meta.name] = current_value
+
+ # Build controlled-field index from metadata
+ for meta in metadata_list:
+ if meta.controlled_by:
+ controlled_when = getattr(meta, 'controlled_when', True)
+ self._controlled_fields[meta.name] = (meta.controlled_by, controlled_when)
+
+ # Group settings by category
+ grouped_settings = self._group_settings(metadata_list)
+
+ # Clear and rebuild group tracking
+ self._group_names.clear()
+ self._group_widgets.clear()
+
+ # Create UI for each group
+ for group_name, settings_in_group in grouped_settings.items():
+ group_box = self._create_settings_group(group_name, settings_in_group)
+ self.settings_layout.addWidget(group_box)
+
+ # Track the group
+ self._group_names.append(group_name)
+ self._group_widgets[group_name] = group_box
+
+ # Register with parent dialog for scrolling
+ if self.parent_dialog and hasattr(self.parent_dialog, 'register_group_box'):
+ self.parent_dialog.register_group_box("Camera", group_name, group_box)
+
+ # Apply initial controlled-field state (greyed-out / locked if controller is on)
+ self._apply_all_controlled_states(settings)
+
+ # Register callback for external setting changes (e.g., async DFC completion)
+ if hasattr(settings, '_ui_update_callback'):
+ settings._ui_update_callback = self._on_external_setting_change
+ debug("Registered UI update callback for external setting changes")
+ else:
+ debug("Settings object does not support _ui_update_callback")
+
+ # Start live polling if any controller is currently active
+ if self._any_controller_active(settings):
+ self._live_poll_timer.start()
+
+ # Update tree items in parent dialog
+ if self.parent_dialog and hasattr(self.parent_dialog, '_update_camera_groups'):
+ self.parent_dialog._update_camera_groups(self._group_names)
+
+ # Enable buttons
+ if self.parent_dialog and hasattr(self.parent_dialog, 'save_btn'):
+ self.parent_dialog.save_btn.setEnabled(True)
+ self.reset_btn.setEnabled(True)
+ self.load_btn.setEnabled(True)
+
+ except Exception as e:
+ error(f"Error loading camera settings: {e}")
+ self._show_error_message(str(e))
+
+ def _clear_settings_display(self) -> None:
+ """Clear all settings widgets"""
+ while self.settings_layout.count():
+ item = self.settings_layout.takeAt(0)
+ if item.widget():
+ item.widget().deleteLater()
+
+ self._settings_widgets.clear()
+
+ # Disable buttons
+ if self.parent_dialog and hasattr(self.parent_dialog, 'save_btn'):
+ self.parent_dialog.save_btn.setEnabled(False)
+ self.reset_btn.setEnabled(False)
+ self.load_btn.setEnabled(False)
+
+ def _show_no_camera_message(self) -> None:
+ """Show message when no camera is available"""
+ label = QLabel("No camera selected. Please select a camera from the dropdown above.")
+ label.setWordWrap(True)
+ label.setStyleSheet("color: gray; padding: 20px;")
+ self.settings_layout.addWidget(label)
+
+ def _show_camera_not_open_message(self) -> None:
+ """Show message when camera is not open"""
+ label = QLabel("Camera is not open. Please open the camera first.")
+ label.setWordWrap(True)
+ label.setStyleSheet("color: orange; padding: 20px;")
+ self.settings_layout.addWidget(label)
+
+ def _show_error_message(self, error_msg: str) -> None:
+ """Show error message"""
+ label = QLabel(f"Error loading settings: {error_msg}")
+ label.setWordWrap(True)
+ label.setStyleSheet("color: red; padding: 20px;")
+ self.settings_layout.addWidget(label)
+
+ def _group_settings(self, metadata_list: list[SettingMetadata]) -> dict[str, list[SettingMetadata]]:
+ """Group settings by their group property"""
+ grouped: dict[str, list[SettingMetadata]] = {}
+
+ for meta in metadata_list:
+ group = meta.group # Always present with default "General"
+
+ if group not in grouped:
+ grouped[group] = []
+
+ grouped[group].append(meta)
+
+ return grouped
+
+ def _create_settings_group(self, group_name: str, settings_list: list[SettingMetadata]) -> QGroupBox:
+ """Create a group box for a category of settings"""
+ group_box = QGroupBox(group_name)
+ layout = QFormLayout(group_box)
+
+ for setting_meta in settings_list:
+ widget = self._create_setting_widget(setting_meta)
+ if widget:
+ # Create label with tooltip
+ label = QLabel(setting_meta.display_name + ":")
+ if setting_meta.description:
+ label.setToolTip(setting_meta.description)
+
+ layout.addRow(label, widget)
+
+ # Store a container that holds references to both label and control
+ # for later styling and enable/disable operations.
+ widget_container = QWidget()
+ widget_container.setProperty("label", label)
+ widget_container.setProperty("control", widget)
+ self._settings_widgets[setting_meta.name] = widget_container
+
+ return group_box
+
+ def _create_setting_widget(self, meta: SettingMetadata) -> QWidget | None:
+ """Create appropriate widget for a setting based on its metadata"""
+ camera = self.ctx.camera
+ if not camera:
+ return None
+
+ settings = camera.settings
+
+ # Convert enum to string value if needed
+ type_str = meta.setting_type.value if hasattr(meta.setting_type, 'value') else str(meta.setting_type)
+
+ # Create widget based on type
+ if type_str == "bool":
+ return self._create_bool_widget(meta, settings)
+ elif type_str == "range":
+ return self._create_range_widget(meta, settings)
+ elif type_str == "dropdown":
+ return self._create_dropdown_widget(meta, settings)
+ elif type_str == "rgba_level":
+ return self._create_rgba_level_widget(meta, settings)
+ elif type_str == "button":
+ return self._create_button_widget(meta, settings)
+ elif type_str == "file_picker_button":
+ return self._create_file_picker_button_widget(meta, settings)
+ elif type_str == "number_picker":
+ return self._create_number_picker_widget(meta, settings)
+ elif type_str == "rgb_gain":
+ # TODO: Implement custom RGB gain widget
+ warning(f"RGB_GAIN widget not yet implemented for {meta.name}")
+ return None
+ else:
+ warning(f"Unknown setting type: {type_str} for {meta.name}")
+ return None
+
+ def _create_bool_widget(self, meta: SettingMetadata, settings) -> QCheckBox | None:
+ """Create checkbox for boolean settings"""
+ # Check if setter exists first
+ setter_name = f"set_{meta.name}"
+ if not hasattr(settings, setter_name):
+ warning(f"No setter found: {setter_name} - skipping widget creation")
+ return None
+
+ checkbox = QCheckBox()
+
+ # Get current value
+ current_value = getattr(settings, meta.name, False)
+ checkbox.setChecked(current_value)
+
+ # Set tooltip
+ if meta.description:
+ checkbox.setToolTip(meta.description)
+
+ # Connect to setter
+ checkbox.checkStateChanged.connect(
+ lambda state: self._on_bool_changed(setter_name, state == Qt.CheckState.Checked)
+ )
+
+ return checkbox
+
+ def _create_range_widget(self, meta: SettingMetadata, settings) -> QWidget | None:
+ """Create slider with value display for range settings"""
+ # Check if setter exists first
+ setter_name = f"set_{meta.name}"
+ if not hasattr(settings, setter_name):
+ warning(f"No setter found: {setter_name} - skipping widget creation")
+ return None
+
+ container = QWidget()
+ layout = QHBoxLayout(container)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ # Determine if we need float or int
+ is_float = meta.min_value is not None and isinstance(meta.min_value, float)
+
+ if is_float:
+ # Use double spin box for float values
+ spinbox = QDoubleSpinBox()
+ spinbox.setDecimals(2)
+ else:
+ # Use regular spin box for int values
+ spinbox = QSpinBox()
+
+ # Set fixed width to accommodate 6 digits plus decimals/sign
+ spinbox.setFixedWidth(90)
+
+ # Set range
+ if meta.min_value is not None and meta.max_value is not None:
+ spinbox.setMinimum(meta.min_value)
+ spinbox.setMaximum(meta.max_value)
+
+ # Get current value
+ current_value = getattr(settings, meta.name, 0)
+ spinbox.setValue(current_value)
+
+ # Set tooltip
+ if meta.description:
+ spinbox.setToolTip(meta.description)
+
+ # Create slider
+ slider = QSlider(Qt.Orientation.Horizontal)
+
+ if is_float:
+ # For float values, scale to int range for slider
+ slider.setMinimum(0)
+ slider.setMaximum(1000)
+ slider.setValue(
+ int((current_value - meta.min_value) / (meta.max_value - meta.min_value) * 1000)
+ )
+ else:
+ slider.setMinimum(int(meta.min_value) if meta.min_value is not None else 0)
+ slider.setMaximum(int(meta.max_value) if meta.max_value is not None else 100)
+ slider.setValue(int(current_value))
+
+ # Connect signals
+ if is_float:
+ spinbox.valueChanged.connect(
+ lambda val: self._on_float_changed(setter_name, val, slider, meta)
+ )
+ slider.valueChanged.connect(
+ lambda val: self._on_slider_changed_float(setter_name, val, spinbox, meta)
+ )
+ else:
+ spinbox.valueChanged.connect(
+ lambda val: self._on_int_changed(setter_name, val, slider)
+ )
+ slider.valueChanged.connect(
+ lambda val: self._on_slider_changed_int(setter_name, val, spinbox)
+ )
+
+ layout.addWidget(slider)
+ layout.addWidget(spinbox)
+
+ return container
+
+ def _create_dropdown_widget(self, meta: SettingMetadata, settings) -> QComboBox | None:
+ """Create dropdown for choice settings"""
+ # Check if setter exists first
+ setter_name = f"set_{meta.name}"
+ if not hasattr(settings, setter_name):
+ warning(f"No setter found: {setter_name} - skipping widget creation")
+ return None
+
+ combo = QComboBox()
+
+ # Add choices
+ if meta.choices:
+ for choice in meta.choices:
+ combo.addItem(str(choice), choice)
+
+ # Set current value
+ current_value = getattr(settings, meta.name, None)
+ if current_value is not None:
+ index = combo.findData(current_value)
+ if index >= 0:
+ combo.setCurrentIndex(index)
+
+ # Set tooltip
+ if meta.description:
+ combo.setToolTip(meta.description)
+
+ # Connect to setter
+ combo.currentIndexChanged.connect(
+ lambda idx: self._on_dropdown_changed(setter_name, idx, combo.itemData(idx))
+ )
+
+ return combo
+
+ def _create_rgba_level_widget(self, meta: SettingMetadata, settings) -> QWidget | None:
+ """Create RGBA level widget with four spinboxes for R, G, B, A"""
+ # Check if setter exists first
+ setter_name = f"set_{meta.name}"
+ if not hasattr(settings, setter_name):
+ warning(f"No setter found: {setter_name} - skipping widget creation")
+ return None
+
+ container = QWidget()
+ layout = QHBoxLayout(container)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ # Get current value (should be an RGBALevel object)
+ current_value = getattr(settings, meta.name, None)
+
+ # Create spinboxes for each channel
+ spinboxes = {}
+ for channel in ['r', 'g', 'b', 'a']:
+ channel_layout = QVBoxLayout()
+ channel_layout.setSpacing(2)
+
+ label = QLabel(channel.upper())
+ label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+
+ spinbox = QSpinBox()
+ spinbox.setMinimum(0)
+ spinbox.setMaximum(255)
+ spinbox.setFixedWidth(60)
+
+ # Set current value if available
+ if current_value and hasattr(current_value, channel):
+ spinbox.setValue(getattr(current_value, channel))
+
+ channel_layout.addWidget(label)
+ channel_layout.addWidget(spinbox)
+
+ layout.addLayout(channel_layout)
+ spinboxes[channel] = spinbox
+
+ # Connect to setter
+ # Create a function that updates all values when any spinbox changes
+ def on_rgba_changed():
+ if self._updating_from_camera:
+ return
+
+ # Import RGBALevel here to avoid circular imports
+ try:
+ from camera.settings.camera_settings import RGBALevel
+
+ new_value = RGBALevel(
+ r=spinboxes['r'].value(),
+ g=spinboxes['g'].value(),
+ b=spinboxes['b'].value(),
+ a=spinboxes['a'].value()
+ )
+
+ setter = getattr(settings, setter_name)
+ setter(new_value)
+
+ # Mark as modified
+ setting_name = setter_name.replace("set_", "")
+ self._mark_setting_modified(setting_name, new_value)
+
+ debug(f"Set {setter_name} to {new_value}")
+ except Exception as e:
+ error(f"Error setting {setter_name}: {e}")
+
+ # Connect all spinboxes to the same handler
+ for spinbox in spinboxes.values():
+ spinbox.valueChanged.connect(on_rgba_changed)
+
+ layout.addStretch()
+ return container
+
+ def _create_button_widget(self, meta: SettingMetadata, settings) -> QPushButton | None:
+ """Create a button that calls a setter method without arguments"""
+ setter_name = f"set_{meta.name}"
+ if not hasattr(settings, setter_name):
+ warning(f"No setter found: {setter_name} - skipping widget creation")
+ return None
+
+ button = QPushButton(meta.display_name)
+
+ # Set tooltip
+ if meta.description:
+ button.setToolTip(meta.description)
+
+ # Connect to setter
+ def on_button_clicked():
+ if self._updating_from_camera:
+ return
+
+ camera = self.ctx.camera
+ if not camera:
+ return
+
+ try:
+ setter = getattr(camera.settings, setter_name)
+ setter()
+ debug(f"Called {setter_name}")
+
+ # Refresh controlled states in case this button enabled other controls
+ self._apply_all_controlled_states(camera.settings)
+
+ if self.ctx and hasattr(self.ctx, 'toast'):
+ self.ctx.toast.success(f"{meta.display_name} completed", duration=2000)
+ except Exception as e:
+ error(f"Error calling {setter_name}: {e}")
+ if self.ctx and hasattr(self.ctx, 'toast'):
+ self.ctx.toast.error(f"Error: {e}", duration=3000)
+
+ button.clicked.connect(on_button_clicked)
+ return button
+
+ def _create_file_picker_button_widget(self, meta: SettingMetadata, settings) -> QPushButton | None:
+ """Create a file picker button that calls a setter method with a filepath"""
+ setter_name = f"set_{meta.name}"
+ if not hasattr(settings, setter_name):
+ warning(f"No setter found: {setter_name} - skipping widget creation")
+ return None
+
+ button = QPushButton(meta.display_name)
+
+ # Set tooltip
+ if meta.description:
+ button.setToolTip(meta.description)
+
+ # Determine if this is an import or export button based on name
+ is_export = 'export' in meta.name.lower()
+
+ # Connect to setter
+ def on_button_clicked():
+ if self._updating_from_camera:
+ return
+
+ camera = self.ctx.camera
+ if not camera:
+ return
+
+ # Determine file extension from metadata name (e.g., dfc_import -> .dfc)
+ name_parts = meta.name.split('_')
+ if len(name_parts) >= 2:
+ file_ext = name_parts[0] # e.g., 'dfc'
+ else:
+ file_ext = 'dat'
+
+ # Get default directory and filename
+ # Try to use stored filepath if available, otherwise use config directory
+ from pathlib import Path
+ default_path = ""
+
+ # Look for a filepath field (e.g., dfc_filepath for dfc_import/dfc_export)
+ filepath_field = f"{file_ext}_filepath"
+ if hasattr(camera.settings, filepath_field):
+ stored_path = getattr(camera.settings, filepath_field)
+ if stored_path:
+ default_path = stored_path
+
+ # If no stored path, use config directory
+ if not default_path:
+ config_dir = Path("./config/cameras") / camera.underlying_camera.model
+ config_dir.mkdir(parents=True, exist_ok=True)
+ if is_export:
+ # Suggest a timestamped filename for exports
+ from datetime import datetime
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ default_path = str(config_dir / f"{file_ext}_{timestamp}.{file_ext}")
+ else:
+ # Just use the directory for imports
+ default_path = str(config_dir)
+
+ # Open file dialog
+ if is_export:
+ file_path, _ = QFileDialog.getSaveFileName(
+ self,
+ f"Export {meta.display_name}",
+ default_path,
+ f"{file_ext.upper()} Files (*.{file_ext});;All Files (*)"
+ )
+ else:
+ file_path, _ = QFileDialog.getOpenFileName(
+ self,
+ f"Import {meta.display_name}",
+ default_path,
+ f"{file_ext.upper()} Files (*.{file_ext});;All Files (*)"
+ )
+
+ if not file_path:
+ return
+
+ try:
+ setter = getattr(camera.settings, setter_name)
+ setter(file_path)
+ debug(f"Called {setter_name} with {file_path}")
+
+ # Refresh controlled states in case this button enabled other controls
+ self._apply_all_controlled_states(camera.settings)
+
+ action = "exported to" if is_export else "imported from"
+ if self.ctx and hasattr(self.ctx, 'toast'):
+ self.ctx.toast.success(f"Successfully {action} {file_path}", duration=2000)
+ except Exception as e:
+ error(f"Error calling {setter_name}: {e}")
+ if self.ctx and hasattr(self.ctx, 'toast'):
+ self.ctx.toast.error(f"Error: {e}", duration=3000)
+
+ button.clicked.connect(on_button_clicked)
+ return button
+
+ def _create_number_picker_widget(self, meta: SettingMetadata, settings) -> QSpinBox | None:
+ """Create a number picker (spinbox only, no slider)"""
+ setter_name = f"set_{meta.name}"
+ if not hasattr(settings, setter_name):
+ warning(f"No setter found: {setter_name} - skipping widget creation")
+ return None
+
+ spinbox = QSpinBox()
+ spinbox.setFixedWidth(90)
+
+ # Set range
+ if meta.min_value is not None and meta.max_value is not None:
+ spinbox.setMinimum(meta.min_value)
+ spinbox.setMaximum(meta.max_value)
+
+ # Get current value
+ current_value = getattr(settings, meta.name, 0)
+ spinbox.setValue(current_value)
+
+ # Set tooltip
+ if meta.description:
+ spinbox.setToolTip(meta.description)
+
+ # Connect to setter
+ def on_value_changed(value: int):
+ if self._updating_from_camera:
+ return
+
+ camera = self.ctx.camera
+ if not camera:
+ return
+
+ try:
+ setter = getattr(camera.settings, setter_name)
+ setter(value)
+
+ # Extract setting name from setter name (remove "set_" prefix)
+ setting_name = setter_name.replace("set_", "")
+ self._mark_setting_modified(setting_name, value)
+
+ debug(f"Set {setter_name} to {value}")
+ except Exception as e:
+ error(f"Error setting {setter_name}: {e}")
+
+ spinbox.valueChanged.connect(on_value_changed)
+ return spinbox
+
+ # ------------------------------------------------------------------
+ # Controlled-field helpers
+ # ------------------------------------------------------------------
+
+ def _any_controller_active(self, settings) -> bool:
+ """Return True if at least one field is currently in its controlled (locked) state."""
+ for field_name, (controller_name, controlled_when) in self._controlled_fields.items():
+ controller_value = bool(getattr(settings, controller_name, False))
+ if controller_value == controlled_when:
+ return True
+ return False
+
+ def _apply_all_controlled_states(self, settings) -> None:
+ """Apply grey-out / lock state for every controlled field based on current settings."""
+ for field_name, (controller_name, controlled_when) in self._controlled_fields.items():
+ controller_value = bool(getattr(settings, controller_name, False))
+ is_locked = controller_value == controlled_when
+ self._set_field_controlled(field_name, is_locked)
+
+ def _set_field_controlled(self, field_name: str, controlled: bool) -> None:
+ """Grey out (and lock) or restore a controlled field widget."""
+ container = self._settings_widgets.get(field_name)
+ if not container:
+ return
+
+ label = container.property("label")
+ control = container.property("control")
+
+ if controlled:
+ # Visually dim the label
+ if label:
+ label.setStyleSheet("QLabel { color: #aaaaaa; }")
+ # Disable all interactive child widgets so the user cannot edit
+ if control:
+ for child in control.findChildren(QWidget):
+ child.setEnabled(False)
+ control.setEnabled(False)
+ else:
+ # Restore normal appearance and re-enable
+ if label:
+ label.setStyleSheet("")
+ if control:
+ control.setEnabled(True)
+ for child in control.findChildren(QWidget):
+ child.setEnabled(True)
+ # Re-apply any existing "modified" orange styling
+ is_modified = field_name in self._modified_settings
+ if is_modified:
+ self._apply_orange_styling(container, True)
+
+ def _update_display_value(self, field_name: str, value: int) -> None:
+ """Update only the visual display of a controlled field without touching settings."""
+ container = self._settings_widgets.get(field_name)
+ if not container:
+ return
+
+ control = container.property("control")
+ if not control:
+ return
+
+ self._updating_from_camera = True
+ try:
+ # Range widgets: container holds a slider and a spinbox
+ spinboxes = control.findChildren(QSpinBox)
+ dbl_spinboxes = control.findChildren(QDoubleSpinBox)
+ sliders = control.findChildren(QSlider)
+
+ for sb in spinboxes:
+ sb.blockSignals(True)
+ sb.setValue(int(value))
+ sb.blockSignals(False)
+ for sb in dbl_spinboxes:
+ sb.blockSignals(True)
+ sb.setValue(float(value))
+ sb.blockSignals(False)
+ for sl in sliders:
+ sl.blockSignals(True)
+ sl.setValue(int(value))
+ sl.blockSignals(False)
+ finally:
+ self._updating_from_camera = False
+
+ @Slot()
+ def _poll_live_values(self) -> None:
+ """Timer slot: read live hardware values and update display widgets."""
+ camera = self.ctx.camera
+ if not camera:
+ self._live_poll_timer.stop()
+ return
+
+ settings = camera.settings
+ try:
+ live = settings.get_live_values()
+ except Exception as e:
+ error(f"Error polling live values: {e}")
+ return
+
+ if not live:
+ # No controlled fields are active; stop polling.
+ self._live_poll_timer.stop()
+ return
+
+ for field_name, value in live.items():
+ self._update_display_value(field_name, value)
+
+ def _on_external_setting_change(self, field_name: str, value) -> None:
+ """Handle setting changes that occur externally (e.g., async callbacks).
+
+ This is called from a camera thread and needs to be marshalled to the UI thread.
+ We emit a signal which will be delivered to the UI thread automatically.
+ """
+ debug(f"_on_external_setting_change called: {field_name} = {value}")
+ self.external_setting_changed.emit(field_name, value)
+
+ @Slot(str, object)
+ def _handle_external_setting_change(self, field_name: str, value) -> None:
+ """Handle external setting change on the UI thread (connected to signal).
+
+ This runs on the UI thread after the signal is emitted from the camera thread.
+ """
+ camera = self.ctx.camera
+ if not camera:
+ debug(f"External setting change for '{field_name}': no camera")
+ return
+
+ # Update the actual setting value in the widget (if it has one)
+ container = self._settings_widgets.get(field_name)
+ if container:
+ control = container.property("control")
+ if control and isinstance(control, QCheckBox):
+ self._updating_from_camera = True
+ try:
+ control.blockSignals(True)
+ control.setChecked(value)
+ control.blockSignals(False)
+ debug(f"Updated checkbox widget for '{field_name}' to {value}")
+ finally:
+ self._updating_from_camera = False
+ else:
+ debug(f"No widget found for '{field_name}' (this is normal for controller-only fields)")
+
+ # If this field controls others, update their state
+ controlled_by_this = [
+ (fn, controlled_when)
+ for fn, (ctrl, controlled_when) in self._controlled_fields.items()
+ if ctrl == field_name
+ ]
+
+ if controlled_by_this:
+ for fn, controlled_when in controlled_by_this:
+ is_locked = value == controlled_when
+ self._set_field_controlled(fn, is_locked)
+
+ # ------------------------------------------------------------------
+
+ def _mark_setting_modified(self, setting_name: str, current_value) -> None:
+ """Mark a setting as modified and update its widget styling"""
+ # Check if value actually changed from saved value
+ saved_value = self._saved_values.get(setting_name)
+
+ # Handle different value types for comparison
+ is_modified = False
+ if saved_value is None:
+ is_modified = current_value is not None
+ elif hasattr(saved_value, '__dict__'):
+ # For objects like RGBALevel, compare attributes
+ is_modified = str(saved_value) != str(current_value)
+ else:
+ is_modified = saved_value != current_value
+
+ # Update modified tracking
+ if is_modified:
+ self._modified_settings.add(setting_name)
+ else:
+ self._modified_settings.discard(setting_name)
+
+ # Update widget styling (skip if still greyed out / controlled)
+ entry = self._controlled_fields.get(setting_name)
+ if entry:
+ controller_name, controlled_when = entry
+ camera = self.ctx.camera
+ if camera:
+ controller_value = bool(getattr(camera.settings, controller_name, False))
+ if controller_value == controlled_when:
+ # Field is still locked — don't apply orange yet
+ self._emit_modifications_changed()
+ return
+
+ self._update_widget_styling(setting_name, is_modified)
+ self._emit_modifications_changed()
+
+ def _emit_modifications_changed(self) -> None:
+ # Update category color in parent dialog
+ if self.parent_dialog and hasattr(self.parent_dialog, 'set_category_modified'):
+ self.parent_dialog.set_category_modified("Camera", len(self._modified_settings) > 0)
+ # Emit signal about modification state change
+ self.modifications_changed.emit(len(self._modified_settings) > 0)
+
+ def _update_widget_styling(self, setting_name: str, is_modified: bool) -> None:
+ """Update the visual styling of a widget to indicate modification"""
+ widget = self._settings_widgets.get(setting_name)
+ if not widget:
+ return
+
+ if is_modified:
+ # Orange text and slider for modified settings
+ self._apply_orange_styling(widget, True)
+ else:
+ # Clear custom styling to revert to default
+ self._apply_orange_styling(widget, False)
+
+ def _apply_orange_styling(self, widget: QWidget, orange: bool) -> None:
+ """Apply or remove orange styling to a widget and its children"""
+ # Get the actual control widget and label from the container
+ label = widget.property("label")
+ control = widget.property("control")
+
+ if not control:
+ return
+
+ if orange:
+ # Color the label text
+ if label:
+ label.setStyleSheet("QLabel { color: #FFA500; }")
+
+ # For different widget types, apply orange
+ if isinstance(control, QCheckBox):
+ control.setStyleSheet("QCheckBox { color: #FFA500; }")
+ elif isinstance(control, QComboBox):
+ control.setStyleSheet("QComboBox { color: #FFA500; }")
+ elif isinstance(control, QWidget):
+ for child in control.findChildren(QSlider):
+ child.setStyleSheet("""
+ QSlider::handle:horizontal {
+ background: #FFA500;
+ border: 1px solid #FFA500;
+ width: 18px;
+ margin: -2px 0;
+ border-radius: 3px;
+ }
+ """)
+ else:
+ # Clear styling
+ if label:
+ label.setStyleSheet("")
+ control.setStyleSheet("")
+ for child in control.findChildren(QWidget):
+ child.setStyleSheet("")
+
+ def _clear_all_modifications(self) -> None:
+ """Clear all modification markers and update saved values"""
+ camera = self.ctx.camera
+ if not camera:
+ return
+
+ settings = camera.settings
+
+ # Update saved values to current values
+ for setting_name in list(self._modified_settings):
+ current_value = getattr(settings, setting_name, None)
+ self._saved_values[setting_name] = current_value
+ self._update_widget_styling(setting_name, False)
+
+ self._modified_settings.clear()
+
+ # Update category color in parent dialog
+ if self.parent_dialog and hasattr(self.parent_dialog, 'set_category_modified'):
+ self.parent_dialog.set_category_modified("Camera", False)
+
+ # Emit signal about modification state change
+ self.modifications_changed.emit(False)
+
+ def has_unsaved_changes(self) -> bool:
+ """Check if there are unsaved changes"""
+ return len(self._modified_settings) > 0
+
+ def get_group_names(self) -> list[str]:
+ """Get list of group names in the settings"""
+ return self._group_names.copy()
+
+ def _on_bool_changed(self, setter_name: str, value: bool) -> None:
+ """Handle boolean setting change"""
+ if self._updating_from_camera:
+ return
+
+ camera = self.ctx.camera
+ if not camera:
+ return
+
+ field_name = setter_name.removeprefix("set_")
+
+ # Determine if this boolean is a controller for other fields.
+ controlled_by_this = [
+ (fn, controlled_when)
+ for fn, (ctrl, controlled_when) in self._controlled_fields.items()
+ if ctrl == field_name
+ ]
+
+ # If we are turning the controller OFF, flush live-value fields (controlled_when=True).
+ if not value and controlled_by_this:
+ try:
+ camera.settings.on_controller_disabled(field_name)
+ except Exception as e:
+ error(f"Error flushing controlled values for {field_name}: {e}")
+
+ try:
+ setter = getattr(camera.settings, setter_name)
+ setter(value)
+ self._mark_setting_modified(field_name, value)
+ debug(f"Set {setter_name} to {value}")
+ except Exception as e:
+ error(f"Error setting {setter_name}: {e}")
+ return
+
+ # Update controlled field state and live polling
+ if controlled_by_this:
+ for fn, controlled_when in controlled_by_this:
+ is_locked = value == controlled_when
+ self._set_field_controlled(fn, is_locked)
+
+ if not is_locked and controlled_when:
+ # A live-value field just became editable — flush its display and mark modified.
+ flushed_value = getattr(camera.settings, fn, None)
+ if flushed_value is not None:
+ self._update_display_value(fn, flushed_value)
+ self._mark_setting_modified(fn, flushed_value)
+
+ if self._any_controller_active(camera.settings):
+ if not self._live_poll_timer.isActive():
+ self._live_poll_timer.start()
+ else:
+ self._live_poll_timer.stop()
+
+ def _on_int_changed(self, setter_name: str, value: int, slider: QSlider) -> None:
+ """Handle integer setting change from spinbox"""
+ if self._updating_from_camera:
+ return
+
+ # Update slider
+ slider.blockSignals(True)
+ slider.setValue(value)
+ slider.blockSignals(False)
+
+ camera = self.ctx.camera
+ if not camera:
+ return
+
+ try:
+ setter = getattr(camera.settings, setter_name)
+ setter(value)
+
+ # Extract setting name from setter name (remove "set_" prefix)
+ setting_name = setter_name.replace("set_", "")
+ self._mark_setting_modified(setting_name, value)
+
+ debug(f"Set {setter_name} to {value}")
+ except Exception as e:
+ error(f"Error setting {setter_name}: {e}")
+
+ def _on_float_changed(self, setter_name: str, value: float, slider: QSlider, meta) -> None:
+ """Handle float setting change from spinbox"""
+ if self._updating_from_camera:
+ return
+
+ # Update slider
+ slider.blockSignals(True)
+ slider_val = int((value - meta.min_value) / (meta.max_value - meta.min_value) * 1000)
+ slider.setValue(slider_val)
+ slider.blockSignals(False)
+
+ camera = self.ctx.camera
+ if not camera:
+ return
+
+ try:
+ setter = getattr(camera.settings, setter_name)
+ setter(value)
+
+ # Extract setting name from setter name (remove "set_" prefix)
+ setting_name = setter_name.replace("set_", "")
+ self._mark_setting_modified(setting_name, value)
+
+ debug(f"Set {setter_name} to {value}")
+ except Exception as e:
+ error(f"Error setting {setter_name}: {e}")
+
+ def _on_slider_changed_int(self, setter_name: str, value: int, spinbox: QSpinBox) -> None:
+ """Handle integer setting change from slider"""
+ if self._updating_from_camera:
+ return
+
+ # Update spinbox
+ spinbox.blockSignals(True)
+ spinbox.setValue(value)
+ spinbox.blockSignals(False)
+
+ camera = self.ctx.camera
+ if not camera:
+ return
+
+ try:
+ setter = getattr(camera.settings, setter_name)
+ setter(value)
+
+ # Extract setting name from setter name (remove "set_" prefix)
+ setting_name = setter_name.replace("set_", "")
+ self._mark_setting_modified(setting_name, value)
+
+ debug(f"Set {setter_name} to {value}")
+ except Exception as e:
+ error(f"Error setting {setter_name}: {e}")
+
+ def _on_slider_changed_float(self, setter_name: str, slider_val: int,
+ spinbox: QDoubleSpinBox, meta) -> None:
+ """Handle float setting change from slider"""
+ if self._updating_from_camera:
+ return
+
+ # Convert slider value to float
+ value = meta.min_value + (slider_val / 1000.0) * (meta.max_value - meta.min_value)
+
+ # Update spinbox
+ spinbox.blockSignals(True)
+ spinbox.setValue(value)
+ spinbox.blockSignals(False)
+
+ camera = self.ctx.camera
+ if not camera:
+ return
+
+ try:
+ setter = getattr(camera.settings, setter_name)
+ setter(value)
+
+ # Extract setting name from setter name (remove "set_" prefix)
+ setting_name = setter_name.replace("set_", "")
+ self._mark_setting_modified(setting_name, value)
+
+ debug(f"Set {setter_name} to {value}")
+ except Exception as e:
+ error(f"Error setting {setter_name}: {e}")
+
+ def _on_dropdown_changed(self, setter_name: str, index: int, value) -> None:
+ """Handle dropdown setting change
+
+ Args:
+ setter_name: Name of the setter method (e.g., 'set_preview_resolution')
+ index: Index of the selected item in the dropdown
+ value: Value associated with the selected item
+ """
+ if self._updating_from_camera:
+ return
+
+ camera = self.ctx.camera
+ if not camera:
+ return
+
+ try:
+ setter = getattr(camera.settings, setter_name)
+ # Pass both index and value to the setter
+ setter(index=index, value=value)
+
+ # Extract setting name from setter name (remove "set_" prefix)
+ setting_name = setter_name.replace("set_", "")
+ self._mark_setting_modified(setting_name, value)
+
+ debug(f"Set {setter_name} to index={index}, value={value}")
+ except Exception as e:
+ error(f"Error setting {setter_name}: {e}")
+
+ @Slot()
+ def _save_settings(self) -> None:
+ """Save current camera settings"""
+ camera = self.ctx.camera
+ if not camera:
+ warning("No camera to save settings from")
+ return
+
+ try:
+ camera.save_settings()
+ info("Camera settings saved successfully")
+
+ # Clear modification markers
+ self._clear_all_modifications()
+
+ self.ctx.toast.info("Settings saved successfully", duration=2000)
+ except Exception as e:
+ error(f"Error saving camera settings: {e}")
+ self.ctx.toast.info(f"Error saving settings: {e}", duration=3000)
+
+ @Slot()
+ def _load_settings(self) -> None:
+ """Load camera settings from file"""
+ camera = self.ctx.camera
+ if not camera:
+ warning("No camera to load settings to")
+ return
+
+ # Open file picker for YAML files
+ file_path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Load Camera Settings",
+ "config/cameras",
+ "YAML Files (*.yaml *.yml);;All Files (*)"
+ )
+
+ # If no file was selected, do nothing
+ if not file_path:
+ return
+
+ # Convert to Path for the callback
+ selected_path = Path(file_path)
+
+ def on_load_complete(success: bool, result):
+ """Callback runs on camera thread - emit signal to UI thread"""
+ self.settings_loaded.emit(success, (result, str(selected_path)))
+
+ # Load settings with callback
+ camera.load_settings(selected_path, on_complete=on_load_complete)
+
+ @Slot(bool, object)
+ def _on_settings_loaded(self, success: bool, data: tuple) -> None:
+ """Handle settings loaded callback on UI thread"""
+ result, file_path = data
+
+ if success:
+ info(f"Camera settings loaded successfully from {file_path}")
+
+ # Refresh the display to show loaded values
+ self._refresh_settings_display()
+
+ self.ctx.toast.success("Settings loaded successfully", duration=2000)
+ else:
+ error(f"Error loading camera settings from {file_path}: {result}")
+ self.ctx.toast.error(f"Error loading settings: {result}", duration=3000)
+
+ @Slot()
+ def _reset_settings(self) -> None:
+ """Reset camera settings to defaults"""
+ camera = self.ctx.camera
+ if not camera:
+ warning("No camera to reset settings on")
+ return
+
+ # Check if we have default values stored
+ if not self._default_values:
+ warning("No default values stored - cannot reset")
+ self.ctx.toast.warning("No default values available to reset to", duration=3000)
+ return
+
+ # Confirm reset with user
+ reply = QMessageBox.question(
+ self,
+ "Reset to Defaults",
+ "Are you sure you want to reset all camera settings to their default values? This cannot be undone.",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No
+ )
+
+ if reply != QMessageBox.StandardButton.Yes:
+ return
+
+ try:
+ settings = camera.settings
+ metadata_list = settings.get_metadata()
+
+ # Create a lookup dict for metadata
+ meta_dict = {meta.name: meta for meta in metadata_list}
+
+ # Reset each setting to its default value
+ for field_name, default_value in self._default_values.items():
+ if default_value is None:
+ continue
+
+ meta = meta_dict.get(field_name)
+ if not meta:
+ continue
+
+ try:
+ setter_name = f"set_{field_name}"
+ if hasattr(settings, setter_name):
+ setter = getattr(settings, setter_name)
+
+ # Convert enum to string value if needed
+ type_str = meta.setting_type.value if hasattr(meta.setting_type, 'value') else str(meta.setting_type)
+
+ # Handle dropdown settings that need index and value
+ if type_str == "dropdown" and hasattr(meta, 'choices') and meta.choices:
+ # Find the index of the default value
+ try:
+ default_index = meta.choices.index(default_value)
+ setter(index=default_index, value=default_value)
+ except (ValueError, AttributeError):
+ setter(default_value)
+ else:
+ setter(default_value)
+ debug(f"Reset {field_name} to default: {default_value}")
+ except Exception as e:
+ warning(f"Could not reset {field_name}: {e}")
+
+ info("Camera settings reset to defaults")
+
+ # Clear modification markers
+ self._clear_all_modifications()
+
+ # Refresh the display
+ self._refresh_settings_display()
+
+ self.ctx.toast.info("Settings reset to defaults", duration=2000)
+ except Exception as e:
+ error(f"Error resetting camera settings: {e}")
+ self.ctx.toast.error(f"Error resetting settings: {e}", duration=3000)
+
+
+def camera_page(parent_dialog=None) -> QWidget:
+ """Create and return the camera settings page widget"""
+ return CameraSettingsWidget(parent_dialog=parent_dialog)
\ No newline at end of file
diff --git a/UI/settings/pages/machine_vision_settings.py b/UI/settings/pages/machine_vision_settings.py
new file mode 100644
index 0000000..33bc5e5
--- /dev/null
+++ b/UI/settings/pages/machine_vision_settings.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from PySide6.QtWidgets import (
+ QVBoxLayout,
+ QWidget,
+ QFormLayout,
+ QGroupBox,
+)
+
+def machine_vision_page() ->QWidget:
+ w = QWidget()
+ layout = QVBoxLayout(w)
+
+ top = QGroupBox("Machine Vision Settings")
+ form = QFormLayout(top)
+ layout.addWidget(top)
+
+ return w
\ No newline at end of file
diff --git a/UI/settings/pages/navigation_settings.py b/UI/settings/pages/navigation_settings.py
new file mode 100644
index 0000000..b91682f
--- /dev/null
+++ b/UI/settings/pages/navigation_settings.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from PySide6.QtWidgets import (
+ QVBoxLayout,
+ QWidget,
+ QFormLayout,
+ QGroupBox,
+)
+
+def navigation_page() ->QWidget:
+ w = QWidget()
+ layout = QVBoxLayout(w)
+
+ top = QGroupBox("Navigation Settings")
+ form = QFormLayout(top)
+ layout.addWidget(top)
+
+ return w
\ No newline at end of file
diff --git a/UI/settings/settings_main.py b/UI/settings/settings_main.py
new file mode 100644
index 0000000..3f67d34
--- /dev/null
+++ b/UI/settings/settings_main.py
@@ -0,0 +1,278 @@
+from __future__ import annotations
+
+from PySide6.QtCore import Qt, Signal, Slot
+from PySide6.QtGui import QCloseEvent
+from PySide6.QtWidgets import (
+ QHBoxLayout,
+ QLabel,
+ QToolButton,
+ QVBoxLayout,
+ QWidget,
+ QDialog,
+ QDialogButtonBox,
+ QListWidget,
+ QListWidgetItem,
+ QStackedWidget,
+ QFrame,
+ QMessageBox,
+ QPushButton,
+ QTreeWidget,
+ QTreeWidgetItem,
+ QScrollArea,
+)
+
+from .pages.camera_settings import camera_page
+from .pages.automation_settings import automation_page
+from .pages.machine_vision_settings import machine_vision_page
+from .pages.navigation_settings import navigation_page
+from .pages.about_settings import about_page
+
+class SettingsButton(QToolButton):
+ def __init__(self, tooltip: str = "Settings", parent: QWidget | None = None)-> None:
+ super().__init__(parent)
+ self.setToolTip(tooltip)
+ self.setText("⚙")
+
+ self.setAutoRaise(True)
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.setFixedWidth(34)
+ self.setFixedHeight(26)
+
+class SettingsDialog(QDialog):
+ # Signal emitted when user wants to save camera settings
+ save_camera_settings = Signal()
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setWindowTitle("Settings")
+ self.resize(860, 580)
+
+ root = QVBoxLayout(self)
+ root.setContentsMargins(0, 0, 0, 0)
+ root.setSpacing(0)
+
+ # Dark grey header bar spanning the top
+ header = QFrame()
+ header.setObjectName("SectionHeader")
+ header.setFixedHeight(40)
+ header_layout = QHBoxLayout(header)
+ header_layout.setContentsMargins(16, 0, 16, 0)
+
+ header_title = QLabel("Categories")
+ header_title.setObjectName("SectionHeaderTitle")
+ header_layout.addWidget(header_title)
+ header_layout.addStretch()
+
+ root.addWidget(header)
+
+ # Main content area
+ content = QWidget()
+ content_layout = QHBoxLayout(content)
+ content_layout.setContentsMargins(0, 0, 0, 0)
+ content_layout.setSpacing(0)
+
+ # Tree sidebar - flush with left and extends to bottom
+ self.sidebar = QTreeWidget()
+ self.sidebar.setFixedWidth(220)
+ self.sidebar.setHeaderHidden(True)
+ self.sidebar.setIndentation(15)
+
+ # Pages container - flush with right edge and white background
+ self.pages = QStackedWidget()
+ self.pages.setStyleSheet("QStackedWidget { background: white; }")
+
+ content_layout.addWidget(self.sidebar)
+ content_layout.addWidget(self.pages)
+
+ root.addWidget(content)
+
+ # Bottom button bar with margins
+ button_container = QWidget()
+ button_container_layout = QHBoxLayout(button_container)
+ button_container_layout.setContentsMargins(10, 10, 10, 10)
+
+ button_box = QDialogButtonBox()
+
+ self.save_btn = QPushButton("Save Settings")
+ close_btn = QPushButton("Close")
+
+ button_box.addButton(self.save_btn, QDialogButtonBox.ButtonRole.ActionRole)
+ button_box.addButton(close_btn, QDialogButtonBox.ButtonRole.RejectRole)
+
+ close_btn.clicked.connect(self._on_close_clicked)
+
+ button_container_layout.addWidget(button_box)
+
+ root.addWidget(button_container)
+
+ # Store page widgets and their group boxes for scrolling
+ self._page_widgets = {}
+ self._group_boxes = {} # Maps (page_name, group_name) -> QGroupBox widget
+
+ self._add_page("Camera", camera_page(self))
+ self._add_page("Navigation", navigation_page())
+ self._add_page("Automation", automation_page())
+ self._add_page("Machine Vision", machine_vision_page())
+ self._add_page("About FieldWeave", about_page())
+
+ self.sidebar.itemClicked.connect(self._on_tree_item_clicked)
+
+ # Expand first item and select it
+ if self.sidebar.topLevelItemCount() > 0:
+ first_item = self.sidebar.topLevelItem(0)
+ first_item.setExpanded(True)
+ self.sidebar.setCurrentItem(first_item)
+ self.pages.setCurrentIndex(0)
+
+ # Initially disable save button
+ self.save_btn.setEnabled(False)
+
+ def open_to(self, category: str) -> None:
+ for i in range(self.sidebar.topLevelItemCount()):
+ item = self.sidebar.topLevelItem(i)
+ if item and item.text(0) == category:
+ self.sidebar.setCurrentItem(item)
+ self._on_tree_item_clicked(item, 0)
+ return
+
+ def set_category_modified(self, category: str, modified: bool) -> None:
+ """Update category text color to indicate modifications"""
+ from PySide6.QtGui import QColor
+ for i in range(self.sidebar.topLevelItemCount()):
+ item = self.sidebar.topLevelItem(i)
+ if item and item.text(0) == category:
+ if modified:
+ # Use orange color (#f28c28 from style.py)
+ item.setForeground(0, QColor("#f28c28"))
+ else:
+ # Reset to default
+ item.setData(0, Qt.ItemDataRole.ForegroundRole, None)
+ return
+
+ def _update_camera_groups(self, group_names: list[str]) -> None:
+ """Update the Camera category tree item with actual group names"""
+ # Find the Camera item
+ for i in range(self.sidebar.topLevelItemCount()):
+ item = self.sidebar.topLevelItem(i)
+ if item and item.text(0) == "Camera":
+ # Remove existing children
+ item.takeChildren()
+
+ # Add new children for each group
+ page_index = item.data(0, Qt.ItemDataRole.UserRole)
+ for group_name in group_names:
+ child_item = QTreeWidgetItem([group_name])
+ child_item.setData(0, Qt.ItemDataRole.UserRole, page_index)
+ child_item.setData(0, Qt.ItemDataRole.UserRole + 1, group_name)
+ item.addChild(child_item)
+
+ # Re-expand the item if it was expanded
+ if item.isExpanded() or self.sidebar.currentItem() == item:
+ item.setExpanded(True)
+
+ return
+
+ def register_group_box(self, page_name: str, group_name: str, group_box: QWidget) -> None:
+ """Register a group box widget for a page so we can scroll to it"""
+ self._group_boxes[(page_name, group_name)] = group_box
+
+ @Slot(QTreeWidgetItem, int)
+ def _on_tree_item_clicked(self, item: QTreeWidgetItem, column: int) -> None:
+ """Handle tree item clicks"""
+ # Get the stored data
+ page_index = item.data(0, Qt.ItemDataRole.UserRole)
+ group_name = item.data(0, Qt.ItemDataRole.UserRole + 1)
+
+ if page_index is not None:
+ # Switch to the page
+ self.pages.setCurrentIndex(page_index)
+
+ # If it's a group item, scroll to that group
+ if group_name:
+ # Get the page name from parent
+ parent = item.parent()
+ if parent:
+ page_name = parent.text(0)
+ group_box = self._group_boxes.get((page_name, group_name))
+
+ if group_box:
+ # Find the scroll area in the current page
+ current_page = self.pages.currentWidget()
+ scroll_area = current_page.findChild(QScrollArea)
+
+ if scroll_area:
+ # Scroll to the group box
+ scroll_area.ensureWidgetVisible(group_box)
+
+ def _on_close_clicked(self) -> None:
+ """Handle close button click with confirmation if settings modified"""
+ if self._handle_close_with_unsaved_changes():
+ self.reject()
+
+ def _handle_close_with_unsaved_changes(self) -> bool:
+ """Handle closing with unsaved changes. Returns True if close should proceed."""
+ # Check if camera page has unsaved changes
+ camera_widget = self._page_widgets.get("Camera")
+ if camera_widget and hasattr(camera_widget, 'has_unsaved_changes') and camera_widget.has_unsaved_changes():
+ # Create custom message box with Yes, Reset to Defaults, No, Cancel buttons
+ msg_box = QMessageBox(self)
+ msg_box.setWindowTitle("Unsaved Changes")
+ msg_box.setText("You have unsaved camera settings.")
+ msg_box.setInformativeText("Would you like to save your settings?")
+ msg_box.setIcon(QMessageBox.Icon.Question)
+
+ # Add custom buttons
+ yes_btn = msg_box.addButton("Yes", QMessageBox.ButtonRole.YesRole)
+ reset_btn = msg_box.addButton("Reset to Defaults", QMessageBox.ButtonRole.DestructiveRole)
+ no_btn = msg_box.addButton("No", QMessageBox.ButtonRole.NoRole)
+ cancel_btn = msg_box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
+
+ msg_box.setDefaultButton(cancel_btn)
+
+ msg_box.exec()
+ clicked = msg_box.clickedButton()
+
+ if clicked == cancel_btn:
+ return False # Don't close
+ elif clicked == yes_btn:
+ # Save settings before closing
+ self.save_camera_settings.emit()
+ return True
+ elif clicked == reset_btn:
+ # Reset to defaults
+ if hasattr(camera_widget, '_reset_settings'):
+ camera_widget._reset_settings()
+ return True
+ elif clicked == no_btn:
+ # Don't save, just close
+ return True
+
+ return True # No unsaved changes, proceed with close
+
+ def closeEvent(self, event: QCloseEvent) -> None:
+ """Handle window close event (X button)"""
+ if self._handle_close_with_unsaved_changes():
+ event.accept()
+ else:
+ event.ignore()
+
+ def _add_page(self, name: str, page: QWidget) -> None:
+ """Add a page and create tree items for it and its groups"""
+ page_index = self.pages.addWidget(page)
+ self._page_widgets[name] = page
+
+ # Create parent tree item for the category
+ parent_item = QTreeWidgetItem([name])
+ parent_item.setData(0, Qt.ItemDataRole.UserRole, page_index)
+ parent_item.setData(0, Qt.ItemDataRole.UserRole + 1, None) # No group name for parent
+ self.sidebar.addTopLevelItem(parent_item)
+
+ # Find all group boxes in the page and add them as child items
+ if hasattr(page, 'get_group_names'):
+ # If the page provides a method to get group names
+ groups = page.get_group_names()
+ for group_name in groups:
+ child_item = QTreeWidgetItem([group_name])
+ child_item.setData(0, Qt.ItemDataRole.UserRole, page_index)
+ child_item.setData(0, Qt.ItemDataRole.UserRole + 1, group_name)
+ parent_item.addChild(child_item)
\ No newline at end of file
diff --git a/UI/style.py b/UI/style.py
new file mode 100644
index 0000000..358d945
--- /dev/null
+++ b/UI/style.py
@@ -0,0 +1,230 @@
+from __future__ import annotations
+
+from PySide6.QtGui import QPalette, QColor
+from PySide6.QtWidgets import QApplication
+
+RIGHT_SIDEBAR_WIDTH = 380
+OUTER_MARGIN = 10
+CAL_LEFT_WIDTH = 260
+
+def apply_style(app: QApplication) -> None:
+ palette = app.palette()
+
+ window_bg = QColor(215, 218, 222)
+ panel_bg = QColor(245, 246, 248)
+ text = QColor(35, 35, 35)
+
+ palette.setColor(QPalette.ColorRole.Window, window_bg)
+ palette.setColor(QPalette.ColorRole.Base, panel_bg)
+ palette.setColor(QPalette.ColorRole.AlternateBase, QColor(235, 237, 240))
+
+ palette.setColor(QPalette.ColorRole.Text, text)
+ palette.setColor(QPalette.ColorRole.WindowText, text)
+ palette.setColor(QPalette.ColorRole.Button, QColor(238, 240, 243))
+ palette.setColor(QPalette.ColorRole.ButtonText, text)
+
+ app.setPalette(palette)
+
+ header_bar_color = "#5f6368" # Dark Gray
+ header_bar_text_color = "#ffffff"
+ header_bar_selected_color = "#f28c28" # Orange
+ header_bar_selected_text_color = "#ffffff"
+ tab_corner_button = "#ffffff"
+
+ header_bar_idle = "#5f6368" # Dark Gray
+ header_bar_active = "#f28c28" # Orange
+ header_bar_finished = "#2e9b51" # Green
+
+ corner_status_line_color = "#ffffff"
+
+ app.setStyleSheet(
+ f"""
+ QTabWidget::pane {{ border: none; }}
+
+ /* Header Bar */
+ QTabBar {{
+ background : {header_bar_color};
+ color: {header_bar_text_color};
+ }}
+ QTabBar::Tab {{
+ padding: 8px 12px;
+ margin: 0px;
+ border-radius: 0px;
+ background: transparent;
+ }}
+ QTabBar::tab:selected {{
+ background: {header_bar_selected_color};
+ color: {header_bar_selected_text_color};
+ }}
+
+
+ /* Corner Widget */
+ QWidget#TabCorner {{
+ background : {header_bar_color};
+ padding: 0px;
+ margin: 0px;
+ }}
+ QWidget#TabCorner QToolButton {{
+ color: {tab_corner_button};
+ background : transparent;
+ }}
+
+ /* Push Buttons - Grey styling */
+ QPushButton {{
+ background-color: #d0d3d6;
+ border: 1px solid #b0b3b6;
+ border-radius: 0px;
+ padding: 2px 8px;
+ color: #2c2c2c;
+ }}
+ QPushButton:hover {{
+ background-color: #c0c3c6;
+ border-color: #a0a3a6;
+ }}
+ QPushButton:pressed {{
+ background-color: #b0b3b6;
+ border-color: #909396;
+ }}
+ QPushButton:disabled {{
+ background-color: #e0e3e6;
+ border-color: #d0d3d6;
+ color: #a0a3a6;
+ }}
+
+
+ /* Status panel in tab corner */
+ QFrame#StatusBar {{
+ padding: 0px 10px;
+ border-radius: 0px;
+ margin: 0px;
+ }}
+ QLabel#StatusLine {{
+ color: {corner_status_line_color};
+ font-weight: 800;
+ }}
+
+ /* Status State */
+ QFrame#StatusBar[kind="idle"] {{
+ background: {header_bar_idle};
+ }}
+ QFrame#StatusBar[kind="active"] {{
+ background: {header_bar_active};
+ }}
+ QFrame#StatusBar[kind="done"] {{
+ background: {header_bar_finished};
+ }}
+
+ /* Status Progress Bar */
+ QProgressBar#CornerStatusProgress {{
+ border: none;
+ background: rgba(255,255,255,0.22);
+ border-radius: 4px;
+ height: 8px;
+
+ color: white;
+ font-weight: 800;
+ }}
+ QProgressBar#CornerStatusProgress::chunk {{
+ background: rgba(255,255,255,0.95);
+ border-radius: 4px;
+ }}
+
+
+
+
+ /* Collapsible section box */
+ QFrame#CollapsibleSection {{
+ background: rgba(255,255,255,0.85);
+ border: 1px solid rgba(0,0,0,0.10);
+ }}
+
+ /* Full-width header strip: dark grey */
+ QFrame#SectionHeader {{
+ background: #5f6368;
+ border-bottom: 1px solid rgba(0,0,0,0.10);
+ }}
+ QLabel#SectionHeaderTitle, QFrame#SectionHeader QLabel {{
+ color: white;
+ font-weight: 800;
+ }}
+
+ /* When collapsed: header rounds bottom corners too (prevents “sticking out” corners) */
+ QFrame#SectionHeader[collapsed="true"] {{
+ border-bottom: none;
+ }}
+
+ QListWidget#SampleList {{
+ background: rgba(255,255,255,0.95);
+ border: 1px solid rgba(0,0,0,0.10);
+ border-radius: 10px;
+ }}
+
+ QFrame#StepCard {{
+ background: rgba(0,0,0,0.03);
+ border: 1px solid rgba(0,0,0,0.06);
+ border-radius: 10px;
+ }}
+
+ /* Calibration selection panels: flat */
+ QFrame#CalLeft, QFrame#CalMid {{
+ background: rgba(255,255,255,0.85);
+ border: 1px solid rgba(0,0,0,0.10);
+ border-radius: 12px;
+ }}
+
+ /* Selected calibration title bar */
+ QFrame#CalTitleBar {{
+ background: rgba(0,0,0,0.10);
+ border-top-left-radius: 12px;
+ border-top-right-radius: 12px;
+ border-bottom: 1px solid rgba(0,0,0,0.08);
+ }}
+ QLabel#CalTitleText {{
+ font-size: 18px;
+ font-weight: 900;
+ color: rgba(0,0,0,0.80);
+ }}
+ QLabel#CalNotesText {{
+ color: rgba(0,0,0,0.62);
+ }}
+
+ /* Camera Preview */
+ QFrame#CameraPreview {{
+ background: #000000;
+ }}
+
+ QLabel#VideoLabel {{
+ color: #888888;
+ font-size: 16px;
+ }}
+
+ /* Camera Preview Overlay Buttons */
+ QPushButton#OverlayButton, QPushButton#CrosshairButton, QPushButton#FocusButton {{
+ background-color: rgba(240, 240, 240, 180);
+ color: #000;
+ border: 1px solid rgba(200, 200, 200, 255);
+ border-radius: 4px;
+ font-size: 18px;
+ font-weight: bold;
+ }}
+ QPushButton#OverlayButton:hover, QPushButton#CrosshairButton:hover, QPushButton#FocusButton:hover {{
+ background-color: rgba(255, 255, 255, 200);
+ }}
+ QPushButton#OverlayButton:checked, QPushButton#CrosshairButton:checked, QPushButton#FocusButton:checked {{
+ background-color: rgba(100, 150, 200, 200);
+ color: white;
+ border: 2px solid rgba(150, 200, 255, 255);
+ }}
+
+ QPushButton#CrosshairButton {{
+ padding-bottom: 4px;
+ }}
+
+ QLabel#FocusOverlayLabel {{
+ font-size: 18px;
+ font-weight: normal;
+ }}
+
+
+ """
+ )
\ No newline at end of file
diff --git a/UI/styles.py b/UI/styles.py
deleted file mode 100644
index bb4a065..0000000
--- a/UI/styles.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from __future__ import annotations
-import pygame
-from UI.text import TextStyle
-from UI.input.button import ButtonColors
-from UI.input.radio import SelectedColors
-
-
-# ---- Text Styles -----------------------------------------------------------
-
-def make_button_text_style() -> TextStyle:
- return TextStyle(color=pygame.Color("#5a5a5a"), font_size=20)
-
-def make_display_text_style(font_size = 18) -> TextStyle:
- return TextStyle(
- color=pygame.Color(32, 32, 32),
- font_size=font_size,
- font_name="assets/fonts/SofiaSans-Regular.ttf",
-)
-
-def make_settings_text_style() -> TextStyle:
- return TextStyle(
- color=pygame.Color(32, 32, 32),
- font_size=20,
- font_name="assets/fonts/SofiaSans-Regular.ttf",
-)
-
-
-# ---- Radio / Button shared styling ----------------------------------------
-
-
-# Base (unselected) colors for radio buttons
-BASE_BUTTON_COLORS = ButtonColors(
- hover_foreground=pygame.Color("#5a5a5a")
-)
-
-# Colors when a radio is selected
-SELECTED_RADIO_COLORS = SelectedColors(
- background=pygame.Color("#b3b4b6"),
- hover_background=pygame.Color("#b3b4b6"),
- foreground=pygame.Color("#b3b4b6"),
- hover_foreground=pygame.Color("#5a5a5a"),
-)
-
-# Text style used by radios
-RADIO_TEXT_STYLE = TextStyle(
- font_size=16,
- color=pygame.Color("#5a5a5a"),
- hover_color=pygame.Color("#5a5a5a"),
- disabled_color=pygame.Color("#5a5a5a"),
-)
\ No newline at end of file
diff --git a/UI/tabs/base_tab.py b/UI/tabs/base_tab.py
new file mode 100644
index 0000000..62351f2
--- /dev/null
+++ b/UI/tabs/base_tab.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from PySide6.QtWidgets import QHBoxLayout, QWidget
+
+from ..style import OUTER_MARGIN
+
+class CameraWithSidebarPage(QWidget):
+ def __init__(self, camera_widget: QWidget, sidebar_widget: QWidget, parent: QWidget | None = None):
+ super().__init__(parent)
+
+ root = QHBoxLayout(self)
+ root.setContentsMargins(OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN, OUTER_MARGIN)
+ root.setSpacing(OUTER_MARGIN)
+ root.addWidget(camera_widget, 1)
+ root.addWidget(sidebar_widget, 0)
\ No newline at end of file
diff --git a/UI/tabs/calibration_tab.py b/UI/tabs/calibration_tab.py
new file mode 100644
index 0000000..5749a96
--- /dev/null
+++ b/UI/tabs/calibration_tab.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from PySide6.QtWidgets import (
+ QCheckBox,
+ QFormLayout,
+ QGroupBox,
+ QHBoxLayout,
+ QLineEdit,
+ QPushButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+class CalibrationTab(QWidget):
+ def __init__(self) -> None:
+ super().__init__()
\ No newline at end of file
diff --git a/UI/tabs/logs_tab.py b/UI/tabs/logs_tab.py
new file mode 100644
index 0000000..604c5d3
--- /dev/null
+++ b/UI/tabs/logs_tab.py
@@ -0,0 +1,246 @@
+from __future__ import annotations
+
+import subprocess
+import sys
+import re
+from datetime import datetime
+
+from PySide6.QtWidgets import (
+ QCheckBox,
+ QHBoxLayout,
+ QPushButton,
+ QTextEdit,
+ QVBoxLayout,
+ QWidget,
+ QLabel,
+)
+
+from common.logger import get_logger
+
+
+class LogsTab(QWidget):
+ """Logs tab showing application logs with controls"""
+
+ def __init__(self) -> None:
+ super().__init__()
+
+ # Log level filters (DEBUG disabled by default)
+ self._level_filters = {
+ 'DEBUG': False,
+ 'INFO': True,
+ 'WARNING': True,
+ 'ERROR': True,
+ 'CRITICAL': True,
+ }
+
+ # Store all log entries that have been received
+ self._log_entries = []
+
+ # Track if we've done initial load
+ self._initial_load_done = False
+
+ # Log display
+ self._log_display = QTextEdit()
+ self._log_display.setReadOnly(True)
+ self._log_display.setStyleSheet("""
+ QTextEdit {
+ background-color: #ffffff;
+ color: #000000;
+ font-family: 'Consolas', 'Courier New', monospace;
+ font-size: 10pt;
+ border: 1px solid #cccccc;
+ }
+ """)
+
+ # Buttons
+ self._clear_btn = QPushButton("Clear Display")
+ self._clear_btn.clicked.connect(self._clear_display)
+
+ self._open_folder_btn = QPushButton("Open Log Folder")
+ self._open_folder_btn.clicked.connect(self._open_log_folder)
+
+ # Auto-scroll checkbox
+ self._auto_scroll_check = QCheckBox("Auto-scroll")
+ self._auto_scroll_check.setChecked(True)
+
+ # Log level filter checkboxes
+ self._level_checkboxes = {}
+
+ # Control layout - all on one line
+ control_layout = QHBoxLayout()
+ control_layout.addWidget(self._clear_btn)
+ control_layout.addWidget(self._open_folder_btn)
+ control_layout.addSpacing(20)
+ control_layout.addWidget(QLabel("Show levels:"))
+
+ for level in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
+ checkbox = QCheckBox(level)
+ checkbox.setChecked(self._level_filters[level])
+ checkbox.stateChanged.connect(lambda state, lvl=level: self._on_filter_changed(lvl, state))
+ self._level_checkboxes[level] = checkbox
+ control_layout.addWidget(checkbox)
+
+ control_layout.addStretch()
+ control_layout.addWidget(self._auto_scroll_check)
+
+ # Main layout
+ layout = QVBoxLayout(self)
+ layout.addWidget(self._log_display, 1)
+ layout.addLayout(control_layout)
+
+ # Register with logger
+ self._logger = get_logger()
+ self._logger.register_callback(self._on_log_message)
+
+ # Load existing logs from current log file (one time only)
+ self._load_existing_logs()
+ self._initial_load_done = True
+
+ def _load_existing_logs(self):
+ """Load existing logs from the current log file (one time only at startup)"""
+ try:
+ current_log_file = self._logger.get_current_log_file()
+ if not current_log_file or not current_log_file.exists():
+ return
+
+ # Read and parse existing log file
+ # Format: [2025-01-26 14:30:45] INFO: Message
+ log_pattern = re.compile(r'\[([^\]]+)\]\s+(\w+):\s+(.*)')
+
+ with open(current_log_file, 'r', encoding='utf-8') as f:
+ for line in f:
+ line = line.rstrip()
+ if not line:
+ continue
+
+ match = log_pattern.match(line)
+ if match:
+ timestamp, level, message = match.groups()
+
+ # Store the log entry
+ self._log_entries.append({
+ 'timestamp': timestamp,
+ 'level': level,
+ 'message': message
+ })
+
+ # Apply level filter and display
+ if self._level_filters.get(level, True):
+ color = self._get_level_color(level)
+ formatted = f'[{timestamp}] [{level}] {self._escape_html(message)}'
+ self._log_display.append(formatted)
+ else:
+ # Line doesn't match pattern, show as-is (might be multiline continuation)
+ self._log_display.append(self._escape_html(line))
+
+ # Auto-scroll to bottom
+ scrollbar = self._log_display.verticalScrollBar()
+ scrollbar.setValue(scrollbar.maximum())
+
+ except Exception as e:
+ self._log_display.append(f"Error loading existing logs: {e}")
+
+ def _on_filter_changed(self, level: str, state: int):
+ """Handle log level filter checkbox change"""
+ self._level_filters[level] = bool(state)
+ # Redisplay logs from memory with new filter
+ self._redisplay_logs()
+
+ def _redisplay_logs(self):
+ """Redisplay all logs from memory with current filters"""
+ self._log_display.clear()
+
+ for entry in self._log_entries:
+ level = entry['level']
+
+ # Apply level filter
+ if not self._level_filters.get(level, True):
+ continue
+
+ # Format with color
+ color = self._get_level_color(level)
+ formatted = f'[{entry["timestamp"]}] [{level}] {self._escape_html(entry["message"])}'
+ self._log_display.append(formatted)
+
+ # Auto-scroll to bottom
+ scrollbar = self._log_display.verticalScrollBar()
+ scrollbar.setValue(scrollbar.maximum())
+
+ def _on_log_message(self, level: str, message: str):
+ """
+ Handle incoming log message.
+ This is called from the logger for each message.
+ """
+ # Get current timestamp
+ timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+
+ # Store the log entry in memory
+ self._log_entries.append({
+ 'timestamp': timestamp,
+ 'level': level,
+ 'message': message
+ })
+
+ # Apply level filter
+ if not self._level_filters.get(level, True):
+ return
+
+ # Format with color based on level
+ color = self._get_level_color(level)
+ formatted = f'[{timestamp}] [{level}] {self._escape_html(message)}'
+
+ self._log_display.append(formatted)
+
+ # Auto-scroll to bottom if enabled
+ if self._auto_scroll_check.isChecked():
+ scrollbar = self._log_display.verticalScrollBar()
+ scrollbar.setValue(scrollbar.maximum())
+
+ def _get_level_color(self, level: str) -> str:
+ """Get color for log level"""
+ colors = {
+ 'DEBUG': '#666666',
+ 'INFO': '#0066cc',
+ 'WARNING': '#cc6600',
+ 'ERROR': '#cc0000',
+ 'CRITICAL': '#990000',
+ }
+ return colors.get(level, '#000000')
+
+ def _escape_html(self, text: str) -> str:
+ """Escape HTML special characters"""
+ return (text
+ .replace('&', '&')
+ .replace('<', '<')
+ .replace('>', '>')
+ .replace('"', '"')
+ .replace("'", '''))
+
+ def _clear_display(self):
+ """Clear the log display and memory"""
+ self._log_display.clear()
+ self._log_entries.clear()
+
+ def _open_log_folder(self):
+ """Open the log folder in file explorer"""
+ log_dir = self._logger.get_log_directory()
+
+ try:
+ if sys.platform == 'win32':
+ # Windows
+ subprocess.Popen(['explorer', str(log_dir)])
+ elif sys.platform == 'darwin':
+ # macOS
+ subprocess.Popen(['open', str(log_dir)])
+ else:
+ # Linux
+ subprocess.Popen(['xdg-open', str(log_dir)])
+
+ self._logger.info(f"Opened log folder: {log_dir}")
+ except Exception as e:
+ self._logger.error(f"Failed to open log folder: {e}")
+
+ def closeEvent(self, event):
+ """Unregister from logger when widget closes"""
+ self._logger.unregister_callback(self._on_log_message)
+ super().closeEvent(event)
diff --git a/UI/tabs/navigate_tab.py b/UI/tabs/navigate_tab.py
new file mode 100644
index 0000000..6df3ba8
--- /dev/null
+++ b/UI/tabs/navigate_tab.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+from PySide6.QtWidgets import (
+ QVBoxLayout,
+ QWidget,
+ QScrollArea,
+ QFrame
+)
+from UI.style import RIGHT_SIDEBAR_WIDTH
+from UI.tabs.base_tab import CameraWithSidebarPage
+
+from UI.widgets.camera_preview import CameraPreview
+from UI.widgets.collapsible_section import CollapsibleSection
+from UI.widgets.camera_controls_widget import CameraControlsWidget
+from UI.widgets.navigation_widget import NavigationWidget
+
+from common.app_context import open_settings
+
+class NavigateTab(CameraWithSidebarPage):
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(CameraPreview(), self._make_sidebar(), parent)
+
+ def _make_sidebar(self) -> QWidget:
+ sidebar_container = QWidget()
+ sidebar_container.setFixedWidth(RIGHT_SIDEBAR_WIDTH)
+
+ sidebar_layout = QVBoxLayout(sidebar_container)
+ sidebar_layout.setContentsMargins(0, 0, 0, 0)
+ sidebar_layout.setSpacing(10)
+
+ content = QWidget()
+ content_layout = QVBoxLayout(content)
+ content_layout.setContentsMargins(0, 0, 0, 0)
+ content_layout.setSpacing(10)
+
+ # Start Widgets
+
+ navigation = CollapsibleSection("Navigation", on_settings=lambda: open_settings("Navigation"))
+ navigation.layout_for_content().addWidget(NavigationWidget())
+ content_layout.addWidget(navigation)
+
+ camera_controls = CollapsibleSection("Camera Controls", on_settings=lambda: open_settings("Camera"))
+ camera_controls.layout_for_content().addWidget(CameraControlsWidget())
+ content_layout.addWidget(camera_controls)
+
+ # End Widgets
+
+ content_layout.addStretch(1)
+ sidebar_layout.addWidget(self._wrap_scroll(content), 1)
+ return sidebar_container
+
+ def _wrap_scroll(self, widget: QWidget) -> QScrollArea:
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
+ scroll.setWidget(widget)
+ return scroll
\ No newline at end of file
diff --git a/UI/tabs/project_tab.py b/UI/tabs/project_tab.py
new file mode 100644
index 0000000..bc25439
--- /dev/null
+++ b/UI/tabs/project_tab.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from PySide6.QtWidgets import (
+ QCheckBox,
+ QFormLayout,
+ QGroupBox,
+ QHBoxLayout,
+ QLineEdit,
+ QPushButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+class ProjectTab(QWidget):
+ def __init__(self) -> None:
+ super().__init__()
\ No newline at end of file
diff --git a/UI/text.py b/UI/text.py
deleted file mode 100644
index fd2e038..0000000
--- a/UI/text.py
+++ /dev/null
@@ -1,285 +0,0 @@
-import pygame
-import pygame.freetype as freetype
-from typing import Tuple, Optional
-from dataclasses import dataclass, field
-from UI.frame import Frame
-
-def default_color() -> pygame.Color:
- return pygame.Color("#b3b4b6")
-
-@dataclass
-class TextStyle:
- """Style configuration for text rendering"""
- color: pygame.Color = field(default_factory=default_color)
- font_size: int = 32
- font_name: Optional[str] = None
- bold: bool = False
- italic: bool = False
-
- hover_color: Optional[pygame.Color] = None
- disabled_color: Optional[pygame.Color] = None
-
-class Text(Frame):
- def __init__(self,
- text: str,
- x: int, y: int,
- style: Optional[TextStyle] = None,
- x_align: str = "left", y_align: str = "top",
- max_width: Optional[int] = None,
- truncate_mode: str = "none",
- show_tooltip_on_hover: bool = True,
- **frame_kwargs):
-
- super().__init__(x=x, y=y, width=0, height=0, **frame_kwargs)
-
- self.mouse_passthrough = True
- self.text = text
- self.style = style or TextStyle()
- self.x_align = x_align
- self.y_align = y_align
- self.max_width = max_width
- self.truncate_mode = truncate_mode
- self.show_tooltip_on_hover = show_tooltip_on_hover
-
- self._is_hover = False
- self._is_enabled = True
-
- self._font = self._create_font()
- self._surface = None
- self._render_text = text
- self._update_surface()
-
- @property
- def debug_outline_color(self) -> pygame.Color:
- return pygame.Color(0, 0, 255)
-
- def _create_font(self) -> freetype.Font:
- """Create a FreeType font object based on style"""
- try:
- font = freetype.Font(self.style.font_name, self.style.font_size)
- except Exception:
- font = freetype.SysFont(None, self.style.font_size)
-
- font.strong = self.style.bold
- font.oblique = self.style.italic
- return font
-
- def _current_color(self) -> pygame.Color:
- # Resolve stateful color with sensible fallbacks
- if not self._is_enabled and self.style.disabled_color is not None:
- return self.style.disabled_color
- if self._is_hover and self.style.hover_color is not None:
- return self.style.hover_color
- return self.style.color
-
- def _update_surface(self) -> None:
- """Render the text to a surface using FreeType"""
- if not self._font:
- return
- self._render_text = self.text
- if self.max_width and self.truncate_mode != "none":
- self._render_text = self._ellipsize(self.text, self.max_width, self.truncate_mode)
- self._surface, _ = self._font.render(
- self._render_text,
- fgcolor=self._current_color(),
- size=self.style.font_size,
- )
-
- @property
- def size(self) -> Tuple[int, int]:
- return self._surface.get_size() if self._surface else (0, 0)
-
- def contains_point(self, px, py):
- # allow hover detection
- abs_x, abs_y, w, h = self.get_absolute_geometry()
- return abs_x <= px <= abs_x + w and abs_y <= py <= abs_y + h
-
- def set_text(self, text: str) -> None:
- """Update the displayed text"""
- if self.text != text:
- self.text = text
- self._update_surface()
-
- def set_style(self, style: TextStyle) -> None:
- """Update the text style"""
- self.style = style
- self._font = self._create_font()
- self._update_surface()
-
- def set_color(self, color) -> None:
- """Set the base text color and re-render the surface."""
- if not isinstance(color, pygame.Color):
- color = pygame.Color(color)
- if self.style.color != color:
- self.style.color = color
- self._update_surface()
-
- def get_color(self) -> pygame.Color:
- """Return the current resolved text color."""
- return self._current_color()
-
- def set_is_hover(self, is_hover: bool) -> None:
- if self._is_hover != is_hover:
- self._is_hover = is_hover
- self._update_surface()
-
- def set_is_enabled(self, is_enabled: bool) -> None:
- if self._is_enabled != is_enabled:
- self._is_enabled = is_enabled
- self._update_surface()
-
- def get_absolute_geometry(self):
- parent_x, parent_y, parent_w, parent_h = (
- self.parent.get_absolute_geometry() if self.parent else (0, 0, *pygame.display.get_surface().get_size())
- )
-
- draw_x = self.x * parent_w if self.x_is_percent else self.x
- draw_y = self.y * parent_h if self.y_is_percent else self.y
- draw_x += parent_x
- draw_y += parent_y
-
- text_w, text_h = self._surface.get_size() if self._surface else (0, 0)
-
- # Apply alignment like in draw()
- if self.x_align == "center":
- draw_x -= text_w // 2
- elif self.x_align == "right":
- draw_x -= text_w
-
- if self.y_align == "center":
- draw_y -= text_h // 2
- elif self.y_align == "bottom":
- draw_y -= text_h
-
- return draw_x, draw_y, text_w, text_h
-
- # --- Truncation helpers ---
- def _measure_width(self, s: str) -> int:
- return self._font.get_rect(s, size=self.style.font_size).width
-
- def _ellipsize(self, s: str, max_w: int, mode: str) -> str:
- if max_w is None:
- return s
- if self._measure_width(s) <= max_w:
- return s
-
- ell = "…"
- ell_w = self._measure_width(ell)
- if ell_w > max_w:
- return ""
-
- def fit_end(prefix: str) -> str:
- lo, hi = 0, len(prefix)
- best = ""
- while lo <= hi:
- mid = (lo + hi) // 2
- cand = prefix[:mid] + ell
- if self._measure_width(cand) <= max_w:
- best = cand
- lo = mid + 1
- else:
- hi = mid - 1
- return best
-
- def fit_start(suffix: str) -> str:
- lo, hi = 0, len(suffix)
- best = ""
- while lo <= hi:
- mid = (lo + hi) // 2
- cand = ell + suffix[-mid:] if mid > 0 else ell
- if self._measure_width(cand) <= max_w:
- best = cand
- lo = mid + 1
- else:
- hi = mid - 1
- return best
-
- if mode == "end":
- return fit_end(s)
- if mode == "start":
- return fit_start(s)
- if mode == "middle":
- left, right = 0, 0
- best = ell
- while left + right < len(s):
- cand = s[:left+1] + ell + (s[-right:] if right else "")
- if self._measure_width(cand) <= max_w:
- left += 1
- best = cand
- else:
- break
- cand = s[:left] + ell + s[-(right+1):]
- if self._measure_width(cand) <= max_w:
- right += 1
- best = cand
- else:
- break
- return best
-
- return s
-
- # --- Rendering ---
- def draw(self, surface: pygame.Surface) -> None:
- if not self._surface or self.is_effectively_hidden:
- return
-
- parent_x, parent_y, parent_w, parent_h = (
- self.parent.get_absolute_geometry() if self.parent else (0, 0, *surface.get_size())
- )
-
- draw_x = self.x * parent_w if self.x_is_percent else self.x
- draw_y = self.y * parent_h if self.y_is_percent else self.y
- draw_x += parent_x
- draw_y += parent_y
-
- text_w, text_h = self._surface.get_size()
-
- if self.x_align == "center":
- draw_x -= text_w // 2
- elif self.x_align == "right":
- draw_x -= text_w
-
- if self.y_align == "center":
- draw_y -= text_h // 2
- elif self.y_align == "bottom":
- draw_y -= text_h
-
- surface.blit(self._surface, (draw_x, draw_y))
-
- # Tooltip rendering
- if (
- self.show_tooltip_on_hover
- and self._render_text != self.text
- ):
- mx, my = pygame.mouse.get_pos()
- if self.contains_point(mx, my):
- self._draw_tooltip(surface, self.text, mx, my)
-
- def _draw_tooltip(self, surface, text, x, y):
- tooltip_font = self._create_font()
- tip_surface, _ = tooltip_font.render(text, fgcolor=pygame.Color("black"))
- padding = 6
- cursor_offset = 12
- margin = 6
-
- tw, th = tip_surface.get_size()
- rect = pygame.Rect(x + cursor_offset, y + cursor_offset, tw + padding * 2, th + padding * 2)
- sw, sh = surface.get_size()
-
- if rect.right > sw - margin:
- rect.x = x - cursor_offset - rect.w
- if rect.right > sw - margin:
- rect.x = sw - rect.w - margin
- if rect.x < margin:
- rect.x = margin
-
- if rect.bottom > sh - margin:
- rect.y = y - cursor_offset - rect.h
- if rect.bottom > sh - margin:
- rect.y = sh - rect.h - margin
- if rect.y < margin:
- rect.y = margin
-
- pygame.draw.rect(surface, pygame.Color(255, 255, 224), rect)
- pygame.draw.rect(surface, pygame.Color("black"), rect, 1)
- surface.blit(tip_surface, (rect.x + padding, rect.y + padding))
diff --git a/UI/tooltip.py b/UI/tooltip.py
deleted file mode 100644
index 10eabd9..0000000
--- a/UI/tooltip.py
+++ /dev/null
@@ -1,180 +0,0 @@
-import pygame
-from UI.frame import Frame
-from UI.text import Text
-from UI.styles import make_display_text_style
-
-
-class Tooltip(Frame):
- """
- Self-attaching, multi-line tooltip using Forge's global make_display_text_style().
-
- Example:
- tip = Tooltip.attach(some_frame, "Hello\nWorld")
- tip.set_text("Updated text")
- tip.detach()
- """
-
- def __init__(
- self,
- parent,
- text: str,
- *,
- font_size: int = 20,
- padding: int = 6,
- bg_color=(255, 255, 224),
- border_color=(0, 0, 0),
- follow_cursor: bool = True,
- margin: int = 6,
- cursor_offset: int = 12,
- z_index: int = 10_000,
- line_spacing: int = 2,
- style_overrides: dict | None = None,
- ):
- super().__init__(parent=parent, x=0, y=0, width=0, height=0, z_index=z_index)
- self.mouse_passthrough = True
-
- self._padding = padding
- self._bg_color = pygame.Color(*bg_color)
- self._border_color = pygame.Color(*border_color)
- self._follow_cursor = follow_cursor
- self._margin = margin
- self._cursor_offset = cursor_offset
- self._line_spacing = line_spacing
- self._font_size = font_size
-
- # base style from global Forge styling system
- self._text_style = make_display_text_style(font_size=font_size)
- if style_overrides:
- for k, v in style_overrides.items():
- if hasattr(self._text_style, k):
- setattr(self._text_style, k, v)
-
- self._target = None
- self._orig_enter = None
- self._orig_leave = None
- self._orig_hover = None
-
- self._line_widgets: list[Text] = []
- self.set_text(text)
- self.hide()
-
- # ---------------- attach / detach ----------------
- @classmethod
- def attach(cls, target: Frame, text: str, **kwargs) -> "Tooltip":
- """Attach to any Frame without editing its class."""
- root = target
- while root.parent is not None:
- root = root.parent
- tip = cls(parent=root, text=text, **kwargs)
- tip._bind(target)
- return tip
-
- def detach(self) -> None:
- """Unbind and remove from scene."""
- if self._target:
- self._target.on_hover_enter = self._orig_enter
- self._target.on_hover_leave = self._orig_leave
- self._target.on_hover = self._orig_hover
- self._target = None
- if self.parent and self in self.parent.children:
- self.parent.children.remove(self)
-
- # ---------------- event wrapping ----------------
- def _bind(self, target: Frame) -> None:
- self._target = target
- self._orig_enter = target.on_hover_enter
- self._orig_leave = target.on_hover_leave
- self._orig_hover = target.on_hover
-
- def wrapped_enter():
- self._orig_enter()
- self._show_at_mouse()
-
- def wrapped_leave():
- self._orig_leave()
- self.hide()
-
- def wrapped_hover():
- self._orig_hover()
- if self._follow_cursor and not self.is_effectively_hidden:
- self._reposition_to_mouse()
-
- target.on_hover_enter = wrapped_enter
- target.on_hover_leave = wrapped_leave
- target.on_hover = wrapped_hover
-
- # ---------------- text layout ----------------
- def set_text(self, text: str):
- """Supports multiple lines using '\\n'."""
- for w in self._line_widgets:
- if w in self.children:
- self.children.remove(w)
- self._line_widgets.clear()
-
- lines = text.split("\n") if text else [""]
- y_cursor = self._padding
- max_w = 0
-
- for line in lines:
- tw = Text(
- text=line,
- x=self._padding,
- y=y_cursor,
- style=self._text_style,
- )
- tw.mouse_passthrough = True
- self.add_child(tw)
- self._line_widgets.append(tw)
-
- lw, lh = tw.size
- y_cursor += lh + self._line_spacing
- max_w = max(max_w, lw)
-
- if self._line_widgets:
- y_cursor -= self._line_spacing
-
- self.width = max_w + self._padding * 2
- self.height = y_cursor + self._padding
-
- # ---------------- positioning ----------------
- def _bring_to_front(self):
- if not self.parent:
- return
- max_z = max((getattr(ch, "z_index", 0) for ch in self.parent.children), default=0)
- if self.z_index <= max_z:
- self.z_index = max_z + 1
- self.parent.children.sort(key=lambda c: c.z_index, reverse=True)
-
- def _show_at_mouse(self):
- self._reposition_to_mouse()
- self._bring_to_front()
- self.show()
-
- def _reposition_to_mouse(self):
- mx, my = pygame.mouse.get_pos()
- sw, sh = pygame.display.get_surface().get_size()
- w, h = self.width, self.height
-
- x = mx + self._cursor_offset
- y = my + self._cursor_offset
-
- if x + w > sw - self._margin:
- x = mx - w - self._cursor_offset
- if y + h > sh - self._margin:
- y = my - h - self._cursor_offset
-
- x = max(self._margin, min(x, sw - w - self._margin))
- y = max(self._margin, min(y, sh - h - self._margin))
-
- self.x, self.y = x, y
- self._bring_to_front()
-
- # ---------------- draw ----------------
- def draw(self, surface: pygame.Surface) -> None:
- if self.is_effectively_hidden:
- return
- abs_x, abs_y, w, h = self.get_absolute_geometry()
- pygame.draw.rect(surface, self._bg_color, (abs_x, abs_y, w, h))
- pygame.draw.rect(surface, self._border_color, (abs_x, abs_y, w, h), 1)
- for child in reversed(self.children):
- child.draw(surface)
diff --git a/UI/ui_layout.py b/UI/ui_layout.py
deleted file mode 100644
index faadc01..0000000
--- a/UI/ui_layout.py
+++ /dev/null
@@ -1,436 +0,0 @@
-from dataclasses import dataclass
-from typing import List, Tuple
-import os
-import sys
-import subprocess
-
-import pygame
-
-from printer.automated_controller import AutomatedPrinter
-
-from UI.text import Text, TextStyle
-from UI.frame import Frame
-from UI.section_frame import Section
-from UI.modal import Modal
-from UI.camera_view import CameraView
-from UI.focus_overlay import FocusOverlay
-from UI.list_frame import ListFrame
-from UI.flex_frame import FlexFrame
-
-from UI.input.text_field import TextField
-from UI.input.button import Button, ButtonShape, ButtonColors
-from UI.input.button_icon import ButtonIcon
-from UI.input.toggle_button import ToggleButton, ToggledColors
-from UI.input.scroll_frame import ScrollFrame
-from UI.styles import (
- make_button_text_style,
- make_display_text_style,
- make_settings_text_style,
-)
-from UI.modals.camera_settings_modal import build_camera_settings_modal
-from UI.modals.automation_settings_modal import build_automation_settings_modal
-
-RIGHT_PANEL_WIDTH = 400
-
-@dataclass
-class ControlPanel:
- frame: Frame
- sample_label: Text
- inc_button: Button
- dec_button: Button
- go_button: Button
- speed_display: Text
- position_display: Text
-
-def make_button(fn, x, y, w, h, text, shape=ButtonShape.RECTANGLE, z_index = 0, args_provider=None):
- btn = Button(
- function_to_call=fn,
- x=x, y=y,
- width=w, height=h,
- text=text,
- text_style=make_button_text_style(),
- args_provider=args_provider,
- shape=shape,
- z_index=z_index
- )
- return btn
-
-def create_control_panel(
- root_frame: Frame,
- movementSystem: AutomatedPrinter,
- camera,
- current_sample_index: int
-) -> Tuple[Frame, Text, Button, Button, Button, Text, Text]:
- """
- Builds the right-side control panel and returns:
- control_frame, sample_label, increment_button, decrement_button, go_to_sample_button,
- speed_display, position_display
- """
-
- control_frame = _build_right_control_panel(root_frame)
-
- # --- Camera View
- camera_view = CameraView(
- camera=camera,
- parent=root_frame,
- x=0, y=0,
- width=1.0, height=1.0,
- x_is_percent=True, y_is_percent=True,
- width_is_percent=True, height_is_percent=True,
- z_index=0,
- background_color=pygame.Color("black"),
- right_margin_px=RIGHT_PANEL_WIDTH # reserve space for the control panel
- )
- machine_vision_overlay = FocusOverlay(camera_view, movementSystem.machine_vision)
-
-
- # --- Control Box ---
- control_box = Section(
- parent=control_frame,
- title="Control",
- collapsible=True,
- x=0, y=0, width=1.0, height=250,
- width_is_percent=True
- )
- speed_display, position_display = _build_movement_controls(control_box, movementSystem)
-
- # --- Automation Box ---
- automation_box = Section(
- parent=control_frame,
- title="Automation",
- collapsible=True,
- x=0, y=0, width=1.0, height=140,
- width_is_percent=True
- )
- automation_settings_modal = Modal(parent=root_frame, title="Automation Settings", overlay=False, width=500, height=445)
- build_automation_settings_modal(automation_settings_modal, movementSystem)
- _build_automation_control(automation_box, movementSystem, machine_vision_overlay, automation_settings_modal)
-
- # --- Camera Settings Modal ---
- camera_settings_modal = Modal(parent=root_frame, title="Camera Settings", overlay=False, width=308, height=660)
- build_camera_settings_modal(camera_settings_modal, camera)
-
- # --- Camera Settings ---
- camera_control = Section(
- parent=control_frame,
- title="Camera Control",
- collapsible=True,
- x=0, y=0, width=1.0, height=163,
- width_is_percent=True
- )
- _build_camera_control(camera_control, movementSystem, camera, camera_settings_modal)
-
- # --- Sample Box ---
- sample_box = Section(
- parent=control_frame,
- title="Sample Management",
- collapsible=True,
- x=0, y=0, width=1.0, fill_remaining_height=True,
- width_is_percent=True, padding=(0,0,10,0)
- )
- go_to_sample_button, decrement_button, increment_button, sample_label = _build_sample_box(
- sample_box, movementSystem, camera, current_sample_index
- )
-
- return (
- sample_label,
- increment_button,
- decrement_button,
- go_to_sample_button,
- speed_display,
- position_display
- )
-
-def _build_right_control_panel(root_frame) -> Frame:
- # --- Control Panel Container (plain Frame) ---
- control_frame = Frame(
- parent=root_frame,
- x=0, y=0,
- width=RIGHT_PANEL_WIDTH,
- height=1.0, # fill vertical space of root
- height_is_percent=True,
- x_align='right',
- y_align='top',
- background_color=pygame.Color("#b3b4b6")
- )
-
- # --- Title Bar (not part of flex) ---
- title_bar = Frame(
- parent=control_frame,
- x=0, y=0,
- width=1.0,
- height=50,
- width_is_percent=True,
- background_color=pygame.Color("#909398")
- )
-
- title_text = Text(
- parent=title_bar,
- text="FORGE",
- x=10, y=10,
- x_align="left",
- y_align="top",
- style=TextStyle(
- color=pygame.Color("white"),
- font_size=40,
- bold=True,
- font_name="assets/fonts/SofiaSans-Light.ttf"
- )
- )
-
- # --- Content Column (this is the flex container) ---
- content_column = FlexFrame(
- parent=control_frame,
- x=0,
- y=50, # start 50px down
- width=RIGHT_PANEL_WIDTH,
- height=0, # ignored when fill_remaining_height=True
- height_is_percent=False,
- padding=(10, 10, 10, 10),
- gap=10,
- fill_child_width=True,
- align_horizontal="left",
-
- # key bits:
- fill_remaining_height=True, # <-- stretch to parent's bottom
- auto_height_to_content=False # <-- avoid fighting with fill-to-bottom
- )
-
- # Return both so caller can attach sections to content_column
- return content_column
-
-def _build_movement_controls(control_box, movementSystem)-> Frame:
-
- # Movement buttons
- control_box.add_child(make_button(movementSystem.move_x_right, 10, 55, 80, 80, "<", ButtonShape.DIAMOND))
- control_box.add_child(make_button(movementSystem.move_x_left, 100, 55, 80, 80, ">", ButtonShape.DIAMOND))
- control_box.add_child(make_button(movementSystem.move_y_backward, 55, 10, 80, 80, "^", ButtonShape.DIAMOND))
- control_box.add_child(make_button(movementSystem.move_y_forward, 55, 100, 80, 80, "v", ButtonShape.DIAMOND))
-
- control_box.add_child(make_button(movementSystem.move_z_up, 200, 53, 40, 40, "+"))
- control_box.add_child(make_button(movementSystem.move_z_down, 200, 103, 40, 40, "-"))
-
- # Speed Buttons
- control_box.add_child(make_button(movementSystem.increase_speed, 250, 53, 40, 40, "S+"))
- control_box.add_child(make_button(movementSystem.decrease_speed, 250, 103, 40, 40, "S-"))
- control_box.add_child(make_button(movementSystem.increase_speed_fast, 300, 53, 40, 40, "F+"))
- control_box.add_child(make_button(movementSystem.decrease_speed_fast, 300, 103, 40, 40, "F-"))
-
- # Homing Button
- control_box.add_child(make_button(movementSystem.home, 70, 70, 50, 50, "H", ButtonShape.DIAMOND, z_index=1))
-
- # --- Live readouts ---
- speed_display = Text(
- text=f"Speed: {movementSystem.speed / 100:.2f}",
- parent=control_box,
- x=200, y=155,
- x_align="left",
- y_align="top",
- style=make_display_text_style()
- )
-
- position_display = Text(
- text=f"X: {movementSystem.position.x/100:.2f} Y: {movementSystem.position.y/100:.2f} Z: {movementSystem.position.z/100:.2f}",
- parent=control_box,
- x=343, y=175,
- x_align="right",
- y_align="top",
- style=make_display_text_style()
- )
-
- return speed_display, position_display
-
-
-def _build_sample_box(sample_box, movementSystem, camera, current_sample_index):
- # --- Sample navigation (callbacks assigned later in main.py) ---
- button_height = 40
-
- # 1st Row
- go_to_sample_button = Button(None, parent=sample_box,
- x=10, y=10, width=150, height=button_height, text="Go to Sample", text_style=make_button_text_style())
-
- decrement_button = Button(None, parent=sample_box,
- x=170, y=10, width=40, height=button_height, text="-", text_style=make_button_text_style())
-
- sample_label = Text(f"Sample {current_sample_index}", parent=sample_box,
- x=220, y=20, x_align="left", y_align="top", style=make_button_text_style())
-
- increment_button = Button(None, parent=sample_box,
- x=330, y=10, width=40, height=button_height, text="+", text_style=make_button_text_style())
-
- # 2nd Row
- """
- Button(movementSystem.setPosition1, 10 , 60, 150, button_height, "Set Position 1", parent=sample_box, text_style=make_button_text_style())
-
- pos1_display = Text(
- text=f"X: {movementSystem.automation_config.x_start/100:.2f} Y: {movementSystem.automation_config.y_start/100:.2f} Z: {movementSystem.automation_config.z_start/100:.2f}",
- parent=sample_box,
- x=170, y=75,
- style=make_display_text_style()
- )
-
- # 3rd Row
- Button(movementSystem.setPosition2, 10, 110, 150, button_height, "Set Position 2", parent=sample_box, text_style=make_button_text_style())
-
- pos2_display = Text(
- text=f"X: {movementSystem.automation_config.x_end/100:.2f} Y: {movementSystem.automation_config.y_end/100:.2f} Z: {movementSystem.automation_config.z_end/100:.2f}",
- parent=sample_box,
- x=170, y=125,
- style=make_display_text_style()
- )
- """
- # 4th Row
- def build_row(i: int, parent: Frame) -> None:
- on_overrides = ToggledColors(
- background=pygame.Color("#7ed957"),
- hover_background=pygame.Color("#6bc24b"),
- foreground=pygame.Color("#2f6f2a"),
- hover_foreground=pygame.Color("#2f6f2a"),
- )
-
- def on_state_changed(state: bool, btn: ToggleButton):
- # Fires only when the ON/OFF value changes.
- btn.set_text("X" if state else "")
-
- ToggleButton(
- parent=parent,
- x=0, y=0, width=30, height=30,
- text="", # label is independent of state; change it in on_change if you want
- toggled=False,
- on_change=on_state_changed,
- toggled_colors=on_overrides,
- text_style=make_button_text_style()
- )
-
- Text(
- text=f"Sample {i+1}:",
- parent=parent,
- x=40, y=5,
- style=make_button_text_style()
- )
-
- TextField(parent=parent, x=150, y=0, width=180, height=30, placeholder=f"Sample {i+1} Name", border_color=pygame.Color("#b3b4b6"), text_color=pygame.Color("#5a5a5a"))
-
- scroll_area = ScrollFrame(parent=sample_box, x=10, y= 60, width=RIGHT_PANEL_WIDTH - 40, height=295, fill_remaining_height=True)
-
- lst = ListFrame(parent=scroll_area, x=10, y=10, width=1.0, height=700,
- width_is_percent=True,
- row_height=35, count=movementSystem.get_num_slots(), row_builder=build_row)
-
- movementSystem.sample_list = lst
-
- return go_to_sample_button, decrement_button, increment_button, sample_label#, pos1_display, pos2_display
-
-
-def _build_camera_control(camera_control, movementSystem: AutomatedPrinter, camera, camera_settings_modal):
-
- # Header Settings Button
- settings = Button(lambda: camera_settings_modal.open(), x=0, y=0,
- width=camera_control.header.height,
- height=camera_control.header.height,
- parent=camera_control.header,
- colors=ButtonColors(
- background=pygame.Color("#dbdbdb"),
- foreground=pygame.Color("#dbdbdb"),
- hover_background=pygame.Color("#b3b4b6"),
- disabled_background=pygame.Color("#dbdbdb"),
- disabled_foreground=pygame.Color("#dbdbdb")
- )
- )
- camera_control.add_header_button(settings)
- ButtonIcon(
- parent_button=settings,
- image="assets/gear.png",
- normal_replace=(122, 122, 122, 255),
- hover_replace=(122, 122, 122, 255),
- size=(camera_control.header.height - 8, camera_control.header.height - 8), # explicit size in pixels
- inset_px=0
- )
-
- # Body of Camera Control
- camera_control.add_child(make_button(
- camera.capture_and_save,
- 10, 10, 117, 40, "Take Photo"
- ))
-
- path_label = Text(f"Save Path: {camera.capture_path}", parent=camera_control,
- x=10, y=60, x_align="left", y_align="top", style=make_display_text_style(), truncate_mode="middle", max_width=RIGHT_PANEL_WIDTH - 20 - 20)
-
- def on_set_path():
- path_label.set_text(f"Save Path: {camera.select_capture_path()}")
-
- Button(on_set_path, 132, 10, 117, 40, "Set Path", parent=camera_control, text_style=make_button_text_style())
-
-
- Button(lambda: movementSystem.start_autofocus(), 10, 85, 117, 40, "Autofocus", parent=camera_control, text_style=make_button_text_style())
- Button(lambda: movementSystem.start_fine_autofocus(), 132, 85, 167, 40, "Fine Autofocus", parent=camera_control, text_style=make_button_text_style())
-
- def open_capture_folder():
- """Open the capture folder in the system's default file explorer."""
- # Convert relative paths to absolute
- folder = os.path.abspath(camera.capture_path)
-
- if not os.path.isdir(folder):
- print(f"Path does not exist or is not a folder: {folder}")
- return
-
- if sys.platform.startswith("win"):
- os.startfile(folder) # type: ignore[attr-defined]
- elif sys.platform.startswith("darwin"): # macOS
- subprocess.run(["open", folder])
- else: # Linux and other Unix
- subprocess.run(["xdg-open", folder])
-
- print("Opened Image Output Folder")
-
- Button(open_capture_folder,x=254, y=10, width=117, height=40, text="Open Path", parent=camera_control, text_style=make_button_text_style())
-
-
-def _build_automation_control(automation_box, movementSystem, machine_vision_overlay, automation_settings_modal):
-
- settings = Button(lambda: automation_settings_modal.open(), x=0, y=0,
- width=automation_box.header.height,
- height=automation_box.header.height,
- parent=automation_box.header,
- colors=ButtonColors(
- background=pygame.Color("#dbdbdb"),
- foreground=pygame.Color("#dbdbdb"),
- hover_background=pygame.Color("#b3b4b6"),
- disabled_background=pygame.Color("#dbdbdb"),
- disabled_foreground=pygame.Color("#dbdbdb")
- )
- )
- automation_box.add_header_button(settings)
- ButtonIcon(
- parent_button=settings,
- image="assets/gear.png",
- normal_replace=(122, 122, 122, 255),
- hover_replace=(122, 122, 122, 255),
- size=(automation_box.header.height - 8, automation_box.header.height - 8), # explicit size in pixels
- inset_px=0
- )
-
-
- Button(movementSystem.start_automation, 10, 10, 115, 40, "Start", parent=automation_box, text_style=make_button_text_style())
- Button(movementSystem.stop, 133, 10, 115, 40, "Stop" , parent=automation_box, text_style=make_button_text_style())
- Button(movementSystem.toggle_pause, 255, 10, 115, 40, "Pause", parent=automation_box, text_style=make_button_text_style())
-
- def toggle_overlay():
- print("Toggling Overlay")
- machine_vision_overlay.toggle_overlay()
-
- Button(toggle_overlay,x=10, y=60, width=117, height=40,text="Toggle MV", parent=automation_box, text_style=make_button_text_style())
-
- def toggle_overlay():
- print("Setting Hot Pixel Map")
- machine_vision_overlay.clear_hot_pixel_map()
- count = machine_vision_overlay.build_hot_pixel_map(include_soft=True)
- print(f"Marked {count} hot tiles invalid")
-
- Button(toggle_overlay,x=132, y=60, width=212, height=40, text="MV Hot Pixel Filter", parent=automation_box, text_style=make_button_text_style())
-
-
-
-
-
-
diff --git a/UI/widgets/camera_controls_widget.py b/UI/widgets/camera_controls_widget.py
new file mode 100644
index 0000000..a47265c
--- /dev/null
+++ b/UI/widgets/camera_controls_widget.py
@@ -0,0 +1,295 @@
+from __future__ import annotations
+
+from pathlib import Path
+from datetime import datetime
+from PySide6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
+ QPushButton, QLineEdit, QLabel, QFileDialog, QMessageBox, QComboBox
+)
+from PySide6.QtCore import Slot, Signal
+from common.logger import info, error, warning, debug
+from common.app_context import get_app_context
+
+
+class CameraControlsWidget(QWidget):
+ """
+ Camera-agnostic widget for camera controls including photo capture and file management.
+ """
+
+ # Signal emitted when photo capture completes
+ photo_captured = Signal(bool, str) # success, filepath
+
+ def __init__(self, parent: QWidget | None = None):
+ super().__init__(parent)
+
+ # Default values
+ self._default_folder = Path("./output")
+ self._current_folder = self._default_folder
+
+ # Supported image formats
+ self._image_formats = {
+ "TIFF": ".tiff",
+ "JPEG": ".jpg",
+ "PNG": ".png"
+ }
+
+ # Ensure output folder exists
+ self._ensure_output_folder()
+
+ # Setup UI
+ self._setup_ui()
+
+ # Connect signal to handler
+ self.photo_captured.connect(self._on_photo_captured)
+
+ def _setup_ui(self):
+ """Setup the user interface"""
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(10, 10, 10, 10)
+ layout.setSpacing(10)
+
+ # Photo capture group
+ capture_group = self._create_capture_group()
+ layout.addWidget(capture_group)
+
+ layout.addStretch()
+
+ def _create_capture_group(self) -> QGroupBox:
+ """Create the photo capture control group"""
+ group = QGroupBox("Photo Capture")
+ layout = QVBoxLayout(group)
+
+ # Folder selection row
+ folder_layout = QHBoxLayout()
+ folder_label = QLabel("Output Folder:")
+ folder_label.setMinimumWidth(100)
+
+ self._folder_edit = QLineEdit()
+ self._folder_edit.setText(str(self._current_folder))
+ self._folder_edit.setPlaceholderText("Select output folder...")
+
+ self._browse_button = QPushButton("Browse...")
+ self._browse_button.clicked.connect(self._browse_folder)
+
+ folder_layout.addWidget(folder_label)
+ folder_layout.addWidget(self._folder_edit, 1)
+ folder_layout.addWidget(self._browse_button)
+
+ # Filename row
+ filename_layout = QHBoxLayout()
+ filename_label = QLabel("Filename:")
+ filename_label.setMinimumWidth(100)
+
+ self._filename_edit = QLineEdit()
+ self._filename_edit.setPlaceholderText("Leave empty for auto-generated name")
+
+ filename_layout.addWidget(filename_label)
+ filename_layout.addWidget(self._filename_edit, 1)
+
+ # Image format row
+ format_layout = QHBoxLayout()
+ format_label = QLabel("Image Format:")
+ format_label.setMinimumWidth(100)
+
+ self._format_combo = QComboBox()
+ self._format_combo.addItems(self._image_formats.keys())
+
+ # Set default format from camera settings
+ self._format_combo.setCurrentText(get_app_context().camera.settings.fformat.upper())
+
+ self._open_folder_button = QPushButton("Browse Output")
+ self._open_folder_button.clicked.connect(self._open_folder)
+
+ format_layout.addWidget(format_label)
+ format_layout.addWidget(self._format_combo)
+ format_layout.addWidget(self._open_folder_button)
+ format_layout.addStretch()
+
+ # Capture button row
+ buttons_layout = QHBoxLayout()
+
+ self._capture_button = QPushButton("Take Photo")
+ self._capture_button.setMinimumHeight(40)
+ self._capture_button.clicked.connect(self._take_photo)
+
+ buttons_layout.addWidget(self._capture_button)
+
+ # Add all to group layout
+ layout.addLayout(folder_layout)
+ layout.addLayout(filename_layout)
+ layout.addLayout(format_layout)
+ layout.addLayout(buttons_layout)
+
+ return group
+
+ def _ensure_output_folder(self):
+ """Ensure the output folder exists"""
+ try:
+ self._current_folder.mkdir(parents=True, exist_ok=True)
+ debug(f"Output folder ready: {self._current_folder}")
+ except Exception as e:
+ error(f"Failed to create output folder: {e}")
+ # Show toast for error
+ ctx = get_app_context()
+ if ctx.toast:
+ ctx.toast.error(f"{str(e)}", title="Folder Creation Failed")
+
+ def _browse_folder(self):
+ """Open folder selection dialog"""
+ folder = QFileDialog.getExistingDirectory(
+ self,
+ "Select Output Folder",
+ str(self._current_folder),
+ QFileDialog.Option.ShowDirsOnly
+ )
+
+ if folder:
+ self._current_folder = Path(folder)
+ self._folder_edit.setText(str(self._current_folder))
+ self._ensure_output_folder()
+ info(f"Output folder changed to: {self._current_folder}")
+
+ # Show toast notification
+ ctx = get_app_context()
+ if ctx.toast:
+ ctx.toast.success(f"{self._current_folder.name}", title="Output Folder Changed")
+
+ def _open_folder(self):
+ """Open the output folder in the system file manager"""
+ import subprocess
+ import sys
+
+ ctx = get_app_context()
+ toast = ctx.toast
+
+ try:
+ folder_path = str(self._current_folder.resolve())
+
+ if sys.platform == 'win32':
+ # Windows
+ subprocess.run(['explorer', folder_path])
+ elif sys.platform == 'darwin':
+ # macOS
+ subprocess.run(['open', folder_path])
+ else:
+ # Linux
+ subprocess.run(['xdg-open', folder_path])
+
+ info(f"Opened folder: {folder_path}")
+ toast.info("Opening in file explorer...", title="Opening Folder", duration=10000)
+ except Exception as e:
+ error(f"Failed to open folder: {e}")
+ toast.error(f"{str(e)}", title="Failed to Open Folder")
+ QMessageBox.warning(
+ self,
+ "Error",
+ f"Could not open folder: {e}"
+ )
+
+ def _generate_filename(self) -> str:
+ """Generate a filename based on current timestamp and selected format"""
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ format_name = self._format_combo.currentText()
+ extension = self._image_formats[format_name]
+ return f"image_{timestamp}{extension}"
+
+ def _get_filepath(self) -> Path:
+ """Get the complete filepath for saving"""
+ # Get selected format
+ format_name = self._format_combo.currentText()
+ extension = self._image_formats[format_name]
+
+ # Use custom filename if provided, otherwise generate one
+ filename = self._filename_edit.text().strip()
+ if not filename:
+ filename = self._generate_filename()
+ else:
+ # Remove any existing extension
+ filename_path = Path(filename)
+ filename_base = filename_path.stem
+
+ # Add the selected extension
+ filename = f"{filename_base}{extension}"
+
+ return self._current_folder / filename
+
+ @Slot()
+ def _take_photo(self):
+ """
+ Capture a still photo from the camera.
+ Works with any camera implementation that supports capture_and_save_still.
+ """
+ ctx = get_app_context()
+ toast = ctx.toast
+
+ try:
+ camera = ctx.camera
+
+ if camera is None:
+ warning("Attempted to capture photo but camera is not available")
+ toast.warning("Camera not available", title="Camera Error")
+ return
+
+ if not camera.underlying_camera.is_open:
+ warning("Attempted to capture photo but camera is not open")
+ toast.warning("Please open the camera first", title="Camera Not Open")
+ return
+
+ # Get filepath
+ filepath = self._get_filepath()
+
+ # Ensure folder exists
+ self._ensure_output_folder()
+
+ info(f"Capturing still image to: {filepath}")
+ toast.info("Capturing high-resolution image...", title="Capturing Image")
+
+ # Disable button while capturing
+ self._capture_button.setEnabled(False)
+
+ # Define completion callback - runs on camera thread!
+ def on_capture_complete(success: bool, result):
+ """Called when capture completes (on camera thread)"""
+ # Emit signal to handle UI updates on main thread
+ self.photo_captured.emit(success, str(filepath))
+
+ # Capture and save still image asynchronously at highest resolution
+ # This returns immediately - UI stays responsive!
+ camera.capture_and_save_still(
+ filepath=filepath,
+ resolution_index=0, # Highest resolution
+ additional_metadata={
+ "source": "still_capture"
+ },
+ timeout_ms=5000,
+ on_complete=on_capture_complete
+ )
+
+ except Exception as e:
+ error(f"Error capturing photo: {e}")
+ toast.error(f"{str(e)}", title="Capture Error")
+ import traceback
+ error(traceback.format_exc())
+ # Re-enable button on error
+ self._capture_button.setEnabled(True)
+
+ @Slot(bool, str)
+ def _on_photo_captured(self, success: bool, filepath: str):
+ """
+ Handle photo capture completion on UI thread.
+ This slot is called via signal from the camera thread.
+ """
+ ctx = get_app_context()
+ toast = ctx.toast
+
+ # Re-enable button
+ self._capture_button.setEnabled(True)
+
+ if success:
+ toast.success(f"Saved to: {Path(filepath).name}",
+ title="Image Captured",
+ duration=10000)
+ # Clear custom filename after successful capture
+ self._filename_edit.clear()
+ else:
+ toast.error("Unable to capture image from camera", title="Capture Failed")
diff --git a/UI/widgets/camera_preview.py b/UI/widgets/camera_preview.py
new file mode 100644
index 0000000..f54e700
--- /dev/null
+++ b/UI/widgets/camera_preview.py
@@ -0,0 +1,335 @@
+from __future__ import annotations
+
+import numpy as np
+from PySide6.QtCore import Qt, Slot, QRect
+from PySide6.QtGui import QImage, QPixmap, QPainter, QPen, QColor
+from PySide6.QtWidgets import (
+ QFrame, QLabel, QVBoxLayout, QWidget, QSizePolicy,
+ QPushButton, QHBoxLayout
+)
+
+from common.app_context import get_app_context
+from common.logger import info, error, warning
+
+
+class OverlayLabel(QLabel):
+ """Custom QLabel that can draw overlays on top of the image"""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.show_grid = False
+ self.show_crosshair = False
+
+ def paintEvent(self, event):
+ """Override paint event to draw overlays"""
+ # First draw the base image
+ super().paintEvent(event)
+
+ # Only draw overlays if we have a pixmap
+ if self.pixmap() is None or self.pixmap().isNull():
+ return
+
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+
+ # Get the actual image rect (considering aspect ratio)
+ pixmap = self.pixmap()
+ if pixmap.width() == 0 or pixmap.height() == 0:
+ return
+
+ # Calculate the displayed image rect
+ widget_rect = self.rect()
+ pixmap_rect = pixmap.rect()
+
+ # Calculate scaled rect maintaining aspect ratio
+ scale = min(
+ widget_rect.width() / pixmap_rect.width(),
+ widget_rect.height() / pixmap_rect.height()
+ )
+
+ scaled_width = int(pixmap_rect.width() * scale)
+ scaled_height = int(pixmap_rect.height() * scale)
+
+ x = (widget_rect.width() - scaled_width) // 2
+ y = (widget_rect.height() - scaled_height) // 2
+
+ image_rect = QRect(x, y, scaled_width, scaled_height)
+
+ # Set up pen for drawing overlays
+ pen = QPen(QColor(0, 0, 0, 180)) # Black with transparency
+ pen.setWidth(2)
+ painter.setPen(pen)
+
+ # Draw grid if enabled
+ if self.show_grid:
+ self._draw_grid(painter, image_rect)
+
+ # Draw crosshair if enabled
+ if self.show_crosshair:
+ self._draw_crosshair(painter, image_rect)
+
+ painter.end()
+
+ def _draw_grid(self, painter: QPainter, rect: QRect):
+ """Draw a 3x3 grid"""
+ x, y, w, h = rect.x(), rect.y(), rect.width(), rect.height()
+
+ # Vertical lines
+ for i in range(1, 3):
+ x_pos = x + (w * i // 3)
+ painter.drawLine(x_pos, y, x_pos, y + h)
+
+ # Horizontal lines
+ for i in range(1, 3):
+ y_pos = y + (h * i // 3)
+ painter.drawLine(x, y_pos, x + w, y_pos)
+
+ def _draw_crosshair(self, painter: QPainter, rect: QRect):
+ """Draw a crosshair at the center"""
+ center_x = rect.x() + rect.width() // 2
+ center_y = rect.y() + rect.height() // 2
+
+ # Draw horizontal line - smaller (1/24 of smaller dimension)
+ line_length = min(rect.width(), rect.height()) // 24
+ painter.drawLine(center_x - line_length, center_y, center_x + line_length, center_y)
+
+ # Draw vertical line
+ painter.drawLine(center_x, center_y - line_length, center_x, center_y + line_length)
+
+
+class CameraPreview(QFrame):
+ """
+ Camera preview widget that displays frames from the camera manager.
+ This widget only handles display - it does not manage camera lifecycle.
+ """
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setFrameShape(QFrame.Shape.NoFrame)
+ self.setObjectName("CameraPreview")
+
+ # Display state
+ self._current_width = 0
+ self._current_height = 0
+
+ # UI elements - use custom overlay label
+ self._video_label = OverlayLabel()
+ self._video_label.setObjectName("VideoLabel")
+ self._video_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self._video_label.setScaledContents(False)
+ self._video_label.setMinimumSize(1, 1)
+ self._video_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
+ self._video_label.setText("No camera stream")
+
+ # Main layout
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.addWidget(self._video_label, 1)
+
+ # Create overlay control buttons as direct children (true overlay)
+ self._crosshair_button = QPushButton("⌖", self)
+ self._crosshair_button.setObjectName("CrosshairButton")
+ self._crosshair_button.setCheckable(True)
+ self._crosshair_button.setFixedSize(30, 30)
+ self._crosshair_button.setToolTip("Toggle Crosshair")
+ self._crosshair_button.clicked.connect(self._toggle_crosshair)
+ self._crosshair_button.move(10, 10) # Position in top left
+ self._crosshair_button.raise_() # Ensure it's on top
+
+ self._grid_button = QPushButton("⌗", self)
+ self._grid_button.setObjectName("OverlayButton")
+ self._grid_button.setCheckable(True)
+ self._grid_button.setFixedSize(30, 30)
+ self._grid_button.setToolTip("Toggle Grid")
+ self._grid_button.clicked.connect(self._toggle_grid)
+ self._grid_button.move(10, 45) # Position below crosshair button (10 + 30 + 5)
+ self._grid_button.raise_() # Ensure it's on top
+
+ # Create focus button with custom overlaid text
+ self._focus_button = QPushButton(self)
+ self._focus_button.setObjectName("FocusButton")
+ self._focus_button.setCheckable(True)
+ self._focus_button.setFixedSize(30, 30)
+ self._focus_button.setToolTip("Toggle Focus Overlay")
+ self._focus_button.clicked.connect(self._toggle_focus)
+ self._focus_button.move(10, 80) # Position below grid button (45 + 30 + 5)
+
+ # Create labels for the overlaid symbols - each line separate
+ focus_top_corners = QLabel("⌜⌝", self._focus_button)
+ focus_top_corners.setObjectName("FocusOverlayLabel")
+ focus_top_corners.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ focus_top_corners.setGeometry(0, -2, 30, 30)
+ focus_top_corners.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
+
+ focus_bottom_corners = QLabel("⌞⌟", self._focus_button)
+ focus_bottom_corners.setObjectName("FocusOverlayLabel")
+ focus_bottom_corners.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ focus_bottom_corners.setGeometry(0, 2, 30, 30)
+ focus_bottom_corners.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
+
+ focus_center = QLabel("⌖", self._focus_button)
+ focus_center.setObjectName("FocusOverlayLabel")
+ focus_center.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ focus_center.setGeometry(0, 0, 30, 30)
+ focus_center.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
+
+ self._focus_button.raise_() # Ensure it's on top
+
+ # Connect to camera manager signals
+ self._connect_to_camera_manager()
+
+ def _connect_to_camera_manager(self):
+ """Connect to camera manager signals"""
+ ctx = get_app_context()
+ camera_manager = ctx.camera_manager
+
+ # Connect to frame ready signal
+ camera_manager.frame_ready.connect(self._on_frame_ready)
+
+ # Connect to streaming status signals
+ camera_manager.streaming_started.connect(self._on_streaming_started)
+ camera_manager.streaming_stopped.connect(self._on_streaming_stopped)
+
+ # Connect to camera status signals
+ camera_manager.camera_error.connect(self._on_camera_error)
+ camera_manager.camera_disconnected.connect(self._on_camera_disconnected)
+ camera_manager.active_camera_changed.connect(self._on_active_camera_changed)
+
+ # Update initial state
+ if camera_manager.is_streaming:
+ width, height = camera_manager.frame_dimensions
+ self._on_streaming_started(width, height)
+ elif camera_manager.has_active_camera:
+ self._video_label.setText("Camera ready - not streaming")
+ else:
+ self._video_label.setText("No camera connected")
+
+ @Slot(bool)
+ def _toggle_crosshair(self, checked: bool):
+ """Toggle crosshair overlay"""
+ self._video_label.show_crosshair = checked
+ self._video_label.update() # Trigger repaint
+ info(f"Preview: Crosshair {'enabled' if checked else 'disabled'}")
+
+ @Slot(bool)
+ def _toggle_grid(self, checked: bool):
+ """Toggle grid overlay"""
+ self._video_label.show_grid = checked
+ self._video_label.update() # Trigger repaint
+ info(f"Preview: Grid {'enabled' if checked else 'disabled'}")
+
+ @Slot(bool)
+ def _toggle_focus(self, checked: bool):
+ """Toggle focus overlay"""
+ info(f"Focus Overlay Toggled {'on' if checked else 'off'}")
+
+ @Slot(int, int)
+ def _on_frame_ready(self, width: int, height: int):
+ """Handle new frame available from camera manager"""
+ ctx = get_app_context()
+ camera_manager = ctx.camera_manager
+
+ # Get frame buffer from camera manager
+ frame_buffer = camera_manager.get_current_frame()
+ if not frame_buffer:
+ return
+
+ try:
+ # Check if dimensions changed
+ if width != self._current_width or height != self._current_height:
+ self._current_width = width
+ self._current_height = height
+
+ # Calculate stride
+ camera = camera_manager.active_camera
+ if not camera:
+ return
+
+ base_camera = camera.underlying_camera
+ base_camera_class = type(base_camera)
+ stride = base_camera_class.calculate_stride(width, 24)
+
+ # Create QImage from buffer
+ image = QImage(
+ frame_buffer,
+ width,
+ height,
+ stride,
+ QImage.Format.Format_RGB888
+ )
+
+ # Make a deep copy for display
+ image = image.copy()
+
+ # Scale to fit label while maintaining aspect ratio
+ if self._video_label.width() > 0 and self._video_label.height() > 0:
+ scaled_image = image.scaled(
+ self._video_label.width(),
+ self._video_label.height(),
+ Qt.AspectRatioMode.KeepAspectRatio,
+ Qt.TransformationMode.FastTransformation
+ )
+ self._video_label.setPixmap(QPixmap.fromImage(scaled_image))
+
+ except Exception as e:
+ error(f"Preview: Error displaying frame: {e}")
+
+ @Slot(int, int)
+ def _on_streaming_started(self, width: int, height: int):
+ """Handle streaming started signal"""
+ info(f"Preview: Streaming started ({width}x{height})")
+ self._current_width = width
+ self._current_height = height
+ self._video_label.setText("") # Clear any text when streaming starts
+
+ @Slot()
+ def _on_streaming_stopped(self):
+ """Handle streaming stopped signal"""
+ info("Preview: Streaming stopped")
+ self._video_label.setText("Camera stream stopped")
+
+ @Slot()
+ def _on_camera_error(self):
+ """Handle camera error"""
+ self._video_label.setText("Camera error occurred")
+ error("Preview: Camera error occurred")
+
+ @Slot()
+ def _on_camera_disconnected(self):
+ """Handle camera disconnection"""
+ self._video_label.setText("Camera disconnected")
+ warning("Preview: Camera disconnected")
+
+ @Slot(object)
+ def _on_active_camera_changed(self, camera_info):
+ """Handle active camera changed"""
+ if camera_info is None:
+ self._video_label.setText("No camera connected")
+ info("Preview: No active camera")
+ else:
+ info(f"Preview: Active camera changed to {camera_info.display_name}")
+ # Don't clear text yet - wait for streaming to start
+ ctx = get_app_context()
+ if not ctx.camera_manager.is_streaming:
+ self._video_label.setText("Camera ready - not streaming")
+
+ def cleanup(self):
+ """Cleanup resources when widget is being destroyed"""
+ info("Preview: cleanup starting...")
+
+ # Disconnect from camera manager signals
+ try:
+ ctx = get_app_context()
+ camera_manager = ctx.camera_manager
+
+ camera_manager.frame_ready.disconnect(self._on_frame_ready)
+ camera_manager.streaming_started.disconnect(self._on_streaming_started)
+ camera_manager.streaming_stopped.disconnect(self._on_streaming_stopped)
+ camera_manager.camera_error.disconnect(self._on_camera_error)
+ camera_manager.camera_disconnected.disconnect(self._on_camera_disconnected)
+ camera_manager.active_camera_changed.disconnect(self._on_active_camera_changed)
+ except Exception as e:
+ error(f"Preview: Error disconnecting signals: {e}")
+
+ info("Preview cleanup complete")
\ No newline at end of file
diff --git a/UI/widgets/collapsible_section.py b/UI/widgets/collapsible_section.py
new file mode 100644
index 0000000..994cd0a
--- /dev/null
+++ b/UI/widgets/collapsible_section.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QVBoxLayout,
+ QWidget,
+ QSizePolicy,
+)
+
+from ..settings.settings_main import SettingsButton
+
+class CollapsibleSection(QFrame):
+ """
+ Collapsible boxed section:
+ - full-width header strip
+ - collapses entire widget height when collapsed
+ - callback on collapse/expand (useful to adjust parent stretch)
+ """
+
+ def __init__(
+ self,
+ title: str,
+ *,
+ on_settings=None,
+ start_collapsed: bool = False,
+ on_collapsed_changed=None,
+ parent: QWidget | None = None,
+ ) -> None:
+ super().__init__(parent)
+ self.setFrameShape(QFrame.Shape.StyledPanel)
+ self.setObjectName("CollapsibleSection")
+ self._on_collapsed_changed = on_collapsed_changed
+
+ root = QVBoxLayout(self)
+ root.setContentsMargins(0, 0, 0, 0)
+ root.setSpacing(0)
+
+ self.header = QFrame()
+ self.header.setObjectName("SectionHeader")
+ self.header.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.header.setProperty("collapsed", False)
+
+ header_layout = QHBoxLayout(self.header)
+ header_layout.setContentsMargins(10, 7, 8, 7)
+ header_layout.setSpacing(8)
+
+ self.caret = QLabel("▾")
+ self.caret.setFixedWidth(16)
+ self.caret.setAlignment(Qt.AlignmentFlag.AlignCenter)
+
+ self.title_lbl = QLabel(title)
+ self.title_lbl.setObjectName("SectionHeaderTitle")
+
+ header_layout.addWidget(self.caret)
+ header_layout.addWidget(self.title_lbl)
+ header_layout.addStretch(1)
+
+ if on_settings is not None:
+ gear = SettingsButton("Section settings")
+ gear.clicked.connect(on_settings)
+ header_layout.addWidget(gear)
+
+ root.addWidget(self.header)
+
+ self.content = QWidget()
+ self.content.setObjectName("SectionContent")
+ self.content_layout = QVBoxLayout(self.content)
+ self.content_layout.setContentsMargins(10, 10, 10, 12)
+ self.content_layout.setSpacing(10)
+ root.addWidget(self.content)
+
+ # natural height unless parent gives stretch
+ self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
+
+ self._collapsed = False
+ self.header.mousePressEvent = self._on_header_click # type: ignore
+ self.set_collapsed(start_collapsed)
+
+ def _on_header_click(self, event) -> None:
+ self.set_collapsed(not self._collapsed)
+
+ def set_collapsed(self, collapsed: bool) -> None:
+ if self._collapsed == collapsed:
+ return
+ self._collapsed = collapsed
+
+ self.content.setVisible(not collapsed)
+ self.caret.setText("▸" if collapsed else "▾")
+
+ # inform stylesheet for corner rounding on collapse
+ self.header.setProperty("collapsed", collapsed)
+ self.header.style().unpolish(self.header)
+ self.header.style().polish(self.header)
+
+ # collapse entire widget height
+ header_h = self.header.sizeHint().height()
+ if collapsed:
+ self.setMaximumHeight(header_h + 2)
+ self.setMinimumHeight(header_h + 2)
+ else:
+ self.setMinimumHeight(0)
+ self.setMaximumHeight(16777215)
+
+ if self._on_collapsed_changed is not None:
+ self._on_collapsed_changed(collapsed)
+
+ def is_collapsed(self) -> bool:
+ return self._collapsed
+
+ def layout_for_content(self) -> QVBoxLayout:
+ return self.content_layout
diff --git a/UI/widgets/navigation_widget.py b/UI/widgets/navigation_widget.py
new file mode 100644
index 0000000..bb5a9a7
--- /dev/null
+++ b/UI/widgets/navigation_widget.py
@@ -0,0 +1,544 @@
+from __future__ import annotations
+
+import math
+from PySide6.QtWidgets import (
+ QWidget,
+ QVBoxLayout,
+ QHBoxLayout,
+ QPushButton,
+ QLabel,
+ QFrame,
+)
+from PySide6.QtGui import QPainter, QColor, QFont, QPen, QPainterPath, QTransform, QRegion, QPolygon
+from PySide6.QtCore import Qt, QRectF, QPointF
+
+def clamp(v: int, lo: int = 0, hi: int = 255) -> int:
+ return max(lo, min(hi, v))
+
+
+def adjust_color(c: QColor, factor: float) -> QColor:
+ return QColor(
+ clamp(int(c.red() * factor)),
+ clamp(int(c.green() * factor)),
+ clamp(int(c.blue() * factor)),
+ )
+
+
+class DiamondButton(QPushButton):
+ def __init__(
+ self,
+ label: str = "",
+ parent: QWidget | None = None,
+ base_color: QColor = QColor(208, 211, 214),
+ font_px: int = 28,
+ size: int = 90,
+ text_offset_y: int = 0
+ ):
+ super().__init__("", parent)
+ self.setFixedSize(size, size)
+ self.setStyleSheet("border: none; background: transparent;")
+ self.setMouseTracking(True)
+
+ # Enable mouse tracking on parent to handle pass-through
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
+
+ self._base = QColor(base_color)
+ self._hover = False
+ self._label = label
+ self._font_px = font_px
+ self._text_offset_y = text_offset_y
+
+ def enterEvent(self, event):
+ # Don't automatically set hover - check in mouseMoveEvent
+ super().enterEvent(event)
+
+ def leaveEvent(self, event):
+ self._hover = False
+ self.unsetCursor()
+ self.update()
+ super().leaveEvent(event)
+
+ def mouseMoveEvent(self, event):
+ # Update hover state based on whether mouse is over diamond
+ was_hover = self._hover
+ is_over_diamond = self.hitButton(event.position().toPoint())
+ self._hover = is_over_diamond
+
+ # Set or unset cursor based on position
+ if is_over_diamond:
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
+ else:
+ self.unsetCursor()
+
+ if was_hover != self._hover:
+ self.update()
+ super().mouseMoveEvent(event)
+
+ def mousePressEvent(self, event):
+ # Check if click is inside diamond
+ if self.hitButton(event.position().toPoint()):
+ super().mousePressEvent(event)
+ else:
+ # Pass the event to the parent by ignoring it
+ event.ignore()
+
+ def mouseReleaseEvent(self, event):
+ if self.hitButton(event.position().toPoint()):
+ super().mouseReleaseEvent(event)
+ else:
+ event.ignore()
+
+ def hitButton(self, pos):
+ w = self.width()
+ h = self.height()
+
+ cx = w / 2
+ cy = h / 2
+
+ # Translate point to origin
+ x = pos.x() - cx
+ y = pos.y() - cy
+
+ # Rotate point by -45 degrees (inverse of the 45 degree rotation in paintEvent)
+ angle = -45 * math.pi / 180
+ cos_a = math.cos(angle)
+ sin_a = math.sin(angle)
+
+ rotated_x = x * cos_a - y * sin_a
+ rotated_y = x * sin_a + y * cos_a
+
+ # Check if the rotated point is inside the square
+ side = min(w, h) / math.sqrt(2)
+ half_side = side / 2
+
+ return (abs(rotated_x) <= half_side and abs(rotated_y) <= half_side)
+
+ def paintEvent(self, event):
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+
+ w = self.width()
+ h = self.height()
+
+ color = QColor(self._base)
+ if self._hover:
+ color = adjust_color(color, 0.90) # Darken on hover
+ if self.isDown():
+ color = adjust_color(color, 0.85)
+
+ # Move origin to center
+ painter.translate(w / 2, h / 2)
+
+ # Rotate 45 degrees
+ painter.rotate(45)
+
+ # Define square centered at origin
+ side = min(w, h) / math.sqrt(2) # scale factor controls diamond size
+ rect = QRectF(-side / 2, -side / 2, side, side)
+
+ # Fill
+ painter.setBrush(color)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.drawRect(rect)
+
+ # Border
+ pen = QPen(QColor(120, 120, 120))
+ pen.setWidth(2)
+ pen.setJoinStyle(Qt.PenJoinStyle.MiterJoin)
+ painter.setPen(pen)
+ painter.setBrush(Qt.BrushStyle.NoBrush)
+ painter.drawRect(rect)
+
+ # Reset transform for text
+ painter.resetTransform()
+
+ # Draw label normally with offset
+ font = self.font()
+ font.setPixelSize(self._font_px)
+ font.setBold(True)
+ painter.setFont(font)
+ painter.setPen(Qt.GlobalColor.black)
+
+ # Apply vertical offset to text rect
+ text_rect = self.rect()
+ if self._text_offset_y != 0:
+ text_rect = text_rect.adjusted(0, self._text_offset_y, 0, self._text_offset_y)
+
+ painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, self._label)
+
+
+
+class NavigationWidget(QWidget):
+ def __init__(self, parent: QWidget | None = None):
+ super().__init__(parent)
+
+ # Mock position data
+ self._x_pos = 123.45
+ self._y_pos = 678.90
+ self._z_pos = 42.00
+
+ # Step size in mm
+ self._step_size = 0.4 # Default to 0.4mm
+
+ self._setup_ui()
+
+ def _setup_ui(self) -> None:
+ main_layout = QVBoxLayout(self)
+ main_layout.setContentsMargins(10, 10, 10, 10)
+ main_layout.setSpacing(15)
+
+ # Set white background
+ self.setStyleSheet("""
+ NavigationWidget {
+ background: white;
+ border-radius: 8px;
+ }
+ """)
+
+ # Step size controls
+ step_size_controls = self._create_step_size_controls()
+ main_layout.addWidget(step_size_controls)
+
+ # Combined jog controls
+ jog_controls = self._create_jog_controls()
+ main_layout.addWidget(jog_controls)
+
+ main_layout.addStretch(1)
+
+ def _create_step_size_controls(self) -> QWidget:
+ """Create step size selection buttons with position display"""
+ from PySide6.QtWidgets import QGroupBox
+
+ group = QGroupBox("Step Size")
+ group_layout = QVBoxLayout(group)
+ group_layout.setContentsMargins(10, 5, 10, 5)
+ group_layout.setSpacing(10)
+
+ # Buttons row
+ buttons_row = QWidget()
+ buttons_layout = QHBoxLayout(buttons_row)
+ buttons_layout.setContentsMargins(0, 0, 0, 0)
+ buttons_layout.setSpacing(8)
+
+ # Step size buttons
+ step_sizes = [0.04, 0.4, 2.0, 10.0]
+ self.step_buttons = []
+
+ for size in step_sizes:
+ btn = QPushButton(f"{size}mm")
+ btn.setFixedHeight(30)
+ btn.setCheckable(True)
+ btn.setStyleSheet("padding: 0px;") # Smaller padding
+ btn.clicked.connect(lambda checked, s=size: self._set_step_size(s))
+ buttons_layout.addWidget(btn)
+ self.step_buttons.append((btn, size))
+
+ # Set default button as checked (0.4mm)
+ self.step_buttons[1][0].setChecked(True)
+
+ group_layout.addWidget(buttons_row)
+
+ # Position display
+ self.position_label = QLabel()
+ self.position_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.position_label.setStyleSheet("""
+ QLabel {
+ font-size: 13px;
+ padding: 2px;
+ }
+ """)
+ self._update_position_display()
+ group_layout.addWidget(self.position_label)
+
+ return group
+
+ def _set_step_size(self, size: float) -> None:
+ """Set the step size and update button states"""
+ self._step_size = size
+
+ # Update button checked states
+ for btn, btn_size in self.step_buttons:
+ btn.setChecked(btn_size == size)
+
+ print(f"Step size set to {size}mm")
+
+ def _create_jog_controls(self) -> QWidget:
+ """Create combined jog controls with diamond navigation and Z-axis"""
+ from PySide6.QtWidgets import QGroupBox
+
+ group = QGroupBox("Jog")
+ group_layout = QVBoxLayout(group)
+ group_layout.setContentsMargins(0, 0, 0, 0)
+ group_layout.setSpacing(0)
+
+ # Top row: diamond and z-axis controls
+ top_row = QWidget()
+ top_layout = QHBoxLayout(top_row)
+ top_layout.setContentsMargins(0, 0, 0, 0)
+ top_layout.setSpacing(15)
+
+ # Diamond panel
+ diamond_container = self._create_diamond_panel()
+ top_layout.addWidget(diamond_container, 0, Qt.AlignmentFlag.AlignCenter)
+
+ # Z-axis controls
+ z_container = self._create_z_controls()
+ top_layout.addWidget(z_container, 0, Qt.AlignmentFlag.AlignCenter)
+
+ top_layout.addStretch(1)
+
+ group_layout.addWidget(top_row)
+
+ return group
+
+ def _create_diamond_panel(self) -> QWidget:
+ """Create the diamond navigation with home button in center"""
+ container = QWidget()
+ container.setFixedSize(240, 200) # Slightly larger for better spacing
+
+ # Outer arrows (Unicode) - larger size
+ self.top_btn = DiamondButton("↑", parent=container, font_px=32, size=90)
+ self.left_btn = DiamondButton("←", parent=container, font_px=32, size=90, text_offset_y=-3)
+ self.right_btn = DiamondButton("→", parent=container, font_px=32, size=90, text_offset_y=-3)
+ self.bot_btn = DiamondButton("↓", parent=container, font_px=32, size=90)
+
+ # Center home icon - smaller and orange
+ self.center_btn = DiamondButton(
+ "H",
+ parent=container,
+ font_px=20,
+ size=60 # Smaller than outer buttons
+ )
+
+ # Install event filters on all buttons for click pass-through
+ for btn in [self.top_btn, self.left_btn, self.right_btn, self.bot_btn, self.center_btn]:
+ btn.installEventFilter(self)
+
+ # Connect buttons to placeholder functions
+ self.top_btn.clicked.connect(self._move_up)
+ self.left_btn.clicked.connect(self._move_left)
+ self.right_btn.clicked.connect(self._move_right)
+ self.bot_btn.clicked.connect(self._move_down)
+ self.center_btn.clicked.connect(self._go_home)
+
+ self.center_btn.raise_()
+
+ # Position buttons when container is shown
+ container.resizeEvent = lambda event: self._layout_diamond_buttons(container)
+
+ return container
+
+ def eventFilter(self, obj, event):
+ """Intercept button events and pass through if in corner regions"""
+ from PySide6.QtCore import QEvent
+
+ # Only filter events on DiamondButtons
+ if not isinstance(obj, DiamondButton):
+ return super().eventFilter(obj, event)
+
+ # Handle mouse move events for hover
+ if event.type() == QEvent.Type.MouseMove:
+ btn_local_pos = event.position().toPoint()
+ is_over_obj_diamond = obj.hitButton(btn_local_pos)
+ global_pos = obj.mapToGlobal(btn_local_pos)
+
+ # Define buttons in z-order (home button is on top)
+ buttons = [self.center_btn, self.top_btn, self.left_btn,
+ self.right_btn, self.bot_btn]
+
+ # Find which button should be hovered
+ hovered_btn = None
+
+ if is_over_obj_diamond:
+ # Mouse is over this button's diamond - it should be hovered
+ hovered_btn = obj
+ else:
+ # Mouse is in corner region - check buttons beneath
+ for btn in buttons:
+ if btn is obj:
+ continue
+
+ btn_local = btn.mapFromGlobal(global_pos)
+ if btn.geometry().contains(btn.mapToParent(btn_local)):
+ if btn.hitButton(btn_local):
+ hovered_btn = btn
+ break # Stop at first match (top-most button)
+
+ # Update hover state on all buttons
+ for btn in buttons:
+ if btn is hovered_btn:
+ # Set hover on this button
+ if not btn._hover:
+ btn._hover = True
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ btn.update()
+ else:
+ # Clear hover on all other buttons
+ if btn._hover:
+ btn._hover = False
+ btn.unsetCursor()
+ btn.update()
+
+ return False # Don't consume move events
+
+ # Handle mouse button press events
+ if event.type() == QEvent.Type.MouseButtonPress:
+ if event.button() == Qt.MouseButton.LeftButton:
+ # Check if click is actually inside the diamond shape
+ btn_local_pos = event.position().toPoint()
+ if not obj.hitButton(btn_local_pos):
+ # Click is in corner region - manually pass to buttons beneath
+ global_pos = obj.mapToGlobal(btn_local_pos)
+
+ # Define buttons in z-order (home button is on top)
+ buttons = [self.center_btn, self.top_btn, self.left_btn,
+ self.right_btn, self.bot_btn]
+
+ for btn in buttons:
+ if btn is obj:
+ continue # Skip the button we're filtering
+
+ # Check if this button is beneath the click
+ btn_local = btn.mapFromGlobal(global_pos)
+ if btn.geometry().contains(btn.mapToParent(btn_local)):
+ if btn.hitButton(btn_local):
+ # Manually trigger this button (first match due to z-order)
+ btn.clicked.emit()
+ return True # Consume the event
+
+ # No button beneath, just consume the event (don't trigger anything)
+ return True
+
+ return super().eventFilter(obj, event)
+
+ def _layout_diamond_buttons(self, container: QWidget) -> None:
+ """Layout the diamond buttons in proper positions"""
+ # Container center
+ cx = container.width() // 2
+ cy = container.height() // 2
+
+ # Simple positioning: place outer buttons at fixed distance from center
+ # Distance should be enough to have visible gap between buttons
+ distance = 50 # Distance from center to outer button centers
+
+ def place(btn: QPushButton, x: int, y: int) -> None:
+ """Place button centered at x, y"""
+ btn.move(x - btn.width() // 2, y - btn.height() // 2)
+
+ # Place outer buttons in cardinal directions
+ place(self.top_btn, cx, cy - distance)
+ place(self.bot_btn, cx, cy + distance)
+ place(self.left_btn, cx - distance, cy)
+ place(self.right_btn, cx + distance, cy)
+
+ # Place home button at center
+ place(self.center_btn, cx, cy)
+
+ self.center_btn.raise_()
+
+ def _create_z_controls(self) -> QWidget:
+ """Create Z-axis increase/decrease buttons"""
+ container = QWidget()
+ container.setFixedHeight(200) # Match diamond container height
+ layout = QVBoxLayout(container)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(10)
+
+ # Add stretch to center vertically
+ layout.addStretch(1)
+
+ # Increase button - smaller with border
+ self.z_up_btn = QPushButton("▲")
+ self.z_up_btn.setFixedSize(55, 55) # Smaller square
+ self.z_up_btn.setStyleSheet("""
+ QPushButton {
+ background-color: rgb(208, 211, 214);
+ border: 2px solid rgb(120, 120, 120);
+ border-radius: 4px;
+ font-size: 14px;
+ }
+ QPushButton:hover {
+ background-color: rgb(187, 190, 193);
+ }
+ QPushButton:pressed {
+ background-color: rgb(177, 180, 182);
+ }
+ """)
+ self.z_up_btn.clicked.connect(self._z_increase)
+ layout.addWidget(self.z_up_btn, 0, Qt.AlignmentFlag.AlignCenter)
+
+ # Decrease button - smaller with border
+ self.z_down_btn = QPushButton("▼")
+ self.z_down_btn.setFixedSize(55, 55) # Smaller square
+ self.z_down_btn.setStyleSheet("""
+ QPushButton {
+ background-color: rgb(208, 211, 214);
+ border: 2px solid rgb(120, 120, 120);
+ border-radius: 4px;
+ font-size: 14px;
+ }
+ QPushButton:hover {
+ background-color: rgb(187, 190, 193);
+ }
+ QPushButton:pressed {
+ background-color: rgb(177, 180, 182);
+ }
+ """)
+ self.z_down_btn.clicked.connect(self._z_decrease)
+ layout.addWidget(self.z_down_btn, 0, Qt.AlignmentFlag.AlignCenter)
+
+ # Add stretch to center vertically
+ layout.addStretch(1)
+
+ return container
+
+ def _update_position_display(self) -> None:
+ """Update the position display label"""
+ self.position_label.setText(
+ f"X: {self._x_pos:.2f} Y: {self._y_pos:.2f} Z: {self._z_pos:.2f} mm"
+ )
+
+ # Placeholder movement functions
+ def _move_up(self) -> None:
+ """Move stage up (positive Y)"""
+ print(f"Moving up (Y+) by {self._step_size}mm")
+ self._y_pos += self._step_size
+ self._update_position_display()
+
+ def _move_down(self) -> None:
+ """Move stage down (negative Y)"""
+ print(f"Moving down (Y-) by {self._step_size}mm")
+ self._y_pos -= self._step_size
+ self._update_position_display()
+
+ def _move_left(self) -> None:
+ """Move stage left (negative X)"""
+ print(f"Moving left (X-) by {self._step_size}mm")
+ self._x_pos -= self._step_size
+ self._update_position_display()
+
+ def _move_right(self) -> None:
+ """Move stage right (positive X)"""
+ print(f"Moving right (X+) by {self._step_size}mm")
+ self._x_pos += self._step_size
+ self._update_position_display()
+
+ def _go_home(self) -> None:
+ """Return stage to home position"""
+ print("Going to home position")
+ self._x_pos = 0.0
+ self._y_pos = 0.0
+ self._z_pos = 0.0
+ self._update_position_display()
+
+ def _z_increase(self) -> None:
+ """Increase Z height"""
+ print(f"Increasing Z height by {self._step_size}mm")
+ self._z_pos += self._step_size
+ self._update_position_display()
+
+ def _z_decrease(self) -> None:
+ """Decrease Z height"""
+ print(f"Decreasing Z height by {self._step_size}mm")
+ self._z_pos -= self._step_size
+ self._update_position_display()
\ No newline at end of file
diff --git a/UI/widgets/toast_widget.py b/UI/widgets/toast_widget.py
new file mode 100644
index 0000000..ef9b281
--- /dev/null
+++ b/UI/widgets/toast_widget.py
@@ -0,0 +1,385 @@
+"""
+Toast notification widget for FieldWeave microscope application.
+
+Provides temporary, color-coded notifications that stack and auto-dismiss.
+Integrates with the logging system for consistent message handling.
+"""
+
+from PySide6.QtWidgets import QWidget, QLabel, QVBoxLayout, QHBoxLayout, QFrame, QPushButton, QProgressBar
+from PySide6.QtCore import Qt, QTimer, QPropertyAnimation, QEasingCurve, Property, QElapsedTimer
+from PySide6.QtGui import QFont
+from enum import Enum
+from typing import Optional
+
+
+class ToastType(Enum):
+ """Toast notification types with associated colors."""
+ INFO = "info"
+ SUCCESS = "success"
+ WARNING = "warning"
+ ERROR = "error"
+
+
+class Toast(QFrame):
+ """Individual toast notification widget."""
+
+ # Color schemes for each toast type (background, border, progress bar)
+ COLORS = {
+ ToastType.INFO: ("#E3F2FD", "#1976D2", "#1976D2"),
+ ToastType.SUCCESS: ("#E8F5E9", "#388E3C", "#388E3C"),
+ ToastType.WARNING: ("#FFF3E0", "#F57C00", "#F57C00"),
+ ToastType.ERROR: ("#FFEBEE", "#D32F2F", "#D32F2F"),
+ }
+
+ # Titles for each toast type
+ TITLES = {
+ ToastType.INFO: "Information",
+ ToastType.SUCCESS: "Success",
+ ToastType.WARNING: "Warning",
+ ToastType.ERROR: "Error",
+ }
+
+ def __init__(self, message: str, toast_type: ToastType = ToastType.INFO,
+ duration: int = 3000, title: str = None, parent: Optional[QWidget] = None):
+ """
+ Initialize a toast notification.
+
+ Args:
+ message: Description text to display
+ toast_type: Type of toast (INFO, SUCCESS, WARNING, ERROR)
+ duration: Duration in milliseconds before auto-dismiss (0 = no auto-dismiss)
+ title: Optional custom title (defaults to toast type name)
+ parent: Parent widget
+ """
+ super().__init__(parent)
+ self.message = message
+ self.toast_type = toast_type
+ self.duration = duration
+ self.title = title if title is not None else self.TITLES[toast_type]
+ self._opacity = 1.0
+ self._progress_value = 100
+ self._start_time = None
+
+ self._setup_ui()
+ self._setup_animations()
+ self._setup_progress_timer()
+
+ # Auto-dismiss timer
+ if duration > 0:
+ QTimer.singleShot(duration, self.dismiss)
+
+ def _setup_ui(self):
+ """Setup the toast UI with appropriate styling."""
+ self.setFrameShape(QFrame.Shape.StyledPanel)
+ self.setFrameShadow(QFrame.Shadow.Raised)
+
+ # Get colors for this toast type
+ bg_color, border_color, progress_color = self.COLORS[self.toast_type]
+
+ # Apply styling - no padding so progress bar can span full width
+ self.setStyleSheet(f"""
+ Toast {{
+ background-color: {bg_color};
+ border: 1px solid {border_color};
+ border-radius: 0px;
+ padding: 0px;
+ }}
+ """)
+
+ # Main layout - no margins so progress bar spans full width
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ # Content container (with padding for text)
+ content_widget = QWidget()
+ content_widget.setStyleSheet("background: transparent; border: none;")
+ content_layout = QVBoxLayout(content_widget)
+ content_layout.setContentsMargins(12, 10, 12, 10) # Increased from 8,6,8,6
+ content_layout.setSpacing(6) # Increased from 4
+
+ # Header row: Title and Close button
+ header_layout = QHBoxLayout()
+ header_layout.setSpacing(6)
+
+ # Title label - no color styling
+ self.title_label = QLabel(self.title)
+ self.title_label.setStyleSheet("background: transparent; border: none; font-weight: bold;")
+ self.title_label.setFont(QFont("Segoe UI", 10, QFont.Weight.Bold))
+ header_layout.addWidget(self.title_label, 1)
+
+ # Close button - no hover effect
+ self.close_button = QPushButton("×")
+ self.close_button.setStyleSheet(f"""
+ QPushButton {{
+ background: transparent;
+ border: none;
+ font-size: 18px;
+ font-weight: bold;
+ padding: 0px;
+ margin: 0px;
+ }}
+ """)
+ self.close_button.setFixedSize(18, 18)
+ self.close_button.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.close_button.clicked.connect(self.dismiss)
+ header_layout.addWidget(self.close_button)
+
+ content_layout.addLayout(header_layout)
+
+ # Message/Description label - no color styling
+ self.message_label = QLabel(self.message)
+ self.message_label.setStyleSheet("background: transparent; border: none;")
+ self.message_label.setWordWrap(True)
+ self.message_label.setFont(QFont("Segoe UI", 9))
+ content_layout.addWidget(self.message_label)
+
+ # Add content widget to main layout
+ layout.addWidget(content_widget)
+
+ # Progress bar at the very bottom edge - spans full width
+ self.progress_bar = QProgressBar()
+ self.progress_bar.setRange(0, 100)
+ self.progress_bar.setValue(100)
+ self.progress_bar.setTextVisible(False)
+ self.progress_bar.setFixedHeight(6)
+ self.progress_bar.setStyleSheet(f"""
+ QProgressBar {{
+ background-color: rgba(0, 0, 0, 0.1);
+ border: none;
+ border-radius: 0px;
+ margin: 0px;
+ }}
+ QProgressBar::chunk {{
+ background-color: {progress_color};
+ border-radius: 0px;
+ }}
+ """)
+ layout.addWidget(self.progress_bar)
+
+ # Set size constraints
+ self.setMinimumWidth(260)
+ self.setMaximumWidth(350)
+ self.adjustSize()
+
+ def _setup_progress_timer(self):
+ """Setup timer to update progress bar."""
+ if self.duration > 0:
+ # Use QElapsedTimer for precise timing
+ self.elapsed_timer = QElapsedTimer()
+ self.elapsed_timer.start()
+
+ # Update progress every 16ms for smooth 60fps animation
+ self.progress_timer = QTimer(self)
+ self.progress_timer.timeout.connect(self._update_progress)
+ self.progress_timer.start(16)
+ else:
+ # No duration, hide progress bar
+ self.progress_bar.hide()
+
+ def _update_progress(self):
+ """Update the progress bar based on elapsed time."""
+ elapsed_ms = self.elapsed_timer.elapsed()
+
+ if elapsed_ms >= self.duration:
+ # Ensure we end at exactly 0%
+ self.progress_bar.setValue(0)
+ self.progress_timer.stop()
+ else:
+ # Calculate remaining percentage
+ remaining_percent = int(((self.duration - elapsed_ms) / self.duration) * 100)
+ self.progress_bar.setValue(remaining_percent)
+
+ def _setup_animations(self):
+ """Setup fade in/out animations."""
+ # Fade in animation
+ self.fade_in_animation = QPropertyAnimation(self, b"opacity")
+ self.fade_in_animation.setDuration(200)
+ self.fade_in_animation.setStartValue(0.0)
+ self.fade_in_animation.setEndValue(1.0)
+ self.fade_in_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
+
+ # Fade out animation
+ self.fade_out_animation = QPropertyAnimation(self, b"opacity")
+ self.fade_out_animation.setDuration(200)
+ self.fade_out_animation.setStartValue(1.0)
+ self.fade_out_animation.setEndValue(0.0)
+ self.fade_out_animation.setEasingCurve(QEasingCurve.Type.InCubic)
+ self.fade_out_animation.finished.connect(self._on_fade_out_finished)
+
+ def show_animated(self):
+ """Show the toast with fade-in animation."""
+ self.show()
+ self.fade_in_animation.start()
+
+ def dismiss(self):
+ """Dismiss the toast with fade-out animation."""
+ # Stop progress timer if it exists
+ if hasattr(self, 'progress_timer') and self.progress_timer.isActive():
+ self.progress_timer.stop()
+ self.fade_out_animation.start()
+
+ def _on_fade_out_finished(self):
+ """Called when fade-out animation completes."""
+ self.hide()
+ self.deleteLater()
+
+ def _get_opacity(self):
+ """Get current opacity value."""
+ return self._opacity
+
+ def _set_opacity(self, value):
+ """Set opacity value and update window opacity."""
+ self._opacity = value
+ self.setWindowOpacity(value)
+
+ opacity = Property(float, _get_opacity, _set_opacity)
+
+
+class ToastManager(QWidget):
+ """
+ Manages multiple toast notifications in a stack.
+
+ Toasts appear in the bottom-right corner and stack vertically upward.
+ """
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ """
+ Initialize the toast manager.
+
+ Args:
+ parent: Parent widget (typically the main window)
+ """
+ super().__init__(parent)
+ self.parent_widget = parent
+ self.toasts = []
+
+ # Setup container
+ self.setWindowFlags(Qt.WindowType.FramelessWindowHint |
+ Qt.WindowType.Tool)
+ self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) # Allow mouse events
+
+ # Layout for stacking toasts (bottom to top) - reduced spacing
+ self.layout = QVBoxLayout(self)
+ self.layout.setContentsMargins(0, 0, 0, 0)
+ self.layout.setSpacing(6) # Reduced from 10 to 6
+ self.layout.setAlignment(Qt.AlignmentFlag.AlignBottom) # Align to bottom
+
+ # Install event filter on parent to track moves
+ if self.parent_widget:
+ self.parent_widget.installEventFilter(self)
+
+ # Position and show
+ self._update_position()
+ self.show()
+
+ def eventFilter(self, obj, event):
+ """Track parent window moves to reposition toasts."""
+ if obj == self.parent_widget:
+ # Update position on move or resize
+ if event.type() in (event.Type.Move, event.Type.Resize):
+ self._update_position()
+ return super().eventFilter(obj, event)
+
+ def _update_position(self):
+ """Update position to bottom-right corner of parent."""
+ if self.parent_widget:
+ # Get the parent widget's geometry in global screen coordinates
+ parent_global_rect = self.parent_widget.geometry()
+ parent_pos = self.parent_widget.pos()
+
+ # For QMainWindow, we need to get the actual screen position
+ if hasattr(self.parent_widget, 'frameGeometry'):
+ parent_global_rect = self.parent_widget.frameGeometry()
+ parent_pos = parent_global_rect.topLeft()
+ else:
+ # Map parent position to global coordinates
+ parent_pos = self.parent_widget.mapToGlobal(parent_pos)
+
+ margin = 10 # Reduced from 20 to 10
+ toast_width = 350 # Reduced from 420 to 350
+ toast_container_height = 600 # Max height for toast container
+
+ # Calculate position for bottom-right corner
+ x = parent_pos.x() + parent_global_rect.width() - toast_width - margin
+ y = parent_pos.y() + parent_global_rect.height() - toast_container_height - margin
+
+ self.setGeometry(
+ x,
+ y,
+ toast_width,
+ toast_container_height
+ )
+
+ def show_toast(self, message: str, toast_type: ToastType = ToastType.INFO,
+ duration: int = 3000, title: str = None):
+ """
+ Show a new toast notification.
+
+ Args:
+ message: Description text to display
+ toast_type: Type of toast (INFO, SUCCESS, WARNING, ERROR)
+ duration: Duration in milliseconds before auto-dismiss
+ title: Optional custom title (defaults to toast type name)
+ """
+ # Create new toast
+ toast = Toast(message, toast_type, duration, title, self)
+
+ # Add to layout and list
+ self.layout.addWidget(toast)
+ self.toasts.append(toast)
+
+ # Show with animation
+ toast.show_animated()
+
+ # Connect deletion signal to cleanup
+ toast.destroyed.connect(lambda: self._remove_toast(toast))
+
+ # Update position
+ self._update_position()
+
+ def _remove_toast(self, toast: Toast):
+ """Remove toast from tracking list."""
+ if toast in self.toasts:
+ self.toasts.remove(toast)
+
+ def info(self, message: str, duration: int = 3000, title: str = None):
+ """Show an info toast."""
+ self.show_toast(message, ToastType.INFO, duration, title)
+
+ def success(self, message: str, duration: int = 3000, title: str = None):
+ """Show a success toast."""
+ self.show_toast(message, ToastType.SUCCESS, duration, title)
+
+ def warning(self, message: str, duration: int = 4000, title: str = None):
+ """Show a warning toast (slightly longer duration)."""
+ self.show_toast(message, ToastType.WARNING, duration, title)
+
+ def error(self, message: str, duration: int = 5000, title: str = None):
+ """Show an error toast (longer duration)."""
+ self.show_toast(message, ToastType.ERROR, duration, title)
+
+ def clear_all(self):
+ """Dismiss all active toasts."""
+ for toast in self.toasts[:]: # Copy list to avoid modification during iteration
+ toast.dismiss()
+
+
+# Convenience function for standalone usage
+def show_toast(parent: QWidget, message: str, toast_type: ToastType = ToastType.INFO,
+ duration: int = 3000, title: str = None):
+ """
+ Show a toast notification (convenience function).
+
+ Args:
+ parent: Parent widget
+ message: Description text to display
+ toast_type: Type of toast
+ duration: Duration in milliseconds
+ title: Optional custom title
+ """
+ if not hasattr(parent, '_toast_manager'):
+ parent._toast_manager = ToastManager(parent)
+
+ parent._toast_manager.show_toast(message, toast_type, duration, title)
\ No newline at end of file
diff --git a/camera/amscope.py b/camera/amscope.py
deleted file mode 100644
index f96390d..0000000
--- a/camera/amscope.py
+++ /dev/null
@@ -1,250 +0,0 @@
-import time
-from pathlib import Path
-import numpy as np
-from PIL import Image
-
-import os
-import sys
-import platform
-import importlib.util
-import ctypes
-import zipfile
-
-from .base_camera import BaseCamera
-from .camera_settings import CameraSettings, CameraSettingsManager
-from image_processing.analyzers import ImageAnalyzer
-
-class AmscopeCamera(BaseCamera):
- # Optional explicit subdir override; otherwise BaseCamera will derive 'amscope'
- CONFIG_SUBDIR = "amscope"
-
- def __init__(self):
- # Minimal vendor state; BaseCamera handles common fields
- self.amcam = None
- self._callback_ref = None # must keep a reference to avoid garbage collection
- self.buffer = None
- self.camera = None
- super().__init__()
-
- # Load vendor SDK before initialize()
- def pre_initialize(self):
- self._load_amcam()
-
- def _ensure_sdk(self, project_root: Path) -> tuple[Path, Path]:
- """
- Ensure the AmScope SDK is available under:
- project_root / "3rd_party_imports" / "official_amscope"
- If not, extract the first amcamsdk*.zip in 3rd_party_imports.
- Returns (sdk_root_dir, sdk_py_path).
- """
- sdk_dir = project_root / "3rd_party_imports"
- official_dir = sdk_dir / "official_amscope"
- sdk_py = official_dir / "python" / "amcam.py"
-
- # Already extracted?
- if sdk_py.is_file():
- return official_dir, sdk_py
-
- # Look for a zip starting with "amcamsdk"
- for f in sdk_dir.iterdir():
- if f.is_file() and f.name.lower().startswith("amcamsdk") and f.suffix.lower() == ".zip":
- with zipfile.ZipFile(f, "r") as zf:
- zf.extractall(official_dir)
- break
- else:
- raise RuntimeError(f"No AmScope SDK found in {sdk_dir}")
-
- # Handle case with nested folder
- if not sdk_py.is_file():
- subdirs = [d for d in official_dir.iterdir() if d.is_dir()]
- if len(subdirs) == 1 and (subdirs[0] / "python" / "amcam.py").is_file():
- tmp = subdirs[0]
- for item in tmp.iterdir():
- shutil.move(str(item), official_dir)
- tmp.rmdir()
-
- if not sdk_py.is_file():
- raise RuntimeError("Extracted SDK does not contain python/amcam.py")
-
- return official_dir, sdk_py
-
- def _load_amcam(self):
- project_root = Path(__file__).resolve().parent.parent
-
- sdk_root, sdk_py = self._ensure_sdk(project_root)
-
- # Determine platform and architecture
- system = platform.system().lower()
- machine = platform.machine().lower()
-
- if system == 'windows':
- dll_dir = os.path.join(sdk_root, 'win', 'x64')
- elif system == 'linux':
- arch_map = {
- 'x86_64': 'x64',
- 'amd64': 'x64',
- 'i386': 'x86',
- 'i686': 'x86',
- 'arm64': 'arm64',
- 'aarch64': 'arm64',
- 'armv7l': 'armhf',
- 'armv6l': 'armel'
- }
- subarch = arch_map.get(machine)
- if not subarch:
- raise RuntimeError(f"Unsupported Linux architecture: {machine}")
- dll_dir = os.path.join(sdk_root, 'linux', subarch)
- elif system == 'darwin':
- dll_dir = os.path.join(sdk_root, 'mac')
- else:
- raise RuntimeError(f"Unsupported operating system: {system}")
-
- # Update PATH or add_dll_directory for shared library resolution
- if system == 'windows':
- if hasattr(os, 'add_dll_directory'):
- os.add_dll_directory(dll_dir)
- else:
- os.environ['PATH'] = dll_dir + os.pathsep + os.environ.get('PATH', '')
- else:
- os.environ['LD_LIBRARY_PATH'] = dll_dir + os.pathsep + os.environ.get('LD_LIBRARY_PATH', '')
-
- # Dynamically import amcam.py and override __file__ so its LoadLibrary logic works
- spec = importlib.util.spec_from_file_location("amcam", sdk_py)
- amcam_module = importlib.util.module_from_spec(spec)
- amcam_module.__file__ = os.path.join(dll_dir, 'amcam.py') # Trick __file__ logic
- sys.modules["amcam"] = amcam_module
- spec.loader.exec_module(amcam_module)
-
- self.amcam = amcam_module
-
- def initialize(self):
- """Initialize the Amscope camera."""
- try:
- available_cameras = self.amcam.Amcam.EnumV2()
- if not available_cameras:
- raise Exception("Failed to Find Amscope Camera")
-
- self.name = available_cameras[0].displayname
- self.camera = self.amcam.Amcam.Open(available_cameras[0].id)
-
- if not self.camera:
- raise Exception("Failed to open Amscope Camera")
-
- self.width, self.height = self.camera.get_Size()
- self.buffer = bytes((self.width * 24 + 31) // 32 * 4 * self.height)
-
- if sys.platform == 'win32':
- self.camera.put_Option(self.amcam.AMCAM_OPTION_BYTEORDER, 0)
-
- # Start the stream immediately after initialization
- self.start_stream()
- return True
-
- except self.amcam.HRESULTException as e:
- print(f"Error initializing camera: {e}")
- self.camera = None
- return False
- except Exception as e:
- print(f"Unexpected error initializing camera: {e}")
- self.camera = None
- return False
-
- def start_stream(self):
- """Start the camera stream with configured settings."""
- if self.camera is None:
- print("Cannot start stream - camera not initialized")
- return
-
- try:
- # Load and apply settings from config/amscope/settings.yaml (via BaseCamera helpers)
- self.load_and_apply_settings(filename="settings.yaml")
-
- # Start the pull mode BEFORE trying to stream
- self.camera.StartPullModeWithCallback(self._camera_callback, self)
-
- except self.amcam.HRESULTException as e:
- print(f"Error starting stream: {e}")
- except Exception as e:
- print(f"Unexpected error starting stream: {e}")
-
- def _apply_settings(self, settings: CameraSettings):
- """Apply camera settings to the hardware."""
-
- # if camera is not initialized, don't apply settings
- if not self.initialized:
- return
-
- try:
- self.camera.put_AutoExpoEnable(settings.auto_expo)
- self.camera.put_AutoExpoTarget(settings.exposure)
- self.camera.put_TempTint(settings.temp, settings.tint)
- self.camera.put_LevelRange(settings.levelrange_low, settings.levelrange_high)
- self.camera.put_Contrast(settings.contrast)
- self.camera.put_Hue(settings.hue)
- self.camera.put_Saturation(settings.saturation)
- self.camera.put_Brightness(settings.brightness)
- self.camera.put_Gamma(settings.gamma)
- self.camera.put_Option(self.amcam.AMCAM_OPTION_SHARPENING, settings.sharpening)
- self.camera.put_Option(self.amcam.AMCAM_OPTION_LINEAR, settings.linear)
-
- curve_options = {'Off': 0, 'Polynomial': 1, 'Logarithmic': 2}
- self.camera.put_Option(self.amcam.AMCAM_OPTION_CURVE, curve_options.get(settings.curve, 1))
-
- except self.amcam.HRESULTException as e:
- print(f"Error applying settings: {e}")
-
- @staticmethod
- def _camera_callback(event, _self):
- """Handle camera events."""
- if event == _self.amcam.AMCAM_EVENT_IMAGE:
- try:
- _self.camera.PullImageV2(_self.buffer, 24, None)
-
- arr = np.frombuffer(_self.buffer, np.uint8).reshape((_self.height, _self.width, 3))
- _self.last_stream_array = arr # (H, W, 3), RGB
- _self.last_stream_ts = time.time()
-
- except _self.amcam.HRESULTException as e:
- print(f"Error in callback stream: {e}")
-
- elif event == _self.amcam.AMCAM_EVENT_STILLIMAGE:
- _self._process_frame() # will set last_image, see below
-
- elif event == _self.amcam.AMCAM_EVENT_EXPO_START:
- print("Exposure start event detected")
-
- def stream(self):
- """This method is now mainly used for error handling and initialization"""
- if self.camera is None:
- print("Camera not initialized. Attempting to initialize...")
- if not self.initialize():
- return
-
- # Ensure buffer is initialized
- if self.buffer is None:
- self.width, self.height = self.camera.get_Size()
- self.buffer = bytes((self.width * 24 + 31) // 32 * 4 * self.height)
-
- def capture_image(self):
- """Capture a still image."""
- self.is_taking_image = True
- self.camera.Snap(0)
-
- def _process_frame(self):
- self.is_taking_image = True
- try:
- w, h = self.camera.get_StillResolution(0)
- buf = bytes(w * h * 3)
- self.camera.PullStillImageV2(buf, 24, None)
- arr = np.frombuffer(buf, np.uint8).reshape((h, w, 3))
- self.last_image = arr
- self.last_image_ts = time.time()
- except self.amcam.HRESULTException as e:
- print(f"Error processing frame: {e}")
- finally:
- self.is_taking_image = False
-
- def update(self):
- """Update camera frame."""
- if not self.is_taking_image and self.camera is not None:
- self.stream()
diff --git a/camera/base_camera.py b/camera/base_camera.py
deleted file mode 100644
index d39319c..0000000
--- a/camera/base_camera.py
+++ /dev/null
@@ -1,304 +0,0 @@
-from abc import ABC, abstractmethod
-import time
-from pathlib import Path
-from PIL import Image
-import tkinter as tk
-import numpy as np
-from tkinter import filedialog
-
-from .camera_settings import (
- CameraSettings,
- CameraSettingsManager,
- ACTIVE_FILENAME,
- DEFAULT_FILENAME
-)
-
-from .image_name_formatter import ImageNameFormatter
-
-
-class BaseCamera(ABC):
- """Abstract base class defining the camera interface."""
-
- # Subclasses may override to control config subfolder name
- CONFIG_SUBDIR: str | None = None
-
- def __init__(self):
- # Public-ish, common state
- self.name = ""
- self.is_taking_image = False
- self.last_image: np.ndarray | None = None # (H, W, 3) RGB uint8
- self.last_stream_array: np.ndarray | None = None # (H, W, 3) RGB uint8
-
- self.last_image_ts: float = 0.0
- self.last_stream_ts: float = 0.0
-
- self.initialized = False
- # Safe default for save_image() until a subclass loads real settings
- self.settings = CameraSettings()
- self._scope = self.get_impl_key()
- CameraSettingsManager.scope_dir(self._scope)
-
- # Camera-native dimensions (subclasses may set real values during initialize())
- self.width = 1280
- self.height = 720
-
- # Capture path
- self.capture_path = "./output/"
- self.image_name_formatter = ImageNameFormatter(template="{d:%Y%m%d_%H%M%S}")
-
- # Config roots
- self._scope = self.get_impl_key()
- CameraSettingsManager.scope_dir(self._scope)
- self.impl_config_dir = self.get_config_dir() # e.g., config/amscope
- self.impl_config_dir.mkdir(parents=True, exist_ok=True)
-
- # Allow subclasses to do pre-initialize work (e.g., load SDKs) before initialize()
- self.pre_initialize()
- self.initialized = self.initialize()
-
- # ----- Lifecycle hooks -----
- def pre_initialize(self):
- """Optional hook to run before initialize(); subclasses may override."""
- pass
-
- @abstractmethod
- def initialize(self) -> bool:
- """Initialize camera hardware and settings."""
- pass
-
- @abstractmethod
- def update(self):
- """Update camera frame."""
- pass
-
- @abstractmethod
- def capture_image(self):
- """Capture a still image (subclass must implement)."""
- pass
-
- # -------------------------------
- # Config & settings convenience
- # -------------------------------
- def get_impl_key(self) -> str:
- """
- Returns the implementation key used for config subfolder naming.
- Default: lowercased class name with trailing 'camera' removed (e.g., AmscopeCamera -> 'amscope').
- Subclasses can override by setting CONFIG_SUBDIR.
- """
- if isinstance(self.CONFIG_SUBDIR, str) and self.CONFIG_SUBDIR.strip():
- return self.CONFIG_SUBDIR.strip()
- cls = self.__class__.__name__
- return (cls[:-6] if cls.lower().endswith("camera") else cls).lower()
-
- def get_config_dir(self) -> Path:
- return CameraSettingsManager.scope_dir(self._scope)
-
- def load_and_apply_settings(self, filename: str = ACTIVE_FILENAME):
- """
- Load settings from YAML and apply to the live camera.
- If the active file is missing, this falls back to default_settings.yaml, else built-in defaults.
- """
- loaded = CameraSettingsManager.load(self._scope)
- self.settings = loaded
- self.apply_settings(self.settings)
-
- def apply_settings(self, settings):
- """
- Apply settings to the hardware. By default this calls a subclass hook named _apply_settings
- if present. Subclasses should implement _apply_settings(settings: CameraSettings).
- """
- hook = getattr(self, "_apply_settings", None)
- if callable(hook):
- hook(settings)
- else:
- raise NotImplementedError(
- f"{self.__class__.__name__} must implement _apply_settings(settings) or override apply_settings()."
- )
-
- def save_settings(self, filename: str = ACTIVE_FILENAME):
- """
- Persist current settings to YAML in the per-implementation folder.
- Automatically creates a timestamped backup of the previous version and keeps the 5 most recent.
- """
- CameraSettingsManager.save(self._scope, self.settings)
-
- def set_settings(self, settings, persist: bool = False, filename: str = ACTIVE_FILENAME):
- """
- Replace the entire settings object, apply immediately, optionally persist to disk.
- """
- self.settings = settings
- self.apply_settings(self.settings)
- if persist:
- self.save_settings(filename=filename)
-
- def update_settings(self, persist: bool = False, filename: str = ACTIVE_FILENAME, **updates):
- """
- Update one or more attributes on the current settings, apply immediately, and
- optionally persist to disk. Example:
-
- camera.update_settings(temp=6500, tint=900, linear=1, persist=True)
- """
- # If settings hasn't been loaded yet, attempt to load from disk first.
- if not hasattr(self.settings, "__dict__"):
- self.load_and_apply_settings(filename=filename)
-
- # Apply updates (only for existing attributes to avoid silent typos)
- for k, v in updates.items():
- if hasattr(self.settings, k):
- setattr(self.settings, k, v)
- else:
- raise AttributeError(f"Unknown camera setting '{k}'")
-
- # Push to hardware and optionally persist
- self.apply_settings(self.settings)
- if persist:
- self.save_settings(filename=filename)
-
- # ----- Defaults helpers -----
- def get_default_config_path(self) -> Path:
- return self.get_config_path(DEFAULT_FILENAME)
-
- def write_default_settings(self, settings: CameraSettings | None = None) -> Path:
- """
- Write default_settings.yaml in this camera's config directory.
- If 'settings' is None, writes built-in defaults.
- """
- return CameraSettingsManager.write_defaults(self._scope, settings)
-
- def load_default_settings(self):
- """
- Load defaults from default_settings.yaml (or built-in defaults if file doesn't exist),
- apply to hardware, but do NOT persist to the active file.
- """
- defaults = CameraSettingsManager.load_defaults(self._scope)
- self.set_settings(defaults, persist=False)
- return defaults
-
- def restore_default_settings(self, persist: bool = True):
- """
- Restore defaults into the active settings file (backup the current one), apply, and optionally persist.
- Useful for a "Restore Defaults" button in the UI.
- """
- restored = CameraSettingsManager.restore_defaults_into_active(self._scope)
- self.set_settings(restored, persist=False)
- if persist:
- self.save_settings()
- return restored
-
- # -------------------------------
- # Image helpers
- # -------------------------------
- def get_last_image(self):
- """Get the last captured image, waiting if a capture is in progress."""
- while self.is_taking_image:
- time.sleep(0.01)
- return self.last_image
-
- def get_last_stream_array(self) -> np.ndarray | None:
- """Return latest live-stream RGB frame as (H, W, 3) uint8, or None."""
- return self.last_stream_array
-
- def get_last_frame(self, prefer: str = "latest", wait_for_still: bool = True):
- """
- Return the latest RGB frame (H, W, 3) uint8 from either a still or the stream.
-
- prefer:
- - "latest" (default): whichever arrived most recently (compares timestamps)
- - "still" : still if present, else stream
- - "stream" : stream if present, else still
-
- wait_for_still:
- - If True, block briefly if a still capture is currently in progress.
- """
- if wait_for_still and self.is_taking_image:
- while self.is_taking_image:
- time.sleep(0.01)
-
- # Fast paths for legacy behavior
- if prefer == "still":
- return self.last_image if self.last_image is not None else self.last_stream_array
- if prefer == "stream":
- return self.last_stream_array if self.last_stream_array is not None else self.last_image
-
- # "latest" behavior: pick the freshest we’ve seen
- li, ls = self.last_image, self.last_stream_array
- ti, ts = self.last_image_ts, self.last_stream_ts
-
- if li is None and ls is None:
- return None
- if li is None:
- return ls
- if ls is None:
- return li
- return li if ti >= ts else ls
-
- def capture_and_save(self, filename: str = "", folder: str = ""):
- self.capture_image()
- self.save_image(filename, folder)
-
- def save_image(self, folder: str = "", filename: str = ""):
- while self.is_taking_image:
- time.sleep(0.01)
-
- arr = self.last_image
- if arr is None:
- print("No image to save (last_image is None).")
- return
-
- try:
- arr = np.asarray(arr)
- if arr.ndim == 2:
- arr = np.stack([arr] * 3, axis=-1)
- if arr.ndim != 3 or arr.shape[2] not in (3, 4):
- raise ValueError(f"Unsupported image shape: {arr.shape}")
- if arr.dtype != np.uint8:
- arr = np.clip(arr, 0, 255).astype(np.uint8)
-
- mode = "RGBA" if arr.shape[2] == 4 else "RGB"
-
- save_path = Path(self.capture_path) / folder
- save_path.mkdir(parents=True, exist_ok=True)
-
- if filename:
- final_filename = filename
- else:
- final_filename = self.image_name_formatter.get_formatted_string(
- auto_increment_index=True
- )
-
- fformat = self.settings.fformat
- full_path = save_path / f"{final_filename}.{fformat}"
- print(f"Saving Image: {full_path}")
- Image.fromarray(arr, mode=mode).save(str(full_path))
- except Exception as e:
- print(f"Error saving image: {e}")
-
- def set_capture_path(self, path: str):
- """Set path for saving captured images."""
- self.capture_path = path
-
- def select_capture_path(self):
- """Open a folder selection dialog to set the capture path."""
- root = tk.Tk()
- root.withdraw() # Hide the main Tk window
- selected_folder = filedialog.askdirectory(title="Select Capture Folder")
- root.destroy()
-
- if selected_folder: # User didn't cancel
- self.set_capture_path(selected_folder)
- print(f"Capture path set to: {self.capture_path}")
- return self.capture_path
-
- # Provide a default close() that gracefully shuts down common SDKs
- def close(self):
- """Clean up camera resources if possible."""
- cam = getattr(self, "camera", None)
- if cam is not None:
- try:
- close_fn = getattr(cam, "Close", None)
- if callable(close_fn):
- close_fn()
- except Exception:
- pass
- finally:
- self.camera = None
diff --git a/camera/camera_enumerator.py b/camera/camera_enumerator.py
new file mode 100644
index 0000000..28fe511
--- /dev/null
+++ b/camera/camera_enumerator.py
@@ -0,0 +1,220 @@
+"""
+Camera enumeration system with plugin architecture.
+Supports multiple camera types through enumerator plugins.
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from enum import Enum
+from typing import Any
+from common.logger import error, exception, debug
+
+from camera.cameras.amscope_camera import AmscopeCamera
+
+class CameraType(Enum):
+ """Supported camera types"""
+ AMSCOPE = "amscope"
+ GENERIC_USB = "generic_usb"
+
+
+@dataclass
+class CameraInfo:
+ """
+ Information about an available camera.
+ Lightweight object returned by enumeration before camera instantiation.
+ """
+ camera_type: CameraType
+ device_id: str
+ display_name: str
+ model: str | None = None
+ manufacturer: str | None = None
+ serial_number: str | None = None
+ max_resolution: tuple[int, int] | None = None
+ metadata: dict[str, Any] | None = None
+
+ def __str__(self) -> str:
+ parts = [self.display_name]
+ if self.model:
+ parts.append(f"({self.model})")
+ if self.serial_number:
+ parts.append(f"SN:{self.serial_number}")
+ return " ".join(parts)
+
+ def __repr__(self) -> str:
+ return f"CameraInfo({self.camera_type.value}, {self.display_name})"
+
+
+class CameraEnumerator(ABC):
+ """
+ Base class for camera enumerators.
+ Each camera type implements this to provide enumeration capability.
+ """
+
+ @abstractmethod
+ def enumerate(self) -> list[CameraInfo]:
+ """
+ Enumerate all cameras of this type.
+
+ Returns:
+ List of CameraInfo objects for available cameras
+ """
+ pass
+
+ @abstractmethod
+ def get_camera_type(self) -> CameraType:
+ """
+ Get the camera type this enumerator handles.
+
+ Returns:
+ CameraType enum value
+ """
+ pass
+
+ @abstractmethod
+ def is_available(self) -> bool:
+ """
+ Check if this camera type is available (SDK loaded, etc).
+
+ Returns:
+ True if this camera type can be enumerated
+ """
+ pass
+
+
+class AmscopeEnumerator(CameraEnumerator):
+ """Enumerator for Amscope cameras"""
+
+ def __init__(self):
+ self._sdk_loaded = False
+ self._sdk = None
+
+ def get_camera_type(self) -> CameraType:
+ return CameraType.AMSCOPE
+
+ def is_available(self) -> bool:
+ """Check if Amscope SDK is available"""
+ if self._sdk_loaded:
+ return self._sdk is not None
+
+ try:
+
+ # Ensure SDK is loaded
+ debug("Loading Amscope SDK...")
+ load_result = AmscopeCamera.ensure_sdk_loaded()
+
+ if not load_result:
+ error("AmscopeCamera.ensure_sdk_loaded() returned False")
+ self._sdk_loaded = True
+ self._sdk = None
+ return False
+
+ # Get SDK instance using the private method
+ self._sdk = AmscopeCamera._get_sdk()
+ self._sdk_loaded = True
+
+ if self._sdk is None:
+ error("Amscope SDK loaded but _get_sdk() returned None")
+ return False
+
+ debug("Amscope SDK loaded successfully")
+ return True
+
+ except ImportError as ie:
+ exception(f"Failed to import AmscopeCamera: {ie}")
+ self._sdk_loaded = True
+ self._sdk = None
+ return False
+ except RuntimeError as re:
+ exception(f"Runtime error loading Amscope SDK: {re}")
+ self._sdk_loaded = True
+ self._sdk = None
+ return False
+ except Exception as e:
+ exception(f"Unexpected error loading Amscope SDK: {e}")
+ self._sdk_loaded = True
+ self._sdk = None
+ return False
+
+ def enumerate(self) -> list[CameraInfo]:
+ """Enumerate Amscope cameras"""
+ # Ensure SDK is available before enumerating
+ if not self.is_available():
+ error("Amscope SDK not available, cannot enumerate cameras")
+ return []
+
+ cameras = []
+
+ try:
+ sdk = AmscopeCamera._get_sdk()
+
+ if sdk is None:
+ error("SDK is None during enumeration")
+ return []
+
+ # Enumerate devices
+ device_list = sdk.Amcam.EnumV2()
+ debug(f"Amscope enumerator found {len(device_list)} camera(s)")
+
+ for idx, device in enumerate(device_list):
+ try:
+ # Get model info
+ model_name = device.model.name if device.model else "Unknown"
+
+ # Get max resolution
+ max_res = None
+ if device.model and device.model.res and len(device.model.res) > 0:
+ # First resolution is typically the highest
+ max_res = (device.model.res[0].width, device.model.res[0].height)
+
+ # Create camera info
+ camera_info = CameraInfo(
+ camera_type=CameraType.AMSCOPE,
+ device_id=device.id,
+ display_name=device.displayname or f"Amscope Camera {idx}",
+ model=model_name,
+ manufacturer="Amscope",
+ serial_number=None, # Could extract from device.id if needed
+ max_resolution=max_res,
+ metadata={
+ 'device_index': idx,
+ 'model_info': device.model,
+ }
+ )
+
+ cameras.append(camera_info)
+
+ except Exception as e:
+ exception(f"Error processing Amscope device {idx}: {e}")
+ continue
+
+ except Exception as e:
+ exception(f"Error enumerating Amscope cameras: {e}")
+
+ return cameras
+
+
+class GenericUSBEnumerator(CameraEnumerator):
+ """
+ Enumerator for generic USB cameras (future implementation).
+ Placeholder for now.
+ """
+
+ def get_camera_type(self) -> CameraType:
+ return CameraType.GENERIC_USB
+
+ def is_available(self) -> bool:
+ """Check if OpenCV or other generic USB support is available"""
+ try:
+ import cv2
+ return True
+ except ImportError:
+ return False
+
+ def enumerate(self) -> list[CameraInfo]:
+ """Enumerate generic USB cameras (placeholder)"""
+ # For now, return empty list
+ # Future: Implement using OpenCV or platform-specific APIs
+ debug("Generic USB camera enumeration not yet implemented")
+ return []
diff --git a/camera/camera_manager.py b/camera/camera_manager.py
new file mode 100644
index 0000000..29830d8
--- /dev/null
+++ b/camera/camera_manager.py
@@ -0,0 +1,573 @@
+"""
+Camera manager for handling camera enumeration, selection, and lifecycle.
+Provides plugin architecture for multiple camera types and manages frame acquisition.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+import numpy as np
+from PySide6.QtCore import QObject, Signal, Slot
+
+from camera.cameras.base_camera import BaseCamera
+from camera.cameras.amscope_camera import AmscopeCamera
+from camera.threaded_camera import ThreadedCamera
+from camera.camera_enumerator import (
+ CameraEnumerator,
+ CameraInfo,
+ CameraType,
+ AmscopeEnumerator,
+ GenericUSBEnumerator
+)
+from common.logger import info, error, warning, exception, debug
+
+
+class CameraManager(QObject):
+ """
+ Manages camera enumeration, selection, lifecycle, and frame acquisition.
+
+ Signals:
+ camera_list_changed: Emitted when available cameras change
+ active_camera_changed: Emitted when active camera changes (camera_info or None)
+ enumeration_complete: Emitted when camera enumeration completes (camera_count)
+ frame_ready: Emitted when a new frame is available (width, height)
+ streaming_started: Emitted when camera streaming starts (width, height)
+ streaming_stopped: Emitted when camera streaming stops
+ camera_error: Emitted when a camera error occurs
+ camera_disconnected: Emitted when camera is disconnected
+ """
+
+ camera_list_changed = Signal()
+ active_camera_changed = Signal(object) # CameraInfo or None
+ enumeration_complete = Signal(int) # count
+ frame_ready = Signal(int, int) # width, height
+ streaming_started = Signal(int, int) # width, height
+ streaming_stopped = Signal()
+ camera_error = Signal()
+ camera_disconnected = Signal()
+
+ # Internal signal for forwarding camera events to UI thread
+ _camera_event = Signal(int)
+
+ def __init__(self):
+ super().__init__()
+
+ # Available camera enumerators (plugin architecture)
+ self._enumerators: list[CameraEnumerator] = [
+ AmscopeEnumerator(),
+ GenericUSBEnumerator(),
+ # Future: Add more enumerators here
+ ]
+
+ # Available cameras (from last enumeration)
+ self._available_cameras: list[CameraInfo] = []
+
+ # Active camera
+ self._active_camera: BaseCamera | None = None
+ self._active_camera_info: CameraInfo | None = None
+ self._camera_thread_started = False
+
+ # Frame management
+ self._current_frame_buffer: bytes | None = None
+ self._frame_width = 0
+ self._frame_height = 0
+ self._is_streaming = False
+
+ # Connect internal camera event signal
+ self._camera_event.connect(self._on_camera_event)
+
+ info("Camera manager initialized")
+
+ @property
+ def available_cameras(self) -> list[CameraInfo]:
+ """Get list of available cameras from last enumeration"""
+ return self._available_cameras.copy()
+
+ @property
+ def active_camera(self) -> BaseCamera | None:
+ """Get the currently active camera (may be None)"""
+ return self._active_camera
+
+ @property
+ def active_camera_info(self) -> CameraInfo | None:
+ """Get info about the currently active camera"""
+ return self._active_camera_info
+
+ @property
+ def has_active_camera(self) -> bool:
+ """Check if there is an active camera"""
+ return self._active_camera is not None
+
+ @property
+ def is_streaming(self) -> bool:
+ """Check if camera is currently streaming"""
+ return self._is_streaming
+
+ @property
+ def frame_dimensions(self) -> tuple[int, int]:
+ """Get current frame dimensions (width, height)"""
+ return (self._frame_width, self._frame_height)
+
+ def enumerate_cameras(self) -> list[CameraInfo]:
+ """
+ Enumerate all available cameras across all enumerators.
+
+ Returns:
+ List of CameraInfo objects for all available cameras
+ """
+ cameras = []
+
+ for enumerator in self._enumerators:
+ enumerator_type = enumerator.get_camera_type().value
+
+ try:
+ if enumerator.is_available():
+ enum_cameras = enumerator.enumerate()
+ cameras.extend(enum_cameras)
+ else:
+ debug(f"{enumerator_type} enumerator not available")
+ except Exception as e:
+ exception(f"Error in {enumerator_type} enumerator: {e}")
+ continue
+
+ self._available_cameras = cameras
+
+ # Single clean summary log
+ if cameras:
+ info(f"Found {len(cameras)} camera(s):")
+ for idx, cam in enumerate(cameras):
+ info(f" [{idx}] {cam.display_name} ({cam.model})")
+ else:
+ info("No cameras found")
+
+ self.camera_list_changed.emit()
+ self.enumeration_complete.emit(len(cameras))
+
+ return cameras
+
+ def get_camera_by_id(self, device_id: str) -> CameraInfo | None:
+ """
+ Find a camera by its device ID.
+
+ Args:
+ device_id: The device ID to search for
+
+ Returns:
+ CameraInfo if found, None otherwise
+ """
+ for camera_info in self._available_cameras:
+ if camera_info.device_id == device_id:
+ return camera_info
+ return None
+
+ def get_cameras_by_type(self, camera_type: CameraType) -> list[CameraInfo]:
+ """
+ Get all cameras of a specific type.
+
+ Args:
+ camera_type: The camera type to filter by
+
+ Returns:
+ List of CameraInfo objects matching the type
+ """
+ return [cam for cam in self._available_cameras if cam.camera_type == camera_type]
+
+ def switch_camera(self, camera_info: CameraInfo, start_streaming: bool = True) -> bool:
+ """
+ Switch to a different camera.
+ Closes the current camera if any, then opens the new one.
+
+ Args:
+ camera_info: Information about the camera to switch to
+ start_streaming: If True, automatically start streaming after opening
+
+ Returns:
+ True if switch was successful, False otherwise
+ """
+ info(f"Switching to camera: {camera_info}")
+
+ # Close current camera if any
+ if self._active_camera is not None:
+ info("Closing current camera before switching")
+ self.close_camera()
+
+ # Create new camera
+ camera = self._create_camera_instance(camera_info)
+ if camera is None:
+ error(f"Failed to create camera instance for {camera_info}")
+ return False
+
+ # Wrap in threaded camera
+ threaded_camera = ThreadedCamera(camera)
+ threaded_camera.start_thread()
+ self._camera_thread_started = True
+
+ # Open the camera with the device_id
+ try:
+ info(f"Opening camera: {camera_info.display_name}")
+
+ # Call open with device_id and wait=True to ensure it completes
+ success, _ = threaded_camera.open(camera_info.device_id, wait=True)
+
+ if not success:
+ error(f"Failed to open camera: {camera_info}")
+ threaded_camera.stop_thread(wait=True)
+ return False
+
+ # Set as active camera
+ self._active_camera = threaded_camera
+ self._active_camera_info = camera_info
+
+ debug(f"Successfully switched to camera: {camera_info}")
+ self.active_camera_changed.emit(camera_info)
+
+ # Start streaming if requested
+ if start_streaming:
+ self.start_streaming()
+
+ return True
+
+ except Exception as e:
+ exception(f"Error opening camera: {e}")
+ try:
+ threaded_camera.stop_thread(wait=True)
+ except Exception as stop_error:
+ exception(f"Error stopping thread: {stop_error}")
+ return False
+
+ def open_first_available(self, start_streaming: bool = True) -> bool:
+ """
+ Convenience method to enumerate and open the first available camera.
+
+ Args:
+ start_streaming: If True, automatically start streaming after opening
+
+ Returns:
+ True if a camera was opened, False otherwise
+ """
+ cameras = self.enumerate_cameras()
+
+ if not cameras:
+ warning("No cameras available to open")
+ return False
+
+ # Try to open the first camera
+ return self.switch_camera(cameras[0], start_streaming=start_streaming)
+
+ def start_streaming(self) -> bool:
+ """
+ Start streaming from the active camera.
+
+ Returns:
+ True if streaming started successfully, False otherwise
+ """
+ if not self._active_camera:
+ error("Cannot start streaming - no active camera")
+ return False
+
+ if self._is_streaming:
+ debug("Streaming already active")
+ return True
+
+ try:
+ # Get underlying camera
+ base_camera = self._active_camera.underlying_camera
+
+ # Get current resolution from underlying camera
+ res_index, width, height = base_camera.get_current_resolution()
+
+ # If no resolution set (0x0), set to first resolution
+ if width == 0 or height == 0:
+ info("Setting default resolution...")
+
+ # Get available resolutions
+ resolutions = base_camera.get_resolutions()
+ if not resolutions:
+ error("No resolutions available")
+ return False
+
+ # Get resolution again after setting
+ res_index, width, height = base_camera.get_current_resolution()
+
+ # Use final (post-rotation) dimensions for buffer.
+ # For 90/270-degree rotations the SDK transposes width and height
+ # before delivering frames; get_output_dimensions() reflects this.
+ width, height = self._active_camera.settings.get_output_dimensions()
+ self._frame_width = width
+ self._frame_height = height
+
+ # Calculate buffer size using base camera class method
+ base_camera_class = type(base_camera)
+ buffer_size = base_camera_class.calculate_buffer_size(width, height, 24)
+ self._current_frame_buffer = bytes(buffer_size)
+
+ # Start capture - use underlying camera directly
+ success = base_camera.start_capture(
+ self._camera_callback,
+ self
+ )
+
+ if not success:
+ error("start_capture returned False")
+ return False
+
+ self._is_streaming = True
+ info(f"Streaming started ({width}x{height})")
+ self.streaming_started.emit(width, height)
+ return True
+
+ except Exception as e:
+ error(f"Camera start streaming error: {e}")
+ import traceback
+ error(traceback.format_exc())
+ return False
+
+ def stop_streaming(self) -> bool:
+ """
+ Stop streaming from the active camera.
+
+ Returns:
+ True if streaming stopped successfully, False otherwise
+ """
+ if not self._is_streaming:
+ debug("Streaming not active")
+ return True
+
+ try:
+ if self._active_camera and self._active_camera.underlying_camera.is_open:
+ info("Stopping camera streaming...")
+ self._active_camera.underlying_camera.stop_capture()
+
+ self._is_streaming = False
+ self._current_frame_buffer = None
+ info("Streaming stopped")
+ self.streaming_stopped.emit()
+ return True
+
+ except Exception as e:
+ error(f"Error stopping streaming: {e}")
+ return False
+
+ def get_current_frame(self) -> bytes | None:
+ """
+ Get the current frame buffer.
+
+ Returns:
+ Frame buffer as bytes, or None if no frame is available
+ """
+ return self._current_frame_buffer
+
+ def copy_current_frame_to_numpy(self) -> np.ndarray | None:
+ """
+ Copy the current frame to a numpy array.
+
+ Returns:
+ Frame as numpy array (height, width, 3) or None if no frame available
+ """
+ if not self._current_frame_buffer or self._frame_width == 0 or self._frame_height == 0:
+ return None
+
+ try:
+ # Create numpy array from buffer
+ # Calculate stride
+ base_camera = self._active_camera.underlying_camera
+ base_camera_class = type(base_camera)
+ stride = base_camera_class.calculate_stride(self._frame_width, 24)
+
+ # Create view of buffer
+ arr = np.frombuffer(self._current_frame_buffer, dtype=np.uint8)
+
+ # Reshape to image dimensions
+ # Note: stride may be larger than width*3 due to alignment
+ bytes_per_pixel = 3
+ if stride == self._frame_width * bytes_per_pixel:
+ # No padding, simple reshape
+ return arr.reshape((self._frame_height, self._frame_width, bytes_per_pixel)).copy()
+ else:
+ # Has padding, need to account for it
+ # Reshape to include stride, then slice off padding
+ arr_2d = arr.reshape((self._frame_height, stride))
+ return arr_2d[:, :self._frame_width * bytes_per_pixel].reshape(
+ (self._frame_height, self._frame_width, bytes_per_pixel)
+ ).copy()
+
+ except Exception as e:
+ error(f"Error converting frame to numpy: {e}")
+ return None
+
+ @staticmethod
+ def _camera_callback(event: int, context: Any):
+ """
+ Camera event callback (called from camera thread).
+ Forward to UI thread via signal.
+ """
+ if isinstance(context, CameraManager):
+ # Emit signal to forward to UI thread
+ context._camera_event.emit(event)
+
+ @Slot(int)
+ def _on_camera_event(self, event: int):
+ """Handle camera events in UI thread"""
+ if not self._active_camera:
+ return
+
+ # Get underlying camera
+ base_camera = self._active_camera.underlying_camera
+
+ # Check if camera is open
+ if not base_camera.is_open:
+ return
+
+ # Get event constants from camera
+ events = base_camera.get_event_constants()
+
+ if event == events.IMAGE:
+ self._handle_image_event()
+ elif event == events.ERROR:
+ self._handle_error()
+ elif event == events.DISCONNECTED:
+ self._handle_disconnected()
+
+ def _handle_image_event(self):
+ """Handle new image from camera"""
+ if not self._active_camera or not self._current_frame_buffer:
+ return
+
+ try:
+ # Check if resolution has changed (use final post-rotation dimensions)
+ base_camera = self._active_camera.underlying_camera
+ current_width, current_height = self._active_camera.settings.get_output_dimensions()
+
+ # If resolution changed, update buffer
+ if current_width != self._frame_width or current_height != self._frame_height:
+ info(f"Resolution changed from {self._frame_width}x{self._frame_height} to {current_width}x{current_height}")
+ self._frame_width = current_width
+ self._frame_height = current_height
+
+ # Recalculate buffer size
+ base_camera_class = type(base_camera)
+ buffer_size = base_camera_class.calculate_buffer_size(current_width, current_height, 24)
+ self._current_frame_buffer = bytes(buffer_size)
+
+ # Pull image into buffer from underlying camera
+ if base_camera.pull_image(self._current_frame_buffer, 24):
+ # Emit signal that frame is ready
+ self.frame_ready.emit(self._frame_width, self._frame_height)
+
+ except Exception as e:
+ error(f"Error handling image: {e}")
+
+ def _handle_error(self):
+ """Handle camera error"""
+ error("Camera error occurred")
+ self.camera_error.emit()
+ self.stop_streaming()
+
+ def _handle_disconnected(self):
+ """Handle camera disconnection"""
+ warning("Camera disconnected")
+ self.camera_disconnected.emit()
+ self.stop_streaming()
+
+ def close_camera(self) -> bool:
+ """
+ Close the currently active camera.
+
+ Returns:
+ True if successful, False otherwise
+ """
+ if self._active_camera is None:
+ info("No active camera to close")
+ return True
+
+ info(f"Closing camera: {self._active_camera_info}")
+
+ # Stop streaming first
+ self.stop_streaming()
+
+ try:
+ # Close the camera
+ result = self._active_camera.close(wait=True)
+
+ if result is not None:
+ success, _ = result
+ if not success:
+ warning("Camera close returned failure")
+
+ # Stop the thread
+ if self._camera_thread_started:
+ self._active_camera.stop_thread(wait=True)
+ self._camera_thread_started = False
+
+ # Clear active camera
+ self._active_camera = None
+ prev_info = self._active_camera_info
+ self._active_camera_info = None
+
+ info(f"Camera closed: {prev_info}")
+ self.active_camera_changed.emit(None)
+ return True
+
+ except Exception as e:
+ exception(f"Error closing camera: {e}")
+
+ # Try to stop thread anyway
+ try:
+ if self._camera_thread_started and self._active_camera:
+ self._active_camera.stop_thread(wait=True)
+ except:
+ pass
+
+ # Clear state
+ self._active_camera = None
+ self._active_camera_info = None
+ self._camera_thread_started = False
+
+ self.active_camera_changed.emit(None)
+ return False
+
+ def _create_camera_instance(self, camera_info: CameraInfo) -> BaseCamera | None:
+ """
+ Factory method to create camera instance based on camera info.
+
+ Note: This only creates the camera instance. The camera must be
+ opened separately using camera.open(device_id).
+
+ Args:
+ camera_info: Information about the camera to create
+
+ Returns:
+ Camera instance or None if creation failed
+ """
+ try:
+ if camera_info.camera_type == CameraType.AMSCOPE:
+ # Create camera instance (does not open it yet)
+ camera = AmscopeCamera(camera_info.model)
+ return camera
+
+ elif camera_info.camera_type == CameraType.GENERIC_USB:
+ # Future: Create generic USB camera
+ error("Generic USB camera not yet implemented")
+ return None
+
+ else:
+ error(f"Unsupported camera type: {camera_info.camera_type}")
+ return None
+
+ except Exception as e:
+ exception(f"Error creating camera instance: {e}")
+ return None
+
+ def cleanup(self):
+ """Cleanup camera manager resources"""
+ info("Cleaning up camera manager")
+
+ # Stop streaming
+ self.stop_streaming()
+
+ # Close active camera
+ self.close_camera()
+
+ # Clear available cameras
+ self._available_cameras.clear()
+ self.camera_list_changed.emit()
\ No newline at end of file
diff --git a/camera/camera_settings.py b/camera/camera_settings.py
deleted file mode 100644
index f76ade0..0000000
--- a/camera/camera_settings.py
+++ /dev/null
@@ -1,108 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import Tuple
-from generic_config import ConfigManager, DEFAULT_FILENAME, ACTIVE_FILENAME
-
-
-# (From the API...)
-# .-[ DEFAULT VALUES FOR THE IMAGE ]--------------------------------.
-# | Parameter | Range | Default |
-# |-----------------------------------------------------------------|
-# | Auto Exposure Target | 16~235 | 120 |
-# | Temp | 2000~15000 | 6503 |
-# | Tint | 200~2500 | 1000 |
-# | LevelRange | 0~255 | Low = 0, High = 255 |
-# | Contrast | -100~100 | 0 |
-# | Hue | -180~180 | 0 |
-# | Saturation | 0~255 | 128 |
-# | Brightness | -64~64 | 0 |
-# | Gamma | 20~180 | 100 |
-# | WBGain | -127~127 | 0 |
-# | Sharpening | 0~500 | 0 |
-# | Linear Tone Mapping | 1/0 | 1 |
-# | Curved Tone Mapping | Log/Pol/Off | 2 (Logarithmic) |
-# '-----------------------------------------------------------------'
-
-@dataclass
-class CameraSettings:
- # Values
- auto_expo: bool = False
- exposure: int = 120 # Auto Exposure Target
- temp: int = 11616 # White balance temperature
- tint: int = 925 # White balance tint
- contrast: int = 0
- hue: int = 0
- saturation: int = 126
- brightness: int = -64
- gamma: int = 100
- sharpening: int = 500
-
- levelrange_low: Tuple[int, int, int, int] = (0, 0, 0, 0)
- levelrange_high: Tuple[int, int, int, int] = (255, 255, 255, 255)
- wbgain: Tuple[int, int, int] = (0, 0, 0) # (R, G, B)
- linear: int = 0 # 0/1
- curve: str = 'Polynomial'
- fformat: str = 'png'
-
- # Ranges (API docs)
- exposure_min: int = 16
- exposure_max: int = 220
-
- temp_min: int = 2000
- temp_max: int = 15000
-
- tint_min: int = 200
- tint_max: int = 2500
-
- levelrange_min: int = 0
- levelrange_max: int = 255
-
- contrast_min: int = -100
- contrast_max: int = 100
-
- hue_min: int = -180
- hue_max: int = 180
-
- saturation_min: int = 0
- saturation_max: int = 255
-
- brightness_min: int = -64
- brightness_max: int = 64
-
- gamma_min: int = 20
- gamma_max: int = 180
-
- wbgain_min: int = -127
- wbgain_max: int = 127
-
- sharpening_min: int = 0
- sharpening_max: int = 500
-
- linear_min: int = 0
- linear_max: int = 1
-
-
-# A pre-bound manager that knows how to load/save CameraSettings.
-# You can instantiate this wherever you need camera configs.
-def make_camera_settings_manager(
- *,
- root_dir: str = "./config/cameras",
- default_filename: str = "default_settings.yaml",
- backup_dirname: str = "backups",
- backup_keep: int = 5,
-) -> ConfigManager[CameraSettings]:
- return ConfigManager[CameraSettings](
- CameraSettings,
- root_dir=root_dir,
- default_filename=default_filename,
- backup_dirname=backup_dirname,
- backup_keep=backup_keep,
- )
-
-CameraSettingsManager = make_camera_settings_manager(
- root_dir="./config/cameras",
- default_filename=DEFAULT_FILENAME,
- backup_dirname="backups",
- backup_keep=5,
-)
\ No newline at end of file
diff --git a/camera/cameras/amscope_camera.py b/camera/cameras/amscope_camera.py
new file mode 100644
index 0000000..bc74b8e
--- /dev/null
+++ b/camera/cameras/amscope_camera.py
@@ -0,0 +1,562 @@
+"""
+Amscope camera implementation using the amcam SDK.
+Now with integrated settings management.
+"""
+
+from __future__ import annotations
+
+from typing import Callable, Any
+from types import SimpleNamespace
+from pathlib import Path
+import ctypes
+import numpy as np
+import threading
+import gc
+
+from camera.cameras.base_camera import BaseCamera, CameraResolution
+from common.logger import info, debug, error, exception, warning
+from camera.settings.amscope_settings import AmscopeSettings
+
+# Module-level reference to the loaded SDK
+_amcam = None
+
+
+class AmscopeCamera(BaseCamera):
+ """
+ Amscope camera implementation using the amcam SDK.
+
+ Now includes integrated settings management with Amscope-specific
+ settings like fan control, TEC, low noise mode, etc.
+
+ The SDK must be loaded before using this class:
+ AmscopeCamera.ensure_sdk_loaded()
+
+ Or it will be loaded automatically on first use.
+ """
+
+ # Class-level flag to track SDK loading
+ _sdk_loaded = False
+
+
+ def __init__(self, model: str):
+ """
+ Initialize Amscope camera.
+
+ Args:
+ model: Camera model name (default "Amscope")
+ """
+ super().__init__(model=model)
+
+ # Set Settings class
+ self._settings_class = AmscopeSettings
+
+ self._hcam = None # Will be amcam.Amcam after SDK loads
+
+ # Ensure SDK is loaded before instantiating
+ if not AmscopeCamera._sdk_loaded:
+ AmscopeCamera.ensure_sdk_loaded()
+
+ self._frame_buffer = None
+ self._dfc_completion_callback = None # Callback for DFC completion
+
+ def _get_settings_class(self):
+ """
+ Get the settings class for Amscope cameras.
+
+ Returns:
+ AmscopeSettings class
+ """
+ from camera.settings.amscope_settings import AmscopeSettings
+ return AmscopeSettings
+
+ @property
+ def settings(self) -> AmscopeSettings:
+ """
+ Get settings with proper type hint for Amscope.
+
+ Returns:
+ AmscopeSettings object
+ """
+ if self._settings is None:
+ raise RuntimeError("Settings not initialized. Call initialize_settings() first.")
+ return self._settings
+
+ # -------------------------
+ # SDK Management
+ # -------------------------
+
+ @classmethod
+ def ensure_sdk_loaded(cls, sdk_path: Path | None = None) -> bool:
+ """
+ Ensure the Amscope SDK is loaded and ready to use.
+
+ Args:
+ sdk_path: Optional path to SDK base directory.
+ If None, auto-detects from project structure.
+
+ Returns:
+ True if SDK loaded successfully, False otherwise
+ """
+ global _amcam
+
+ if cls._sdk_loaded and _amcam is not None:
+ return True
+
+ try:
+ from camera.sdk_loaders.amscope_sdk_loader import AmscopeSdkLoader
+
+ loader = AmscopeSdkLoader(sdk_path)
+ _amcam = loader.load()
+
+ cls._sdk_loaded = True
+ info("Amscope SDK loaded successfully")
+ return True
+
+ except Exception as e:
+ error(f"Failed to load Amscope SDK: {e}")
+ info("Attempting fallback to direct import...")
+
+ try:
+ # Fallback to direct import if loader fails
+ import amcam as amcam_module
+ _amcam = amcam_module
+ cls._sdk_loaded = True
+ info("Amscope SDK loaded via direct import")
+ return True
+ except ImportError as ie:
+ error(f"Direct import also failed: {ie}")
+ return False
+
+ @staticmethod
+ def _get_sdk():
+ """Get the loaded SDK module"""
+ global _amcam
+ if _amcam is None:
+ raise RuntimeError(
+ "Amscope SDK not loaded. Call AmscopeCamera.ensure_sdk_loaded() first."
+ )
+ return _amcam
+
+ @classmethod
+ def _get_sdk_static(cls):
+ """Static version of _get_sdk for class methods"""
+ return cls._get_sdk()
+
+ # -------------------------
+ # Event Constants
+ # -------------------------
+
+ @classmethod
+ def get_event_constants(cls):
+ """Get event constants as a namespace object."""
+ amcam = cls._get_sdk_static()
+ return SimpleNamespace(
+ IMAGE=amcam.AMCAM_EVENT_IMAGE,
+ EXPOSURE=amcam.AMCAM_EVENT_EXPOSURE,
+ TEMPTINT=amcam.AMCAM_EVENT_TEMPTINT,
+ STILLIMAGE=amcam.AMCAM_EVENT_STILLIMAGE,
+ ERROR=amcam.AMCAM_EVENT_ERROR,
+ DISCONNECTED=amcam.AMCAM_EVENT_DISCONNECTED
+ )
+
+ @property
+ def EVENT_IMAGE(self):
+ return self._get_sdk().AMCAM_EVENT_IMAGE
+
+ @property
+ def EVENT_EXPOSURE(self):
+ return self._get_sdk().AMCAM_EVENT_EXPOSURE
+
+ @property
+ def EVENT_TEMPTINT(self):
+ return self._get_sdk().AMCAM_EVENT_TEMPTINT
+
+ @property
+ def EVENT_STILLIMAGE(self):
+ return self._get_sdk().AMCAM_EVENT_STILLIMAGE
+
+ @property
+ def EVENT_ERROR(self):
+ return self._get_sdk().AMCAM_EVENT_ERROR
+
+ @property
+ def EVENT_DISCONNECTED(self):
+ return self._get_sdk().AMCAM_EVENT_DISCONNECTED
+
+ @property
+ def handle(self):
+ """Get the underlying amcam handle"""
+ return self._hcam
+
+ # -------------------------
+ # Camera Control
+ # -------------------------
+
+ def open(self, camera_id: str) -> bool:
+ """Open connection to Amscope camera"""
+ amcam = self._get_sdk()
+ try:
+ self._hcam = amcam.Amcam.Open(camera_id)
+ if self._hcam:
+ # Set RGB byte order for Qt compatibility
+ self._hcam.put_Option(_amcam.AMCAM_OPTION_BYTEORDER, 0)
+ # Initialize settings
+ self.initialize_settings()
+ self._is_open = True
+ return True
+ return False
+ except self._get_sdk().HRESULTException:
+ return False
+
+ def close(self):
+ """Close camera connection"""
+ if self._hcam:
+ self._hcam.Close()
+ self._hcam = None
+ self._is_open = False
+ self._callback = None
+ self._callback_context = None
+ self._frame_buffer = None
+
+ def _reallocate_frame_buffer(self):
+ """Reallocate frame buffer based on current resolution."""
+ try:
+ width, height = self._hcam.get_Size()
+ buffer_size = self.calculate_buffer_size(width, height, 24)
+ self._frame_buffer = bytes(buffer_size)
+ info(f"Reallocated frame buffer: {width}x{height}, size={buffer_size}")
+ except Exception as e:
+ error(f"Failed to reallocate frame buffer: {e}")
+
+ def start_capture(self, callback: Callable, context: Any) -> bool:
+ """Start capturing frames with callback"""
+ if not self._hcam:
+ return False
+
+ amcam = self._get_sdk()
+ try:
+ # Get current resolution to allocate frame buffer
+ res_index, width, height = self.get_current_resolution()
+
+ # Create persistent frame buffer
+ buffer_size = amcam.TDIBWIDTHBYTES(width * 24) * height
+ self._frame_buffer = bytearray(buffer_size)
+
+ self._callback = callback
+ self._callback_context = context
+ self._hcam.StartPullModeWithCallback(self._event_callback_wrapper, self)
+ return True
+ except self._get_sdk().HRESULTException:
+ return False
+
+ def stop_capture(self):
+ """Stop capturing frames"""
+ if self._hcam:
+ try:
+ self._hcam.Stop()
+ except:
+ pass
+
+ def pull_image(self, buffer: ctypes.Array, bits_per_pixel: int = 24, timeout_ms: int = 1000) -> bool:
+ """
+ Pull the latest image into buffer (expects ctypes.create_string_buffer)
+
+ Args:
+ buffer: ctypes buffer to receive image data
+ bits_per_pixel: Bits per pixel (typically 24)
+ timeout_ms: Timeout in milliseconds to wait for frame
+
+ Returns:
+ True if successful, False otherwise
+ """
+ if not self._hcam:
+ error("Cannot pull image: camera handle is None")
+ return False
+
+ amcam = self._get_sdk()
+ try:
+ # Use WaitImageV4 to wait for a frame (bStill=0 for video stream)
+ # This is more reliable than PullImageV4 which may fail if no frame is ready
+ self._hcam.WaitImageV4(timeout_ms, buffer, 0, bits_per_pixel, 0, None)
+ return True
+ except amcam.HRESULTException as e:
+ # If timeout or no frame available, log the error
+ error(f"Failed to pull image: {e}")
+ return False
+
+ def snap_image(self, resolution_index: int = 0) -> bool:
+ """Capture a still image at specified resolution"""
+ if not self._hcam:
+ return False
+
+ try:
+ self._hcam.Snap(resolution_index)
+ return True
+ except:
+ return False
+
+ # -------------------------
+ # Resolution Management
+ # -------------------------
+
+ def get_resolutions(self) -> list[CameraResolution]:
+ """Get available preview resolutions"""
+ return self.settings.get_resolutions()
+
+ def get_current_resolution(self) -> tuple[int, int, int]:
+ """Get current resolution index, width, and height"""
+ return self.settings.get_current_resolution()
+
+ def set_resolution(self, resolution_index: int) -> bool:
+ """Set camera resolution"""
+ return self.settings.set_still_resolution(resolution_index)
+
+ def supports_still_capture(self) -> bool:
+ """Check if camera supports separate still image capture"""
+ return len(self.settings.get_still_resolutions()) > 0
+
+ def get_still_resolutions(self) -> list[CameraResolution]:
+ """Get available still image resolutions"""
+ return self.settings.get_still_resolutions()
+
+
+ def pull_still_image(self, buffer: ctypes.Array, bits_per_pixel: int = 24) -> tuple[bool, int, int]:
+ """
+ Pull a still image into buffer
+
+ Args:
+ buffer: Buffer to receive image data (ctypes.create_string_buffer)
+ bits_per_pixel: Bits per pixel (typically 24)
+
+ Returns:
+ Tuple of (success, width, height)
+ """
+ if not self._hcam:
+ return False, 0, 0
+
+ amcam = self._get_sdk()
+ try:
+ # Get still resolution to return dimensions
+ w, h = self._hcam.get_StillResolution(0)
+ # Use PullStillImageV2 which works with ctypes.create_string_buffer
+ self._hcam.PullStillImageV2(buffer, bits_per_pixel, None)
+ return True, w, h
+ except amcam.HRESULTException:
+ return False, 0, 0
+
+ # -------------------------
+ # Metadata
+ # -------------------------
+
+ def get_camera_metadata(self) -> dict[str, Any]:
+ """Get current camera metadata for image saving"""
+ metadata = {
+ 'model': self.model,
+ }
+
+ # Get metadata from settings if available
+ if self._settings is not None:
+ metadata['exposure_time_us'] = self._settings.get_exposure_time()
+ metadata['temperature'] = self._settings.temp
+ metadata['tint'] = self._settings.tint
+
+ # Add serial number if available
+ try:
+ if self._hcam:
+ metadata['serial'] = self._hcam.get_SerialNumber()
+ except:
+ pass
+
+ return metadata
+
+ # -------------------------
+ # Image Capture and Saving
+ # -------------------------
+
+ def capture_and_save_still(
+ self,
+ filepath: Path,
+ resolution_index: int = 0,
+ additional_metadata: dict[str, Any] | None = None,
+ timeout_ms: int = 5000
+ ) -> bool:
+ """Capture a still image and save it with metadata."""
+ if not self._hcam:
+ error("Camera not open")
+ return False
+
+ amcam = self._get_sdk()
+
+ try:
+ # Allocate buffer for still image
+ width, height = self._hcam.get_StillResolution(resolution_index)
+ buffer_size = amcam.TDIBWIDTHBYTES(width * 24) * height
+ pData = bytes(buffer_size)
+
+ # Setup threading for still capture
+ still_ready = threading.Event()
+ capture_success = {'success': False, 'width': 0, 'height': 0}
+
+ # Save original callback
+ original_callback = self._callback
+ original_context = self._callback_context
+
+ def still_callback(event, ctx):
+ if event == self.EVENT_STILLIMAGE:
+ # Pull the still image
+ info_struct = amcam.AmcamFrameInfoV3()
+ try:
+ self._hcam.PullImageV3(pData, 1, 24, 0, info_struct)
+ capture_success['success'] = True
+ capture_success['width'] = info_struct.width
+ capture_success['height'] = info_struct.height
+ except Exception as e:
+ error(f"Failed to pull still image: {e}")
+ capture_success['success'] = False
+ still_ready.set()
+
+ # Call original callback if exists
+ if original_callback:
+ original_callback(event, original_context)
+
+ # Temporarily replace callback
+ self._callback = still_callback
+ self._callback_context = None
+
+ # Trigger still capture
+ if not self.snap_image(resolution_index):
+ error("Failed to trigger still capture")
+ self._callback = original_callback
+ self._callback_context = original_context
+ return False
+
+ # Wait for still image
+ if not still_ready.wait(timeout_ms / 1000.0):
+ error(f"Still capture timed out after {timeout_ms}ms")
+ self._callback = original_callback
+ self._callback_context = original_context
+ return False
+
+ # Restore original callback
+ self._callback = original_callback
+ self._callback_context = original_context
+
+ if not capture_success['success']:
+ error("Failed to pull still image")
+ return False
+
+ # Convert to numpy array
+ w = capture_success['width']
+ h = capture_success['height']
+ stride = amcam.TDIBWIDTHBYTES(w * 24)
+ image_data = np.frombuffer(pData, dtype=np.uint8).reshape((h, stride))[:, :w*3].reshape((h, w, 3)).copy()
+
+ del pData
+
+ # Save with metadata
+ success = self.save_image(image_data, filepath, additional_metadata)
+
+ del image_data
+ gc.collect()
+
+ if success:
+ info(f"Still image captured and saved: {filepath}")
+ else:
+ error(f"Failed to save still image: {filepath}")
+
+ return success
+
+ except Exception as e:
+ exception(f"Failed to capture and save still image: {filepath}")
+ return False
+
+ def capture_and_save_stream(
+ self,
+ filepath: Path,
+ additional_metadata: dict[str, Any] | None = None
+ ) -> bool:
+ """Capture current frame from live stream and save it."""
+ if not self._hcam or not self._is_open:
+ error("Camera not in capture mode")
+ return False
+
+ if not hasattr(self, '_frame_buffer') or self._frame_buffer is None:
+ error("No frame buffer available")
+ return False
+
+ try:
+ # Get current resolution
+ res_index, width, height = self.get_current_resolution()
+
+ # Copy from frame buffer
+ amcam = self._get_sdk()
+ stride = amcam.TDIBWIDTHBYTES(width * 24)
+
+ # Create numpy array from buffer
+ image_data = np.frombuffer(self._frame_buffer, dtype=np.uint8).reshape((height, stride))[:, :width*3].reshape((height, width, 3)).copy()
+
+ # Convert BGR to RGB
+ image_data = image_data[:, :, ::-1].copy()
+
+ # Save with metadata
+ success = self.save_image(image_data, filepath, additional_metadata)
+
+ del image_data
+ gc.collect()
+
+ if success:
+ info(f"Stream frame captured and saved: {filepath}")
+ else:
+ error(f"Failed to save stream frame: {filepath}")
+
+ return success
+
+ except Exception as e:
+ exception(f"Failed to capture and save stream frame: {filepath}")
+ return False
+
+ # -------------------------
+ # Utility Methods
+ # -------------------------
+
+ @staticmethod
+ def calculate_buffer_size(width: int, height: int, bits_per_pixel: int = 24) -> int:
+ """Calculate required buffer size for image data"""
+ amcam = AmscopeCamera._get_sdk_static()
+ return amcam.TDIBWIDTHBYTES(width * bits_per_pixel) * height
+
+ @staticmethod
+ def calculate_stride(width: int, bits_per_pixel: int = 24) -> int:
+ """Calculate image stride (bytes per row)"""
+ amcam = AmscopeCamera._get_sdk_static()
+ return amcam.TDIBWIDTHBYTES(width * bits_per_pixel)
+
+ @classmethod
+ def enable_gige(cls, callback: Callable | None = None, context: Any = None):
+ """Enable GigE camera support"""
+ if not cls._sdk_loaded:
+ cls.ensure_sdk_loaded()
+
+ amcam = cls._get_sdk_static()
+ amcam.Amcam.GigeEnable(callback, context)
+
+ def _event_callback_wrapper(self, event: int, context: Any):
+ """Internal wrapper for camera events."""
+ amcam = self._get_sdk()
+
+ # Update frame buffer on IMAGE events
+ if event == self.EVENT_IMAGE and hasattr(self, '_frame_buffer') and self._frame_buffer is not None:
+ try:
+ self._hcam.PullImageV4(self._frame_buffer, 0, 24, 0, None)
+ debug("Frame captured and pulled to buffer")
+ except:
+ pass
+ elif event == amcam.AMCAM_EVENT_DFC:
+ # DFC event received - call completion callback if registered
+ debug("DFC event received")
+ if hasattr(self, '_dfc_completion_callback') and self._dfc_completion_callback:
+ self._dfc_completion_callback()
+
+ # Call registered callback
+ if self._callback:
+ self._callback(event, self._callback_context)
\ No newline at end of file
diff --git a/camera/cameras/base_camera.py b/camera/cameras/base_camera.py
new file mode 100644
index 0000000..2199321
--- /dev/null
+++ b/camera/cameras/base_camera.py
@@ -0,0 +1,691 @@
+"""
+Base camera class that defines the interface for camera operations.
+All specific camera implementations should inherit from this class.
+"""
+
+from abc import ABC, abstractmethod
+from typing import Callable, Any, TYPE_CHECKING
+from dataclasses import dataclass, asdict
+from pathlib import Path
+from datetime import datetime
+import numpy as np
+from PIL import Image, ExifTags
+from PIL.Image import Exif
+from PIL import PngImagePlugin
+import json
+
+from common.logger import info, debug, error, exception
+from camera.settings.camera_settings import CameraSettings, CameraSettingsManager
+
+
+@dataclass
+class CameraResolution:
+ """Represents a camera resolution"""
+ width: int
+ height: int
+
+ def __str__(self):
+ return f"{self.width}*{self.height}"
+
+
+class BaseCamera(ABC):
+ """
+ Abstract base class for camera operations.
+ Defines the interface that all camera implementations must follow.
+ """
+
+ # Class-level flag to track if SDK has been loaded
+ _sdk_loaded = False
+
+ def __init__(self, model: str):
+ """
+ Initialize camera base class.
+
+ Args:
+ model: Camera model identifier (e.g., "MU500", "MU3000")
+ """
+ self.model = model
+ self._is_open = False
+ self._callback = None
+ self._callback_context = None
+
+ # Settings management (initialized after camera is opened)
+ self._settings_manager: CameraSettingsManager | None = None
+ self._settings: CameraSettings | None = None
+
+ @property
+ def is_open(self) -> bool:
+ """Check if camera is currently open"""
+ return self._is_open
+
+ @classmethod
+ @abstractmethod
+ def ensure_sdk_loaded(cls, sdk_path: Path | None = None) -> bool:
+ """
+ Ensure the camera SDK is loaded and ready to use.
+
+ This method should be called before any camera operations.
+ Implementations should handle:
+ - Loading vendor SDK libraries
+ - Platform-specific initialization
+ - Setting up library search paths
+ - Extracting SDK files if needed
+
+ Args:
+ sdk_path: Optional path to SDK location. If None, use default location.
+
+ Returns:
+ True if SDK is loaded successfully, False otherwise
+
+ Note:
+ This is a class method so it can be called before instantiating cameras.
+ Most implementations should track SDK load state to avoid reloading.
+ """
+ pass
+
+ @classmethod
+ def is_sdk_loaded(cls) -> bool:
+ """
+ Check if SDK has been loaded.
+
+ Returns:
+ True if SDK is loaded, False otherwise
+ """
+ return cls._sdk_loaded
+
+ @abstractmethod
+ def _get_settings_class(self) -> type[CameraSettings]:
+ """
+ Get the appropriate settings class for this camera.
+
+ This method must be implemented by subclasses to return their
+ concrete settings class (e.g., AmscopeSettings, ToupcamSettings).
+
+ Returns:
+ Concrete CameraSettings subclass for this camera type
+
+ Example:
+ In AmscopeCamera:
+ >>> def _get_settings_class(self):
+ ... from camera.settings.amscope_settings import AmscopeSettings
+ ... return AmscopeSettings
+ """
+ pass
+
+ def initialize_settings(self) -> None:
+ """
+ Initialize the settings system for this camera.
+
+ This should be called after the camera is opened.
+ It creates a settings manager specific to this camera model,
+ loads the saved settings (or defaults if none exist), and
+ applies them to the camera hardware.
+
+ Note:
+ The settings manager expects a CameraSettings subclass specific
+ to this camera model. The subclass must implement all abstract
+ methods from CameraSettings and provide metadata via get_metadata().
+
+ Example:
+ >>> camera = MU500Camera()
+ >>> camera.open("camera_id")
+ >>> camera.initialize_settings()
+ >>> # Now camera.settings is available
+ """
+
+ info(f"Initializing settings for {self.model}")
+
+ # Create model-specific settings manager
+ self._settings_manager = CameraSettingsManager(
+ model=self.model,
+ settings_class=self._get_settings_class()
+ )
+
+ # Load saved settings or create defaults
+ self._settings = self._settings_manager.load()
+
+ # Then apply settings to camera hardware
+ self._settings.apply_to_camera(self)
+
+ @property
+ def settings(self) -> CameraSettings:
+ """
+ Get the current settings object.
+
+ The GUI can use this to read and modify settings.
+
+ Returns:
+ CameraSettings object for this camera
+
+ Raises:
+ RuntimeError: If settings haven't been initialized yet
+
+ Example:
+ >>> # GUI code
+ >>> settings = camera.settings
+ >>> settings.set_exposure(150)
+ >>> settings.set_contrast(10)
+ >>> # Changes are immediately applied to camera hardware
+ """
+ if self._settings is None:
+ raise RuntimeError(
+ "Settings not initialized. Call initialize_settings() first."
+ )
+ return self._settings
+
+ def save_settings(self) -> None:
+ """
+ Save current settings to config file.
+
+ This creates a backup of the previous settings before saving.
+ Call this when the user clicks "Save" or "Apply" in the GUI.
+
+ Example:
+ >>> # User adjusted settings via GUI
+ >>> camera.settings.set_exposure(150)
+ >>> camera.settings.set_contrast(10)
+ >>> # User clicks "Save"
+ >>> camera.save_settings()
+ """
+ if self._settings is None or self._settings_manager is None:
+ raise RuntimeError("Settings not initialized")
+
+ info(f"Saving settings for {self.model}")
+ self._settings_manager.save(self._settings)
+ info("Settings saved successfully")
+
+ def load_settings(self, filepath: Path | str | None = None) -> None:
+ """
+ Load settings from file and apply to camera.
+
+ Args:
+ filepath: Optional path to load from. If None, loads from default location.
+
+ Example:
+ >>> # Load from default location
+ >>> camera.load_settings()
+ >>>
+ >>> # Load from specific file
+ >>> camera.load_settings("./saved_configs/night_mode.yaml")
+ """
+ if self._settings_manager is None:
+ raise RuntimeError("Settings not initialized")
+
+ info(f"Loading settings for {self.model}")
+
+ if filepath is None:
+ # Load from default location
+ self._settings = self._settings_manager.load()
+ else:
+ # Load from specific file
+ self._settings = self._settings_manager.load_from_file(filepath)
+
+ # Refresh to ensure we have camera reference
+ self._settings.refresh_from_camera(self)
+
+ # Apply to camera hardware
+ self._settings.apply_to_camera(self)
+
+ info("Settings loaded and applied to camera")
+
+ def reset_settings(self) -> None:
+ """
+ Reset settings to last saved state and apply to camera.
+
+ Call this when the user clicks "Cancel" or "Reset" in the GUI.
+
+ Example:
+ >>> # User made changes but wants to discard them
+ >>> camera.reset_settings()
+ """
+ if self._settings_manager is None:
+ raise RuntimeError("Settings not initialized")
+
+ info(f"Resetting settings for {self.model}")
+
+ # Reload from disk
+ self._settings = self._settings_manager.load()
+
+ # Refresh to ensure camera reference
+ self._settings.refresh_from_camera(self)
+
+ # Re-apply to camera
+ self._settings.apply_to_camera(self)
+
+ info("Settings reset to saved state")
+
+ def reset_to_defaults(self) -> None:
+ """
+ Reset settings to factory defaults and apply to camera.
+
+ This also saves the defaults as the current settings.
+
+ Example:
+ >>> # User wants factory defaults
+ >>> camera.reset_to_defaults()
+ """
+ if self._settings_manager is None:
+ raise RuntimeError("Settings not initialized")
+
+ info(f"Resetting to factory defaults for {self.model}")
+
+ # Restore defaults (this also saves them)
+ self._settings = self._settings_manager.restore_defaults()
+
+ # Refresh to ensure camera reference
+ self._settings.refresh_from_camera(self)
+
+ # Apply to camera
+ self._settings.apply_to_camera(self)
+
+ info("Factory defaults restored and applied")
+
+ def refresh_settings_from_camera(self) -> None:
+ """
+ Read current camera state and update settings object.
+
+ Useful if the camera was adjusted outside of the settings system
+ (e.g., via hardware buttons or external software).
+
+ Example:
+ >>> # Camera was adjusted externally
+ >>> camera.refresh_settings_from_camera()
+ >>> # Now settings object matches camera hardware
+ """
+ if self._settings is None:
+ raise RuntimeError("Settings not initialized")
+
+ info("Refreshing settings from camera hardware")
+ self._settings.refresh_from_camera(self)
+ info("Settings refreshed")
+
+ @abstractmethod
+ def open(self, camera_id: str) -> bool:
+ """
+ Open camera connection
+
+ Args:
+ camera_id: Identifier for the camera to open
+
+ Returns:
+ True if successful, False otherwise
+ """
+ pass
+
+ @abstractmethod
+ def close(self):
+ """Close camera connection and cleanup resources"""
+ pass
+
+ @abstractmethod
+ def start_capture(self, callback: Callable, context: Any) -> bool:
+ """
+ Start capturing frames
+
+ Args:
+ callback: Function to call when events occur
+ context: Context object to pass to callback
+
+ Returns:
+ True if successful, False otherwise
+ """
+ pass
+
+ @abstractmethod
+ def stop_capture(self):
+ """Stop capturing frames"""
+ pass
+
+ @abstractmethod
+ def pull_image(self, buffer: bytes, bits_per_pixel: int = 24, timeout_ms: int = 1000) -> bool:
+ """
+ Pull the latest image into provided buffer
+
+ Args:
+ buffer: Pre-allocated buffer to receive image data
+ bits_per_pixel: Bits per pixel (typically 24 for RGB)
+ timeout_ms: Timeout in milliseconds
+
+ Returns:
+ True if successful, False otherwise
+ """
+ pass
+
+ @abstractmethod
+ def snap_image(self, resolution_index: int = 0) -> bool:
+ """
+ Capture a still image at specified resolution
+
+ Args:
+ resolution_index: Index of resolution to use
+
+ Returns:
+ True if successful, False otherwise
+ """
+ pass
+
+ @abstractmethod
+ def get_camera_metadata(self) -> dict[str, Any]:
+ """
+ Get camera metadata for image saving.
+
+ This method retrieves current camera settings and information
+ to be embedded in saved images.
+
+ Returns:
+ Dictionary containing camera metadata including:
+ - model: Camera model name
+ - All other camera settings from the settings object
+ """
+ metadata = {
+ "model": self.model,
+ }
+
+ # Get all dataclass fields as a dictionary
+ settings_dict = asdict(self._settings)
+
+ # Remove internal fields and complex types that don't serialize well
+ settings_dict.pop("version", None)
+
+ # Convert NamedTuples to dicts for better serialization
+ for key, value in settings_dict.items():
+ if hasattr(value, "_asdict"):
+ settings_dict[key] = value._asdict()
+
+ # Merge with metadata
+ metadata.update(settings_dict)
+
+ return metadata
+
+ @abstractmethod
+ def supports_still_capture(self) -> bool:
+ """
+ Check if camera supports separate still image capture
+
+ Returns:
+ True if supported, False otherwise
+ """
+ pass
+
+ @abstractmethod
+ def capture_and_save_still(
+ self,
+ filepath: Path,
+ resolution_index: int = 0,
+ additional_metadata: dict[str, Any] | None = None,
+ timeout_ms: int = 5000
+ ) -> bool:
+ """
+ Capture a still image and save it with metadata.
+
+ Args:
+ filepath: Path where image should be saved
+ resolution_index: Camera resolution to use (0 = highest)
+ additional_metadata: Optional dict of extra metadata to save
+ timeout_ms: Timeout for capture in milliseconds
+
+ Returns:
+ True if successful, False otherwise
+
+ """
+ pass
+
+ @abstractmethod
+ def capture_and_save_stream(
+ self,
+ filepath: Path,
+ additional_metadata: dict[str, Any] | None = None
+ ) -> bool:
+ """
+ Capture current stream frame and save it with metadata.
+
+ Args:
+ filepath: Path where image should be saved
+ additional_metadata: Optional dict of extra metadata to save
+
+ Returns:
+ True if successful, False otherwise
+
+ """
+ pass
+
+ @abstractmethod
+ def calculate_buffer_size(width: int, height: int, bits_per_pixel: int) -> int:
+ pass
+
+ @abstractmethod
+ def calculate_stride(width: int, bits_per_pixel: int) -> int:
+ pass
+
+ def save_image(
+ self,
+ image_data: np.ndarray,
+ filepath: Path,
+ additional_metadata: dict[str, Any] | None = None
+ ) -> bool:
+ """
+ Save image data with embedded metadata.
+
+ Args:
+ image_data: Image as numpy array (height, width, channels) or (height, width)
+ filepath: Path where image should be saved
+ additional_metadata: Optional dictionary of additional metadata to save
+
+ Returns:
+ True if successful, False otherwise
+
+ Note:
+ - TIFF/TIF: Metadata saved in TIFF tags and as JSON in UserComment
+ - JPG/JPEG: Metadata saved in EXIF UserComment as JSON
+ - PNG: Metadata saved in PNG text chunks
+ """
+ pil_image = None
+ try:
+ # Ensure filepath is a Path object
+ filepath = Path(filepath)
+
+ # Get camera metadata
+ camera_metadata = self.get_camera_metadata()
+
+ # Combine with additional metadata
+ full_metadata = {
+ "timestamp": datetime.now().isoformat(),
+ "camera": camera_metadata
+ }
+
+ if additional_metadata:
+ full_metadata["additional"] = additional_metadata
+
+ # Convert to PIL Image
+ if image_data.dtype != np.uint8:
+ # Normalize to uint8 if needed
+ if image_data.max() > 255:
+ image_data = (image_data / image_data.max() * 255).astype(np.uint8)
+ else:
+ image_data = image_data.astype(np.uint8)
+
+ # Handle grayscale vs RGB
+ if len(image_data.shape) == 2:
+ pil_image = Image.fromarray(image_data, mode='L')
+ elif image_data.shape[2] == 3:
+ pil_image = Image.fromarray(image_data, mode='RGB')
+ elif image_data.shape[2] == 4:
+ pil_image = Image.fromarray(image_data, mode='RGBA')
+ else:
+ error(f"Unsupported image shape: {image_data.shape}")
+ return False
+
+ # Get file extension
+ ext = filepath.suffix.lower()
+
+ # Save with format-specific metadata
+ if ext in ['.tif', '.tiff']:
+ self._save_tiff_with_metadata(pil_image, filepath, full_metadata)
+ elif ext in ['.jpg', '.jpeg']:
+ self._save_jpeg_with_metadata(pil_image, filepath, full_metadata)
+ elif ext == '.png':
+ self._save_png_with_metadata(pil_image, filepath, full_metadata)
+ else:
+ error(f"Unsupported file format: {ext}")
+ return False
+
+ debug(f"Image saved successfully: {filepath}")
+ return True
+
+ except Exception as e:
+ exception(f"Failed to save image to {filepath}")
+ return False
+ finally:
+ # Explicitly close and delete PIL image to free memory
+ if pil_image is not None:
+ pil_image.close()
+ del pil_image
+
+ def _save_tiff_with_metadata(
+ self,
+ pil_image: Image.Image,
+ filepath: Path,
+ metadata: dict[str, Any]
+ ):
+ """Save TIFF with metadata in EXIF tags and UserComment"""
+ # Get tag mappings from Base enum
+ base_tags = {tag.name: tag.value for tag in ExifTags.Base}
+
+ # Create Exif object
+ exif = Exif()
+
+ # Add software information
+ from common.app_context import get_app_context
+ exif[base_tags['Software']] = f"FieldWeave - v{get_app_context().settings.version}"
+
+ # Add timestamp
+ timestamp = metadata.get("timestamp", datetime.now().isoformat())
+ exif[base_tags['DateTime']] = datetime.fromisoformat(timestamp).strftime("%Y:%m:%d %H:%M:%S")
+
+ # Add camera metadata if available
+ camera_meta = metadata.get("camera", {})
+
+ # Camera Model
+ if "model" in camera_meta:
+ exif[base_tags['Model']] = str(camera_meta["model"])
+
+ # Get the EXIF IFD to add camera-specific tags
+ exif_ifd = exif.get_ifd(ExifTags.IFD.Exif)
+
+ # Exposure time
+ if "exposure_time_us" in camera_meta:
+ exposure_sec = camera_meta["exposure_time_us"] / 1_000_000
+ exif_ifd[base_tags['ExposureTime']] = (int(exposure_sec * 1_000_000), 1_000_000)
+
+ # ISO Speed (using gain as proxy)
+ if "gain_percent" in camera_meta:
+ iso_value = camera_meta["gain_percent"]
+ exif_ifd[base_tags['ISOSpeedRatings']] = iso_value
+
+ # Add timestamp to EXIF IFD
+ exif_ifd[base_tags['DateTimeOriginal']] = datetime.fromisoformat(timestamp).strftime("%Y:%m:%d %H:%M:%S")
+ exif_ifd[base_tags['DateTimeDigitized']] = datetime.fromisoformat(timestamp).strftime("%Y:%m:%d %H:%M:%S")
+
+ # Image description from user metadata
+ additional_meta = metadata.get("additional", {})
+ description_parts = []
+
+ if "description" in additional_meta:
+ description_parts.append(str(additional_meta["description"]))
+ if "sample_id" in additional_meta:
+ description_parts.append(f"Sample: {additional_meta['sample_id']}")
+
+ if description_parts:
+ exif[base_tags['ImageDescription']] = " | ".join(description_parts)
+
+ # Store complete metadata as JSON in UserComment
+ metadata_json = json.dumps(metadata, indent=2)
+ exif_ifd[base_tags['UserComment']] = metadata_json.encode('utf-16')
+
+ # Save with EXIF
+ pil_image.save(filepath, format='TIFF', exif=exif, compression='tiff_deflate')
+ debug(f"TIFF with EXIF metadata saved to {filepath}")
+
+ def _save_jpeg_with_metadata(
+ self,
+ pil_image: Image.Image,
+ filepath: Path,
+ metadata: dict[str, Any]
+ ):
+ """Save JPEG with metadata in EXIF tags"""
+ # Get tag mappings from Base enum
+ base_tags = {tag.name: tag.value for tag in ExifTags.Base}
+
+ # Create Exif object
+ exif = Exif()
+
+ # Add software information
+ from common.app_context import get_app_context
+ exif[base_tags['Software']] = f"FieldWeave - v{get_app_context().settings.version}"
+
+ # Add timestamp
+ timestamp = metadata.get("timestamp", datetime.now().isoformat())
+ exif[base_tags['DateTime']] = datetime.fromisoformat(timestamp).strftime("%Y:%m:%d %H:%M:%S")
+
+ # Add camera metadata
+ camera_meta = metadata.get("camera", {})
+
+ if "model" in camera_meta:
+ exif[base_tags['Model']] = str(camera_meta["model"])
+
+ # Image description from additional metadata
+ additional_meta = metadata.get("additional", {})
+ if "description" in additional_meta:
+ exif[base_tags['ImageDescription']] = str(additional_meta["description"])
+ elif "sample_id" in additional_meta:
+ exif[base_tags['ImageDescription']] = f"Sample: {additional_meta['sample_id']}"
+
+ # Get the EXIF IFD
+ exif_ifd = exif.get_ifd(ExifTags.IFD.Exif)
+
+ # Exposure time
+ if "exposure_time_us" in camera_meta:
+ exposure_sec = camera_meta["exposure_time_us"] / 1_000_000
+ exif_ifd[base_tags['ExposureTime']] = (int(exposure_sec * 1_000_000), 1_000_000)
+
+ # ISO Speed
+ if "gain_percent" in camera_meta:
+ iso_value = camera_meta["gain_percent"]
+ exif_ifd[base_tags['ISOSpeedRatings']] = iso_value
+
+ # Add timestamp to EXIF IFD
+ exif_ifd[base_tags['DateTimeOriginal']] = datetime.fromisoformat(timestamp).strftime("%Y:%m:%d %H:%M:%S")
+ exif_ifd[base_tags['DateTimeDigitized']] = datetime.fromisoformat(timestamp).strftime("%Y:%m:%d %H:%M:%S")
+
+ # Store complete metadata as JSON in UserComment
+ metadata_json = json.dumps(metadata, indent=2)
+ exif_ifd[base_tags['UserComment']] = metadata_json.encode('utf-16')
+
+ # Save with EXIF
+ pil_image.save(filepath, format='JPEG', exif=exif, quality=95)
+ debug(f"JPEG with EXIF metadata saved to {filepath}")
+
+ def _save_png_with_metadata(
+ self,
+ pil_image: Image.Image,
+ filepath: Path,
+ metadata: dict[str, Any]
+ ):
+ """Save PNG with metadata in text chunks"""
+
+ # Create PNG info
+ pnginfo = PngImagePlugin.PngInfo()
+
+ # Add software info
+ from common.app_context import get_app_context
+ pnginfo.add_text("Software", f"FieldWeave - v{get_app_context().settings.version}")
+ pnginfo.add_text("Metadata", json.dumps(metadata, indent=2))
+
+ # Add individual camera settings as separate chunks
+ camera_meta = metadata.get("camera", {})
+ for key, value in camera_meta.items():
+ pnginfo.add_text(f"Camera.{key}", str(value))
+
+ # Save with metadata
+ pil_image.save(filepath, format='PNG', pnginfo=pnginfo)
+ debug(f"PNG metadata saved to {filepath}")
\ No newline at end of file
diff --git a/camera/sdk_loaders/amscope_sdk_loader.py b/camera/sdk_loaders/amscope_sdk_loader.py
new file mode 100644
index 0000000..988cc9b
--- /dev/null
+++ b/camera/sdk_loaders/amscope_sdk_loader.py
@@ -0,0 +1,271 @@
+"""
+Utility for loading the Amscope SDK dynamically.
+
+This module handles:
+- Extracting the SDK from zip if needed
+- Platform-specific DLL/SO path configuration
+- Dynamic module import with correct __file__ override
+"""
+
+import os
+import sys
+import platform
+import zipfile
+import shutil
+import importlib.util
+from pathlib import Path
+from typing import Optional
+
+from common.logger import get_logger
+
+class AmscopeSdkLoader:
+ """
+ Loader for the Amscope camera SDK.
+
+ Handles automatic extraction from zip, platform detection,
+ and dynamic module loading.
+ """
+
+ def __init__(self, sdk_base_dir: Optional[Path] = None):
+ """
+ Initialize the SDK loader.
+
+ Args:
+ sdk_base_dir: Optional base directory for SDK files.
+ If None, uses project_root/3rd_party_imports
+ """
+ if sdk_base_dir is None:
+ # Auto-detect project root (2 levels up from this file)
+ project_root = Path(__file__).resolve().parent.parent.parent
+ sdk_base_dir = project_root / "3rd_party_imports"
+
+ self.sdk_base_dir = Path(sdk_base_dir)
+ self.official_dir = self.sdk_base_dir / "official_amscope"
+ self.amcam_module = None
+
+ def load(self):
+ """
+ Load the Amscope SDK.
+
+ Returns:
+ The loaded amcam module
+
+ Raises:
+ RuntimeError: If SDK cannot be found or loaded
+ """
+ # Ensure SDK is extracted
+ sdk_root, sdk_py = self._ensure_sdk()
+
+ # Get platform-specific DLL directory
+ dll_dir = self._get_dll_directory(sdk_root)
+
+ # Configure library search path
+ self._configure_library_path(dll_dir)
+
+ # Load the module
+ self.amcam_module = self._load_module(sdk_py, dll_dir)
+
+ return self.amcam_module
+
+ def _ensure_sdk(self) -> tuple[Path, Path]:
+ """
+ Ensure the AmScope SDK is available under:
+ sdk_base_dir / "official_amscope"
+ If not, extract the first amcamsdk*.zip in sdk_base_dir.
+
+ Returns:
+ Tuple of (sdk_root_dir, sdk_py_path)
+
+ Raises:
+ RuntimeError: If SDK cannot be found or extracted
+ """
+ sdk_py = self.official_dir / "python" / "amcam.py"
+
+ # Already extracted?
+ if sdk_py.is_file():
+ return self.official_dir, sdk_py
+
+ # Ensure base directory exists
+ self.sdk_base_dir.mkdir(parents=True, exist_ok=True)
+
+ # Look for a zip starting with "amcamsdk"
+ for f in self.sdk_base_dir.iterdir():
+ if (f.is_file() and
+ f.name.lower().startswith("amcamsdk") and
+ f.suffix.lower() == ".zip"):
+
+ get_logger().info(f"Extracting AmScope SDK from {f.name}...")
+ with zipfile.ZipFile(f, "r") as zf:
+ zf.extractall(self.official_dir)
+ break
+ else:
+ raise RuntimeError(
+ f"No AmScope SDK zip found in {self.sdk_base_dir}\n"
+ f"Expected a file named amcamsdk*.zip"
+ )
+
+ # Handle case where zip contains a single subdirectory
+ if not sdk_py.is_file():
+ subdirs = [d for d in self.official_dir.iterdir() if d.is_dir()]
+ if len(subdirs) == 1:
+ nested_sdk_py = subdirs[0] / "python" / "amcam.py"
+ if nested_sdk_py.is_file():
+ # Move contents up one level
+ tmp = subdirs[0]
+ for item in tmp.iterdir():
+ shutil.move(str(item), self.official_dir)
+ tmp.rmdir()
+
+ # Verify extraction succeeded
+ if not sdk_py.is_file():
+ raise RuntimeError(
+ f"Extracted SDK does not contain python/amcam.py\n"
+ f"Expected at: {sdk_py}"
+ )
+
+ get_logger().info(f"AmScope SDK ready at {self.official_dir}")
+ return self.official_dir, sdk_py
+
+ def _get_dll_directory(self, sdk_root: Path) -> Path:
+ """
+ Determine platform-specific DLL/SO directory.
+
+ Args:
+ sdk_root: Root directory of the SDK
+
+ Returns:
+ Path to the directory containing platform libraries
+
+ Raises:
+ RuntimeError: If platform is not supported
+ """
+ system = platform.system().lower()
+ machine = platform.machine().lower()
+
+ if system == 'windows':
+ dll_dir = sdk_root / 'win' / 'x64'
+
+ elif system == 'linux':
+ arch_map = {
+ 'x86_64': 'x64',
+ 'amd64': 'x64',
+ 'i386': 'x86',
+ 'i686': 'x86',
+ 'arm64': 'arm64',
+ 'aarch64': 'arm64',
+ 'armv7l': 'armhf',
+ 'armv6l': 'armel'
+ }
+ subarch = arch_map.get(machine)
+ if not subarch:
+ raise RuntimeError(
+ f"Unsupported Linux architecture: {machine}\n"
+ f"Supported: {', '.join(arch_map.keys())}"
+ )
+ dll_dir = sdk_root / 'linux' / subarch
+
+ elif system == 'darwin':
+ dll_dir = sdk_root / 'mac'
+
+ else:
+ raise RuntimeError(f"Unsupported operating system: {system}")
+
+ if not dll_dir.exists():
+ raise RuntimeError(
+ f"Platform library directory not found: {dll_dir}\n"
+ f"System: {system}, Architecture: {machine}"
+ )
+
+ return dll_dir
+
+ def _configure_library_path(self, dll_dir: Path):
+ """
+ Configure library search paths for the current platform.
+
+ Args:
+ dll_dir: Directory containing platform libraries
+ """
+ system = platform.system().lower()
+ dll_dir_str = str(dll_dir)
+
+ if system == 'windows':
+ # Windows: Use add_dll_directory if available (Python 3.8+)
+ if hasattr(os, 'add_dll_directory'):
+ os.add_dll_directory(dll_dir_str)
+ else:
+ # Fallback for older Python versions
+ os.environ['PATH'] = dll_dir_str + os.pathsep + os.environ.get('PATH', '')
+
+ else:
+ # Linux/macOS: Set LD_LIBRARY_PATH or DYLD_LIBRARY_PATH
+ if system == 'darwin':
+ env_var = 'DYLD_LIBRARY_PATH'
+ else:
+ env_var = 'LD_LIBRARY_PATH'
+
+ current_path = os.environ.get(env_var, '')
+ os.environ[env_var] = dll_dir_str + os.pathsep + current_path
+
+ def _load_module(self, sdk_py: Path, dll_dir: Path):
+ """
+ Dynamically load the amcam module.
+
+ Args:
+ sdk_py: Path to amcam.py
+ dll_dir: Directory containing platform libraries
+
+ Returns:
+ The loaded amcam module
+ """
+ # Create module spec
+ spec = importlib.util.spec_from_file_location("amcam", sdk_py)
+ amcam_module = importlib.util.module_from_spec(spec)
+
+ # Override __file__ to trick the SDK's LoadLibrary logic
+ # The SDK uses __file__ to find the DLL, so we point it to the DLL directory
+ amcam_module.__file__ = str(dll_dir / 'amcam.py')
+
+ # Register in sys.modules before execution
+ sys.modules["amcam"] = amcam_module
+
+ # Execute the module
+ spec.loader.exec_module(amcam_module)
+
+ return amcam_module
+
+
+def load_amscope_sdk(sdk_base_dir: Optional[Path] = None):
+ """
+ Convenience function to load the Amscope SDK.
+
+ Args:
+ sdk_base_dir: Optional base directory for SDK files.
+ If None, auto-detects from project structure.
+
+ Returns:
+ The loaded amcam module
+
+ Example:
+ >>> amcam = load_amscope_sdk()
+ >>> cameras = amcam.Amcam.EnumV2()
+ """
+ loader = AmscopeSdkLoader(sdk_base_dir)
+ return loader.load()
+
+
+if __name__ == "__main__":
+ # Test the loader
+ try:
+ amcam = load_amscope_sdk()
+ print(f"Successfully loaded amcam SDK")
+ print(f"Module location: {amcam.__file__}")
+
+ # Try to enumerate cameras
+ cameras = amcam.Amcam.EnumV2()
+ print(f"Found {len(cameras)} camera(s)")
+ for i, cam in enumerate(cameras):
+ print(f" {i+1}. {cam.displayname}")
+
+ except Exception as e:
+ print(f"Error loading SDK: {e}")
+ sys.exit(1)
diff --git a/camera/settings/amscope_settings.py b/camera/settings/amscope_settings.py
new file mode 100644
index 0000000..acc5e00
--- /dev/null
+++ b/camera/settings/amscope_settings.py
@@ -0,0 +1,929 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+from pathlib import Path
+
+from camera.settings.camera_settings import (
+ CameraSettings,
+ SettingMetadata,
+ SettingType,
+ RGBALevel,
+ FileFormat,
+)
+from common.logger import info, error, exception, debug, warning
+
+if TYPE_CHECKING:
+ from camera.cameras.base_camera import BaseCamera, CameraResolution
+
+
+@dataclass
+class AmscopeSettings(CameraSettings):
+ version: str = "0"
+ auto_exposure: bool = True
+ exposure: int = 128
+ exposure_time: int = 50000
+ preview_resolution: str = ""
+ still_resolution: str = ""
+ temp: int = 6500
+ tint: int = 1000
+ contrast: int = 0
+ hue: int = 0
+ saturation: int = 128
+ brightness: int = 0
+ gamma: int = 100
+ gain: int = 100
+ level_range_low: RGBALevel = RGBALevel(0, 0, 0, 0)
+ level_range_high: RGBALevel = RGBALevel(255, 255, 255, 255)
+ fformat: FileFormat = FileFormat.TIFF
+ rotate: int = 0
+ hflip: bool = False
+ vflip: bool = False
+
+ # Dark Field Correction
+ dfc_enable: bool = False
+ _dfc_initialized: bool = False # Track if DFC has been captured or imported
+ dfc_quantity: int = 10
+ dfc_filepath: str = "" # Path to the DFC file
+
+ _camera: BaseCamera | None = field(default=None, repr=False, compare=False)
+ _ui_update_callback: callable | None = field(default=None, repr=False, compare=False)
+
+ def __post_init__(self) -> None:
+ super().__post_init__()
+
+ def get_metadata(self) -> list[SettingMetadata]:
+ """
+ Get metadata for all settings with dynamically populated resolution choices.
+ """
+ # Get available resolutions from camera
+ resolutions = self.get_resolutions()
+ resolution_choices = [f"{res.width}x{res.height}" for res in resolutions]
+
+ still_resolutions = self.get_still_resolutions()
+ still_resolution_choices = [f"{res.width}x{res.height}" for res in still_resolutions]
+
+ return [
+ SettingMetadata(
+ name="preview_resolution",
+ display_name="Preview Resolution",
+ setting_type=SettingType.DROPDOWN,
+ description="Camera preview resolution",
+ choices=resolution_choices,
+ group="Capture",
+ runtime_changeable=False,
+ ),
+ SettingMetadata(
+ name="still_resolution",
+ display_name="Still Resolution",
+ setting_type=SettingType.DROPDOWN,
+ description="Resolution used when capturing a still image",
+ choices=still_resolution_choices,
+ group="Capture",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="fformat",
+ display_name="File Format",
+ setting_type=SettingType.DROPDOWN,
+ description="Default file format for saved images",
+ choices=self._file_formats,
+ group="Capture",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="rotate",
+ display_name="Rotation",
+ setting_type=SettingType.DROPDOWN,
+ description="Rotate the camera image clockwise. Requires camera restart to apply.",
+ choices=["0", "90", "180", "270"],
+ group="Capture",
+ runtime_changeable=False,
+ ),
+ SettingMetadata(
+ name="hflip",
+ display_name="Flip Horizontal",
+ setting_type=SettingType.BOOL,
+ description="Mirror the image horizontally",
+ group="Capture",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="vflip",
+ display_name="Flip Vertical",
+ setting_type=SettingType.BOOL,
+ description="Mirror the image vertically",
+ group="Capture",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="auto_exposure",
+ display_name="Auto Exposure",
+ setting_type=SettingType.BOOL,
+ description="Enable automatic exposure control",
+ group="Exposure",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="exposure",
+ display_name="Exposure Target",
+ setting_type=SettingType.RANGE,
+ description="Target brightness for auto exposure",
+ min_value=16,
+ max_value=235,
+ group="Exposure",
+ runtime_changeable=True,
+ controlled_by="auto_exposure",
+ controlled_when=False,
+ ),
+ SettingMetadata(
+ name="exposure_time",
+ display_name="Exposure Time (µs)",
+ setting_type=SettingType.RANGE,
+ description="Manual exposure time in microseconds",
+ min_value=1,
+ max_value=1000000,
+ group="Exposure",
+ runtime_changeable=True,
+ controlled_by="auto_exposure",
+ ),
+ SettingMetadata(
+ name="gain",
+ display_name="Gain",
+ setting_type=SettingType.RANGE,
+ description="Sensor gain",
+ min_value=100,
+ max_value=300,
+ group="Exposure",
+ runtime_changeable=True,
+ controlled_by="auto_exposure",
+ ),
+ SettingMetadata(
+ name="dfc_enable",
+ display_name="Enable",
+ setting_type=SettingType.BOOL,
+ description="Enable dark field correction (must capture or import DFC data first)",
+ group="Dark Field Correction",
+ runtime_changeable=True,
+ controlled_by="_dfc_initialized",
+ controlled_when=False,
+ ),
+ SettingMetadata(
+ name="dfc_capture",
+ display_name="Capture",
+ setting_type=SettingType.BUTTON,
+ description="Capture dark field correction frames",
+ group="Dark Field Correction",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="dfc_import",
+ display_name="Import",
+ setting_type=SettingType.FILE_PICKER_BUTTON,
+ description="Import dark field correction from file",
+ group="Dark Field Correction",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="dfc_export",
+ display_name="Export",
+ setting_type=SettingType.FILE_PICKER_BUTTON,
+ description="Export dark field correction to file",
+ group="Dark Field Correction",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="dfc_quantity",
+ display_name="Quantity",
+ setting_type=SettingType.NUMBER_PICKER,
+ description="Number of frames to average for dark field correction",
+ min_value=1,
+ max_value=255,
+ group="Dark Field Correction",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="temp",
+ display_name="Color Temperature",
+ setting_type=SettingType.RANGE,
+ description="White balance temperature in Kelvin",
+ min_value=2000,
+ max_value=15000,
+ group="White Balance",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="tint",
+ display_name="Tint",
+ setting_type=SettingType.RANGE,
+ description="White balance tint adjustment",
+ min_value=200,
+ max_value=2500,
+ group="White Balance",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="hue",
+ display_name="Hue",
+ setting_type=SettingType.RANGE,
+ description="Color hue adjustment",
+ min_value=-180,
+ max_value=180,
+ group="Color",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="saturation",
+ display_name="Saturation",
+ setting_type=SettingType.RANGE,
+ description="Color saturation",
+ min_value=0,
+ max_value=255,
+ group="Color",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="brightness",
+ display_name="Brightness",
+ setting_type=SettingType.RANGE,
+ description="Image brightness adjustment",
+ min_value=-64,
+ max_value=64,
+ group="Color",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="contrast",
+ display_name="Contrast",
+ setting_type=SettingType.RANGE,
+ description="Image contrast adjustment",
+ min_value=-100,
+ max_value=100,
+ group="Color",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="gamma",
+ display_name="Gamma",
+ setting_type=SettingType.RANGE,
+ description="Gamma correction",
+ min_value=0,
+ max_value=180,
+ group="Color",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="level_range_low",
+ display_name="Black Point",
+ setting_type=SettingType.RGBA_LEVEL,
+ description="Output level for darkest input values",
+ group="Levels",
+ runtime_changeable=True,
+ ),
+ SettingMetadata(
+ name="level_range_high",
+ display_name="White Point",
+ setting_type=SettingType.RGBA_LEVEL,
+ description="Output level for brightest input values",
+ group="Levels",
+ runtime_changeable=True,
+ ),
+ ]
+
+ def _get_metadata_map(self) -> dict[str, SettingMetadata]:
+ return {m.name: m for m in self.get_metadata()}
+
+ def _validate_range(self, name: str, value: int) -> None:
+ meta = self._get_metadata_map().get(name)
+ if meta and meta.setting_type == SettingType.RANGE:
+ if not (meta.min_value <= value <= meta.max_value):
+ raise ValueError(
+ f"{name} must be in [{meta.min_value}, {meta.max_value}], got {value}"
+ )
+
+ # ------------------------------------------------------------------
+ # Live-value protocol
+ # ------------------------------------------------------------------
+
+ def get_live_values(self) -> dict[str, int]:
+ """Return live hardware exposure_time and gain while auto_exposure is on."""
+ if not self.auto_exposure:
+ return {}
+ if not (self._camera and hasattr(self._camera, '_hcam')):
+ return {}
+ try:
+ hcam = self._camera._hcam
+ return {
+ "exposure_time": hcam.get_ExpoTime(),
+ "gain": hcam.get_ExpoAGain(),
+ }
+ except Exception as e:
+ error(f"Failed to read live exposure values: {e}")
+ return {}
+
+ def on_controller_disabled(self, controller_name: str) -> None:
+ """Flush live exposure_time / gain into stored settings when auto_exposure turns off."""
+ if controller_name != "auto_exposure":
+ super().on_controller_disabled(controller_name)
+ return
+
+ if not (self._camera and hasattr(self._camera, '_hcam')):
+ return
+ try:
+ hcam = self._camera._hcam
+ self.exposure_time = hcam.get_ExpoTime()
+ self.gain = hcam.get_ExpoAGain()
+ debug(
+ f"Flushed auto-exposure values: exposure_time={self.exposure_time}, gain={self.gain}"
+ )
+ except Exception as e:
+ error(f"Failed to flush live exposure values: {e}")
+
+ # ------------------------------------------------------------------
+
+ def set_auto_exposure(self, enabled: bool) -> None:
+ if not enabled and self.auto_exposure:
+ # Flush hardware values before turning off so stored settings are up-to-date.
+ self.on_controller_disabled("auto_exposure")
+ self.auto_exposure = enabled
+ if self._camera and hasattr(self._camera, '_hcam'):
+ self._camera._hcam.put_AutoExpoEnable(1 if enabled else 0)
+
+ def set_exposure(self, value: int) -> None:
+ self._validate_range("exposure", value)
+ self.exposure = value
+ if self._camera and hasattr(self._camera, '_hcam'):
+ self._camera._hcam.put_AutoExpoTarget(value)
+
+ def set_exposure_time(self, time_us: int) -> bool:
+ self._validate_range("exposure_time", time_us)
+ self.exposure_time = time_us
+ if self._camera and hasattr(self._camera, '_hcam'):
+ self._camera._hcam.put_ExpoTime(time_us)
+ return True
+
+ def set_gain(self, gain: int) -> None:
+ self._validate_range("gain", gain)
+ self.gain = gain
+ if self._camera and hasattr(self._camera, '_hcam'):
+ self._camera._hcam.put_ExpoAGain(gain)
+
+ def set_temp(self, value: int) -> None:
+ self._validate_range("temp", value)
+ self.temp = value
+ if self._camera and hasattr(self._camera, '_hcam'):
+ self._camera._hcam.put_TempTint(value, self.tint)
+
+ def set_tint(self, value: int) -> None:
+ self._validate_range("tint", value)
+ self.tint = value
+ if self._camera and hasattr(self._camera, '_hcam'):
+ self._camera._hcam.put_TempTint(self.temp, value)
+
+ def set_temp_tint(self, temp: int, tint: int) -> None:
+ self._validate_range("temp", temp)
+ self._validate_range("tint", tint)
+ self.temp = temp
+ self.tint = tint
+ if self._camera and hasattr(self._camera, '_hcam'):
+ self._camera._hcam.put_TempTint(temp, tint)
+
+ def set_hue(self, value: int) -> None:
+ self._validate_range("hue", value)
+ self.hue = value
+ if self._camera and hasattr(self._camera, '_hcam'):
+ self._camera._hcam.put_Hue(value)
+
+ def set_saturation(self, value: int) -> None:
+ self._validate_range("saturation", value)
+ self.saturation = value
+ if self._camera and hasattr(self._camera, '_hcam'):
+ self._camera._hcam.put_Saturation(value)
+
+ def set_brightness(self, value: int) -> None:
+ self._validate_range("brightness", value)
+ self.brightness = value
+ if self._camera and hasattr(self._camera, '_hcam'):
+ self._camera._hcam.put_Brightness(value)
+
+ def set_contrast(self, value: int) -> None:
+ self._validate_range("contrast", value)
+ self.contrast = value
+ if self._camera and hasattr(self._camera, '_hcam'):
+ self._camera._hcam.put_Contrast(value)
+
+ def set_gamma(self, value: int) -> None:
+ self._validate_range("gamma", value)
+ self.gamma = value
+ if self._camera and hasattr(self._camera, '_hcam'):
+ self._camera._hcam.put_Gamma(value)
+
+ def set_level_range(self, low: RGBALevel, high: RGBALevel) -> None:
+ low.validate()
+ high.validate()
+ self.level_range_low = low
+ self.level_range_high = high
+ if self._camera:
+ self._camera._hcam.put_LevelRange(
+ (low.r, low.g, low.b, low.a),
+ (high.r, high.g, high.b, high.a)
+ )
+
+ def set_level_range_low(self, low: RGBALevel) -> None:
+ low.validate()
+ if self._camera:
+ high = self.level_range_high
+ self._camera._hcam.put_LevelRange(
+ (low.r, low.g, low.b, low.a),
+ (high.r, high.g, high.b, high.a)
+ )
+ self.level_range_low = low
+
+ def set_level_range_high(self, high: RGBALevel) -> None:
+ high.validate()
+ if self._camera:
+ low = self.level_range_low
+ self._camera._hcam.put_LevelRange(
+ (low.r, low.g, low.b, low.a),
+ (high.r, high.g, high.b, high.a)
+ )
+ self.level_range_high = high
+
+ def set_rotate(self, value: int | str, index: int | None = None) -> bool:
+ """
+ Set the camera image rotation (0, 90, 180, 270 degrees clockwise).
+
+ AMCAM_OPTION_ROTATE cannot be changed while the camera is running, so
+ this method follows the same stop/restart pattern as set_preview_resolution.
+ The dropdown supplies both the string label (e.g. "90") and the index of
+ that label in the choices list. When ``index`` is provided it is used
+ directly; otherwise it is derived from ``value``.
+ """
+ valid_degrees = [0, 90, 180, 270]
+
+ if index is not None:
+ if not (0 <= index < len(valid_degrees)):
+ error(f"Invalid rotation index: {index}. Valid range: 0-{len(valid_degrees) - 1}")
+ return False
+ degrees = valid_degrees[index]
+ else:
+ try:
+ degrees = int(value)
+ except (ValueError, TypeError):
+ error(f"Invalid rotation value: {value!r}. Must be one of {valid_degrees}")
+ return False
+ if degrees not in valid_degrees:
+ error(f"Invalid rotation value: {degrees}. Must be one of {valid_degrees}")
+ return False
+
+ try:
+ self.rotate = degrees
+
+ if not (self._camera and hasattr(self._camera, '_hcam')):
+ return True
+
+ camera_was_open = self._camera.is_open
+ saved_callback = self._camera._callback
+ saved_context = self._camera._callback_context
+
+ if camera_was_open:
+ debug("Camera is open, stopping to set rotation")
+ self._camera.stop_capture()
+
+ amcam = self._camera._get_sdk()
+ self._camera._hcam.put_Option(amcam.AMCAM_OPTION_ROTATE, degrees)
+
+ if camera_was_open:
+ debug("Restarting camera after rotation change")
+ self._camera.start_capture(saved_callback, saved_context)
+
+ debug(f"Successfully changed rotation to {degrees} degrees")
+ return True
+ except Exception as e:
+ error(f"Failed to set rotation: {e}")
+ return False
+
+ def set_hflip(self, enabled: bool) -> None:
+ """Flip the image horizontally."""
+ self.hflip = enabled
+ if self._camera and hasattr(self._camera, '_hcam'):
+ self._camera._hcam.put_HFlip(1 if enabled else 0)
+
+ def set_vflip(self, enabled: bool) -> None:
+ """Flip the image vertically."""
+ self.vflip = enabled
+ if self._camera and hasattr(self._camera, '_hcam'):
+ self._camera._hcam.put_VFlip(1 if enabled else 0)
+
+ # Dark Field Correction methods
+ def set_dfc_enable(self, enabled: bool) -> None:
+ """Enable or disable dark field correction."""
+ self.dfc_enable = enabled
+ if self._camera and hasattr(self._camera, '_hcam'):
+ try:
+ amcam = self._camera._get_sdk()
+ self._camera._hcam.put_Option(amcam.AMCAM_OPTION_DFC, 1 if enabled else 0)
+ debug(f"Set DFC enable to {enabled}")
+ except Exception as e:
+ error(f"Failed to set DFC enable: {e}")
+
+ def set_dfc_capture(self) -> None:
+ """Capture dark field correction frames and save to config directory."""
+ if self._camera and hasattr(self._camera, '_hcam'):
+ try:
+ # Save whether DFC was enabled before capture
+ dfc_was_enabled = self.dfc_enable
+
+ # Reset initialized flag when starting new capture
+ self._dfc_initialized = False
+
+ # Reset DFC to clear any existing data before capturing new frames
+ amcam = self._camera._get_sdk()
+ self._camera._hcam.put_Option(amcam.AMCAM_OPTION_DFC, -1)
+ info("Reset DFC before capturing new frames")
+
+ # Set the average number
+ self._camera._hcam.put_Option(amcam.AMCAM_OPTION_DFC, 0xff000000 | self.dfc_quantity)
+
+ info(f"Starting DFC capture with {self.dfc_quantity} frames...")
+
+ # Store completion handler on camera
+ logged_sequences = set()
+
+ def on_dfc_event():
+ """Called when DFC event fires - check if we're done"""
+ try:
+ # Query the current DFC state
+ dfc_val = self._camera._hcam.get_Option(amcam.AMCAM_OPTION_DFC)
+ dfc_state = dfc_val & 0xff # 0=disabled, 1=enabled, 2=inited
+ dfc_sequence = (dfc_val & 0xff00) >> 8 # Current sequence number
+
+ # Log frame capture if we haven't logged this sequence yet
+ if dfc_sequence > 0 and dfc_sequence not in logged_sequences:
+ info(f"DFC frame {dfc_sequence}/{self.dfc_quantity} captured")
+ logged_sequences.add(dfc_sequence)
+
+ # Check if DFC is initialized (state == 2) - means all frames captured
+ if dfc_state == 2:
+ info(f"All {self.dfc_quantity} DFC frames captured and processed")
+
+ # Generate timestamped filename
+ filename = f"dark_field_correction.dfc"
+
+ # Get config directory
+ config_dir = Path("./config/cameras") / self._camera.model
+ config_dir.mkdir(parents=True, exist_ok=True)
+
+ filepath = config_dir / filename
+
+ # Export the captured DFC to the file
+ self._camera._hcam.DfcExport(str(filepath))
+
+ # Store the filepath
+ self.dfc_filepath = str(filepath)
+ self._dfc_initialized = True
+
+ info(f"DFC successfully exported to {filepath}")
+
+ # Re-enable DFC if it was enabled before capture
+ if dfc_was_enabled:
+ self._camera._hcam.put_Option(amcam.AMCAM_OPTION_DFC, 1)
+ self.dfc_enable = True
+ info("Re-enabled DFC after capture completion")
+
+ # Clean up - remove the callback
+ self._camera._dfc_completion_callback = None
+
+ # Notify UI that _dfc_initialized has changed
+ # This will enable the dfc_enable checkbox in the UI
+ if self._ui_update_callback:
+ try:
+ debug(f"Calling UI update callback for _dfc_initialized=True")
+ self._ui_update_callback('_dfc_initialized', True)
+ debug(f"UI update callback completed successfully")
+ except Exception as e:
+ error(f"Failed to notify UI of DFC initialization: {e}")
+ else:
+ warning("No UI update callback registered - UI won't be notified of DFC initialization")
+
+ except Exception as e:
+ error(f"Failed to process DFC completion: {e}")
+ # Clean up on error
+ self._camera._dfc_completion_callback = None
+
+ # Register the callback
+ self._camera._dfc_completion_callback = on_dfc_event
+
+ # Trigger the capture (async - will complete via events)
+ self._camera._hcam.DfcOnce()
+
+ info("DFC capture started (will complete asynchronously)")
+
+ except Exception as e:
+ error(f"Failed to start DFC capture: {e}")
+ raise
+
+ def set_dfc_import(self, filepath: str) -> None:
+ """Import dark field correction from file."""
+ if self._camera and hasattr(self._camera, '_hcam'):
+ try:
+ self._camera._hcam.DfcImport(filepath)
+ self.dfc_filepath = filepath
+ self._dfc_initialized = True
+ info(f"Imported DFC from {filepath} - DFC initialized")
+
+ # Notify UI that _dfc_initialized has changed
+ if self._ui_update_callback:
+ try:
+ self._ui_update_callback('_dfc_initialized', True)
+ except Exception as e:
+ error(f"Failed to notify UI of DFC initialization: {e}")
+ except Exception as e:
+ error(f"Failed to import DFC: {e}")
+ raise # Re-raise so UI can show error
+
+ def set_dfc_export(self, filepath: str) -> None:
+ """Export dark field correction to file."""
+ if self._camera and hasattr(self._camera, '_hcam'):
+ try:
+ self._camera._hcam.DfcExport(filepath)
+ info(f"Exported DFC to {filepath}")
+ except Exception as e:
+ error(f"Failed to export DFC: {e}")
+
+ def set_dfc_quantity(self, value: int) -> None:
+ """Set the number of frames to average for dark field correction."""
+ if not (1 <= value <= 255):
+ error(f"DFC quantity must be between 1 and 255, got {value}")
+ return
+ self.dfc_quantity = value
+ debug(f"Set DFC quantity to {value}")
+
+ def get_resolutions(self) -> list[CameraResolution]:
+ if self._camera is None or not hasattr(self._camera, '_hcam'):
+ return []
+
+ from camera.cameras.base_camera import CameraResolution
+
+ try:
+ resolutions = []
+ hcam = self._camera._hcam
+ count = hcam.ResolutionNumber()
+ for i in range(count):
+ width, height = hcam.get_Resolution(i)
+ resolutions.append(CameraResolution(width=width, height=height))
+ return resolutions
+ except Exception as e:
+ error(f"Failed to get resolutions: {e}")
+ return []
+
+ def get_current_resolution(self) -> tuple[int, int, int]:
+ if self._camera is None or not hasattr(self._camera, '_hcam'):
+ return (0, 0, 0)
+
+ try:
+ hcam = self._camera._hcam
+ index = hcam.get_eSize()
+ width, height = hcam.get_Size()
+ return (index, width, height)
+ except Exception as e:
+ error(f"Failed to get current resolution: {e}")
+ return (0, 0, 0)
+
+ def get_output_dimensions(self) -> tuple[int, int]:
+ """
+ Return the final (width, height) of frames delivered by the SDK.
+ """
+ if self._camera is None or not hasattr(self._camera, '_hcam'):
+ return (0, 0)
+ try:
+ width, height = self._camera._hcam.get_FinalSize()
+ return (width, height)
+ except Exception:
+ pass
+ # Fallback: raw sensor resolution (no rotation compensation)
+ _, width, height = self.get_current_resolution()
+ return (width, height)
+
+ def set_preview_resolution(self, value: str, index: int | None = None) -> bool:
+ """
+ Set camera preview resolution. Requires camera restart.
+ """
+ try:
+ resolutions = self.get_resolutions()
+ choices = [f"{r.width}x{r.height}" for r in resolutions]
+
+ if index is None:
+ if value not in choices:
+ error(f"Invalid resolution value: {value!r}. Available: {choices}")
+ return False
+ index = choices.index(value)
+ else:
+ if not (0 <= index < len(choices)):
+ error(f"Invalid resolution index: {index}. Valid range: 0-{len(choices) - 1}")
+ return False
+ value = choices[index]
+
+ camera_was_open = self._camera.is_open
+ saved_callback = self._camera._callback
+ saved_context = self._camera._callback_context
+
+ if camera_was_open:
+ debug("Camera is open, stopping to set resolution")
+ self._camera.stop_capture()
+
+ self._camera._hcam.put_eSize(index)
+ self.preview_resolution = value
+
+ if camera_was_open:
+ debug("Restarting camera after resolution change")
+ self._camera.start_capture(saved_callback, saved_context)
+
+ debug(f"Successfully changed preview resolution to {value} (index {index})")
+ return True
+ except Exception as e:
+ error(f"Failed to set resolution: {e}")
+ return False
+
+ def set_still_resolution(self, value: str, index: int | None = None) -> bool:
+ """
+ Set the still-capture resolution.
+
+ The dropdown supplies both the string label (e.g. "2592x1944") and the index
+ of that label in the choices list. When ``index`` is provided it is used
+ directly; otherwise it is derived from ``value``.
+ """
+ try:
+ still_resolutions = self.get_still_resolutions()
+ choices = [f"{r.width}x{r.height}" for r in still_resolutions]
+
+ if not choices:
+ self.still_resolution = value
+ return True
+
+ if index is None:
+ if value not in choices:
+ error(f"Invalid still resolution value: {value!r}. Available: {choices}")
+ return False
+ index = choices.index(value)
+ else:
+ if not (0 <= index < len(choices)):
+ error(f"Invalid still resolution index: {index}. Valid range: 0-{len(choices) - 1}")
+ return False
+ value = choices[index]
+
+ self.still_resolution = value
+ debug(f"Successfully changed still resolution to {value} (index {index})")
+ return True
+ except Exception as e:
+ error(f"Failed to set still resolution: {e}")
+ return False
+
+ def get_still_resolutions(self) -> list[CameraResolution]:
+ if self._camera is None or not hasattr(self._camera, '_hcam'):
+ return []
+
+ from camera.cameras.base_camera import CameraResolution
+
+ try:
+ resolutions = []
+ hcam = self._camera._hcam
+ count = hcam.StillResolutionNumber()
+ for i in range(count):
+ width, height = hcam.get_StillResolution(i)
+ resolutions.append(CameraResolution(width=width, height=height))
+ return resolutions
+ except Exception as e:
+ error(f"Failed to get still resolutions: {e}")
+ return []
+
+ def get_exposure_time(self) -> int:
+ if self._camera is None or not hasattr(self._camera, '_hcam'):
+ return self.exposure_time
+
+ try:
+ return self._camera._hcam.get_ExpoTime()
+ except Exception as e:
+ error(f"Failed to get exposure time: {e}")
+ return self.exposure_time
+
+ def apply_to_camera(self, camera: BaseCamera) -> None:
+ self._camera = camera
+ info(f"Applying settings to camera {camera.model}")
+
+ try:
+ if self.preview_resolution:
+ self.set_preview_resolution(self.preview_resolution)
+ if self.still_resolution:
+ self.set_still_resolution(self.still_resolution)
+ self.set_auto_exposure(self.auto_exposure)
+ self.set_exposure(self.exposure)
+ self.set_exposure_time(self.exposure_time)
+ self.set_gain(self.gain)
+
+ self.set_temp_tint(self.temp, self.tint)
+
+ self.set_hue(self.hue)
+ self.set_saturation(self.saturation)
+ self.set_brightness(self.brightness)
+ self.set_contrast(self.contrast)
+ self.set_gamma(self.gamma)
+
+ self.set_level_range(self.level_range_low, self.level_range_high)
+
+ self.set_rotate(self.rotate)
+ self.set_hflip(self.hflip)
+ self.set_vflip(self.vflip)
+
+ # Dark Field Correction
+ # Load DFC file if filepath is set and file exists
+ if self.dfc_filepath:
+ dfc_path = Path(self.dfc_filepath)
+ if dfc_path.exists():
+ try:
+ self._camera._hcam.DfcImport(str(dfc_path))
+ self._dfc_initialized = True
+ info(f"Loaded DFC from {dfc_path}")
+ except Exception as e:
+ error(f"Failed to load DFC from {dfc_path}: {e}")
+ self._dfc_initialized = False
+ self.dfc_filepath = ""
+ else:
+ warning(f"DFC file not found: {dfc_path}")
+ self._dfc_initialized = False
+ self.dfc_filepath = ""
+ else:
+ self._dfc_initialized = False
+
+ if self.dfc_enable and not self._dfc_initialized:
+ warning("Cannot enable DFC: no DFC data available. Disabling DFC.")
+ self.dfc_enable = False
+
+ self.set_dfc_quantity(self.dfc_quantity)
+ self.set_dfc_enable(self.dfc_enable)
+
+ debug("Successfully applied all settings to camera")
+ except Exception as e:
+ exception(f"Failed to apply settings to camera: {e}")
+ raise
+
+ def refresh_from_camera(self, camera: BaseCamera) -> None:
+ self._camera = camera
+ info(f"Refreshing settings from camera {camera.model}")
+
+ if not hasattr(camera, '_hcam') or camera._hcam is None:
+ error("Camera not available for refresh")
+ return
+
+ hcam = camera._hcam
+
+ try:
+ self.auto_exposure = bool(hcam.get_AutoExpoEnable())
+ self.exposure = hcam.get_AutoExpoTarget()
+ self.exposure_time = hcam.get_ExpoTime()
+ self.gain = hcam.get_ExpoAGain()
+ temp, tint = hcam.get_TempTint()
+ self.temp = temp
+ self.tint = tint
+
+ self.hue = hcam.get_Hue()
+ self.saturation = hcam.get_Saturation()
+ self.brightness = hcam.get_Brightness()
+ self.contrast = hcam.get_Contrast()
+ self.gamma = hcam.get_Gamma()
+
+ low, high = hcam.get_LevelRange()
+ self.level_range_low = RGBALevel(r=low[0], g=low[1], b=low[2], a=low[3])
+ self.level_range_high = RGBALevel(r=high[0], g=high[1], b=high[2], a=high[3])
+
+ index = hcam.get_eSize()
+ resolutions = self.get_resolutions()
+ if 0 <= index < len(resolutions):
+ r = resolutions[index]
+ self.preview_resolution = f"{r.width}x{r.height}"
+ else:
+ self.preview_resolution = ""
+
+ still_resolutions = self.get_still_resolutions()
+ if still_resolutions:
+ r = still_resolutions[0]
+ self.still_resolution = f"{r.width}x{r.height}"
+
+ rotate_raw = hcam.get_Option(camera._get_sdk().AMCAM_OPTION_ROTATE)
+ self.rotate = rotate_raw if rotate_raw in (0, 90, 180, 270) else 0
+ self.hflip = bool(hcam.get_HFlip())
+ self.vflip = bool(hcam.get_VFlip())
+
+ # Dark Field Correction
+ amcam = camera._get_sdk()
+ dfc_val = hcam.get_Option(amcam.AMCAM_OPTION_DFC)
+ dfc_state = dfc_val & 0xff
+ self.dfc_enable = (dfc_state == 1) # 0=disabled, 1=enabled, 2=inited
+ self._dfc_initialized = (dfc_state >= 1)
+ dfc_avg = (dfc_val & 0xff0000) >> 16
+ if dfc_avg > 0:
+ self.dfc_quantity = dfc_avg
+
+ info("Successfully refreshed all settings from camera")
+ except Exception as e:
+ exception(f"Failed to refresh settings from camera: {e}")
\ No newline at end of file
diff --git a/camera/settings/camera_settings.py b/camera/settings/camera_settings.py
new file mode 100644
index 0000000..e044f03
--- /dev/null
+++ b/camera/settings/camera_settings.py
@@ -0,0 +1,281 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from enum import Enum
+from pathlib import Path
+from typing import Any, NamedTuple, TYPE_CHECKING
+
+from common.generic_config import ConfigManager
+from common.logger import info, debug, exception, error
+
+from common.setting_types import FileFormat, RGBALevel, SettingType, SettingMetadata
+
+if TYPE_CHECKING:
+ from camera.cameras.base_camera import BaseCamera, CameraResolution
+
+@dataclass
+class CameraSettings(ABC):
+ """
+ Abstract base camera settings class with validation and hardware manipulation.
+
+ Live-value protocol (for settings driven by automatic hardware control):
+ - Mark controlled fields with ``controlled_by=""`` in
+ SettingMetadata.
+ - Override ``get_live_values()`` to return {field_name: current_hw_value} for
+ all fields currently being driven by hardware. Return an empty dict when no
+ field is under hardware control.
+ - Override ``on_controller_disabled()`` if you need custom flush logic; the
+ default calls ``get_live_values()`` and writes each value to self.
+ - The GUI polls ``get_live_values()`` on a timer, updates display widgets only,
+ and calls ``on_controller_disabled()`` when the controlling boolean turns off.
+ """
+
+ version: str
+ auto_exposure: bool
+ exposure: int
+ exposure_time: int
+ preview_resolution: str
+ still_resolution: str
+ tint: int
+ contrast: int
+ hue: int
+ saturation: int
+ brightness: int
+ gamma: int
+ level_range_low: RGBALevel
+ level_range_high: RGBALevel
+ fformat: FileFormat
+
+ _camera: BaseCamera | None = field(default=None, repr=False, compare=False)
+ _file_formats: tuple[str] = (f.value for f in FileFormat)
+
+ def __post_init__(self) -> None:
+ if isinstance(self.fformat, str):
+ self.fformat = FileFormat(self.fformat)
+
+ if isinstance(self.level_range_low, (tuple, list)):
+ self.level_range_low = RGBALevel(*self.level_range_low)
+ if isinstance(self.level_range_high, (tuple, list)):
+ self.level_range_high = RGBALevel(*self.level_range_high)
+
+ def validate(self) -> None:
+ metadata_list = self.get_metadata()
+ metadata_by_name = {m.name: m for m in metadata_list}
+
+ for name, meta in metadata_by_name.items():
+ if meta.setting_type == SettingType.RANGE:
+ value = getattr(self, name, None)
+ if value is not None and meta.min_value is not None and meta.max_value is not None:
+ if not (meta.min_value <= value <= meta.max_value):
+ raise ValueError(
+ f"{name} = {value} is outside valid range [{meta.min_value}, {meta.max_value}]"
+ )
+
+ try:
+ self.level_range_low.validate()
+ except ValueError as e:
+ raise ValueError(f"level_range_low invalid: {e}") from e
+
+ try:
+ self.level_range_high.validate()
+ except ValueError as e:
+ raise ValueError(f"level_range_high invalid: {e}") from e
+
+ if not isinstance(self.fformat, FileFormat):
+ raise ValueError(f"fformat must be a FileFormat enum, got {type(self.fformat)}")
+
+ @abstractmethod
+ def get_metadata(cls) -> list[SettingMetadata]:
+ pass
+
+ # ------------------------------------------------------------------
+ # Live-value protocol
+ # ------------------------------------------------------------------
+
+ def get_live_values(self) -> dict[str, int]:
+ """Return the current hardware values for any fields under automatic control.
+
+ Returns a mapping of ``{field_name: current_hardware_value}`` for fields
+ whose controlling boolean is currently True. Return an empty dict when no
+ field is actively being driven by hardware.
+
+ The GUI polls this on a short interval and updates display widgets without
+ writing back to the stored settings object.
+ """
+ return {}
+
+ def on_controller_disabled(self, controller_name: str) -> None:
+ """Flush current hardware values for fields controlled by *controller_name*.
+
+ Called by the GUI immediately after the user turns off a controlling
+ boolean (e.g. ``auto_exposure``). The default implementation reads
+ ``get_live_values()`` and writes any value whose metadata ``controlled_by``
+ matches *controller_name* back into self, so that the stored settings
+ reflect the actual hardware state the moment control was released.
+
+ Subclasses may override for clamping, extra register reads, etc.
+ """
+ live = self.get_live_values()
+ metadata_map = {m.name: m for m in self.get_metadata()}
+ for field_name, value in live.items():
+ meta = metadata_map.get(field_name)
+ if (
+ meta
+ and meta.controlled_by == controller_name
+ and meta.controlled_when # only flush live-value fields (controlled_when=True)
+ and hasattr(self, field_name)
+ ):
+ setattr(self, field_name, value)
+ debug(f"Flushed live value {field_name}={value} after {controller_name} disabled")
+
+ # ------------------------------------------------------------------
+
+ def apply_to_camera(self, camera: BaseCamera) -> None:
+ self._camera = camera
+ info(f"Applying settings to camera {camera.model}")
+
+ try:
+ self.set_auto_exposure(self.auto_exposure)
+ self.set_exposure(self.exposure)
+ self.set_tint(self.tint)
+ self.set_contrast(self.contrast)
+ self.set_hue(self.hue)
+ self.set_saturation(self.saturation)
+ self.set_brightness(self.brightness)
+ self.set_gamma(self.gamma)
+ self.set_level_range(self.level_range_low, self.level_range_high)
+
+ debug("Successfully applied all settings to camera")
+
+ except Exception as e:
+ exception(f"Failed to apply settings to camera: {e}")
+ raise
+
+ @abstractmethod
+ def set_auto_exposure(self, enabled: bool) -> None:
+ pass
+
+ @abstractmethod
+ def set_exposure(self, value: int) -> None:
+ pass
+
+ @abstractmethod
+ def set_tint(self, value: int) -> None:
+ pass
+
+ @abstractmethod
+ def set_contrast(self, value: int) -> None:
+ pass
+
+ @abstractmethod
+ def set_hue(self, value: int) -> None:
+ pass
+
+ @abstractmethod
+ def set_saturation(self, value: int) -> None:
+ pass
+
+ @abstractmethod
+ def set_brightness(self, value: int) -> None:
+ pass
+
+ @abstractmethod
+ def set_gamma(self, value: int) -> None:
+ pass
+
+ @abstractmethod
+ def set_level_range(self, low: RGBALevel, high: RGBALevel) -> None:
+ pass
+
+ def set_fformat(self, value: str, index: int | None = None) -> None:
+ try:
+ format_enum = FileFormat(value)
+ self.fformat = format_enum
+ except ValueError as e:
+ raise ValueError(f"Invalid file format: {value}. Must be one of: png, tiff, jpeg") from e
+
+ @abstractmethod
+ def get_resolutions(self) -> list['CameraResolution']:
+ pass
+
+ @abstractmethod
+ def get_current_resolution(self) -> tuple[int, int, int]:
+ pass
+
+ @abstractmethod
+ def set_preview_resolution(self, value: str, index: int | None = None) -> bool:
+ pass
+
+ @abstractmethod
+ def set_still_resolution(self, value: str, index: int | None = None) -> bool:
+ pass
+
+ def get_still_resolutions(self) -> list['CameraResolution']:
+ return []
+
+ @abstractmethod
+ def get_exposure_time(self) -> int:
+ pass
+
+ @abstractmethod
+ def set_exposure_time(self, time_us: int) -> bool:
+ pass
+
+ @abstractmethod
+ def refresh_from_camera(self, camera: BaseCamera) -> None:
+ pass
+
+
+class CameraSettingsManager(ConfigManager[CameraSettings]):
+ """
+ Settings manager for camera configurations.
+
+ Manages camera-specific settings directories and handles serialization
+ of camera settings with custom types (RGBALevel, RGBGain, FileFormat).
+ """
+
+ def __init__(self, model: str, settings_class: type[CameraSettings]):
+ self.model = model
+ self.settings_class = settings_class
+
+ root_dir = Path("./config/cameras") / model
+
+ super().__init__(
+ config_type=f"camera_settings_{model}",
+ root_dir=root_dir
+ )
+
+ debug(f"Initialized CameraSettingsManager for model '{model}' at {self.root_dir}")
+
+ def from_dict(self, data: dict[str, Any]) -> CameraSettings:
+ processed_data = data.copy()
+
+ if 'level_range_low' in processed_data and isinstance(processed_data['level_range_low'], dict):
+ processed_data['level_range_low'] = RGBALevel(**processed_data['level_range_low'])
+
+ if 'level_range_high' in processed_data and isinstance(processed_data['level_range_high'], dict):
+ processed_data['level_range_high'] = RGBALevel(**processed_data['level_range_high'])
+
+ settings = self.settings_class(**processed_data)
+ return settings
+
+ def to_dict(self, settings: CameraSettings) -> dict[str, Any]:
+ data = {}
+
+ for field_name in settings.__dataclass_fields__:
+ if field_name.startswith('_'):
+ continue
+
+ value = getattr(settings, field_name)
+
+ if isinstance(value, RGBALevel):
+ data[field_name] = value._asdict()
+ elif isinstance(value, FileFormat):
+ data[field_name] = value.value
+ elif isinstance(value, Enum):
+ data[field_name] = value.value
+ else:
+ data[field_name] = value
+
+ return data
\ No newline at end of file
diff --git a/camera/threaded_camera.py b/camera/threaded_camera.py
new file mode 100644
index 0000000..013a094
--- /dev/null
+++ b/camera/threaded_camera.py
@@ -0,0 +1,394 @@
+"""
+Threaded camera wrapper using dynamic attribute access.
+Provides full IDE type hinting by transparently proxying to the underlying camera.
+"""
+
+from typing import Optional, Callable, Any, TypeVar, Generic
+from queue import Queue, Empty
+from threading import Thread, Event, Lock
+from functools import wraps
+
+from PySide6.QtCore import QObject, Signal
+
+from camera.cameras.base_camera import BaseCamera
+from common.logger import info, error, warning, debug, exception
+
+T = TypeVar('T', bound=BaseCamera)
+
+
+class AsyncResult:
+ """
+ Represents the result of an async operation.
+ Can be used with callbacks or awaited in the future.
+ """
+ def __init__(self):
+ self._event = Event()
+ self._success = False
+ self._result = None
+
+ def set_result(self, success: bool, result: Any):
+ self._success = success
+ self._result = result
+ self._event.set()
+
+ def wait(self, timeout: Optional[float] = None) -> tuple[bool, Any]:
+ """Wait for result (blocking)"""
+ self._event.wait(timeout)
+ return self._success, self._result
+
+
+class CameraCommand:
+ """Command to execute on camera thread"""
+ def __init__(
+ self,
+ method_name: str,
+ args: tuple,
+ kwargs: dict,
+ completion_callback: Optional[Callable] = None
+ ):
+ self.method_name = method_name
+ self.args = args
+ self.kwargs = kwargs
+ self.completion_callback = completion_callback
+ self.result = AsyncResult()
+
+
+class ShutdownCommand:
+ """Signal to shutdown the thread"""
+ pass
+
+
+class CameraThread(QObject):
+ """
+ Qt-aware camera thread that runs camera operations in background.
+
+ Signals:
+ operation_completed: Emitted when any operation completes (method_name, success, result)
+ error_occurred: Emitted when an error occurs (error_msg)
+ """
+
+ operation_completed = Signal(str, bool, object) # method_name, success, result
+ error_occurred = Signal(str) # error_msg
+
+ def __init__(self, camera: BaseCamera):
+ super().__init__()
+ self._camera = camera
+ self._command_queue = Queue()
+ self._thread = None
+ self._running = Event()
+ self._lock = Lock()
+
+ def start(self):
+ """Start the camera thread"""
+ if self._thread is not None and self._thread.is_alive():
+ warning("Camera thread already running")
+ return
+
+ self._running.set()
+ self._thread = Thread(target=self._run, daemon=True, name="CameraThread")
+ self._thread.start()
+ info("Camera thread started")
+
+ def stop(self, wait: bool = True):
+ """Stop the camera thread"""
+ if self._thread is None or not self._thread.is_alive():
+ return
+
+ info("Stopping camera thread")
+ self._running.clear()
+
+ # Clear any pending commands
+ pending_count = 0
+ try:
+ while True:
+ command = self._command_queue.get_nowait()
+ if not isinstance(command, ShutdownCommand):
+ # Signal that this command won't be executed
+ command.result.set_result(False, None)
+ if command.completion_callback:
+ try:
+ command.completion_callback(False, None)
+ except:
+ pass
+ pending_count += 1
+ except Empty:
+ pass
+
+ if pending_count > 0:
+ info(f"Cancelled {pending_count} pending commands")
+
+ # Send shutdown command
+ self._command_queue.put(ShutdownCommand())
+
+ if wait and self._thread is not None:
+ # Wait longer for thread to finish processing
+ self._thread.join(timeout=3.0) # Reduced from 10s
+ if self._thread.is_alive():
+ warning("Camera thread did not stop within 3 seconds")
+ else:
+ info("Camera thread stopped successfully")
+
+ def execute(self, command: CameraCommand) -> AsyncResult:
+ """
+ Execute a command and return AsyncResult
+
+ Args:
+ command: The command to execute
+
+ Returns:
+ AsyncResult that can be waited on or ignored
+ """
+ self._command_queue.put(command)
+ return command.result
+
+ def _run(self):
+ """Main thread loop"""
+ debug("Camera thread running")
+
+ while self._running.is_set():
+ try:
+ # Get command with timeout
+ try:
+ command = self._command_queue.get(timeout=0.1)
+ except Empty:
+ continue
+
+ # Handle shutdown
+ if isinstance(command, ShutdownCommand):
+ debug("Received shutdown command")
+ break
+
+ # Check if we should still process (thread might be stopping)
+ if not self._running.is_set():
+ debug(f"Thread stopping, skipping command: {command.method_name}")
+ command.result.set_result(False, None)
+ if command.completion_callback:
+ try:
+ command.completion_callback(False, None)
+ except Exception as e:
+ exception(f"Error in completion callback: {e}")
+ continue
+
+ # Execute command
+ try:
+ success, result = self._execute_command(command)
+
+ # Set result
+ command.result.set_result(success, result)
+
+ # Emit signal
+ self.operation_completed.emit(command.method_name, success, result)
+
+ # Call completion callback if provided
+ if command.completion_callback is not None:
+ try:
+ command.completion_callback(success, result)
+ except Exception as e:
+ exception(f"Error in completion callback: {e}")
+
+ except Exception as e:
+ exception(f"Error executing {command.method_name}")
+ error_msg = str(e)
+
+ command.result.set_result(False, None)
+ self.operation_completed.emit(command.method_name, False, None)
+ self.error_occurred.emit(error_msg)
+
+ if command.completion_callback is not None:
+ try:
+ command.completion_callback(False, None)
+ except Exception as cb_error:
+ exception(f"Error in completion callback: {cb_error}")
+
+ except Exception as e:
+ exception("Unexpected error in camera thread")
+ self.error_occurred.emit(str(e))
+
+ debug("Camera thread exiting")
+
+ def _execute_command(self, command: CameraCommand) -> tuple[bool, Any]:
+ """Execute a single command"""
+ with self._lock:
+ method = getattr(self._camera, command.method_name, None)
+ if method is None:
+ error(f"Method not found: {command.method_name}")
+ return False, None
+
+ try:
+ result = method(*command.args, **command.kwargs)
+
+ # If method returns bool, use that as success indicator
+ # Otherwise assume success
+ if isinstance(result, bool):
+ return result, None
+ else:
+ return True, result
+
+ except Exception as e:
+ exception(f"Error calling {command.method_name}")
+ raise
+
+
+class ThreadedCamera(Generic[T]):
+ """
+ Wrapper around BaseCamera that executes all operations in a background thread.
+
+ This class uses Python's __getattr__ magic method to transparently proxy
+ all method calls to the underlying camera, providing full IDE type hinting.
+
+ Usage:
+ # Create with type hint for full IDE support
+ base_camera = AmscopeCamera()
+ camera: AmscopeCamera = ThreadedCamera(base_camera)
+ camera.start_thread()
+
+ # Now you get full type hinting and autocomplete!
+ camera.set_white_balance(5000, 1000) # IDE knows this method exists
+ camera.auto_white_balance() # IDE autocompletes this
+
+ # All methods are async by default
+ camera.snap_image(0) # Returns immediately
+
+ # Use callbacks for chaining
+ camera.snap_image(0, on_complete=lambda s, r: print("Done!"))
+
+ # Or wait for result
+ success, result = camera.snap_image(0, wait=True)
+ """
+
+ def __init__(self, camera: T):
+ # Use object.__setattr__ to avoid triggering __setattr__
+ object.__setattr__(self, '_camera', camera)
+ object.__setattr__(self, '_thread', CameraThread(camera))
+ object.__setattr__(self, '_started', False)
+
+ def start_thread(self):
+ """Start the background thread"""
+ self._thread.start()
+ object.__setattr__(self, '_started', True)
+
+ def stop_thread(self, wait: bool = True):
+ """Stop the background thread"""
+ self._thread.stop(wait)
+ object.__setattr__(self, '_started', False)
+
+ @property
+ def operation_completed(self):
+ """Access to operation_completed signal"""
+ return self._thread.operation_completed
+
+ @property
+ def error_occurred(self):
+ """Access to error_occurred signal"""
+ return self._thread.error_occurred
+
+ @property
+ def underlying_camera(self) -> T:
+ """Get the underlying camera instance"""
+ return self._camera
+
+ def __getattr__(self, name: str):
+ """
+ Magic method that intercepts all attribute access.
+
+ This provides transparent proxying to the underlying camera while
+ running everything in the background thread.
+ """
+ # Get the attribute from underlying camera
+ attr = getattr(self._camera, name)
+
+ # If it's not callable, just return it
+ if not callable(attr):
+ return attr
+
+ # If it's a method, wrap it
+ @wraps(attr)
+ def threaded_method(
+ *args,
+ wait: bool = False,
+ on_complete: Optional[Callable[[bool, Any], None]] = None,
+ **kwargs
+ ):
+ """
+ Threaded wrapper for camera methods.
+
+ Args:
+ *args: Positional arguments for the method
+ wait: If True, wait for operation to complete (blocking)
+ on_complete: Optional callback(success, result) when done
+ **kwargs: Keyword arguments for the method
+
+ Returns:
+ If wait=True: (success, result)
+ If wait=False: None
+ """
+ if not self._started:
+ debug(f"Camera thread not running, calling {name} on main thread")
+ # Call underlying method directly
+ result = attr(*args, **kwargs)
+
+ # If wait=True, we need to return a tuple
+ # But underlying method might return bool, None, or tuple
+ if wait:
+ if isinstance(result, tuple):
+ return result
+ elif isinstance(result, bool):
+ return (result, None)
+ else:
+ # None or other - treat as success
+ return (True, result)
+ return result
+
+ # Create command
+ command = CameraCommand(name, args, kwargs, on_complete)
+
+ # Execute
+ result = self._thread.execute(command)
+
+ # Wait if requested
+ if wait:
+ return result.wait()
+
+ return None
+
+ return threaded_method
+
+ def __setattr__(self, name: str, value: Any):
+ """
+ Intercept attribute setting to forward to underlying camera.
+ """
+ # Our own attributes (those set in __init__)
+ if name in ('_camera', '_thread', '_started'):
+ object.__setattr__(self, name, value)
+ else:
+ # Forward to underlying camera
+ setattr(self._camera, name, value)
+
+ def __dir__(self):
+ """
+ Return the combined attributes of this class and the underlying camera.
+ This helps IDE autocomplete work properly.
+ """
+ return list(set(
+ dir(type(self)) +
+ list(self.__dict__.keys()) +
+ dir(self._camera)
+ ))
+
+
+def create_threaded_camera(camera: T) -> T:
+ """
+ Factory function to create a threaded camera with proper type hints.
+
+ Args:
+ camera: The base camera instance
+
+ Returns:
+ ThreadedCamera that appears as the same type as input
+
+ Example:
+ base = AmscopeCamera()
+ camera = create_threaded_camera(base) # Type is AmscopeCamera
+ camera.set_white_balance(5000, 1000) # Full type hints!
+ """
+ return ThreadedCamera(camera) # type: ignore
\ No newline at end of file
diff --git a/common/app_context.py b/common/app_context.py
new file mode 100644
index 0000000..b5fc5e1
--- /dev/null
+++ b/common/app_context.py
@@ -0,0 +1,192 @@
+"""
+Application context for managing shared resources and state.
+Provides a singleton pattern for accessing camera and other shared resources.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from camera.camera_manager import CameraManager
+from camera.cameras.base_camera import BaseCamera
+from common.logger import info, error, warning, debug
+from common.fieldweaveConfig import FieldWeaveSettingsManager, FieldWeaveSettings
+
+if TYPE_CHECKING:
+ from UI.settings.settings_main import SettingsDialog
+ from UI.widgets.toast_widget import ToastManager
+
+FIELDWEAVE_VERSION = "1.2"
+
+
+class AppContext:
+ """
+ Singleton application context managing shared resources.
+ """
+ _instance: AppContext | None = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._initialized = False
+ return cls._instance
+
+ def __init__(self):
+ if self._initialized:
+ return
+
+ self._camera_manager: CameraManager | None = None
+ self._settings_dialog: SettingsDialog | None = None
+ self._settings_manager: FieldWeaveSettingsManager | None = None
+ self._settings: FieldWeaveSettings | None = None
+ self._toast_manager: ToastManager | None = None
+ self._main_window = None
+ self._initialized = True
+
+ # Load settings
+ self._load_settings()
+
+ # Initialize camera manager
+ self._initialize_camera_manager()
+
+ @property
+ def camera_manager(self) -> CameraManager:
+ """
+ Get the camera manager instance.
+ Use this to enumerate cameras, switch cameras, start/stop streaming, etc.
+ """
+ if self._camera_manager is None:
+ self._initialize_camera_manager()
+ return self._camera_manager
+
+ @property
+ def camera(self) -> BaseCamera | None:
+ """
+ Get the currently active camera instance.
+ Returns None if no camera is active.
+
+ This is a convenience property that delegates to camera_manager.
+ """
+ if self._camera_manager is None:
+ return None
+ return self._camera_manager.active_camera
+
+ @property
+ def has_camera(self) -> bool:
+ """Check if there is an active camera"""
+ return self.camera is not None
+
+ @property
+ def settings(self) -> FieldWeaveSettings | None:
+ """Get the FieldWeave settings"""
+ return self._settings
+
+ @property
+ def settings_manager(self) -> FieldWeaveSettingsManager | None:
+ """Get the FieldWeave settings manager"""
+ return self._settings_manager
+
+ @property
+ def settings_dialog(self) -> SettingsDialog | None:
+ """Get the settings dialog instance"""
+ return self._settings_dialog
+
+ @property
+ def toast(self) -> ToastManager | None:
+ """Get the toast manager instance"""
+ return self._toast_manager
+
+ @property
+ def current_version(self) -> str:
+ """Get the current FieldWeave version"""
+ return FIELDWEAVE_VERSION
+
+ def register_main_window(self, window):
+ """Register the main window instance"""
+ self._main_window = window
+ # Initialize toast manager when main window is registered
+ if self._toast_manager is None:
+ from UI.widgets.toast_widget import ToastManager
+ self._toast_manager = ToastManager(window)
+
+ def register_settings_dialog(self, dialog: SettingsDialog):
+ """Register the settings dialog instance"""
+ self._settings_dialog = dialog
+
+ def open_settings(self, category: str):
+ """
+ Open settings dialog to a specific category.
+
+ Args:
+ category: Name of the settings category to open to
+ """
+ if self._settings_dialog:
+ self._settings_dialog.open_to(category)
+ self._settings_dialog.show()
+ self._settings_dialog.raise_()
+ self._settings_dialog.activateWindow()
+
+ def _load_settings(self):
+ """Load FieldWeave application settings"""
+ try:
+ self._settings_manager = FieldWeaveSettingsManager()
+ self._settings = self._settings_manager.load()
+
+ info(f"FieldWeave settings loaded - running v{FIELDWEAVE_VERSION}")
+
+ # Check if we should show patch notes
+ if self._settings.show_patchnotes:
+ info("New version detected - patch notes should be displayed")
+
+ except Exception as e:
+ error(f"Failed to load FieldWeave settings: {e}")
+ # Create default settings if loading fails
+ self._settings = FieldWeaveSettings()
+ warning("Using default FieldWeave settings")
+
+ def _initialize_camera_manager(self):
+ """Initialize the camera manager and open first available camera"""
+ if self._camera_manager is not None:
+ return
+
+ try:
+ info("Initializing camera manager...")
+ self._camera_manager = CameraManager()
+
+ # Enumerate cameras
+ info("Enumerating cameras...")
+ cameras = self._camera_manager.enumerate_cameras()
+
+ if cameras:
+ # Auto-open the first camera and start streaming
+ info("Auto-opening first available camera...")
+ if self._camera_manager.open_first_available(start_streaming=True):
+ debug("Camera opened and streaming started successfully")
+ else:
+ warning("Failed to auto-open first camera")
+ else:
+ warning("No cameras found during enumeration")
+
+ except Exception as e:
+ error(f"Failed to initialize camera manager: {e}")
+ self._camera_manager = None
+
+ def cleanup(self):
+ """Cleanup resources"""
+ if self._camera_manager:
+ self._camera_manager.cleanup()
+
+ self._camera_manager = None
+ self._settings_dialog = None
+ self._settings_manager = None
+ self._settings = None
+ self._toast_manager = None
+ self._main_window = None
+
+
+# Global instance accessor
+def get_app_context() -> AppContext:
+ """Get the global application context"""
+ return AppContext()
+
+def open_settings(category: str):
+ AppContext().open_settings(category)
diff --git a/common/fieldweaveConfig.py b/common/fieldweaveConfig.py
new file mode 100644
index 0000000..82c0530
--- /dev/null
+++ b/common/fieldweaveConfig.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Union
+
+from common.generic_config import ConfigManager
+from common.logger import info
+
+@dataclass
+class FieldWeaveSettings:
+ """FieldWeave application settings"""
+
+ version: str = "1.2" # Version from last startup
+ show_patchnotes: bool = False # Runtime flag - set when version changes, not saved
+
+ def validate(self) -> None:
+ """
+ Validate FieldWeave settings.
+
+ Raises:
+ ValueError: If any setting is invalid
+ """
+ if not isinstance(self.version, str) or not self.version:
+ raise ValueError("version must be a non-empty string")
+
+
+class FieldWeaveSettingsManager(ConfigManager[FieldWeaveSettings]):
+ """
+ Configuration manager for FieldWeave application settings.
+
+ When a version mismatch is detected during load, the migration
+ updates the stored version and sets show_patchnotes flag.
+ """
+
+ def __init__(
+ self,
+ *,
+ root_dir: Union[str, Path] = "./config/fieldweave",
+ default_filename: str = "default_settings.yaml",
+ backup_dirname: str = "backups",
+ backup_keep: int = 5,
+ ) -> None:
+ super().__init__(
+ config_type="fieldweave_settings",
+ root_dir=root_dir,
+ default_filename=default_filename,
+ backup_dirname=backup_dirname,
+ backup_keep=backup_keep,
+ )
+
+ def migrate(
+ self,
+ data: dict[str, Any],
+ from_version: str,
+ to_version: str
+ ) -> dict[str, Any]:
+ """
+ Migrate FieldWeave settings and update version.
+
+ When version changes:
+ 1. Updates the stored version to current
+ 2. Sets show_patchnotes flag (handled after from_dict)
+
+ Args:
+ data: Dictionary containing settings data
+ from_version: Version from the file
+ to_version: Current FieldWeave version
+
+ Returns:
+ Migrated dictionary with updated version
+ """
+ info(f"FieldWeave version changed: {from_version} -> {to_version}")
+
+ # Update version to current
+ data["version"] = to_version
+
+ # Add any future version-specific migrations here
+
+ return data
+
+ def from_dict(self, data: dict[str, Any]) -> FieldWeaveSettings:
+ """
+ Convert dictionary to FieldWeaveSettings object.
+
+ Sets show_patchnotes flag if migration occurred.
+
+ Args:
+ data: Dictionary containing settings data
+
+ Returns:
+ FieldWeaveSettings instance with show_patchnotes set if needed
+ """
+ # Handle empty dict (fresh instance)
+ if not data:
+ settings = FieldWeaveSettings()
+ else:
+ # Extract only valid fields for FieldWeaveSettings
+ valid_fields = {"version"}
+ filtered_data = {k: v for k, v in data.items() if k in valid_fields}
+ settings = FieldWeaveSettings(**filtered_data)
+
+ # If migration happened, set the show_patchnotes flag
+ if settings.version != self.get_fieldweave_version():
+ settings.show_patchnotes = True
+ info("Patch notes flag set - new version detected")
+
+ # Save the updated version
+ self.save(settings)
+
+ return settings
+
+ def to_dict(self, settings: FieldWeaveSettings) -> dict[str, Any]:
+ """
+ Convert FieldWeaveSettings object to dictionary.
+
+ Only includes fields that should be saved (excludes show_patchnotes).
+
+ Args:
+ settings: FieldWeaveSettings instance to convert
+
+ Returns:
+ Dictionary representation
+ """
+ return {
+ "version": settings.version,
+ }
diff --git a/common/generic_config.py b/common/generic_config.py
new file mode 100644
index 0000000..196a8e1
--- /dev/null
+++ b/common/generic_config.py
@@ -0,0 +1,620 @@
+# config_manager.py
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Any, Generic, Iterator, TypeVar, Union
+import shutil
+import time
+import yaml
+
+from common.logger import info, debug, error, warning
+
+# File/dir names are generic—usable for ANY config
+ACTIVE_FILENAME = "settings.yaml"
+DEFAULT_FILENAME = "default_settings.yaml"
+BACKUP_DIRNAME = "backups"
+BACKUP_KEEP = 5 # keep most recent N backups
+
+S = TypeVar("S") # Config schema type (must be a dataclass)
+
+
+class ConfigValidationError(Exception):
+ """Raised when settings validation fails."""
+ pass
+
+
+class ConfigManager(Generic[S], ABC):
+ """
+ Generic YAML-backed config manager for ANY dataclass-based settings.
+ Manages a single configuration directory with active settings, defaults, and backups.
+
+ All config files include metadata fields:
+ - config_type: Identifies which config loader to use
+ - config_version: The FieldWeave version that created/last modified this config
+
+ Directory structure:
+ root_dir/
+ settings.yaml # Active settings
+ default_settings.yaml # Factory defaults
+ backups/ # Timestamped backups
+ settings.20250128-143052.yaml
+ settings.20250128-120301.yaml
+
+ Child classes must implement:
+ - from_dict(data: dict[str, Any]) -> S: Convert dictionary to settings object
+ - to_dict(settings: S) -> dict[str, Any]: Convert settings object to dictionary
+
+ Example:
+ >>> @dataclass
+ ... class MySettings:
+ ... value: int = 10
+ ... def validate(self):
+ ... if self.value < 0:
+ ... raise ValueError("value must be non-negative")
+ >>>
+ >>> class MySettingsManager(ConfigManager[MySettings]):
+ ... def __init__(self):
+ ... super().__init__(
+ ... config_type="my_settings",
+ ... root_dir="./config/my_component"
+ ... )
+ ...
+ ... def from_dict(self, data: dict[str, Any]) -> MySettings:
+ ... return MySettings(**data)
+ ...
+ ... def to_dict(self, settings: MySettings) -> dict[str, Any]:
+ ... return asdict(settings)
+ >>>
+ >>> manager = MySettingsManager()
+ >>> settings = manager.load()
+ >>> settings.value = 20
+ >>> manager.save(settings)
+ """
+
+ def __init__(
+ self,
+ config_type: str,
+ *,
+ root_dir: Union[str, Path] = "./config",
+ default_filename: str = DEFAULT_FILENAME,
+ backup_dirname: str = BACKUP_DIRNAME,
+ backup_keep: int = BACKUP_KEEP,
+ save_defaults_on_init: bool = True,
+ ) -> None:
+ """
+ Initialize the config manager.
+
+ Args:
+ config_type: Identifier for this config type (e.g., "camera_settings", "fieldweave_settings")
+ root_dir: Directory for config files (settings, defaults, backups)
+ default_filename: Name for the defaults file
+ backup_dirname: Name for the backups subdirectory
+ backup_keep: Number of backup files to retain (oldest are deleted)
+ save_defaults_on_init: If True, saves default settings on initialization if none exist
+
+ Raises:
+ ValueError: If config_type is empty
+ """
+ if not config_type:
+ raise ValueError("config_type must be a non-empty string")
+
+ self.config_type = config_type
+ self.root_dir = Path(root_dir).resolve()
+ self.root_dir.mkdir(parents=True, exist_ok=True)
+ self.default_filename = default_filename
+ self.backup_dirname = backup_dirname
+ self.backup_keep = backup_keep
+
+ debug(f"Initialized ConfigManager for '{config_type}' at {self.root_dir}")
+
+ # Save default settings if no settings exist and save_defaults_on_init is True
+ if save_defaults_on_init:
+ dp = self.default_path()
+ ap = self.active_path()
+ if not dp.exists() and not ap.exists():
+ try:
+ default_settings = self.from_dict({})
+ self.write_defaults(default_settings)
+ info(f"Saved initial default settings for '{config_type}'")
+ except Exception as e:
+ warning(f"Failed to save initial default settings: {e}")
+
+ @abstractmethod
+ def from_dict(self, data: dict[str, Any]) -> S:
+ """
+ Convert a dictionary (loaded from YAML) into a settings object.
+
+ The dictionary will NOT include config_type or config_version fields,
+ as those are handled separately by the base class.
+
+ Args:
+ data: Dictionary containing the settings data
+
+ Returns:
+ Settings object instance
+
+ Raises:
+ Any exception appropriate for conversion failures
+ """
+ pass
+
+ @abstractmethod
+ def to_dict(self, settings: S) -> dict[str, Any]:
+ """
+ Convert a settings object into a dictionary for YAML serialization.
+
+ Do NOT include config_type or config_version in the returned dictionary,
+ as those are added automatically by the base class.
+
+ Args:
+ settings: Settings object to convert
+
+ Returns:
+ Dictionary containing the settings data
+ """
+ pass
+
+ def migrate(
+ self,
+ data: dict[str, Any],
+ from_version: str,
+ to_version: str
+ ) -> dict[str, Any]:
+ """
+ Migrate config data from one version to another.
+
+ Override this method in child classes to handle version migrations.
+ The base implementation does nothing (no migration).
+
+ Args:
+ data: Dictionary containing the config data (without metadata fields)
+ from_version: Version the config was created with
+ to_version: Current FieldWeave version
+
+ Returns:
+ Migrated dictionary (or original if no migration needed)
+ """
+ return data
+
+ def get_fieldweave_version(self) -> str:
+ """Get the current FieldWeave version."""
+ from common.app_context import FIELDWEAVE_VERSION
+ return FIELDWEAVE_VERSION
+
+ def active_path(self) -> Path:
+ """Return path to the active settings file."""
+ return self.root_dir / ACTIVE_FILENAME
+
+ def default_path(self) -> Path:
+ """Return path to the default settings file."""
+ return self.root_dir / self.default_filename
+
+ def backup_dir(self) -> Path:
+ """Return path to the backup directory."""
+ bd = self.root_dir / self.backup_dirname
+ bd.mkdir(exist_ok=True)
+ return bd
+
+ def _add_metadata(self, data: dict[str, Any]) -> dict[str, Any]:
+ """
+ Add config_type and config_version to a data dictionary.
+
+ Args:
+ data: Dictionary to add metadata to
+
+ Returns:
+ New dictionary with metadata fields added
+ """
+ return {
+ "config_type": self.config_type,
+ "config_version": self.get_fieldweave_version(),
+ **data,
+ }
+
+ def _extract_metadata(
+ self, data: dict[str, Any]
+ ) -> tuple[str | None, str | None, dict[str, Any]]:
+ """
+ Extract metadata and migrate if needed.
+
+ Args:
+ data: Dictionary loaded from YAML
+
+ Returns:
+ Tuple of (config_type, config_version, remaining_data)
+ The remaining_data will be migrated if version mismatch detected
+ """
+ data = data.copy() # Don't modify the original
+
+ config_type = data.pop("config_type", None)
+ config_version = data.pop("config_version", None)
+
+ # Validate config_type matches if present
+ if config_type is not None and config_type != self.config_type:
+ warning(
+ f"Config type mismatch: expected '{self.config_type}', "
+ f"got '{config_type}' in file"
+ )
+
+ # Handle migration if version mismatch
+ if config_version is not None:
+ current_version = self.get_fieldweave_version()
+
+ if config_version != current_version and current_version != "unknown":
+ info(
+ f"Config version mismatch: file has v{config_version}, "
+ f"current is v{current_version}. Running migration..."
+ )
+ try:
+ data = self.migrate(data, config_version, current_version)
+ info("Migration completed successfully")
+ except Exception as e:
+ error(f"Migration failed: {e}")
+ # Continue with unmigrated data - child class should handle it
+ else:
+ debug(f"Config version matches: v{config_version}")
+
+ return config_type, config_version, data
+
+ def _load_dict_from_file(self, path: Path) -> dict[str, Any]:
+ """
+ Load a dictionary from a YAML file.
+
+ Args:
+ path: Path to the YAML file
+
+ Returns:
+ Dictionary loaded from the file (empty dict if file is empty)
+
+ Raises:
+ IOError: If file cannot be read or parsed
+ """
+
+ try:
+ with open(path, "r") as f:
+ data = yaml.safe_load(f)
+ return data or {}
+ except Exception as e:
+ error(f"Failed to load YAML from {path}: {e}")
+ raise IOError(f"Failed to load config from {path}") from e
+
+ def _save_dict_to_file(self, data: dict[str, Any], path: Path) -> None:
+ """
+ Save a dictionary to a YAML file.
+
+ Args:
+ data: Dictionary to save
+ path: Path to save to
+
+ Raises:
+ IOError: If file cannot be written
+ """
+
+ try:
+ with open(path, "w") as f:
+ yaml.safe_dump(data, f, sort_keys=False)
+ debug(f"Saved config to {path.name}")
+ except Exception as e:
+ error(f"Failed to save YAML to {path}: {e}")
+ raise IOError(f"Failed to save config to {path}") from e
+
+ # -------------------------
+ # Validation
+ # -------------------------
+
+ def _validate(self, settings: S, context: str = "") -> None:
+ """
+ Validate settings if validate() method exists.
+
+ Args:
+ settings: Settings instance to validate
+ context: Context string for logging (e.g., "after load", "before save")
+
+ Raises:
+ ConfigValidationError: If validation fails
+ """
+ if not hasattr(settings, "validate"):
+ return
+
+ try:
+ settings.validate()
+ debug(f"Validation passed{f' ({context})' if context else ''}")
+ except Exception as e:
+ error(
+ f"Validation failed{f' ({context})' if context else ''}: {e}"
+ )
+ raise ConfigValidationError(f"Settings validation failed: {e}") from e
+
+ # -------------------------
+ # Backup management
+ # -------------------------
+
+ def _backup_if_exists(self) -> Path | None:
+ """
+ Create a timestamped backup of the active settings file if it exists.
+ Cleans up old backups to maintain backup_keep limit.
+
+ Returns:
+ Path to the created backup, or None if no file to backup
+ """
+ src = self.active_path()
+ if not src.exists():
+ return None
+
+ backup_dir = self.backup_dir()
+ timestamp = time.strftime("%Y%m%d-%H%M%S")
+ backup_name = f"{ACTIVE_FILENAME.split('.')[0]}.{timestamp}.yaml"
+ dst = backup_dir / backup_name
+
+ try:
+ shutil.copy2(src, dst)
+ info(f"Created backup: {backup_name}")
+ except Exception as e:
+ warning(f"Failed to create backup: {e}")
+ return None
+
+ # Clean up old backups
+ self._cleanup_old_backups()
+
+ return dst
+
+ def _cleanup_old_backups(self) -> None:
+ """Remove old backups beyond the configured limit."""
+ backups = self.list_backups()
+ to_delete = backups[self.backup_keep:]
+
+ for backup in to_delete:
+ try:
+ backup.unlink()
+ debug(f"Deleted old backup: {backup.name}")
+ except Exception as e:
+ warning(f"Failed to delete old backup {backup.name}: {e}")
+
+ # -------------------------
+ # Public API
+ # -------------------------
+
+ def load(self) -> S:
+ """
+ Load settings with fallback chain: active → defaults → fresh instance.
+
+ Returns:
+ Settings instance (validated if validate() method exists)
+
+ Raises:
+ ConfigValidationError: If loaded settings fail validation
+ IOError: If all load attempts fail
+ """
+ # Try active settings first
+ ap = self.active_path()
+ if ap.exists():
+ try:
+ data_dict = self._load_dict_from_file(ap)
+ _, _, clean_data = self._extract_metadata(data_dict)
+ settings = self.from_dict(clean_data)
+ self._validate(settings, "active settings")
+ info(f"Loaded active settings from {ap.name}")
+ return settings
+ except ConfigValidationError:
+ raise
+ except Exception as e:
+ error(f"Failed to load active settings from {ap}: {e}")
+ raise IOError("Failed to load active settings") from e
+
+ # Fallback to defaults
+ dp = self.default_path()
+ if dp.exists():
+ try:
+ data_dict = self._load_dict_from_file(dp)
+ _, _, clean_data = self._extract_metadata(data_dict)
+ settings = self.from_dict(clean_data)
+ self._validate(settings, "default settings")
+ info(f"Loaded default settings from {dp.name}")
+ return settings
+ except ConfigValidationError:
+ raise
+ except Exception as e:
+ error(f"Failed to load default settings from {dp}: {e}")
+ raise IOError("Failed to load default settings") from e
+
+ # Last resort: create fresh instance
+ info("No existing settings found, using fresh instance")
+ settings = self.from_dict({})
+ self._validate(settings, "fresh instance")
+ return settings
+
+ def load_from_file(self, path: Union[str, Path]) -> S:
+ """
+ Load settings from an arbitrary file path.
+
+ This is useful for loading user-provided or downloaded configuration files.
+
+ Args:
+ path: Path to the settings file
+
+ Returns:
+ Settings instance (validated if validate() method exists)
+
+ Raises:
+ ConfigValidationError: If loaded settings fail validation
+ IOError: If file cannot be read or config type mismatch
+ """
+ p = Path(path)
+ try:
+ data_dict = self._load_dict_from_file(p)
+ file_config_type, _, clean_data = self._extract_metadata(data_dict)
+
+ # Check if config_type matches
+ if file_config_type is not None and file_config_type != self.config_type:
+ error(
+ f"Config type mismatch when loading from {p}: "
+ f"expected '{self.config_type}', got '{file_config_type}'"
+ )
+ raise IOError(
+ f"Config type mismatch: file is '{file_config_type}', "
+ f"but this manager expects '{self.config_type}'"
+ )
+
+ settings = self.from_dict(clean_data)
+ self._validate(settings, f"file {p.name}")
+ info(f"Loaded settings from file: {p}")
+ return settings
+ except ConfigValidationError:
+ raise
+ except IOError:
+ raise
+ except Exception as e:
+ error(f"Failed to load settings from {p}: {e}")
+ raise IOError(f"Failed to load settings from {path}") from e
+
+ def save(self, settings: S) -> None:
+ """
+ Save settings to the active settings file.
+
+ Creates a backup of existing settings before saving.
+ Automatically adds config_type and config_version metadata.
+
+ Args:
+ settings: Settings instance to save
+
+ Raises:
+ ConfigValidationError: If settings fail validation
+ IOError: If file cannot be written
+ """
+ # Validate before saving
+ self._validate(settings, "before save")
+
+ # Backup existing file
+ self._backup_if_exists()
+
+ # Convert to dict and add metadata
+ data = self.to_dict(settings)
+ data_with_metadata = self._add_metadata(data)
+
+ # Save
+ p = self.active_path()
+ self._save_dict_to_file(data_with_metadata, p)
+ info(f"Saved settings to {p.name}")
+
+ def write_defaults(self, settings: S | None = None) -> Path:
+ """
+ Write default settings file.
+
+ Args:
+ settings: Settings to write as defaults. If None, uses from_dict({})
+
+ Returns:
+ Path to the written defaults file
+
+ Raises:
+ ConfigValidationError: If settings fail validation
+ IOError: If file cannot be written
+ """
+ settings_to_save = settings if settings is not None else self.from_dict({})
+ self._validate(settings_to_save, "defaults")
+
+ data = self.to_dict(settings_to_save)
+ data_with_metadata = self._add_metadata(data)
+
+ dp = self.default_path()
+ self._save_dict_to_file(data_with_metadata, dp)
+ info(f"Wrote default settings to {dp.name}")
+ return dp
+
+ def restore_defaults(self) -> S:
+ """
+ Restore default settings as the active settings.
+
+ Creates a backup of current active settings before restoring.
+
+ Returns:
+ The restored default settings
+
+ Raises:
+ ConfigValidationError: If default settings fail validation
+ IOError: If restore operation fails
+ """
+ defaults = self.load_defaults()
+ self._backup_if_exists()
+ self.save(defaults)
+ info("Restored defaults as active settings")
+ return defaults
+
+ def load_defaults(self) -> S:
+ """
+ Load default settings.
+
+ Returns:
+ Default settings instance
+
+ Raises:
+ ConfigValidationError: If default settings fail validation
+ IOError: If defaults file cannot be read
+ """
+ dp = self.default_path()
+ if not dp.exists():
+ debug("No defaults file, using fresh instance")
+ settings = self.from_dict({})
+ self._validate(settings, "fresh defaults")
+ return settings
+
+ try:
+ data_dict = self._load_dict_from_file(dp)
+ _, _, clean_data = self._extract_metadata(data_dict)
+ settings = self.from_dict(clean_data)
+ self._validate(settings, "defaults")
+ info(f"Loaded default settings from {dp.name}")
+ return settings
+ except ConfigValidationError:
+ raise
+ except Exception as e:
+ error(f"Failed to load default settings from {dp}: {e}")
+ raise IOError("Failed to load defaults") from e
+
+ def list_backups(self) -> list[Path]:
+ """List all backup files, sorted by last modified."""
+ bd = self.backup_dir()
+ try:
+ backups = sorted(
+ bd.glob(f"{ACTIVE_FILENAME.split('.')[0]}.*.yaml"),
+ key=lambda p: p.stat().st_mtime,
+ reverse=True
+ )
+ debug(f"Found {len(backups)} backup(s)")
+ return backups
+ except Exception as e:
+ warning(f"Failed to list backups: {e}")
+ return []
+
+ @contextmanager
+ def edit(self) -> Iterator[S]:
+ """
+ Context manager for transactional settings editing.
+
+ Loads settings, yields for editing, and automatically saves
+ on successful exit. If an exception occurs, changes are discarded.
+
+ Yields:
+ Settings instance for editing
+
+ Raises:
+ ConfigValidationError: If edited settings fail validation
+ IOError: If load or save operations fail
+
+ Example:
+ >>> with manager.edit() as settings:
+ ... settings.value = 150
+ # Auto-saves on successful exit
+ """
+ debug("Starting edit transaction")
+ settings = self.load()
+ try:
+ yield settings
+ except Exception as e:
+ error(f"Edit transaction failed: {e}")
+ raise
+ else:
+ self.save(settings)
+ info("Edit transaction completed")
diff --git a/common/logger.py b/common/logger.py
new file mode 100644
index 0000000..639ae40
--- /dev/null
+++ b/common/logger.py
@@ -0,0 +1,260 @@
+"""
+Centralized logging system for the application.
+Provides logging to both file and UI components.
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+from pathlib import Path
+from datetime import datetime
+from typing import Callable
+from logging.handlers import RotatingFileHandler
+
+
+class AppLogger:
+ """
+ Singleton application logger with file and UI output.
+ """
+ _instance: AppLogger | None = None
+ _initialized = False
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ def __init__(self):
+ if self._initialized:
+ return
+
+ self._log_callbacks: list[Callable[[str, str], None]] = []
+ self._logger = logging.getLogger('FieldWeaveApp')
+ self._logger.setLevel(logging.DEBUG)
+
+ # Default log directory
+ self._log_dir = Path.cwd() / "logs"
+ self._log_dir.mkdir(exist_ok=True)
+
+ # Setup file handler
+ self._setup_file_handler()
+
+ # Setup console handler for development
+ self._setup_console_handler()
+
+ self._initialized = True
+
+ def _setup_file_handler(self):
+ """Setup rotating file handler"""
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+ log_file = self._log_dir / f"FieldWeave_{timestamp}.log"
+
+ # Store current log file path
+ self._current_log_file = log_file
+
+ # Rotating file handler - 10MB max, keep 5 backups
+ file_handler = RotatingFileHandler(
+ log_file,
+ maxBytes=10 * 1024 * 1024,
+ backupCount=5,
+ encoding='utf-8'
+ )
+ file_handler.setLevel(logging.DEBUG)
+
+ # Format: [2025-01-26 14:30:45] INFO: Message
+ formatter = logging.Formatter(
+ '[%(asctime)s] %(levelname)s: %(message)s',
+ datefmt='%Y-%m-%d %H:%M:%S'
+ )
+ file_handler.setFormatter(formatter)
+
+ self._logger.addHandler(file_handler)
+ self._file_handler = file_handler
+
+ # Clean up old log files before creating new one
+ self._cleanup_old_logs()
+
+ def _cleanup_old_logs(self):
+ """
+ Keep only the last 10 log file sets.
+ Each set includes the main log and its rotated backups (.1, .2, etc).
+ """
+ try:
+ # Get all base log files (without .1, .2, etc extensions)
+ log_files = sorted(
+ [f for f in self._log_dir.glob("FieldWeave_*.log")
+ if not f.stem.split('.')[-1].isdigit()],
+ key=lambda x: x.stat().st_mtime,
+ reverse=True
+ )
+
+ # Keep only the 10 most recent sets
+ max_log_sets = 10
+ if len(log_files) >= max_log_sets:
+ deleted_files = []
+
+ # Delete older log sets (beyond the 10 most recent)
+ for old_log in log_files[max_log_sets:]:
+ # Delete the main log file
+ if old_log.exists():
+ old_log.unlink(missing_ok=True)
+ deleted_files.append(old_log.name)
+
+ # Delete all rotated versions (.1, .2, .3, etc)
+ for rotated in self._log_dir.glob(f"{old_log.name}.*"):
+ if rotated.suffix[1:].isdigit(): # Check if extension is a number
+ rotated.unlink(missing_ok=True)
+ deleted_files.append(rotated.name)
+
+ # Log the cleanup action
+ if deleted_files:
+ files_list = ", ".join(deleted_files)
+ self._logger.info(f"Log cleanup: Deleted old log files: {files_list}")
+
+ except Exception as e:
+ # Don't let cleanup errors break logging
+ print(f"Error cleaning up old logs: {e}")
+ self._logger.error(f"Error cleaning up old logs: {e}")
+
+ def _setup_console_handler(self):
+ """Setup console handler for stdout"""
+ console_handler = logging.StreamHandler(sys.stdout)
+ console_handler.setLevel(logging.INFO)
+
+ formatter = logging.Formatter(
+ '[%(levelname)s] %(message)s'
+ )
+ console_handler.setFormatter(formatter)
+
+ self._logger.addHandler(console_handler)
+
+ def set_log_directory(self, directory: Path):
+ """
+ Change the log directory.
+
+ Args:
+ directory: New directory for log files
+ """
+ self._log_dir = Path(directory)
+ self._log_dir.mkdir(exist_ok=True)
+
+ # Remove old file handler
+ self._logger.removeHandler(self._file_handler)
+
+ # Setup new file handler
+ self._setup_file_handler()
+
+ self.info(f"Log directory changed to: {self._log_dir}")
+
+ def get_log_directory(self) -> Path:
+ """Get current log directory"""
+ return self._log_dir
+
+ def get_current_log_file(self) -> Path | None:
+ """Get current log file path"""
+ return getattr(self, '_current_log_file', None)
+
+ def register_callback(self, callback: Callable[[str, str], None]):
+ """
+ Register a callback for log messages.
+
+ Args:
+ callback: Function(level, message) to call on each log message
+ """
+ # Remove if already registered to avoid duplicates
+ if callback in self._log_callbacks:
+ self._log_callbacks.remove(callback)
+ self._log_callbacks.append(callback)
+
+ def unregister_callback(self, callback: Callable[[str, str], None]):
+ """
+ Unregister a log callback.
+
+ Args:
+ callback: Callback to remove
+ """
+ if callback in self._log_callbacks:
+ self._log_callbacks.remove(callback)
+
+ def _notify_callbacks(self, level: str, message: str):
+ """Notify all registered callbacks"""
+ for callback in self._log_callbacks:
+ try:
+ callback(level, message)
+ except Exception as e:
+ # Don't let callback errors break logging
+ print(f"Error in log callback: {e}")
+
+ def debug(self, message: str):
+ """Log debug message"""
+ self._logger.debug(message)
+ self._notify_callbacks('DEBUG', message)
+
+ def info(self, message: str):
+ """Log info message"""
+ self._logger.info(message)
+ self._notify_callbacks('INFO', message)
+
+ def warning(self, message: str):
+ """Log warning message"""
+ self._logger.warning(message)
+ self._notify_callbacks('WARNING', message)
+
+ def error(self, message: str):
+ """Log error message"""
+ self._logger.error(message)
+ self._notify_callbacks('ERROR', message)
+
+ def critical(self, message: str):
+ """Log critical message"""
+ self._logger.critical(message)
+ self._notify_callbacks('CRITICAL', message)
+
+ def exception(self, message: str):
+ """Log exception with traceback"""
+ self._logger.exception(message)
+ self._notify_callbacks('ERROR', message)
+
+
+# Global logger instance
+_app_logger: AppLogger | None = None
+
+
+def get_logger() -> AppLogger:
+ """Get the global application logger"""
+ global _app_logger
+ if _app_logger is None:
+ _app_logger = AppLogger()
+ return _app_logger
+
+
+# Convenience functions for easy access
+def debug(message: str):
+ """Log debug message"""
+ get_logger().debug(message)
+
+
+def info(message: str):
+ """Log info message"""
+ get_logger().info(message)
+
+
+def warning(message: str):
+ """Log warning message"""
+ get_logger().warning(message)
+
+
+def error(message: str):
+ """Log error message"""
+ get_logger().error(message)
+
+
+def critical(message: str):
+ """Log critical message"""
+ get_logger().critical(message)
+
+
+def exception(message: str):
+ """Log exception with traceback"""
+ get_logger().exception(message)
diff --git a/common/setting_types.py b/common/setting_types.py
new file mode 100644
index 0000000..56be4b5
--- /dev/null
+++ b/common/setting_types.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from typing import NamedTuple
+from enum import Enum
+
+class FileFormat(str, Enum):
+ PNG = 'png'
+ TIFF = 'tiff'
+ JPEG = 'jpeg'
+
+
+class RGBALevel(NamedTuple):
+ r: int
+ g: int
+ b: int
+ a: int
+
+ def validate(self) -> None:
+ for name, value in [('r', self.r), ('g', self.g), ('b', self.b), ('a', self.a)]:
+ if not (0 <= value <= 255):
+ raise ValueError(f"RGBALevel.{name} must be in range [0, 255], got {value}")
+
+
+class SettingType(str, Enum):
+ BOOL = "bool"
+ RANGE = "range"
+ DROPDOWN = "dropdown"
+ RGBA_LEVEL = "rgba_level"
+ BUTTON = "button"
+ FILE_PICKER_BUTTON = "file_picker_button"
+ NUMBER_PICKER = "number_picker"
+
+
+@dataclass
+class SettingMetadata:
+ name: str
+ display_name: str
+ setting_type: SettingType
+ description: str = ""
+ min_value: int | None = None
+ max_value: int | None = None
+ choices: list[str] | None = None
+ group: str = "General"
+ runtime_changeable: bool = True
+ # When set, this field is greyed out (and, for live-value fields, polled from
+ # hardware) while the named boolean setting equals *controlled_when*.
+ #
+ # controlled_when=True (default): grey out while the controller is ON.
+ # Example: exposure_time / gain are greyed while auto_exposure is True.
+ # controlled_when=False: grey out while the controller is OFF.
+ # Example: exposure target is greyed while auto_exposure is False.
+ controlled_by: str | None = None
+ controlled_when: bool = True
\ No newline at end of file
diff --git a/common/state.py b/common/state.py
new file mode 100644
index 0000000..cf74f90
--- /dev/null
+++ b/common/state.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+
+class MachineState(str, Enum):
+ DISCONNECTED: str = "Disconnected"
+ CONNECTED: str = "Connected"
+
+ def __str__(self) -> str:
+ return self.value
+
+class AutomationState(str, Enum):
+ IDLE: str = "Idle"
+ COMPLETE: str = "Completed"
+ RUNNING: str = "Running"
+ PAUSED: str = "Paused"
+
+ def __str__(self) -> str:
+ return self.value
+
+
+
+@dataclass(frozen=True)
+class State:
+ machine_state: str = MachineState.DISCONNECTED
+ automation_state: str = AutomationState.IDLE
+ camera_state: str = ""
+
+ activity: str = "-"
+ job_name: str = "-"
+
+ progress_current: int = 0
+ progress_total: int = 0
+
+ def format_status_text(self) -> str:
+ parts: list[str] = [f"{self.machine_state} • {self.automation_state}"]
+ if self.job_name != "-" and self.activity != "-":
+ parts.append(f"{self.job_name}: {self.activity}")
+ elif self.activity != "-":
+ parts.append(self.activity)
+ if self.progress_total > 0:
+ parts.append(f"{self.progress_current}/{self.progress_total}")
+ return " | ".join(parts)
\ No newline at end of file
diff --git a/config/.gitignore b/config/.gitignore
new file mode 100644
index 0000000..83a2cad
--- /dev/null
+++ b/config/.gitignore
@@ -0,0 +1,2 @@
+/cameras/*
+/forge/*
\ No newline at end of file
diff --git a/config/cameras/amscope/default_settings.yaml b/config/cameras/amscope/default_settings.yaml
deleted file mode 100644
index 3990878..0000000
--- a/config/cameras/amscope/default_settings.yaml
+++ /dev/null
@@ -1,60 +0,0 @@
-# Camera configuration (values + ranges). Ranges mirror settings.py.
-auto_expo: false
-
-# Auto Exposure Target
-exposure: 16
-exposure_min: 16
-exposure_max: 220
-
-# White balance temperature
-temp: 10794
-temp_min: 2000
-temp_max: 15000
-
-# White balance tint
-tint: 925
-tint_min: 200
-tint_max: 2500
-
-# Level ranges (RGBA). Min/max apply per channel.
-levelrange_low: [0, 0, 0, 0]
-levelrange_high: [255, 255, 255, 255]
-levelrange_min: 0
-levelrange_max: 255
-
-contrast: 10
-contrast_min: -100
-contrast_max: 100
-
-hue: 0
-hue_min: -180
-hue_max: 180
-
-saturation: 45
-saturation_min: 0
-saturation_max: 255
-
-brightness: -3
-brightness_min: -64
-brightness_max: 64
-
-gamma: 100
-gamma_min: 20
-gamma_max: 180
-
-# White balance gains (R, G, B). Min/max apply per channel.
-wbgain: [0, 0, 0]
-wbgain_min: -127
-wbgain_max: 127
-
-sharpening: 48
-sharpening_min: 0
-sharpening_max: 500
-
-# 0/1; range is discrete but included for completeness
-linear: 1
-linear_min: 0
-linear_max: 1
-
-curve: Polynomial
-fformat: tiff
diff --git a/config/fieldweave/default_settings.yaml b/config/fieldweave/default_settings.yaml
new file mode 100644
index 0000000..39ad112
--- /dev/null
+++ b/config/fieldweave/default_settings.yaml
@@ -0,0 +1,3 @@
+config_type: fieldweave_settings
+config_version: '1.2'
+version: '1.2'
diff --git a/config/forge/default_settings.yaml b/config/forge/default_settings.yaml
deleted file mode 100644
index 1879bac..0000000
--- a/config/forge/default_settings.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-serial_port: "COM9"
-windowWidth: 1440
-windowHeight: 810
-version: "1.1"
\ No newline at end of file
diff --git a/config/printers/Ender3/default_settings.yaml b/config/printers/Ender3/default_settings.yaml
index 039dea8..6ef3b87 100644
--- a/config/printers/Ender3/default_settings.yaml
+++ b/config/printers/Ender3/default_settings.yaml
@@ -25,4 +25,10 @@ sample_positions:
17: { x: 202.04, y: 200.00, z: 29.00 }
18: { x: 213.36, y: 200.00, z: 29.00 }
19: { x: 224.88, y: 200.00, z: 29.00 }
- 20: { x: 224.88, y: 200.00, z: 29.00 }
\ No newline at end of file
+ 20: { x: 224.88, y: 200.00, z: 29.00 }
+calibration_y: 220.0
+calibration_z: 26.0
+calibration_pattern_position:
+ x: 226.08
+ y: 186.9
+ z: 33.2
\ No newline at end of file
diff --git a/forgeConfig.py b/forgeConfig.py
deleted file mode 100644
index 88c79d6..0000000
--- a/forgeConfig.py
+++ /dev/null
@@ -1,34 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-
-from generic_config import ConfigManager, DEFAULT_FILENAME, ACTIVE_FILENAME
-
-@dataclass
-class ForgeSettings():
- serial_port: str = "COM9"
- windowWidth: int = 1440
- windowHeight: int = 810
- version: str = "1.1"
-
-def make_forge_settings_manager(
- *,
- root_dir: str = "./config/forge",
- default_filename: str = "default_settings.yaml",
- backup_dirname: str = "backups",
- backup_keep: int = 5,
-) -> ConfigManager[ForgeSettings]:
- return ConfigManager[ForgeSettings](
- ForgeSettings,
- root_dir=root_dir,
- default_filename=default_filename,
- backup_dirname=backup_dirname,
- backup_keep=backup_keep,
- )
-
-ForgeSettingsManager = make_forge_settings_manager(
- root_dir="./config/forge",
- default_filename=DEFAULT_FILENAME,
- backup_dirname="backups",
- backup_keep=5,
-)
\ No newline at end of file
diff --git a/generic_config.py b/generic_config.py
deleted file mode 100644
index 7a96b6a..0000000
--- a/generic_config.py
+++ /dev/null
@@ -1,168 +0,0 @@
-# config_manager.py
-from __future__ import annotations
-
-from dataclasses import asdict, fields, is_dataclass
-from pathlib import Path
-from typing import Any, Dict, Generic, List, Type, TypeVar, Callable
-import shutil
-import time
-
-# File/dir names are generic—usable for ANY config
-ACTIVE_FILENAME = "settings.yaml"
-DEFAULT_FILENAME = "default_settings.yaml"
-BACKUP_DIRNAME = "backups"
-BACKUP_KEEP = 5 # keep most recent N backups
-
-S = TypeVar("S") # Config schema type (must be a dataclass)
-
-
-class ConfigManager(Generic[S]):
- """
- Generic YAML-backed config manager for ANY dataclass-based settings.
- Handles: load/save, defaults file, timestamped backups (+ pruning), restore.
- """
-
- def __init__(
- self,
- schema_cls: Type[S],
- *,
- root_dir: str | Path = "./config",
- scope_namer: Callable[[str], str] | None = None,
- default_filename: str = DEFAULT_FILENAME,
- backup_dirname: str = BACKUP_DIRNAME,
- backup_keep: int = BACKUP_KEEP,
- ) -> None:
- if not is_dataclass(schema_cls):
- raise TypeError("schema_cls must be a dataclass type")
- self.schema_cls = schema_cls
- self.root_dir = Path(root_dir).resolve()
- self.root_dir.mkdir(parents=True, exist_ok=True)
- self.scope_namer = scope_namer or (lambda s: s)
- self.default_filename = default_filename
- self.backup_dirname = backup_dirname
- self.backup_keep = backup_keep
-
- # -------------------------
- # YAML (de)serialization
- # -------------------------
- def _to_dict(self, settings: S) -> Dict[str, Any]:
- return asdict(settings)
-
- def _from_dict(self, data: Dict[str, Any] | None) -> S:
- data = data or {}
- allowed = {f.name for f in fields(self.schema_cls)}
- return self.schema_cls(**{k: v for k, v in data.items() if k in allowed}) # type: ignore
-
- def scope_dir(self, scope: str) -> Path:
- d = self.root_dir / self.scope_namer(scope)
- d.mkdir(parents=True, exist_ok=True)
- return d
-
- def active_path(self, scope: str) -> Path:
- return self.scope_dir(scope) / ACTIVE_FILENAME
-
- def default_path(self, scope: str) -> Path:
- return self.scope_dir(scope) / self.default_filename
-
- def backup_dir(self, scope: str) -> Path:
- bd = self.scope_dir(scope) / self.backup_dirname
- bd.mkdir(parents=True, exist_ok=True)
- return bd
-
- def _backup_if_exists(self, scope: str) -> None:
- src = self.active_path(scope)
- if not src.exists():
- return
- ts = time.strftime("%Y%m%d-%H%M%S")
- dst = self.backup_dir(scope) / f"{src.stem}.{ts}{src.suffix}"
- try:
- shutil.copy2(src, dst)
- except Exception as e:
- print(f"Warning: failed to create settings backup: {e}")
- return
- try:
- backups: List[Path] = sorted(
- self.backup_dir(scope).glob(f"{src.stem}.*{src.suffix}"),
- key=lambda p: p.stat().st_mtime,
- reverse=True,
- )
- for old in backups[self.backup_keep:]:
- try:
- old.unlink(missing_ok=True)
- except TypeError:
- old.unlink()
- except Exception as e:
- print(f"Warning: failed to prune backups: {e}")
-
- # -------- public scope-first API
- def load(self, scope: str) -> S:
- import yaml
- p = self.active_path(scope)
- if p.exists():
- try:
- with open(p, "r") as f:
- return self._from_dict(yaml.safe_load(f) or {})
- except Exception as e:
- print(f"Error loading settings: {e}")
- # fallbacks
- dp = self.default_path(scope)
- if dp.exists():
- try:
- with open(dp, "r") as f:
- return self._from_dict(yaml.safe_load(f) or {})
- except Exception as e:
- print(f"Error loading default settings: {e}")
- return self.schema_cls()
-
- def load_from_file(self, path: str | Path):
- import yaml
- p = Path(path)
- with open(p, "r") as f:
- data = yaml.safe_load(f) or {}
- return self._from_dict(data)
-
- def save(self, scope: str, settings: S) -> None:
- import yaml
- self._backup_if_exists(scope)
- try:
- with open(self.active_path(scope), "w") as f:
- yaml.safe_dump(self._to_dict(settings), f, sort_keys=False)
- except Exception as e:
- print(f"Error saving settings: {e}")
-
- def write_defaults(self, scope: str, settings: S | None = None) -> Path:
- import yaml
- payload = self._to_dict(settings or self.schema_cls())
- dp = self.default_path(scope)
- try:
- with open(dp, "w") as f:
- yaml.safe_dump(payload, f, sort_keys=False)
- except Exception as e:
- print(f"Error writing default settings: {e}")
- return dp
-
- def restore_defaults_into_active(self, scope: str) -> S:
- defaults = self.load_defaults(scope)
- self._backup_if_exists(scope)
- self.save(scope, defaults)
- return defaults
-
- def load_defaults(self, scope: str) -> S:
- import yaml
- dp = self.default_path(scope)
- if not dp.exists():
- return self.schema_cls()
- try:
- with open(dp, "r") as f:
- return self._from_dict(yaml.safe_load(f) or {})
- except Exception as e:
- print(f"Error loading default settings: {e}")
- return self.schema_cls()
-
- def list_backups(self, scope: str) -> list[Path]:
- bd = self.backup_dir(scope)
- try:
- return sorted(bd.glob(f"{ACTIVE_FILENAME.split('.')[0]}.*.yaml"),
- key=lambda p: p.stat().st_mtime, reverse=True)
- except Exception:
- return []
\ No newline at end of file
diff --git a/hardware/X Axis Motor Cover.3mf b/hardware/X Axis Motor Cover.3mf
new file mode 100644
index 0000000..770a5f1
Binary files /dev/null and b/hardware/X Axis Motor Cover.3mf differ
diff --git a/hardware/bedspacer.3mf b/hardware/bedspacer.3mf
new file mode 100644
index 0000000..b7bc709
Binary files /dev/null and b/hardware/bedspacer.3mf differ
diff --git a/hardware/handle.3mf b/hardware/handle.3mf
new file mode 100644
index 0000000..d9dfd59
Binary files /dev/null and b/hardware/handle.3mf differ
diff --git a/main.py b/main.py
index c222a1b..63d17da 100644
--- a/main.py
+++ b/main.py
@@ -1,160 +1,34 @@
-import pygame
-import time
-from typing import List
-import multiprocessing as mp
-
-from camera.amscope import AmscopeCamera
-from printer.automated_controller import AutomatedPrinter
-
-from forgeConfig import (
- ForgeSettings,
- ForgeSettingsManager
-)
-
-from UI.frame import Frame
-from UI.ui_layout import create_control_panel, RIGHT_PANEL_WIDTH
-
-if __name__ == "__main__":
- mp.freeze_support()
- mp.set_start_method("spawn", force=True)
-
- config = ForgeSettings()
- config = ForgeSettingsManager.load("")
-
- pygame.init()
- pygame.display.set_caption("FORGE")
- width, height = (config.windowWidth, config.windowHeight)
- screen = pygame.display.set_mode((width, height), pygame.RESIZABLE)
-
- # Frame in which everything is based on
- root_frame = Frame(x=0, y=0, width=width, height=height)
-
- # A clock to limit the frame rate.
- clock = pygame.time.Clock()
-
- right_panel_width = RIGHT_PANEL_WIDTH
- # Initialize camera with the refactored class
- camera = AmscopeCamera()
-
-
- # Initialize the automated printer with configurations
- movementSystem = AutomatedPrinter(config, camera)
-
- time.sleep(1.5)
-
- current_sample_index = 1
-
- (
- sample_label,
- inc_btn,
- dec_btn,
- go_btn,
- speed_display,
- position_display
- ) = create_control_panel(root_frame, movementSystem, camera, current_sample_index)
-
- # Verify no duplicate nodes are present
- def audit_tree(node):
- seen = {}
- for ch in node.children:
- seen.setdefault(id(ch), []).append(ch)
- for ids, lst in seen.items():
- if len(lst) > 1:
- print(f"[DUP] {node.__class__.__name__} id={id(node)} has child repeated x{len(lst)} -> {lst[0].__class__.__name__} id={id(lst[0])}")
- for ch in node.children:
- audit_tree(ch)
-
- audit_tree(root_frame)
-
-
- def go_to_sample():
- pos = movementSystem.get_sample_position(current_sample_index)
- movementSystem.move_to_position(pos)
-
- def increment_sample():
- global current_sample_index
- if current_sample_index < movementSystem.get_num_slots():
- current_sample_index += 1
- sample_label.set_text(f"Sample {current_sample_index}")
-
- def decrement_sample():
- global current_sample_index
- if current_sample_index > 1:
- current_sample_index -= 1
- sample_label.set_text(f"Sample {current_sample_index}")
-
-
- inc_btn.function_to_call = increment_sample
- dec_btn.function_to_call = decrement_sample
- go_btn.function_to_call = go_to_sample
-
-
-
-
-
- running = True
- while running:
- clock.tick(60)
- # Mouse Position
- pos = pygame.mouse.get_pos()
-
- for event in pygame.event.get():
- if event.type == pygame.QUIT:
- running = False
- elif event.type == pygame.VIDEORESIZE:
- new_width, new_height = event.w, event.h
-
- width, height = new_width, new_height
-
- root_frame.width = new_width
- root_frame.height = new_height
-
- print(width, height)
-
- elif event.type == pygame.MOUSEWHEEL:
- mx, my = pos
- root_frame.process_mouse_wheel(mx, my, dx=event.x, dy=event.y)
- elif event.type == pygame.MOUSEBUTTONUP:
- if event.button in (1, 2, 3):
- root_frame.process_mouse_release(*pos, button="left")
- elif event.type == pygame.MOUSEBUTTONDOWN:
- if event.button in (1, 2, 3): # left, middle, right only
- root_frame.broadcast_mouse_press(*pos, button="left")
- root_frame.process_mouse_press(*pos, button="left")
- elif event.type == pygame.KEYDOWN:
- root_frame.broadcast_key_event(event)
- if event.key == pygame.K_ESCAPE:
- running = False
- elif event.type == pygame.KEYUP:
- root_frame.broadcast_key_event(event)
-
- root_frame.process_mouse_move(*pos)
-
- # Rendering
- screen.fill([60, 60, 60])
-
- def draw_debug_outline(surface, frame):
- cx, cy, cw, ch = frame.get_content_geometry()
- pygame.draw.rect(surface, pygame.Color(0, 255, 0), (cx, cy, cw, ch), 2)
-
- x, y, w, h = frame.get_absolute_geometry()
- color = frame.debug_outline_color
- pygame.draw.rect(surface, color, pygame.Rect(x, y, w, h), 1)
-
- for child in frame.children:
- draw_debug_outline(surface, child)
-
- # Draw GUI
- root_frame.draw(screen)
-
- #draw_debug_outline(screen, root_frame)
-
- speed_display.set_text(f"Step Size: {movementSystem.speed / 100:.2f}mm")
- position_display.set_text( f"X: {movementSystem.position.x/100:.2f} Y: {movementSystem.position.y/100:.2f} Z: {movementSystem.position.z/100:.2f}")
- #position1_display.set_text(f"X: {movementSystem.automation_config.x_start/100:.2f} Y: {movementSystem.automation_config.y_start/100:.2f} Z: {movementSystem.automation_config.z_start/100:.2f}")
- #position2_display.set_text(f"X: {movementSystem.automation_config.x_end/100:.2f} Y: {movementSystem.automation_config.y_end/100:.2f} Z: {movementSystem.automation_config.z_end/100:.2f}")
- pygame.display.flip()
-
- # Ensure camera is properly closed
- camera.close()
- pygame.quit()
\ No newline at end of file
+import sys
+import multiprocessing as mp
+
+from PySide6.QtWidgets import QApplication
+
+# GUI
+from UI.main_window import MainWindow
+from UI.style import apply_style
+
+# Initialize app context early
+from common.app_context import get_app_context
+from common.logger import info
+
+
+if __name__ == "__main__":
+ mp.freeze_support()
+ mp.set_start_method("spawn", force=True)
+
+ app = QApplication(sys.argv)
+ apply_style(app)
+
+ ctx = get_app_context()
+ info("FieldWeave application starting")
+
+ win = MainWindow()
+ win.show()
+
+ exit_code = app.exec()
+
+ # Cleanup
+ info("FieldWeave application shutting down")
+ ctx.cleanup()
+
+ sys.exit(exit_code)
diff --git a/misc/.gitignore b/misc/.gitignore
new file mode 100644
index 0000000..b51236f
--- /dev/null
+++ b/misc/.gitignore
@@ -0,0 +1,3 @@
+/qttest/
+/calculateDPIError.py
+/color_test.py
\ No newline at end of file
diff --git a/color.py b/misc/color.py
similarity index 100%
rename from color.py
rename to misc/color.py
diff --git a/misc/generic_image_stitch/.gitignore b/misc/generic_image_stitch/.gitignore
new file mode 100644
index 0000000..ea5c1ad
--- /dev/null
+++ b/misc/generic_image_stitch/.gitignore
@@ -0,0 +1 @@
+/input/*
diff --git a/misc/generic_image_stitch/focusstack.py b/misc/generic_image_stitch/focusstack.py
new file mode 100644
index 0000000..b054121
--- /dev/null
+++ b/misc/generic_image_stitch/focusstack.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""
+Batch focus stacking script.
+Processes all subfolders in a given directory, running focus-stack on the JPEG images in each.
+"""
+
+import os
+import sys
+import subprocess
+from pathlib import Path
+
+
+def main():
+ if len(sys.argv) != 2:
+ print("Usage: python focus_stack_batch.py ")
+ sys.exit(1)
+
+ input_folder = Path(sys.argv[1])
+
+ if not input_folder.exists():
+ print(f"Error: Folder '{input_folder}' does not exist")
+ sys.exit(1)
+
+ if not input_folder.is_dir():
+ print(f"Error: '{input_folder}' is not a directory")
+ sys.exit(1)
+
+ # Get the focus-stack executable path relative to this script
+ script_dir = Path(__file__).parent
+ focus_stack_exe = script_dir / "../../focus-stack/focus-stack.exe"
+ focus_stack_exe = focus_stack_exe.resolve()
+
+ if not focus_stack_exe.exists():
+ print(f"Error: focus-stack executable not found at '{focus_stack_exe}'")
+ sys.exit(1)
+
+ # Process each subfolder
+ subfolders = [d for d in input_folder.iterdir() if d.is_dir()]
+
+ if not subfolders:
+ print(f"No subfolders found in '{input_folder}'")
+ sys.exit(0)
+
+ print(f"Found {len(subfolders)} subfolder(s) to process")
+
+ for subfolder in sorted(subfolders):
+ # Check if there are any JPEG files in this subfolder
+ jpeg_files = sorted(subfolder.glob("*.jpeg"))
+
+ if not jpeg_files:
+ print(f"Skipping '{subfolder.name}': no JPEG files found")
+ continue
+
+ # Construct output path (use absolute path)
+ output_file = (input_folder / f"{subfolder.name}.jpeg").resolve()
+
+ # Build the command with expanded file list (use absolute paths)
+ cmd = [str(focus_stack_exe)]
+ cmd.extend([str(f.resolve()) for f in jpeg_files])
+ cmd.append(f"--output={str(output_file)}")
+
+ print(f"\nProcessing '{subfolder.name}' ({len(jpeg_files)} images)...")
+ print(f"Output: {output_file}")
+
+ try:
+ result = subprocess.run(cmd, check=True, capture_output=True, text=True)
+ print(f"Success: {subfolder.name}")
+ if result.stdout:
+ print(result.stdout)
+ except subprocess.CalledProcessError as e:
+ print(f"Error processing '{subfolder.name}':")
+ print(f"Exit code: {e.returncode}")
+ if e.stderr:
+ print(f"Error output: {e.stderr}")
+
+ print("\nBatch processing complete!")
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/misc/generic_image_stitch/inc_stitch.py b/misc/generic_image_stitch/inc_stitch.py
new file mode 100644
index 0000000..52c9f62
--- /dev/null
+++ b/misc/generic_image_stitch/inc_stitch.py
@@ -0,0 +1,993 @@
+#!/usr/bin/env python3
+"""
+Progressive Image Stitching Viewer
+Shows images being stitched together in real-time using pygame.
+Improved version with template matching fallback for vertical alignment.
+"""
+
+import cv2
+import numpy as np
+import os
+import argparse
+import pygame
+import time
+from pathlib import Path
+
+
+class ProgressiveStitcher:
+ def __init__(self, display_width=1200, display_height=800, row_counts=None):
+ """Initialize the progressive stitcher with pygame display."""
+ pygame.init()
+ self.display_width = display_width
+ self.display_height = display_height
+ self.screen = pygame.display.set_mode((display_width, display_height))
+ pygame.display.set_caption("Progressive Image Stitching")
+
+ self.clock = pygame.time.Clock()
+ self.font = pygame.font.Font(None, 36)
+ self.small_font = pygame.font.Font(None, 24)
+
+ # Spatial layout information
+ self.row_counts = row_counts or [] # Number of images per row
+
+ # World space for images
+ self.images = [] # List of dicts with 'image', 'world_pos', 'index'
+ self.world_offset = [0, 0] # Camera offset for panning
+ self.zoom_scale = 1.0
+
+ # Track current position in snake pattern
+ self.current_row = 0
+ self.current_col = 0
+ self.direction = -1 # -1 for left, 1 for right
+
+ # Estimated image size (will be updated with first image)
+ self.avg_image_width = 800
+ self.avg_image_height = 600
+ self.overlap_ratio = 0.4 # Estimated overlap between images
+
+ def load_images_from_folder(self, folder_path):
+ """Load all JPEG images from the specified folder in order."""
+ image_files = []
+ valid_extensions = {'.jpg', '.jpeg', '.JPG', '.JPEG'}
+
+ # Get all image files
+ all_files = []
+ for file in os.listdir(folder_path):
+ if any(file.endswith(ext) for ext in valid_extensions):
+ all_files.append(file)
+
+ # Sort by numeric prefix if possible
+ def get_numeric_key(filename):
+ # Extract leading numbers from filename
+ import re
+ match = re.match(r'(\d+)', filename)
+ if match:
+ return int(match.group(1))
+ return filename
+
+ all_files.sort(key=get_numeric_key)
+
+ image_files = [os.path.join(folder_path, f) for f in all_files]
+
+ if not image_files:
+ raise ValueError(f"No JPEG images found in {folder_path}")
+
+ print(f"Found {len(image_files)} images:")
+ for img_file in image_files:
+ print(f" - {os.path.basename(img_file)}")
+
+ return image_files
+
+ def find_feature_rich_region(self, image_gray, direction):
+ """
+ Use edge detection to find the most feature-rich vertical strip in the image.
+
+ Args:
+ image_gray: Grayscale image
+ direction: -1 for left, 1 for right (which side to prioritize)
+
+ Returns:
+ tuple: (start_col, width) for the best region, or None if detection fails
+ """
+ h, w = image_gray.shape
+
+ # Detect edges using Canny
+ edges = cv2.Canny(image_gray, 50, 150)
+
+ # Divide image into vertical strips and count edges in each
+ num_strips = 10
+ strip_width = w // num_strips
+ edge_counts = []
+
+ for i in range(num_strips):
+ start_x = i * strip_width
+ end_x = min((i + 1) * strip_width, w)
+ strip = edges[:, start_x:end_x]
+ edge_count = np.sum(strip > 0)
+ edge_counts.append((i, edge_count, start_x, end_x))
+
+ # Sort by edge count (most features first)
+ edge_counts.sort(key=lambda x: x[1], reverse=True)
+
+ # Prioritize strips on the side we're interested in
+ if direction == -1:
+ # Going left: prefer left side (lower indices)
+ # Weight: lower index = higher priority
+ weighted_scores = [(i, count - (idx * count * 0.1), start_x, end_x)
+ for idx, count, start_x, end_x in edge_counts]
+ else:
+ # Going right: prefer right side (higher indices)
+ # Weight: higher index = higher priority
+ weighted_scores = [(i, count + (idx * count * 0.1), start_x, end_x)
+ for idx, count, start_x, end_x in edge_counts]
+
+ # Re-sort by weighted score
+ weighted_scores.sort(key=lambda x: x[1], reverse=True)
+
+ # Take top 3-4 strips and use them (they might be adjacent)
+ best_strips = weighted_scores[:4]
+ best_indices = [x[0] for x in best_strips]
+ best_indices.sort()
+
+ # Find contiguous region
+ if len(best_indices) >= 2:
+ start_idx = best_indices[0]
+ end_idx = best_indices[-1]
+ # Expand to include strips in between
+ start_col = start_idx * strip_width
+ end_col = min((end_idx + 1) * strip_width, w)
+ width = end_col - start_col
+
+ # Ensure width is reasonable (20-50% of image)
+ min_width = int(w * 0.2)
+ max_width = int(w * 0.5)
+ if width < min_width:
+ width = min_width
+ if width > max_width:
+ width = max_width
+ # Adjust start_col if needed
+ if direction == -1:
+ start_col = 0
+ else:
+ start_col = w - width
+
+ print(f" Edge detection: Best region at x={start_col}-{start_col + width} ({width}px wide)")
+ return start_col, width
+
+ return None
+
+ def find_vertical_offset_template_matching(self, new_image, prev_row_image):
+ """
+ Use template matching to find vertical offset between images.
+ Also detects horizontal offset based on where the match occurred.
+
+ Args:
+ new_image: The new image (top)
+ prev_row_image: The image from previous row (bottom)
+
+ Returns:
+ tuple: (y_offset, x_offset, confidence) or (None, None, 0) if matching fails
+ """
+ print(f" Attempting template matching for vertical alignment...")
+
+ new_h, new_w = new_image.shape[:2]
+ prev_h, prev_w = prev_row_image.shape[:2]
+
+ # Convert to grayscale for template matching
+ new_gray = cv2.cvtColor(new_image, cv2.COLOR_BGR2GRAY)
+ prev_gray = cv2.cvtColor(prev_row_image, cv2.COLOR_BGR2GRAY)
+
+ # Calculate estimated overlap based on overlap ratio
+ estimated_overlap_height = int(self.avg_image_height * self.overlap_ratio)
+
+ # Use a more conservative template height (60% of estimated overlap)
+ # This ensures the template is significantly smaller than the search region
+ overlap_height = int(estimated_overlap_height * 0.6)
+ overlap_height = min(overlap_height, int(new_h * 0.3)) # Cap at 30% of image height
+ template_full = new_gray[-overlap_height:, :]
+
+ # Search region should be generous: estimated overlap + 100% margin
+ search_margin = estimated_overlap_height # Full overlap height as margin
+ search_height = estimated_overlap_height + search_margin
+ search_height = min(search_height, prev_h) # Don't exceed image bounds
+
+ # Use edge detection to find feature-rich region for the template
+ feature_region = self.find_feature_rich_region(template_full, self.direction)
+
+ if feature_region is not None:
+ feature_start_col, feature_width = feature_region
+
+ # Crop template to feature-rich region
+ template = template_full[:, feature_start_col:feature_start_col + feature_width]
+
+ print(f" Using edge-detected template region: {feature_width}px wide at x={feature_start_col}")
+
+ # Store where we cropped the template from (for offset calculation later)
+ template_crop_start = feature_start_col
+ template_crop_width = feature_width
+ else:
+ # Fallback: use fixed percentage on the side based on direction
+ print(f" Edge detection failed, using fallback template crop")
+ template_crop_ratio = 0.3
+ template_crop_width = int(new_w * template_crop_ratio)
+
+ if self.direction == -1:
+ # Going left: use left side
+ template = template_full[:, :template_crop_width]
+ template_crop_start = 0
+ else:
+ # Going right: use right side
+ template = template_full[:, -template_crop_width:]
+ template_crop_start = new_w - template_crop_width
+
+ # Search region: use the same side as template, but wider
+ # This ensures the template region will be found in the search region
+ search_crop_ratio = 0.5 # Use 50% of the width for search
+ search_crop_width = int(prev_w * search_crop_ratio)
+
+ if self.direction == -1:
+ # Going left: search in left portion
+ search_region_full = prev_gray[:search_height, :]
+ search_region = search_region_full[:, :search_crop_width]
+ search_crop_start = 0
+ print(f" Direction: LEFT, searching in left {search_crop_ratio:.0%} ({search_crop_width}px)")
+ else:
+ # Going right: search in right portion
+ search_region_full = prev_gray[:search_height, :]
+ search_region = search_region_full[:, -search_crop_width:]
+ search_crop_start = prev_w - search_crop_width
+ print(f" Direction: RIGHT, searching in right {search_crop_ratio:.0%} ({search_crop_width}px)")
+
+ print(f" Template: {overlap_height}x{template_crop_width}px, Search: {search_height}x{search_crop_width}px")
+
+ print(f" Using estimated overlap: {estimated_overlap_height}px, template: {overlap_height}px height")
+
+ # Resize if images are too large for efficient matching
+ # Scale based on the TEMPLATE size to keep it manageable
+ max_dim = 500
+ scale = 1.0
+ template_h_before_scale, template_w_before_scale = template.shape
+ template_max_dim = max(template_h_before_scale, template_w_before_scale)
+ if template_max_dim > max_dim:
+ scale = max_dim / template_max_dim
+ template = cv2.resize(template, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
+ search_region = cv2.resize(search_region, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
+
+ template_h, template_w = template.shape
+ search_h, search_w = search_region.shape
+
+ print(f" After scaling: Template {template_h}x{template_w}, Search {search_h}x{search_w}")
+
+ # Save debug images to see what's being matched
+ debug_dir = "template_matching_debug"
+ import os
+ os.makedirs(debug_dir, exist_ok=True)
+
+ # Save template and search region
+ cv2.imwrite(f"{debug_dir}/template_{self.current_row}.png", template)
+ cv2.imwrite(f"{debug_dir}/search_{self.current_row}.png", search_region)
+ print(f" Debug: Saved template and search images to {debug_dir}/")
+
+ # OpenCV requires search region to be strictly larger than template in BOTH dimensions
+ if template_h >= search_h or template_w >= search_w:
+ print(f" Template matching failed: template too large")
+ return None, None, 0
+
+ # Additional check: ensure there's meaningful room for searching (template < 70% of search)
+ if template_h > search_h * 0.7 or template_w > search_w * 0.7:
+ print(f" Template matching failed: insufficient search space (template needs to be <70% of search)")
+ return None, None, 0
+
+ # Perform template matching using normalized cross-correlation
+ result = cv2.matchTemplate(search_region, template, cv2.TM_CCOEFF_NORMED)
+ min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
+
+ print(f" Template matching confidence: {max_val:.3f}")
+
+ # If confidence is too low and we used edge detection, try fallback with side-based crop
+ if max_val < 0.5 and feature_region is not None:
+ print(f" Edge-detected template confidence too low, trying side-based fallback...")
+
+ # Try side-based template instead
+ fallback_crop_ratio = 0.4
+ fallback_crop_width = int(new_w * fallback_crop_ratio)
+
+ if self.direction == -1:
+ template_fallback = template_full[:, :fallback_crop_width]
+ template_crop_start = 0
+ else:
+ template_fallback = template_full[:, -fallback_crop_width:]
+ template_crop_start = new_w - fallback_crop_width
+
+ # Resize fallback template
+ template_h_fb, template_w_fb = template_fallback.shape
+ template_max_dim_fb = max(template_h_fb, template_w_fb)
+ scale_fb = 1.0
+ if template_max_dim_fb > max_dim:
+ scale_fb = max_dim / template_max_dim_fb
+ template_fallback = cv2.resize(template_fallback, None, fx=scale_fb, fy=scale_fb, interpolation=cv2.INTER_AREA)
+ search_region_fb = cv2.resize(search_region, None, fx=scale_fb, fy=scale_fb, interpolation=cv2.INTER_AREA)
+ else:
+ search_region_fb = search_region.copy()
+
+ template_h_fb, template_w_fb = template_fallback.shape
+ search_h_fb, search_w_fb = search_region_fb.shape
+
+ # Check if valid
+ if template_h_fb < search_h_fb and template_w_fb < search_w_fb and \
+ template_h_fb < search_h_fb * 0.7 and template_w_fb < search_w_fb * 0.7:
+
+ result_fb = cv2.matchTemplate(search_region_fb, template_fallback, cv2.TM_CCOEFF_NORMED)
+ min_val_fb, max_val_fb, min_loc_fb, max_loc_fb = cv2.minMaxLoc(result_fb)
+
+ print(f" Fallback confidence: {max_val_fb:.3f}")
+
+ # Use fallback if it's better
+ if max_val_fb > max_val:
+ print(f" Using fallback template (better confidence)")
+ template = template_fallback
+ search_region = search_region_fb
+ max_val = max_val_fb
+ max_loc = max_loc_fb
+ scale = scale_fb
+ template_h, template_w = template_h_fb, template_w_fb
+ search_h, search_w = search_h_fb, search_w_fb
+
+ # Save fallback debug images
+ cv2.imwrite(f"{debug_dir}/template_{self.current_row}_fallback.png", template)
+
+ # Require a minimum confidence threshold
+ if max_val < 0.5:
+ print(f" Template matching confidence too low")
+ return None, None, 0
+
+ # The match location tells us where the TOP-LEFT of template matched in the search region
+ # Template is the bottom overlap_height pixels of new_image
+ # Search region is the top search_height pixels of prev_row_image
+ match_y = max_loc[1]
+ match_x = max_loc[0]
+
+ # Scale back to original resolution
+ match_y = int(match_y / scale)
+ match_x = int(match_x / scale)
+
+ # Calculate the Y offset:
+ # The template (bottom of new image) matched at position match_y in the search region
+ # Y offset = match_y + overlap_height - new_h (should be negative)
+ y_offset = match_y + overlap_height - new_h
+
+ # Calculate the X offset:
+ # match_x tells us where the template matched within the search region (in search_region coordinates)
+ # We need to convert this to world coordinates accounting for our crops
+ # Template was cropped starting at template_crop_start
+ # Search was cropped starting at search_crop_start
+ # If match_x = 0, it means template aligned perfectly with search start
+ # The actual X offset in world coordinates:
+ x_offset = search_crop_start + match_x - template_crop_start
+
+ # Create visualization showing where the match was found
+ debug_dir = "template_matching_debug"
+
+ # Also save edge detection visualization
+ edges_new = cv2.Canny(new_gray, 50, 150)
+ edges_prev = cv2.Canny(prev_gray, 50, 150)
+ cv2.imwrite(f"{debug_dir}/edges_new_{self.current_row}.png", edges_new)
+ cv2.imwrite(f"{debug_dir}/edges_prev_{self.current_row}.png", edges_prev)
+
+ vis_search = cv2.cvtColor(search_region, cv2.COLOR_GRAY2BGR)
+ # Draw rectangle where template matched
+ cv2.rectangle(vis_search,
+ (match_x, match_y),
+ (match_x + template_w, match_y + template_h),
+ (0, 255, 0), 2)
+ # Add text showing match position
+ cv2.putText(vis_search, f"Match: ({match_x}, {match_y})",
+ (match_x, match_y - 10),
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
+ cv2.imwrite(f"{debug_dir}/match_visualization_{self.current_row}.png", vis_search)
+
+ print(f" Template match found at x={match_x}, y={match_y}")
+ print(f" Template height={overlap_height}, new image height={new_h}")
+ print(f" Calculated offsets: X={x_offset:.1f}, Y={y_offset:.1f} pixels")
+
+ return y_offset, x_offset, max_val
+
+ def find_horizontal_offset_template_matching(self, new_image, prev_image, direction):
+ """
+ Use template matching to find horizontal offset between images.
+
+ Args:
+ new_image: The new image
+ prev_image: The previous image
+ direction: -1 for left, 1 for right
+
+ Returns:
+ tuple: (x_offset, confidence) or (None, 0) if matching fails
+ """
+ print(f" Attempting template matching for horizontal alignment...")
+
+ new_h, new_w = new_image.shape[:2]
+ prev_h, prev_w = prev_image.shape[:2]
+
+ # Convert to grayscale
+ new_gray = cv2.cvtColor(new_image, cv2.COLOR_BGR2GRAY)
+ prev_gray = cv2.cvtColor(prev_image, cv2.COLOR_BGR2GRAY)
+
+ # Calculate estimated overlap based on overlap ratio
+ estimated_overlap_width = int(self.avg_image_width * self.overlap_ratio)
+
+ # Take overlap region based on direction
+ # Use estimated overlap width, but cap it at 40% of image width for safety
+ overlap_width = min(estimated_overlap_width, int(new_w * 0.4))
+
+ # Focus search region around expected overlap area
+ search_margin = int(estimated_overlap_width * 0.5)
+ search_width = estimated_overlap_width + search_margin
+ search_width = min(search_width, prev_w) # Don't exceed image bounds
+
+ if direction == -1:
+ # Moving left: take right edge of new image as template
+ template = new_gray[:, -overlap_width:]
+ # Search in left portion of previous image (focused on overlap region)
+ search_region = prev_gray[:, :search_width]
+ else:
+ # Moving right: take left edge of new image as template
+ template = new_gray[:, :overlap_width]
+ # Search in right portion of previous image (focused on overlap region)
+ search_region = prev_gray[:, -search_width:]
+
+ print(f" Using estimated overlap: {estimated_overlap_width}px, template: {overlap_width}px, search: {search_width}px")
+
+ # Resize if images are too large
+ max_height = 600
+ scale = 1.0
+ if new_h > max_height or prev_h > max_height:
+ scale = max_height / max(new_h, prev_h)
+ template = cv2.resize(template, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
+ search_region = cv2.resize(search_region, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
+
+ template_h, template_w = template.shape
+ search_h, search_w = search_region.shape
+
+ # OpenCV requires search region to be strictly larger than template in BOTH dimensions
+ if template_w >= search_w or template_h >= search_h:
+ print(f" Template matching failed: template too large (template: {template_h}x{template_w}, search: {search_h}x{search_w})")
+ return None, 0
+
+ # Additional check: ensure there's meaningful room for searching
+ if template_w > search_w * 0.9 or template_h > search_h * 0.95:
+ print(f" Template matching failed: insufficient search space (template: {template_h}x{template_w}, search: {search_h}x{search_w})")
+ return None, 0
+
+ # Perform template matching
+ result = cv2.matchTemplate(search_region, template, cv2.TM_CCOEFF_NORMED)
+ min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
+
+ print(f" Template matching confidence: {max_val:.3f}")
+
+ if max_val < 0.5:
+ print(f" Template matching confidence too low")
+ return None, 0
+
+ match_x = max_loc[0]
+ match_x = int(match_x / scale)
+
+ # Calculate x offset
+ if direction == -1:
+ # Moving left: new image goes to the left of previous
+ x_offset = -(prev_w - match_x)
+ else:
+ # Moving right: new image goes to the right of previous
+ x_offset = (prev_w - search_width) + match_x
+
+ print(f" Template match found at x={match_x}, offset={x_offset:.1f} pixels")
+
+ return x_offset, max_val
+
+ def get_nearby_images(self, world_pos, radius=2.5):
+ """Get images within radius of the given world position."""
+ nearby = []
+ px, py = world_pos
+
+ for img_data in self.images:
+ ix, iy = img_data['world_pos']
+ # Calculate distance in "image units"
+ dist_x = abs(px - ix) / self.avg_image_width
+ dist_y = abs(py - iy) / self.avg_image_height
+ dist = (dist_x**2 + dist_y**2)**0.5
+
+ if dist <= radius:
+ nearby.append(img_data)
+
+ return nearby
+
+ def world_to_screen(self, world_pos):
+ """Convert world coordinates to screen coordinates."""
+ wx, wy = world_pos
+ sx = (wx + self.world_offset[0]) * self.zoom_scale + self.display_width // 2
+ sy = (wy + self.world_offset[1]) * self.zoom_scale + self.display_height // 2
+ return [sx, sy]
+
+ def render_worldspace(self):
+ """Render all images in their worldspace positions."""
+ self.screen.fill((30, 30, 30))
+
+ # Sort images by index to render in order (earlier images first)
+ sorted_images = sorted(self.images, key=lambda x: x['index'])
+
+ for img_data in sorted_images:
+ image = img_data['image']
+ world_pos = img_data['world_pos']
+
+ # Convert to screen space
+ screen_pos = self.world_to_screen(world_pos)
+
+ # Scale image
+ h, w = image.shape[:2]
+ scaled_w = int(w * self.zoom_scale)
+ scaled_h = int(h * self.zoom_scale)
+
+ # Skip if too small or off screen
+ if scaled_w < 2 or scaled_h < 2:
+ continue
+ if screen_pos[0] + scaled_w < 0 or screen_pos[0] > self.display_width:
+ continue
+ if screen_pos[1] + scaled_h < 0 or screen_pos[1] > self.display_height:
+ continue
+
+ # Resize and convert to pygame
+ scaled_img = cv2.resize(image, (scaled_w, scaled_h), interpolation=cv2.INTER_AREA)
+ # Convert BGR to RGB
+ rgb_image = cv2.cvtColor(scaled_img, cv2.COLOR_BGR2RGB)
+ # Transpose to get correct orientation for pygame (swap axes)
+ rgb_image = np.transpose(rgb_image, (1, 0, 2))
+ surface = pygame.surfarray.make_surface(rgb_image)
+
+ # Draw with slight transparency for overlaps
+ surface.set_alpha(220)
+ self.screen.blit(surface, screen_pos)
+
+ # Draw border
+ pygame.draw.rect(self.screen, (100, 100, 100),
+ (*screen_pos, scaled_w, scaled_h), 1)
+
+ def auto_frame_images(self):
+ """Automatically adjust zoom and offset to fit all images."""
+ if not self.images:
+ return
+
+ # Find bounding box of all images
+ min_x = min(img['world_pos'][0] for img in self.images)
+ max_x = max(img['world_pos'][0] + img['image'].shape[1] for img in self.images)
+ min_y = min(img['world_pos'][1] for img in self.images)
+ max_y = max(img['world_pos'][1] + img['image'].shape[0] for img in self.images)
+
+ # Calculate zoom to fit
+ width = max_x - min_x
+ height = max_y - min_y
+
+ zoom_x = (self.display_width - 100) / width if width > 0 else 1.0
+ zoom_y = (self.display_height - 150) / height if height > 0 else 1.0
+ self.zoom_scale = min(zoom_x, zoom_y, 1.0)
+
+ # Center on images
+ center_x = (min_x + max_x) / 2
+ center_y = (min_y + max_y) / 2
+ self.world_offset = [-center_x, -center_y]
+
+ def draw_status_overlay(self, message, image_num, total_images, loading_file=None):
+ """Draw status text overlay on top of rendered scene."""
+ # Draw semi-transparent background for text
+ overlay = pygame.Surface((self.display_width, 130), pygame.SRCALPHA)
+ overlay.fill((30, 30, 30, 200))
+ self.screen.blit(overlay, (0, 0))
+
+ # Draw main message
+ text = self.font.render(message, True, (255, 255, 255))
+ text_rect = text.get_rect(center=(self.display_width // 2, 30))
+ self.screen.blit(text, text_rect)
+
+ # Draw progress
+ progress_text = f"Image {image_num} of {total_images}"
+ progress = self.small_font.render(progress_text, True, (200, 200, 200))
+ progress_rect = progress.get_rect(center=(self.display_width // 2, 70))
+ self.screen.blit(progress, progress_rect)
+
+ # Draw loading indicator if provided
+ if loading_file:
+ loading_text = f"Loading: {loading_file}"
+ loading = self.small_font.render(loading_text, True, (150, 200, 255))
+ loading_rect = loading.get_rect(center=(self.display_width // 2, 100))
+ self.screen.blit(loading, loading_rect)
+
+ def add_image(self, new_image, index):
+ """Add a new image to the worldspace using stitching to determine precise position."""
+ h, w = new_image.shape[:2]
+
+ if not self.images:
+ # First image at origin
+ self.avg_image_width = w
+ self.avg_image_height = h
+ self.current_col = self.row_counts[0] - 1 if self.row_counts else 0
+ self.current_row = 0
+ self.direction = -1
+ print(f" First image size: {w}x{h}")
+
+ self.images.append({
+ 'image': new_image,
+ 'world_pos': [0, 0],
+ 'index': index,
+ 'row': 0,
+ 'col': self.current_col
+ })
+ return True
+
+ # Check if we're moving to a new row
+ need_new_row = False
+ if self.direction == -1:
+ # Moving left
+ if self.current_col == 0:
+ # About to finish this row
+ need_new_row = True
+ else:
+ # Moving right
+ if self.current_row < len(self.row_counts) and self.current_col == self.row_counts[self.current_row] - 1:
+ # About to finish this row
+ need_new_row = True
+
+ if need_new_row:
+ # Moving to new row - need to stitch with image from previous row
+ print(f" Moving to new row {self.current_row + 1}")
+
+ # Get the last image from the current row (this is where we transition from)
+ transition_image = self.images[-1]
+
+ self.current_row += 1
+ self.direction = -self.direction # Flip direction
+
+ if self.direction == -1:
+ # Now moving left, start at rightmost column of new row
+ self.current_col = self.row_counts[self.current_row] - 1 if self.current_row < len(self.row_counts) else 0
+ else:
+ # Now moving right, start at leftmost column of new row
+ self.current_col = 0
+
+ # Find the appropriate image from the previous row to stitch with
+ prev_row_images = [img for img in self.images if img.get('row', 0) == self.current_row - 1]
+
+ if prev_row_images:
+ transition_x = transition_image['world_pos'][0]
+
+ # Find candidate images from previous row to try matching against
+ # Sort by X distance from transition point
+ candidates = sorted(prev_row_images, key=lambda img: abs(img['world_pos'][0] - transition_x))
+
+ # Try up to 3 candidates from previous row
+ max_candidates = min(3, len(candidates))
+
+ print(f" Will try matching against {max_candidates} candidates from previous row")
+
+ best_match = None
+ best_confidence = 0
+
+ for i, candidate_img in enumerate(candidates[:max_candidates]):
+ print(f" Candidate {i+1}/{max_candidates}: image {candidate_img['index']} (X: {candidate_img['world_pos'][0]:.1f})")
+
+ # First try SIFT-based stitching
+ stitcher = cv2.Stitcher.create(cv2.Stitcher_SCANS)
+ status, stitched = stitcher.stitch([new_image, candidate_img['image']])
+
+ if status == cv2.Stitcher_OK:
+ # Calculate Y offset from stitched result
+ stitched_h, stitched_w = stitched.shape[:2]
+ prev_h, prev_w = candidate_img['image'].shape[:2]
+
+ # Y offset is negative (moving up)
+ y_offset = -(stitched_h - prev_h)
+ next_y = candidate_img['world_pos'][1] + y_offset
+
+ # X position: use the candidate image's X position
+ next_x = candidate_img['world_pos'][0]
+
+ print(f" SIFT successful! Y offset: {y_offset:.1f} pixels")
+ world_pos = [next_x, next_y]
+ break # Found a good match, stop searching
+ else:
+ # SIFT failed, try template matching
+ print(f" SIFT failed, trying template matching...")
+ y_offset, x_offset, confidence = self.find_vertical_offset_template_matching(
+ new_image, candidate_img['image']
+ )
+
+ if confidence > best_confidence:
+ best_match = {
+ 'candidate': candidate_img,
+ 'y_offset': y_offset,
+ 'x_offset': x_offset,
+ 'confidence': confidence
+ }
+ best_confidence = confidence
+ print(f" Template confidence: {confidence:.3f} (new best)")
+ else:
+ print(f" Template confidence: {confidence:.3f}")
+
+ # If we found a very good match, stop searching
+ if confidence > 0.8:
+ break
+
+ # Check if we found a good match through SIFT (world_pos was set) or template matching
+ if 'world_pos' not in locals():
+ # SIFT didn't work for any candidate, use best template match
+ if best_match and best_confidence > 0.5:
+ next_y = best_match['candidate']['world_pos'][1] + best_match['y_offset']
+ next_x = best_match['candidate']['world_pos'][0] + best_match['x_offset']
+ print(f" Using best template match: image {best_match['candidate']['index']}, confidence: {best_confidence:.3f}")
+ print(f" Offsets: X={best_match['x_offset']:.1f}, Y={best_match['y_offset']:.1f} pixels")
+ world_pos = [next_x, next_y]
+ else:
+ # All methods failed, use estimated offset
+ closest_img = candidates[0]
+ y_offset = -(self.avg_image_height * (1 - self.overlap_ratio))
+ x_offset = 0
+ print(f" All matching attempts failed (best confidence: {best_confidence:.3f}), using estimated offset")
+ print(f" Estimated offsets: X={x_offset:.1f}, Y={y_offset:.1f} pixels")
+ next_y = closest_img['world_pos'][1] + y_offset
+ next_x = closest_img['world_pos'][0]
+ world_pos = [next_x, next_y]
+ else:
+ # No previous row images, use estimated offset from last image
+ prev_pos = self.images[-1]['world_pos']
+ y_offset = -(self.avg_image_height * (1 - self.overlap_ratio))
+ x_offset = 0 # No horizontal offset for new row
+ print(f" No previous row images found, using estimated offset")
+ print(f" Estimated offsets: X={x_offset:.1f}, Y={y_offset:.1f} pixels")
+ world_pos = [prev_pos[0], prev_pos[1] + y_offset]
+
+ else:
+ # Continue in same row - stitch horizontally with previous image
+ prev_image_data = self.images[-1]
+ prev_image = prev_image_data['image']
+ prev_pos = prev_image_data['world_pos']
+
+ # Use stitcher to find homography/transformation
+ stitcher = cv2.Stitcher.create(cv2.Stitcher_SCANS)
+
+ # Stitch the two images
+ if self.direction == -1:
+ # Moving left: prev_image on right, new_image on left
+ status, stitched = stitcher.stitch([new_image, prev_image])
+ else:
+ # Moving right: prev_image on left, new_image on right
+ status, stitched = stitcher.stitch([prev_image, new_image])
+
+ if status == cv2.Stitcher_OK:
+ # Calculate offset based on stitched result
+ stitched_h, stitched_w = stitched.shape[:2]
+ prev_h, prev_w = prev_image.shape[:2]
+ new_h, new_w = new_image.shape[:2]
+
+ if self.direction == -1:
+ # Moving left: new image adds width to the left
+ x_offset = -(stitched_w - prev_w)
+ next_x = prev_pos[0] + x_offset
+ next_y = prev_pos[1]
+ print(f" Horizontal stitch (SIFT) successful! X offset: {x_offset:.1f} pixels (moving left)")
+ else:
+ # Moving right: new image adds width to the right
+ x_offset = stitched_w - prev_w
+ next_x = prev_pos[0] + x_offset
+ next_y = prev_pos[1]
+ print(f" Horizontal stitch (SIFT) successful! X offset: {x_offset:.1f} pixels (moving right)")
+
+ world_pos = [next_x, next_y]
+ else:
+ # SIFT failed, try template matching
+ print(f" Horizontal stitch (SIFT) failed, trying template matching...")
+ x_offset, confidence = self.find_horizontal_offset_template_matching(
+ new_image, prev_image, self.direction
+ )
+
+ if x_offset is not None and confidence > 0.5:
+ next_x = prev_pos[0] + x_offset
+ next_y = prev_pos[1]
+ print(f" Template matching successful! X offset: {x_offset:.1f} pixels (confidence: {confidence:.3f})")
+ world_pos = [next_x, next_y]
+ else:
+ # Both methods failed, use estimated position
+ x_offset = self.avg_image_width * (1 - self.overlap_ratio) * self.direction
+ y_offset = 0 # No vertical offset in same row
+ print(f" Template matching also failed, using estimated position")
+ print(f" Estimated offsets: X={x_offset:.1f}, Y={y_offset:.1f} pixels")
+ next_x = prev_pos[0] + x_offset
+ next_y = prev_pos[1]
+ world_pos = [next_x, next_y]
+
+ # Update column counter
+ if self.direction == -1:
+ self.current_col -= 1
+ else:
+ self.current_col += 1
+
+ print(f" Placing at world position: ({world_pos[0]:.1f}, {world_pos[1]:.1f}), row: {self.current_row}, col: {self.current_col}")
+
+ # Add image to worldspace
+ self.images.append({
+ 'image': new_image,
+ 'world_pos': world_pos,
+ 'index': index,
+ 'row': self.current_row,
+ 'col': self.current_col
+ })
+
+ return True
+
+ def run(self, image_files, delay=1.0):
+ """Run the progressive stitching visualization."""
+ total_images = len(image_files)
+ running = True
+
+ try:
+ for idx, img_file in enumerate(image_files, 1):
+ # Check for quit events
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+ break
+ elif event.type == pygame.KEYDOWN:
+ if event.key == pygame.K_ESCAPE:
+ running = False
+ break
+
+ if not running:
+ break
+
+ # Show loading message while keeping previous view
+ filename = os.path.basename(img_file)
+ print(f"\nLoading image {idx}/{total_images}: {filename}")
+
+ if self.images:
+ # Render current worldspace with loading indicator
+ self.render_worldspace()
+ self.draw_status_overlay(
+ f"Panorama Progress: {len(self.images)}/{total_images} images",
+ len(self.images),
+ total_images,
+ loading_file=filename
+ )
+ pygame.display.flip()
+
+ # Simulate capture delay
+ start_time = time.time()
+ while time.time() - start_time < delay:
+ # Check for quit events during delay
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+ break
+ elif event.type == pygame.KEYDOWN:
+ if event.key == pygame.K_ESCAPE:
+ running = False
+ break
+
+ if not running:
+ break
+
+ time.sleep(0.01)
+
+ if not running:
+ break
+
+ # Load image
+ img = cv2.imread(img_file)
+
+ if img is None:
+ print(f"Warning: Could not load {img_file}, skipping...")
+ continue
+
+ # Add image to worldspace
+ print(f"Adding image {idx} to worldspace...")
+ self.add_image(img, idx)
+
+ # Auto-frame to show all images
+ self.auto_frame_images()
+
+ # Render updated worldspace
+ self.render_worldspace()
+ self.draw_status_overlay(
+ f"Panorama Progress: {len(self.images)}/{total_images} images",
+ len(self.images),
+ total_images
+ )
+ pygame.display.flip()
+
+ # Small pause to show the result
+ time.sleep(0.3)
+
+ # Final display
+ if running and self.images:
+ print("\nAll images loaded!")
+ self.render_worldspace()
+ self.draw_status_overlay(
+ "All Images Loaded! (Press ESC or close window to exit)",
+ total_images,
+ total_images
+ )
+ pygame.display.flip()
+
+ # Wait for user to close
+ waiting = True
+ while waiting:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ waiting = False
+ elif event.type == pygame.KEYDOWN:
+ if event.key == pygame.K_ESCAPE:
+ waiting = False
+ self.clock.tick(30)
+
+ finally:
+ pygame.quit()
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description='Progressive image stitching viewer with real-time display',
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Examples:
+ python inc_stitch_improved.py /path/to/images --rows 9 9 9 10 8 8
+ python inc_stitch_improved.py /path/to/images --delay 2.0 --rows 9 9 9 10 8 8
+ python inc_stitch_improved.py /path/to/images --width 1600 --height 900 --rows 9 9 9 10 8 8
+ """
+ )
+
+ parser.add_argument('input_folder', type=str,
+ help='Folder containing JPEG images to stitch')
+ parser.add_argument('--delay', '-d', type=float, default=1.0,
+ help='Delay between loading images in seconds (default: 1.0)')
+ parser.add_argument('--width', '-w', type=int, default=1200,
+ help='Display width in pixels (default: 1200)')
+ parser.add_argument('--height', '-ht', type=int, default=800,
+ help='Display height in pixels (default: 800)')
+ parser.add_argument('--rows', '-r', type=int, nargs='+', default=None,
+ help='Number of images per row in snake pattern (e.g., 9 9 9 10 8 8)')
+
+ args = parser.parse_args()
+
+ # Validate input folder
+ if not os.path.isdir(args.input_folder):
+ print(f"Error: {args.input_folder} is not a valid directory")
+ return 1
+
+ try:
+ # Create stitcher
+ stitcher = ProgressiveStitcher(args.width, args.height, row_counts=args.rows)
+
+ # Load image file paths
+ image_files = stitcher.load_images_from_folder(args.input_folder)
+
+ if len(image_files) < 1:
+ print("Error: Need at least 1 image")
+ return 1
+
+ # Validate row counts if provided
+ if args.rows:
+ total_expected = sum(args.rows)
+ if total_expected != len(image_files):
+ print(f"Warning: Row counts sum to {total_expected} but found {len(image_files)} images")
+ print("Proceeding anyway...")
+
+ # Run progressive stitching
+ stitcher.run(image_files, delay=args.delay)
+
+ return 0
+
+ except Exception as e:
+ print(f"Error: {e}")
+ import traceback
+ traceback.print_exc()
+ return 1
+
+
+if __name__ == "__main__":
+ exit(main())
\ No newline at end of file
diff --git a/misc/generic_image_stitch/siftstitch.py b/misc/generic_image_stitch/siftstitch.py
new file mode 100644
index 0000000..ea5d787
--- /dev/null
+++ b/misc/generic_image_stitch/siftstitch.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+"""
+Image Stitching Script
+Stitches together overlapping JPEG images from a folder into a single panorama.
+"""
+
+import cv2
+import numpy as np
+import os
+import argparse
+from pathlib import Path
+
+
+def load_images_from_folder(folder_path):
+ """Load all JPEG images from the specified folder."""
+ image_files = []
+ valid_extensions = {'.jpg', '.jpeg', '.JPG', '.JPEG'}
+
+ # Get all image files and sort them
+ for file in sorted(os.listdir(folder_path)):
+ if any(file.endswith(ext) for ext in valid_extensions):
+ image_files.append(os.path.join(folder_path, file))
+
+ if not image_files:
+ raise ValueError(f"No JPEG images found in {folder_path}")
+
+ print(f"Found {len(image_files)} images:")
+ for img_file in image_files:
+ print(f" - {os.path.basename(img_file)}")
+
+ # Load images
+ images = []
+ for img_file in image_files:
+ img = cv2.imread(img_file)
+ if img is None:
+ print(f"Warning: Could not load {img_file}, skipping...")
+ continue
+ images.append(img)
+
+ return images
+
+
+def stitch_images(images):
+ """
+ Stitch images together using OpenCV's Stitcher.
+
+ Args:
+ images: List of images to stitch
+
+ Returns:
+ Stitched image or None if stitching failed
+ """
+ print(f"\nStitching {len(images)} images...")
+
+ # Create stitcher object
+ stitcher = cv2.Stitcher.create(cv2.Stitcher_SCANS)
+
+ # Perform stitching
+ status, stitched = stitcher.stitch(images)
+
+ # Check stitching status
+ if status == cv2.Stitcher_OK:
+ print("Stitching successful!")
+ return stitched
+ else:
+ error_messages = {
+ cv2.Stitcher_ERR_NEED_MORE_IMGS: "Need more images",
+ cv2.Stitcher_ERR_HOMOGRAPHY_EST_FAIL: "Homography estimation failed",
+ cv2.Stitcher_ERR_CAMERA_PARAMS_ADJUST_FAIL: "Camera parameters adjustment failed"
+ }
+ error_msg = error_messages.get(status, f"Unknown error (code: {status})")
+ print(f"Stitching failed: {error_msg}")
+ return None
+
+
+def crop_black_borders(image):
+ """Remove black borders from stitched image."""
+ # Convert to grayscale
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
+
+ # Threshold to find non-black regions
+ _, thresh = cv2.threshold(gray, 1, 255, cv2.THRESH_BINARY)
+
+ # Find contours
+ contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
+
+ if contours:
+ # Get bounding box of largest contour
+ largest_contour = max(contours, key=cv2.contourArea)
+ x, y, w, h = cv2.boundingRect(largest_contour)
+
+ # Crop image
+ cropped = image[y:y+h, x:x+w]
+ return cropped
+
+ return image
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description='Stitch overlapping JPEG images into a panorama',
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Examples:
+ python image_stitcher.py /path/to/images
+ python image_stitcher.py /path/to/images --output my_panorama.jpg
+ python image_stitcher.py /path/to/images --no-crop
+ """
+ )
+
+ parser.add_argument('input_folder', type=str,
+ help='Folder containing JPEG images to stitch')
+ parser.add_argument('--output', '-o', type=str, default='stitched_panorama.jpg',
+ help='Output filename (default: stitched_panorama.jpg)')
+ parser.add_argument('--no-crop', action='store_true',
+ help='Skip automatic cropping of black borders')
+
+ args = parser.parse_args()
+
+ # Validate input folder
+ if not os.path.isdir(args.input_folder):
+ print(f"Error: {args.input_folder} is not a valid directory")
+ return 1
+
+ try:
+ # Load images
+ images = load_images_from_folder(args.input_folder)
+
+ if len(images) < 2:
+ print("Error: Need at least 2 images to stitch")
+ return 1
+
+ # Stitch images
+ result = stitch_images(images)
+
+ if result is None:
+ print("\nStitching failed. Tips:")
+ print(" - Ensure images have sufficient overlap (30-50%)")
+ print(" - Images should be taken from the same position")
+ print(" - Ensure images are in the correct order")
+ return 1
+
+ # Crop black borders unless disabled
+ if not args.no_crop:
+ print("Cropping black borders...")
+ result = crop_black_borders(result)
+
+ # Save result
+ cv2.imwrite(args.output, result)
+ print(f"\nPanorama saved to: {args.output}")
+ print(f"Output size: {result.shape[1]}x{result.shape[0]} pixels")
+
+ return 0
+
+ except Exception as e:
+ print(f"Error: {e}")
+ return 1
+
+
+if __name__ == "__main__":
+ exit(main())
\ No newline at end of file
diff --git a/misc/siftstitch.py b/misc/siftstitch.py
deleted file mode 100644
index 4c3f217..0000000
--- a/misc/siftstitch.py
+++ /dev/null
@@ -1,168 +0,0 @@
-#!/usr/bin/env python3
-"""
-stitch_sift.py
-
-Usage:
- python stitch_sift.py /path/to/images_folder -o output.jpg
-
-Dependencies:
- pip install opencv-contrib-python numpy
-"""
-
-import os
-import argparse
-import cv2
-import numpy as np
-
-
-def load_images_from_folder(folder):
- exts = (".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp")
- files = [os.path.join(folder, f) for f in sorted(os.listdir(folder))
- if f.lower().endswith(exts)]
- imgs = [cv2.imread(f) for f in files]
- files = [f for f, im in zip(files, imgs) if im is not None]
- imgs = [im for im in imgs if im is not None]
- return files, imgs
-
-
-def detect_and_compute_sift(img, sift):
- gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
- kps, des = sift.detectAndCompute(gray, None)
- return kps, des
-
-
-def match_descriptors(des1, des2):
- # BFMatcher with default params; use kNN and ratio test
- bf = cv2.BFMatcher(cv2.NORM_L2)
- knn = bf.knnMatch(des1, des2, k=2)
- good = []
- for m_n in knn:
- if len(m_n) != 2:
- continue
- m, n = m_n
- if m.distance < 0.75 * n.distance:
- good.append(m)
- return good
-
-
-def find_homography_from_matches(kp1, kp2, matches, min_matches=8):
- if len(matches) < min_matches:
- return None
- pts1 = np.float32([kp1[m.queryIdx].pt for m in matches])
- pts2 = np.float32([kp2[m.trainIdx].pt for m in matches])
- H, mask = cv2.findHomography(pts2, pts1, cv2.RANSAC, 5.0) # maps pts2 -> pts1
- return H, mask
-
-
-def compose_global_homographies(images):
- sift = cv2.SIFT_create()
- kps = []
- dess = []
- for im in images:
- kp, des = detect_and_compute_sift(im, sift)
- kps.append(kp)
- dess.append(des)
-
- # global_h[0] = identity (map image 0 into base coord)
- global_h = [np.eye(3)]
- for i in range(1, len(images)):
- des_prev = dess[i - 1]
- des_cur = dess[i]
- kp_prev = kps[i - 1]
- kp_cur = kps[i]
- if des_prev is None or des_cur is None or len(kp_prev) < 4 or len(kp_cur) < 4:
- print(f"WARNING: not enough features between images {i-1} and {i}; using identity.")
- H_pair = np.eye(3)
- else:
- matches = match_descriptors(des_prev, des_cur)
- pair = find_homography_from_matches(kp_prev, kp_cur, matches)
- if pair is None or pair[0] is None:
- print(f"WARNING: homography failed between images {i-1} and {i}; using identity.")
- H_pair = np.eye(3)
- else:
- H_pair = pair[0]
- # compose: H maps points in image_i to image_{i-1}
- # global for i = global_{i-1} @ H_pair
- H_global = global_h[i - 1] @ H_pair
- # normalize
- H_global = H_global / H_global[2, 2]
- global_h.append(H_global)
- return global_h
-
-
-def warp_and_blend(images, homographies):
- # compute canvas extents by transforming corners
- corners = []
- for im, H in zip(images, homographies):
- h, w = im.shape[:2]
- pts = np.array([[0, 0], [w, 0], [w, h], [0, h]], dtype=np.float32)
- pts_h = cv2.perspectiveTransform(pts.reshape(-1, 1, 2), H).reshape(-1, 2)
- corners.append(pts_h)
- all_pts = np.vstack(corners)
- x_min, y_min = np.floor(all_pts.min(axis=0)).astype(int)
- x_max, y_max = np.ceil(all_pts.max(axis=0)).astype(int)
-
- # translation to shift everything into positive coordinates
- tx = -x_min if x_min < 0 else 0
- ty = -y_min if y_min < 0 else 0
- canvas_w = x_max - x_min
- canvas_h = y_max - y_min
- print(f"Canvas size: {canvas_w} x {canvas_h}")
-
- accumulator = np.zeros((canvas_h, canvas_w, 3), dtype=np.float32)
- weight = np.zeros((canvas_h, canvas_w), dtype=np.float32)
-
- for idx, (im, H) in enumerate(zip(images, homographies)):
- Ht = H.copy()
- # add translation
- T = np.array([[1, 0, tx],
- [0, 1, ty],
- [0, 0, 1]], dtype=np.float64)
- Ht = T @ Ht
- warped = cv2.warpPerspective(im, Ht, (canvas_w, canvas_h))
- mask = cv2.warpPerspective(np.ones((im.shape[0], im.shape[1]), dtype=np.uint8), Ht, (canvas_w, canvas_h))
- mask_f = mask.astype(np.float32)
-
- # accumulate weighted sum (simple averaging where images overlap)
- accumulator += warped.astype(np.float32) * mask_f[:, :, None]
- weight += mask_f
-
- # avoid divide by zero
- nonzero = weight > 0
- result = np.zeros_like(accumulator, dtype=np.uint8)
- result[nonzero] = (accumulator[nonzero] / weight[nonzero, None]).astype(np.uint8)
-
- # crop to content bbox
- ys, xs = np.where(weight > 0)
- if len(xs) == 0 or len(ys) == 0:
- return result
- x0, x1 = xs.min(), xs.max()
- y0, y1 = ys.min(), ys.max()
- cropped = result[y0:y1 + 1, x0:x1 + 1]
- return cropped
-
-
-def main():
- parser = argparse.ArgumentParser(description="Stitch images in a folder using SIFT.")
- parser.add_argument("folder", help="Folder containing images (overlapping)")
- parser.add_argument("-o", "--output", default="panorama.jpg", help="Output filename")
- args = parser.parse_args()
-
- files, images = load_images_from_folder(args.folder)
- if len(images) == 0:
- print("No images found in folder.")
- return
- if len(images) == 1:
- cv2.imwrite(args.output, images[0])
- print("Single image - saved as output.")
- return
-
- print(f"Loaded {len(images)} images.")
- homographies = compose_global_homographies(images)
- pano = warp_and_blend(images, homographies)
- cv2.imwrite(args.output, pano)
- print(f"Panorama saved to {args.output}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/misc/usb_camera.py b/misc/usb_camera.py
new file mode 100644
index 0000000..239453a
--- /dev/null
+++ b/misc/usb_camera.py
@@ -0,0 +1,247 @@
+#!/usr/bin/env python3
+"""
+Camera Detection Script
+Detects all available cameras using OpenCV and displays their capabilities.
+"""
+
+from __future__ import annotations
+
+import cv2
+import sys
+import os
+
+try:
+ from cv2_enumerate_cameras import enumerate_cameras
+except ImportError:
+ print("Error: cv2_enumerate_cameras is not installed")
+ print("Install it with: pip install opencv-camera-enumeration")
+ sys.exit(1)
+
+
+def get_camera_properties(cap: cv2.VideoCapture, camera_info: dict) -> dict[str, float | str | int]:
+ """Get detailed properties of a camera."""
+ vid = camera_info.get('vid')
+ pid = camera_info.get('pid')
+
+ properties = {
+ 'Index': camera_info['index'],
+ 'Name': camera_info.get('name', 'Unknown'),
+ 'Path': camera_info.get('path', 'Unknown'),
+ 'VID': f"0x{vid:04X}" if vid is not None else "N/A",
+ 'PID': f"0x{pid:04X}" if pid is not None else "N/A",
+ 'Backend': camera_info.get('backend_name', 'Unknown'),
+ 'Width': cap.get(cv2.CAP_PROP_FRAME_WIDTH),
+ 'Height': cap.get(cv2.CAP_PROP_FRAME_HEIGHT),
+ 'FPS': cap.get(cv2.CAP_PROP_FPS),
+ 'Codec': int(cap.get(cv2.CAP_PROP_FOURCC)),
+ 'Brightness': cap.get(cv2.CAP_PROP_BRIGHTNESS),
+ 'Contrast': cap.get(cv2.CAP_PROP_CONTRAST),
+ 'Saturation': cap.get(cv2.CAP_PROP_SATURATION),
+ 'Hue': cap.get(cv2.CAP_PROP_HUE),
+ 'Gain': cap.get(cv2.CAP_PROP_GAIN),
+ 'Exposure': cap.get(cv2.CAP_PROP_EXPOSURE),
+ }
+
+ # Convert FOURCC code to readable format
+ fourcc = properties['Codec']
+ if fourcc > 0:
+ properties['Codec_String'] = "".join([chr((fourcc >> 8 * i) & 0xFF) for i in range(4)])
+ else:
+ properties['Codec_String'] = 'Unknown'
+
+ return properties
+
+
+def test_resolutions(cap: cv2.VideoCapture) -> list[tuple[int, int]]:
+ """Test common resolutions to see which ones are supported."""
+ common_resolutions = [
+ (320, 240),
+ (640, 480),
+ (800, 600),
+ (1024, 768),
+ (1280, 720),
+ (1280, 1024),
+ (1920, 1080),
+ (2560, 1440),
+ (3840, 2160),
+ ]
+
+ supported = []
+
+ for width, height in common_resolutions:
+ cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
+ cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
+
+ actual_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
+ actual_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
+
+ if actual_width == width and actual_height == height:
+ supported.append((width, height))
+
+ return supported
+
+
+def get_backend_name(backend: int) -> str:
+ """Convert backend ID to name."""
+ backend_names = {
+ cv2.CAP_ANY: "CAP_ANY",
+ cv2.CAP_VFW: "CAP_VFW",
+ cv2.CAP_V4L: "CAP_V4L",
+ cv2.CAP_V4L2: "CAP_V4L2",
+ cv2.CAP_FIREWIRE: "CAP_FIREWIRE",
+ cv2.CAP_FIREWARE: "CAP_FIREWARE",
+ cv2.CAP_IEEE1394: "CAP_IEEE1394",
+ cv2.CAP_DC1394: "CAP_DC1394",
+ cv2.CAP_CMU1394: "CAP_CMU1394",
+ cv2.CAP_DSHOW: "CAP_DSHOW",
+ cv2.CAP_PVAPI: "CAP_PVAPI",
+ cv2.CAP_OPENNI: "CAP_OPENNI",
+ cv2.CAP_OPENNI_ASUS: "CAP_OPENNI_ASUS",
+ cv2.CAP_ANDROID: "CAP_ANDROID",
+ cv2.CAP_XIAPI: "CAP_XIAPI",
+ cv2.CAP_AVFOUNDATION: "CAP_AVFOUNDATION",
+ cv2.CAP_GIGANETIX: "CAP_GIGANETIX",
+ cv2.CAP_MSMF: "CAP_MSMF",
+ cv2.CAP_WINRT: "CAP_WINRT",
+ cv2.CAP_INTELPERC: "CAP_INTELPERC",
+ cv2.CAP_OPENNI2: "CAP_OPENNI2",
+ cv2.CAP_OPENNI2_ASUS: "CAP_OPENNI2_ASUS",
+ cv2.CAP_GPHOTO2: "CAP_GPHOTO2",
+ cv2.CAP_GSTREAMER: "CAP_GSTREAMER",
+ cv2.CAP_FFMPEG: "CAP_FFMPEG",
+ cv2.CAP_IMAGES: "CAP_IMAGES",
+ cv2.CAP_ARAVIS: "CAP_ARAVIS",
+ cv2.CAP_OPENCV_MJPEG: "CAP_OPENCV_MJPEG",
+ cv2.CAP_INTEL_MFX: "CAP_INTEL_MFX",
+ cv2.CAP_XINE: "CAP_XINE",
+ }
+ return backend_names.get(backend, f"Unknown ({backend})")
+
+
+def detect_cameras(backend: int = cv2.CAP_ANY) -> list[dict]:
+ """Detect available cameras using cv2_enumerate_cameras."""
+ cameras = []
+
+ # Suppress OpenCV error messages temporarily
+ original_stderr = sys.stderr
+ sys.stderr = open(os.devnull, 'w')
+
+ try:
+ for camera_info in enumerate_cameras(backend):
+ cameras.append({
+ 'index': camera_info.index,
+ 'name': camera_info.name,
+ 'path': camera_info.path,
+ 'vid': camera_info.vid,
+ 'pid': camera_info.pid,
+ 'backend': camera_info.backend,
+ 'backend_name': get_backend_name(camera_info.backend),
+ })
+ finally:
+ # Restore stderr
+ sys.stderr.close()
+ sys.stderr = original_stderr
+
+ return cameras
+
+
+def main() -> None:
+ """Main function to detect and display camera information."""
+ print("=" * 80)
+ print("OpenCV Camera Detection Script")
+ print("=" * 80)
+ print(f"OpenCV Version: {cv2.__version__}")
+ print()
+
+ # Detect available cameras
+ print("Scanning for cameras...")
+ all_cameras = detect_cameras()
+
+ # Filter out cameras with None VID/PID (virtual cameras, VR headsets, etc.)
+ cameras = [cam for cam in all_cameras if cam['vid'] is not None and cam['pid'] is not None]
+
+ if not cameras:
+ print("No physical cameras detected!")
+ sys.exit(1)
+
+ print(f"Found {len(cameras)} camera instance(s)")
+ print()
+
+ # Group cameras by VID/PID to identify duplicates across backends
+ vid_pid_groups = {}
+ for cam in cameras:
+ key = (cam['vid'], cam['pid'])
+ if key not in vid_pid_groups:
+ vid_pid_groups[key] = []
+ vid_pid_groups[key].append(cam)
+
+ print(f"Unique physical cameras: {len(vid_pid_groups)}")
+ for (vid, pid), cam_list in vid_pid_groups.items():
+ vid_str = f"0x{vid:04X}"
+ pid_str = f"0x{pid:04X}"
+ print(f" VID: {vid_str}, PID: {pid_str} - {cam_list[0]['name']}")
+ if len(cam_list) > 1:
+ backends = ', '.join([c['backend_name'] for c in cam_list])
+ print(f" Available on {len(cam_list)} backend(s): {backends}")
+ print()
+
+ # Process only one instance per unique VID/PID combination
+ # Prefer the first instance found for each unique camera
+ unique_cameras = {}
+ for cam in cameras:
+ key = (cam['vid'], cam['pid'])
+ if key not in unique_cameras:
+ unique_cameras[key] = cam
+
+ # Get detailed information for each unique camera
+ for (vid, pid), camera_info in unique_cameras.items():
+ print("=" * 80)
+ print(f"Camera: {camera_info['name']}")
+ print("=" * 80)
+
+ cap = cv2.VideoCapture(camera_info['index'], camera_info['backend'])
+
+ if not cap.isOpened():
+ print(f"Error: Could not open camera {camera_info['index']}")
+ continue
+
+ # Get camera properties
+ props = get_camera_properties(cap, camera_info)
+
+ print(f"Name: {props['Name']}")
+ print(f"Path: {props['Path']}")
+ print(f"VID: {props['VID']}")
+ print(f"PID: {props['PID']}")
+ print(f"Backend: {props['Backend']}")
+ print(f"Resolution: {int(props['Width'])}x{int(props['Height'])}")
+ print(f"FPS: {props['FPS']}")
+ print(f"Codec: {props['Codec_String']} (FOURCC: {props['Codec']})")
+ print(f"Brightness: {props['Brightness']}")
+ print(f"Contrast: {props['Contrast']}")
+ print(f"Saturation: {props['Saturation']}")
+ print(f"Hue: {props['Hue']}")
+ print(f"Gain: {props['Gain']}")
+ print(f"Exposure: {props['Exposure']}")
+ print()
+
+ # Test supported resolutions
+ print("Testing supported resolutions...")
+ supported_resolutions = test_resolutions(cap)
+
+ if supported_resolutions:
+ print("Supported resolutions:")
+ for width, height in supported_resolutions:
+ print(f" - {width}x{height}")
+ else:
+ print("No standard resolutions detected")
+
+ cap.release()
+ print()
+
+ print("=" * 80)
+ print("Camera detection complete!")
+ print("=" * 80)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/printer/automated_controller.py b/printer/automated_controller.py
index bd54b46..fa4736d 100644
--- a/printer/automated_controller.py
+++ b/printer/automated_controller.py
@@ -5,6 +5,8 @@
from .models import Position, FocusScore
from .base_controller import BasePrinterController
+from .automation.autofocus_mixin import AutofocusMixin
+from .automation.camera_calibration_mixin import CameraCalibrationMixin
from image_processing.machine_vision import MachineVision
from UI.list_frame import ListFrame
@@ -12,8 +14,8 @@
from UI.input.toggle_button import ToggleButton
from UI.input.text_field import TextField
-from forgeConfig import (
- ForgeSettings,
+from common.fieldweaveConfig import (
+ FieldWeaveSettings,
)
from .automation_config import (
AutomationSettings,
@@ -27,157 +29,11 @@
from .automation_config import AutomationSettings, AutomationSettingsManager
-def _scan_bounds_plotter(proc_queue, y_min: float, y_max: float):
- """
- Messages accepted:
- ("data", y, r, g, b, ylum) # add color sample
- ("focus", y, hard_count, soft_count) # add focus counts
- ("break",) # insert NaN gap in both graphs
- ("title", text)
- ("done",) # leave windows open (blocking show)
- ("close",) # close immediately
- """
- import time, math, matplotlib
-
- # ---- pick a GUI backend that exists ----
- import tkinter # noqa: F401
- backend = "TkAgg"
-
- if backend is None:
- # No GUI: drain queue until done/close and exit quietly
- t0 = time.time()
- while True:
- try:
- msg = proc_queue.get(timeout=0.2)
- if isinstance(msg, tuple) and msg and msg[0] in ("done", "close"):
- break
- except Exception:
- if time.time() - t0 > 2.0:
- break
- return
-
- try:
- matplotlib.use(backend, force=True)
- except Exception:
- return
-
- import matplotlib.pyplot as plt
-
- # ---- Figure 1: Color vs Y ----
- plt.ion()
- fig1 = plt.figure(figsize=(8, 5), dpi=120)
- ax1 = fig1.add_subplot(111)
- base_title_1 = "Average Color vs Y (live)"
- ax1.set_title(base_title_1)
- ax1.set_xlabel("Y position (mm)")
- ax1.set_ylabel("Value")
- ax1.grid(True, alpha=0.3)
- ax1.set_xlim(y_min, y_max)
- ys, rs, gs, bs, yls = [], [], [], [], []
- (l_r,) = ax1.plot([], [], label="R")
- (l_g,) = ax1.plot([], [], label="G")
- (l_b,) = ax1.plot([], [], label="B")
- (l_y,) = ax1.plot([], [], label="Y (luminance)")
- ax1.legend(loc="best")
- fig1.canvas.draw_idle()
- try: plt.show(block=False)
- except Exception: pass
-
- # ---- Figure 2: Focus counts vs Y ----
- fig2 = plt.figure(figsize=(8, 5), dpi=120)
- ax2 = fig2.add_subplot(111)
- base_title_2 = "Focus Tiles vs Y (live)"
- ax2.set_title(base_title_2)
- ax2.set_xlabel("Y position (mm)")
- ax2.set_ylabel("Count")
- ax2.grid(True, alpha=0.3)
- ax2.set_xlim(y_min, y_max)
- ys_f, hard_counts, soft_counts = [], [], []
- (l_hard,) = ax2.plot([], [], label="Hard (>= min_score)")
- (l_soft,) = ax2.plot([], [], label="Soft (>= soft_min_score)")
- ax2.legend(loc="best")
- fig2.canvas.draw_idle()
- try: plt.show(block=False)
- except Exception: pass
-
- last_elapsed = None # seconds (float)
-
- running = True
- while running:
- try:
- msg = proc_queue.get(timeout=0.05)
- except Exception:
- msg = None
-
- if msg:
- tag = msg[0]
- if tag == "data":
- _, y, r, g, b, ylum = msg
- ys.append(float(y)); rs.append(float(r)); gs.append(float(g)); bs.append(float(b)); yls.append(float(ylum))
- l_r.set_data(ys, rs); l_g.set_data(ys, gs); l_b.set_data(ys, bs); l_y.set_data(ys, yls)
- ax1.relim(); ax1.autoscale_view(scalex=False, scaley=True)
-
- elif tag == "focus":
- _, y, h, s = msg
- ys_f.append(float(y)); hard_counts.append(int(h)); soft_counts.append(int(s))
- l_hard.set_data(ys_f, hard_counts); l_soft.set_data(ys_f, soft_counts)
- ax2.relim(); ax2.autoscale_view(scalex=False, scaley=True)
-
- elif tag == "break":
- # Insert NaNs to create a visual gap (no connecting line)
- nan = math.nan
- ys.append(nan); rs.append(nan); gs.append(nan); bs.append(nan); yls.append(nan)
- ys_f.append(nan); hard_counts.append(nan); soft_counts.append(nan)
-
- elif tag == "title":
- # allow setting a new base title text for fig1 if you want
- base_title_1 = str(msg[1]) or base_title_1
- # re-apply elapsed if we have one
- if last_elapsed is not None:
- ax1.set_title(f"{base_title_1} (t={last_elapsed:.1f}s)")
- else:
- ax1.set_title(base_title_1)
-
- elif tag == "elapsed":
- # NEW: show elapsed seconds on both figure titles
- _, secs = msg
- last_elapsed = float(secs)
- ax1.set_title(f"{base_title_1} (t={last_elapsed:.1f}s)")
- ax2.set_title(f"{base_title_2} (t={last_elapsed:.1f}s)")
- elif tag == "close":
- running = False
-
- elif tag == "done":
- try:
- plt.ioff()
- plt.show()
- except Exception:
- pass
- running = False
-
- # draw frames
- try:
- fig1.canvas.draw_idle()
- fig2.canvas.draw_idle()
- plt.pause(0.01)
- except Exception:
- break
-
- try:
- plt.close(fig1); plt.close(fig2)
- except Exception:
- pass
-
-
-_AFTPM = 100 # ticks/mm (0.01 mm units)
-_AFSTEP = 4 # 0.04 mm (printer min step)
-_AF_ZFLOOR = 0 # 0.00 mm -> 0 ticks
-
-class AutomatedPrinter(BasePrinterController):
+class AutomatedPrinter(CameraCalibrationMixin, AutofocusMixin, BasePrinterController):
"""Extended printer controller with automation capabilities"""
AUTOMATION_CONFIG_SUBDIR = ""
- def __init__(self, forgeConfig: ForgeSettings, camera):
- super().__init__(forgeConfig)
+ def __init__(self, fieldweaveConfig: FieldWeaveSettings, camera):
+ super().__init__(fieldweaveConfig)
AutomationSettingsManager.scope_dir(self.AUTOMATION_CONFIG_SUBDIR)
self.automation_settings: AutomationSettings = AutomationSettingsManager.load(self.AUTOMATION_CONFIG_SUBDIR)
@@ -212,10 +68,11 @@ def __init__(self, forgeConfig: ForgeSettings, camera):
self.current_sample_index = 1
self.live_plots_enabled: bool = False
- # Autofocus
- self.register_handler("AUTOFOCUS_DESCENT", self.autofocus_descent_macro)
- self.register_handler("AUTOFOCUS", self.autofocus_macro)
- self.register_handler("FINE_AUTOFOCUS", self.fine_autofocus)
+ # Initialize autofocus handlers from mixin
+ self._init_autofocus_handlers()
+
+ # Initialize camera calibration handlers from mixin
+ self._init_camera_calibration_handlers()
# Automation Routines
@@ -368,616 +225,6 @@ def get_enabled_samples(self) -> List[Tuple[int, str]]:
def status(self, msg: str, log: bool = True) -> None:
self._handle_status(self.status_cmd(msg), log)
- def _af_quantize(self, z_ticks: int) -> int:
- return int(round(z_ticks / _AFSTEP) * _AFSTEP)
-
- def _af_move_to_ticks(self, z_ticks: int) -> None:
- z_ticks = max(z_ticks, _AF_ZFLOOR)
- z_mm = z_ticks / _AFTPM
- self._exec_gcode(f"G0 Z{z_mm:.2f}", wait=True)
-
- # Score frames
- def _af_score_still(self) -> float:
- """Capture a STILL and return its focus score (or -inf if unusable)."""
- self._exec_gcode("M400", wait=True)
- time.sleep(0.1) # vibration settle for stills
- self.camera.capture_image()
- while self.camera.is_taking_image:
- time.sleep(0.01)
- if self.machine_vision.is_black(source="still"):
- return float("-inf")
- try:
- img = self.camera.get_last_frame(prefer="still", wait_for_still=False)
- res = self.machine_vision.analyze_focus()
- return float(getattr(res, "focus_score", float("-inf")))
- except Exception:
- return float("-inf")
-
- def _af_score_preview(self) -> float:
- """Score the live preview/stream (no still capture). Much faster."""
- self._exec_gcode("M400", wait=True)
- time.sleep(0.05) # tiny settle is enough for stream
- if self.machine_vision.is_black(source="stream"):
- return float("-inf")
- try:
- img = self.camera.get_last_frame(prefer="stream", wait_for_still=False)
- res = self.machine_vision.analyze_focus()
- return float(getattr(res, "focus_score", float("-inf")))
- except Exception:
- return float("-inf")
-
- def _af_score_at(
- self,
- zt: int,
- cache: dict[int, float],
- bounds_ok: Optional[Callable[[int], bool]] = None,
- scorer: Optional[Callable[[], float]] = None,
- ) -> float:
- """
- Quantize → bounds → cache → move → score using the provided scorer.
- Defaults to STILL scorer if not provided.
- """
- scorer = scorer or self._af_score_still
- zt = self._af_quantize(zt)
- if zt < _AF_ZFLOOR:
- return float("-inf")
- if bounds_ok and not bounds_ok(zt):
- return float("-inf")
- if zt in cache:
- return cache[zt]
- self._af_move_to_ticks(zt)
- s = scorer(zt, cache, bounds_ok)
- cache[zt] = s
- return s
-
- def _af_climb_fine(
- self,
- start: int,
- step_ticks: int,
- cache: dict[int, float],
- bounds_ok: Optional[Callable[[int], bool]] = None,
- no_improve_limit: int = 2,
- scorer: Optional[Callable[[], float]] = None,
- baseline: Optional[float] = None,
- ) -> tuple[int, float]:
- scorer = scorer or self._af_score_still
- zt = start
- best_z = start
- best_s = cache.get(start, self._af_score_at(start, cache, bounds_ok, scorer))
- no_imp = 0
- while True:
- nxt = self._af_quantize(zt + step_ticks)
- if nxt < _AF_ZFLOOR or (bounds_ok and not bounds_ok(nxt)):
- break
- s = self._af_score_at(nxt, cache, bounds_ok, scorer)
- delta = f" Δbase={s - baseline:+.1f}" if baseline is not None else ""
- self.status(
- f"[AF-Fine] {step_ticks/_AFTPM:.2f}mm step {'up' if step_ticks>0 else 'down'}: "
- f"Z={nxt / _AFTPM:.2f} score={s:.1f}{delta}",
- False
- )
- if s > best_s + 1e-6:
- best_z, best_s = nxt, s
- zt = nxt
- no_imp = 0
- else:
- no_imp += 1
- zt = nxt
- if no_imp >= no_improve_limit:
- break
- return best_z, best_s
-
- def _af_refine_around(
- self,
- center: int,
- cache: dict[int, float],
- bounds_ok: Optional[Callable[[int], bool]] = None,
- fine_step_ticks: int = _AFSTEP,
- no_improve_limit: int = 2,
- scorer: Optional[Callable[[], float]] = None,
- baseline: Optional[float] = None,
- ) -> tuple[int, float]:
- scorer = scorer or self._af_score_still
- up_z, up_s = self._af_climb_fine(center, fine_step_ticks, cache, bounds_ok, no_improve_limit, scorer, baseline)
- down_z, down_s = self._af_climb_fine(center, -fine_step_ticks, cache, bounds_ok, no_improve_limit, scorer, baseline)
- return (up_z, up_s) if up_s >= down_s else (down_z, down_s)
-
- # Autofocus
- def autofocus_descent_macro(self, cmd: command) -> None:
- """
- Descent-only autofocus with configurable envelope, step sizes, and scoring.
- Coarse: fixed downward march from the start position toward Z floor.
- Refine: fine polish around the best coarse Z.
-
- Behavior mirrors the 'tunables' style of `autofocus_macro` and `fine_autofocus`.
- """
-
- # =========================== TUNABLES (easy to tweak) ===========================
- # Focus/strategy thresholds
- FOCUS_PREVIEW_THRESHOLD = 90000.0 # if baseline STILL < this → use PREVIEW during coarse
- Z_FLOOR_MM = 0.00 # hard lower bound to protect hardware
-
- # Step sizes (mm)
- COARSE_STEP_MM = 0.20 # coarse, fixed downward step
- FINE_STEP_MM = 0.04 # fine polish
- MAX_OFFSET_MM = 5.60 # max explore distance downward from start
-
- # Early-stop behavior (relative to baseline and local peak)
- DROP_STOP_PEAK = 5000.0 # stop if drop from local peak exceeds this
- DROP_STOP_BASE = 3000.0 # early stop if below baseline by this amount with no better peak
-
- # Settling (seconds) – only used inside the scoring helpers
- SETTLE_STILL_S = 0.4
- SETTLE_PREVIEW_S = 0.4
-
- # Fine search behavior
- FINE_NO_IMPROVE_LIMIT = 2 # stop after this many non-improving steps per direction
- FINE_ALLOW_PREVIEW = False # if True, allow PREVIEW fine search when baseline is weak (like fine_autofocus)
-
- # Messaging
- LOG_VERBOSE = True
- # ==============================================================================
-
- # ---- derived constants (ticks) ----
- _AFTPM = 100
- _AF_ZFLOOR = int(round(Z_FLOOR_MM * _AFTPM))
- COARSE_STEP = int(round(COARSE_STEP_MM * _AFTPM))
- _AFSTEP = int(round(FINE_STEP_MM * _AFTPM))
- MAX_OFFSET = int(round(MAX_OFFSET_MM * _AFTPM))
-
- def quantize(zt: int) -> int:
- # keep multiples of printer min step (0.04 mm = 4 ticks)
- step = 4
- return (zt // step) * step
-
- # Envelope: allow [start - MAX_OFFSET, start], clamped to floor
- def within_env(zt: int) -> bool:
- return (start - MAX_OFFSET) <= zt <= start and zt >= _AF_ZFLOOR
-
- # ---- scorers (wrapped to match _af_score_at's current call style) ----
- def score_still_lambda(_z, _c, _b) -> float:
- self._exec_gcode("M400", wait=True)
- if SETTLE_STILL_S > 0: time.sleep(SETTLE_STILL_S)
- self.camera.capture_image()
- while self.camera.is_taking_image:
- time.sleep(0.01)
- if self.machine_vision.is_black(source="still"):
- return float("-inf")
- try:
- img = self.camera.get_last_frame(prefer="still", wait_for_still=False)
- res = self.machine_vision.analyze_focus()
- return float(res.focus_score)
- except Exception:
- return float("-inf")
-
- def score_preview_lambda(_z, _c, _b) -> float:
- self._exec_gcode("M400", wait=True)
- if SETTLE_PREVIEW_S > 0: time.sleep(SETTLE_PREVIEW_S)
- if self.machine_vision.is_black(source="stream"):
- return float("-inf")
- try:
- img = self.camera.get_last_frame(prefer="stream", wait_for_still=False)
- res = self.machine_vision.analyze_focus()
- return float(res.focus_score)
- except Exception:
- return float("-inf")
-
- # ---- start ----
- self.status(cmd.message or "Autofocus (descent) starting…", cmd.log)
- if self.pause_point(): return
-
- pos = self.get_position()
- start = quantize(int(round(getattr(pos, "z", 1600))))
- self.status(f"Start @ Z={start / _AFTPM:.2f} mm (descent expected)", cmd.log)
-
- scores: dict[int, float] = {}
-
- # Baseline STILL (reliable for Δbase & scorer choice)
- self._af_move_to_ticks(start)
- baseline = self._af_score_at(
- start, scores, within_env,
- scorer=score_still_lambda
- )
- scores[start] = baseline
- best_z = start
- best_s = baseline
- self.status(f"[AF-Descent] Baseline Z={start / _AFTPM:.2f} score={baseline:.1f}", LOG_VERBOSE)
-
- # Choose coarse scorer based on baseline (like autofocus_macro)
- coarse_scorer = score_preview_lambda if (baseline < FOCUS_PREVIEW_THRESHOLD) else score_still_lambda
- self.status(f"[AF-Descent] Coarse scorer: "
- f"{'PREVIEW' if coarse_scorer is score_preview_lambda else 'STILL'} "
- f"(baseline={baseline:.1f} < thresh={FOCUS_PREVIEW_THRESHOLD:.1f})", LOG_VERBOSE)
-
- # -------- Coarse descent-only march --------
- peak_s = baseline
- peak_z = start
- steps = min(MAX_OFFSET // COARSE_STEP, (start - _AF_ZFLOOR) // COARSE_STEP)
-
- for k in range(1, steps + 1):
- if self.pause_point():
- self.status("Autofocus paused/stopped.", True); return
-
- target = quantize(start - k * COARSE_STEP)
- if target <= _AF_ZFLOOR:
- target = _AF_ZFLOOR
-
- s = self._af_score_at(target, scores, within_env, scorer=coarse_scorer)
- d_base = s - baseline
- self.status(
- f"[AF-Descent] ↓{COARSE_STEP_MM:.2f}mm Z={target / _AFTPM:.2f}"
- f"{' (FLOOR)' if target == _AF_ZFLOOR else ''} score={s:.1f} Δbase={d_base:+.1f}",
- LOG_VERBOSE
- )
-
- if s > best_s: best_s, best_z = s, target
- if s > peak_s: peak_s, peak_z = s, target
-
- if best_z == start and (baseline - s) >= DROP_STOP_BASE:
- self.status("[AF-Descent] Early stop (baseline-drop)", LOG_VERBOSE)
- break
- if (peak_s - s) >= DROP_STOP_PEAK:
- self.status("[AF-Descent] Early stop (peak-drop)", LOG_VERBOSE)
- break
- if target == _AF_ZFLOOR:
- break
-
- # -------- Fine polish around best --------
- if self.pause_point():
- self.status("Autofocus paused/stopped.", True); return
-
- # Optionally allow preview during fine if baseline is weak (like fine_autofocus)
- if FINE_ALLOW_PREVIEW and baseline < FOCUS_PREVIEW_THRESHOLD:
- fine_scorer = score_preview_lambda
- scorer_name = "PREVIEW"
- else:
- fine_scorer = score_still_lambda
- scorer_name = "STILL"
-
- self.status(f"[AF-Descent] Fine search using {scorer_name} (step={FINE_STEP_MM:.2f}mm)", LOG_VERBOSE)
-
- local_z, local_s = self._af_refine_around(
- center=best_z,
- cache=scores,
- bounds_ok=within_env,
- fine_step_ticks=_AFSTEP,
- no_improve_limit=FINE_NO_IMPROVE_LIMIT,
- scorer=fine_scorer,
- baseline=baseline
- )
- if local_s > best_s:
- best_z, best_s = local_z, local_s
-
- if self.pause_point(): return
- self._af_move_to_ticks(best_z)
- self.status(
- f"Autofocus (descent) complete: Best Z={best_z / _AFTPM:.2f} mm "
- f"Score={best_s:.1f} Δbase={(best_s - baseline):+.1f} "
- f"(coarse={'PREVIEW' if coarse_scorer is score_preview_lambda else 'STILL'}, "
- f"fine={scorer_name}, step={FINE_STEP_MM:.2f}mm, max_offset={MAX_OFFSET_MM:.2f}mm)",
- True
- )
-
- def fine_autofocus(self, cmd: command) -> None:
- """
- Fine autofocus around current Z with configurable window, step, and scoring.
- Behavior mirrors the 'tunables' style of `autofocus_macro`.
- """
-
- # =========================== TUNABLES (easy to tweak) ===========================
- # Search window & step sizes (mm)
- WINDOW_MM = 0.16 # half-range; searches center ± WINDOW_MM
- FINE_STEP_MM = 0.04 # printer min step by default
-
- # Stopping behavior
- NO_IMPROVE_LIMIT = 1 # stop after this many non-improving fine steps per direction
-
- # Scoring strategy
- USE_PREVIEW_IF_BELOW = False # allow faster preview scoring if baseline is weak
- FOCUS_PREVIEW_THRESHOLD= 90000.0 # if baseline STILL < this → use PREVIEW for the fine search
-
- # Messaging
- LOG_VERBOSE = True
- # ==============================================================================
-
- # ---- derived constants (ticks) ----
- _AFTPM = 100 # ticks per mm (0.01 mm units)
- _AF_ZFLOOR = 0
- FINE_STEP_TICKS = int(round(FINE_STEP_MM * _AFTPM))
- WINDOW_TICKS = int(round(WINDOW_MM * _AFTPM))
-
- # ---- local helpers that honor tunables ----
- def within_window(zt: int, center: int) -> bool:
- return (center - WINDOW_TICKS) <= zt <= (center + WINDOW_TICKS) and zt >= _AF_ZFLOOR
-
- # ---- start ----
- self.status(cmd.message or "Fine autofocus…", cmd.log)
-
- pos = self.get_position()
- center = self._af_quantize(int(round(getattr(pos, "z", 1600)))) # fallback 16.00 mm
- self.status(f"[AF-Fine] Center Z={center / _AFTPM:.2f} mm Window=±{WINDOW_MM:.2f} mm Step={FINE_STEP_MM:.2f} mm", LOG_VERBOSE)
-
- scores: dict[int, float] = {}
-
- # Baseline with STILL (for reliable Δbase and scorer decision).
- baseline = self._af_score_at(center, scores, lambda z: within_window(z, center), scorer=lambda _z, _c, _b: self._af_score_still())
-
- # Choose scorer for the *search* (preview if baseline is weak and allowed)
- if USE_PREVIEW_IF_BELOW and baseline < FOCUS_PREVIEW_THRESHOLD:
- fine_scorer = lambda _z, _c, _b: self._af_score_preview()
- scorer_name = "PREVIEW"
- else:
- fine_scorer = lambda _z, _c, _b: self._af_score_still()
- scorer_name = "STILL"
-
- self.status(f"[AF-Fine] Using {scorer_name} scorer for search (baseline={baseline:.1f} thresh={FOCUS_PREVIEW_THRESHOLD:.1f})", LOG_VERBOSE)
-
- # Perform the fine search around center using the chosen scorer.
- if self.pause_point(): # graceful stop
- return
-
- best_z, best_s = self._af_refine_around(
- center=center,
- cache=scores,
- bounds_ok=lambda z: within_window(z, center),
- fine_step_ticks=FINE_STEP_TICKS,
- no_improve_limit=NO_IMPROVE_LIMIT,
- scorer=fine_scorer,
- baseline=baseline
- )
-
- if self.pause_point(): # graceful stop
- return
-
- # Move to best and report Δbase.
- self._af_move_to_ticks(best_z)
- self.status(
- f"[AF-Fine] Best Z={best_z / _AFTPM:.2f} mm "
- f"Score={best_s:.1f} Δbase={(best_s - baseline):+.1f} "
- f"(search={scorer_name}, step={FINE_STEP_MM:.2f}mm, window=±{WINDOW_MM:.2f}mm, "
- f"no_improve_limit={NO_IMPROVE_LIMIT})",
- True
- )
-
- def autofocus_macro(self, cmd: command) -> None:
- """
- Coarse (0.40 mm) alternating with bias → 0.20 mm refine march → 0.04 mm fine polish.
- Coarse uses PREVIEW if a quick baseline STILL focus is below the configured threshold;
- fine stage always uses STILLs.
- """
-
- # =========================== TUNABLES (easy to tweak) ===========================
- # Focus/strategy thresholds
- FOCUS_PREVIEW_THRESHOLD = 90000.0 # if baseline STILL < this → use PREVIEW during coarse/refine
- COARSE_IMPROVE_THRESH = 1000.0 # improvement vs baseline that triggers biasing a side
- COARSE_DROP_STOP_PEAK = 2000.0 # stop a biased march if drop from local peak exceeds this
- COARSE_DROP_STOP_BASE = 3000.0 # early stop if below baseline by this amount with no better peak
- Z_FLOOR_MM = 0.00 # hard lower bound to protect hardware
-
- # Step sizes (mm)
- COARSE_STEP_MM = 0.20 # coarse alternating outward step
- REFINE_COARSE_MM = 0.12 # directionally consistent refine march
- FINE_STEP_MM = 0.04 # fine polish
- MAX_OFFSET_MM = 5.60 # max explore distance from start
-
- # Settling (seconds)
- SETTLE_STILL_S = 0.4 # wait before scoring a still
- SETTLE_PREVIEW_S = 0.4 # small settle for preview scoring
-
- # Fine search behavior
- FINE_NO_IMPROVE_LIMIT = 2 # stop after this many non-improving fine steps per direction
-
- # Messaging
- LOG_VERBOSE = True # set False to quiet step-by-step logs
- # ==============================================================================
-
- # ---- derived constants (ticks) ----
- _AFTPM = 100 # ticks per mm (0.01 mm units) – keep consistent with your code
- _AF_ZFLOOR = int(round(Z_FLOOR_MM * _AFTPM))
- COARSE_STEP = int(round(COARSE_STEP_MM * _AFTPM))
- REFINE_COARSE = int(round(REFINE_COARSE_MM * _AFTPM))
- _AFSTEP = int(round(FINE_STEP_MM * _AFTPM))
- MAX_OFFSET = int(round(MAX_OFFSET_MM * _AFTPM))
-
- # ---- local helpers that honor tunables ----
- def quantize(zt: int) -> int:
- # ensure multiples of printer min step (0.04 mm = 4 ticks)
- step = 4
- return (zt // step) * step
-
- def within_env(zt: int) -> bool:
- return (start - MAX_OFFSET) <= zt <= (start + MAX_OFFSET) and zt >= _AF_ZFLOOR
-
- def score_still() -> float:
- self._exec_gcode("M400", wait=True)
- if SETTLE_STILL_S > 0: time.sleep(SETTLE_STILL_S)
- self.camera.capture_image()
- while self.camera.is_taking_image:
- time.sleep(0.01)
- if self.machine_vision.is_black(source="still"):
- return float("-inf")
- img = self.camera.get_last_frame(prefer="still", wait_for_still=False)
- res = self.machine_vision.analyze_focus()
- return float(res.focus_score)
-
- def score_preview() -> float:
- self._exec_gcode("M400", wait=True)
- if SETTLE_PREVIEW_S > 0: time.sleep(SETTLE_PREVIEW_S)
- if self.machine_vision.is_black(source="stream"):
- return float("-inf")
- img = self.camera.get_last_frame(prefer="stream", wait_for_still=False)
- res = self.machine_vision.analyze_focus()
- return float(res.focus_score)
-
- def score_at(zt: int, cache: dict, scorer) -> float:
- zt = quantize(zt)
- if zt < _AF_ZFLOOR or not within_env(zt):
- return float("-inf")
- if zt in cache:
- return cache[zt]
- self._af_move_to_ticks(zt)
- s = scorer()
- cache[zt] = s
- return s
-
- # ---- start ----
- self.status(cmd.message or "Autofocus starting…", cmd.log)
- if self.pause_point(): return
-
- pos = self.get_position()
- start = quantize(int(round(getattr(pos, "z", 1600))))
- self.status(f"Start @ Z={start / _AFTPM:.2f} mm", cmd.log)
-
- scores: dict[int, float] = {}
-
- # Baseline STILL and choose coarse scorer
- self._af_move_to_ticks(start)
- baseline = score_still()
- scores[start] = baseline
- best_z = start
- best_s = baseline
- self.status(f"[AF] Baseline Z={start / _AFTPM:.2f} score={baseline:.1f}", LOG_VERBOSE)
-
- coarse_scorer = score_preview if (baseline < FOCUS_PREVIEW_THRESHOLD) else score_still
- self.status(f"[AF] Coarse scorer: "
- f"{'PREVIEW' if coarse_scorer is score_preview else 'STILL'} "
- f"(baseline={baseline:.1f} < thresh={FOCUS_PREVIEW_THRESHOLD:.1f})",
- LOG_VERBOSE)
-
- # -------- Coarse alternating with bias --------
- k_right = 1; k_left = 1
- max_k = MAX_OFFSET // COARSE_STEP
- left_max_safe = min(max_k, (start - _AF_ZFLOOR) // COARSE_STEP)
- right_max_safe = max_k
- bias_side = None
- last_side = None
- peak_on_bias = baseline
-
- while True:
- if self.pause_point():
- self.status("Autofocus paused/stopped.", True); return
-
- right_has = k_right <= right_max_safe
- left_has = k_left <= left_max_safe
- if not right_has and not left_has:
- break
-
- # choose side (alternate until bias is set)
- if bias_side:
- if bias_side == 'right' and right_has:
- side = 'right'
- elif bias_side == 'left' and left_has:
- side = 'left'
- else:
- side = 'right' if right_has else 'left'
- else:
- if last_side == 'left' and right_has: side = 'right'
- elif last_side == 'right' and left_has: side = 'left'
- elif right_has: side = 'right'
- else: side = 'left'
-
- target = quantize(start + (k_right * COARSE_STEP if side == 'right' else -k_left * COARSE_STEP))
- if side == 'left' and target < _AF_ZFLOOR:
- self.status("[AF-Coarse] Reached Z floor; stop left.", LOG_VERBOSE)
- k_left = left_max_safe + 1
- last_side = side
- continue
-
- s = score_at(target, scores, coarse_scorer)
- if s > best_s: best_s, best_z = s, target
-
- improv = s - baseline
- self.status(f"[AF-Coarse] side={side:<5} Z={target / _AFTPM:.2f} score={s:.1f} Δbase={improv:+.1f}", LOG_VERBOSE)
-
- if best_z == start and (baseline - s) >= COARSE_DROP_STOP_BASE:
- self.status("[AF-Coarse] Early stop (baseline-drop)", LOG_VERBOSE)
- break
-
- if not bias_side and improv >= COARSE_IMPROVE_THRESH:
- bias_side = side
- peak_on_bias = s
- self.status(f"[AF-Coarse] Bias → {bias_side.upper()} (≥+{COARSE_IMPROVE_THRESH:.0f})", LOG_VERBOSE)
-
- if bias_side and side == bias_side:
- if s > peak_on_bias:
- peak_on_bias = s
- elif (peak_on_bias - s) >= COARSE_DROP_STOP_PEAK:
- self.status("[AF-Coarse] Early stop (peak-drop)", LOG_VERBOSE)
- break
-
- if side == 'right': k_right += 1
- else: k_left += 1
- last_side = side
-
- if bias_side and ((bias_side == 'right' and not (k_right <= max_k)) or
- (bias_side == 'left' and not (k_left <= max_k))):
- break
-
- # -------- 0.20 mm refine march (uses same coarse_scorer) --------
- if self.pause_point():
- self.status("Autofocus paused/stopped.", True); return
-
- up_zt = quantize(best_z + REFINE_COARSE)
- down_zt = quantize(best_z - REFINE_COARSE)
- up_s = score_at(up_zt, scores, coarse_scorer)
- down_s = score_at(down_zt, scores, coarse_scorer)
- dir1, z1, s1 = (('up', up_zt, up_s) if up_s >= down_s else ('down', down_zt, down_s))
- self.status(f"[AF-Refine] Probe {REFINE_COARSE_MM:.2f}mm {dir1}: Z={z1 / _AFTPM:.2f} score={s1:.1f}", LOG_VERBOSE)
- if s1 > best_s: best_s, best_z = s1, z1
-
- current, prev = z1, s1
- while True:
- if self.pause_point():
- self.status("Autofocus paused/stopped.", True); return
- step = REFINE_COARSE if dir1 == 'up' else -REFINE_COARSE
- nxt = quantize(current + step)
- if nxt < _AF_ZFLOOR or not within_env(nxt):
- break
- s = score_at(nxt, scores, coarse_scorer)
- self.status(f"[AF-Refine] {REFINE_COARSE_MM:.2f}mm step {dir1}: Z={nxt / _AFTPM:.2f} score={s:.1f}", LOG_VERBOSE)
- if s > best_s: best_s, best_z = s, nxt
- if s + 1e-6 >= prev:
- current, prev = nxt, s
- else:
- break
-
- # -------- Fine polish (ALWAYS STILLs) --------
- def climb_fine(start_zt: int, step_ticks: int) -> tuple[int, float]:
- zt = start_zt
- best_local_z = start_zt
- best_local_s = scores.get(start_zt, score_at(start_zt, scores, score_still))
- no_imp = 0
- while True:
- nxt = quantize(zt + step_ticks)
- if nxt < _AF_ZFLOOR or not within_env(nxt):
- break
- s = score_at(nxt, scores, score_still)
- self.status(f"[AF-Fine] {FINE_STEP_MM:.2f}mm step {'up' if step_ticks>0 else 'down'}: Z={nxt / _AFTPM:.2f} score={s:.1f}", LOG_VERBOSE)
- if s > best_local_s + 1e-6:
- best_local_z, best_local_s = nxt, s
- zt = nxt
- no_imp = 0
- else:
- no_imp += 1
- zt = nxt
- if no_imp >= FINE_NO_IMPROVE_LIMIT:
- break
- return best_local_z, best_local_s
-
- up_z, up_s = climb_fine(best_z, _AFSTEP)
- down_z, down_s = climb_fine(best_z, -_AFSTEP)
- if (up_s, up_z) >= (down_s, down_z):
- local_z, local_s = up_z, up_s
- else:
- local_z, local_s = down_z, down_s
- if local_s > best_s:
- best_z, best_s = local_z, local_s
-
- if self.pause_point(): return
- self._af_move_to_ticks(best_z)
- self.status(f"Autofocus complete: Best Z={best_z / _AFTPM:.2f} mm Score={best_s:.1f}", True)
-
-
# Automation
# --- Handler --------------------------------------------------------------
def scan_sample_bounds(self, cmd: command) -> None:
@@ -990,38 +237,6 @@ def report(msg: str, log: bool = True) -> None:
# Folder name to save images into (from command.value, fallback to current index)
sample_folder = str(cmd.value).strip() if (cmd and getattr(cmd, "value", "")) else f"sample_{self.current_sample_index}"
-
- # --- start plotter process (spawn-safe) ---
- plot_ok = [False]
- plot_queue = None
- if self.live_plots_enabled:
- try:
- import multiprocessing as mp
- ctx = mp.get_context("spawn")
- plot_queue = ctx.Queue()
- plot_proc = ctx.Process(target=_scan_bounds_plotter, args=(plot_queue, 0, Y_MAX_MM), daemon=True)
- plot_proc.start()
- plot_ok[0] = True
- plot_queue.put(("title", "Average Color vs Y (live)"))
- except Exception as e:
- report(f"[SCAN_SAMPLE_BOUNDS] Live plot process unavailable: {e}")
-
- def send_data(y_now: float, r: float, g: float, b: float, ylum: float, hard_ct: int, soft_ct: int) -> None:
- if plot_ok[0] and plot_queue is not None:
- try:
- plot_queue.put(("data", float(y_now), float(r), float(g), float(b), float(ylum)))
- plot_queue.put(("focus", float(y_now), int(hard_ct), int(soft_ct)))
- except Exception:
- plot_ok[0] = False
-
-
- def send_break():
- if plot_ok[0] and plot_queue is not None:
- try:
- plot_queue.put(("break",))
- except Exception:
- plot_ok[0] = False
-
# --- capture start Y ---
start_y = float(self.position.y) / 100
start_z = float(self.position.z) / 100
@@ -1035,13 +250,6 @@ def send_break():
self.autofocus_descent_macro(cmd)
self.pause_point()
- def send_elapsed():
- if plot_ok[0] and plot_queue is not None:
- try:
- plot_queue.put(("elapsed", time.time() - start_time))
- except Exception:
- pass
-
# --- measurement helper: color + focus counts ---
def refine_and_measure(y_now: float) -> None:
"""
@@ -1092,10 +300,6 @@ def refine_and_measure(y_now: float) -> None:
f"{'(fine AF skipped)' if not run_fine else ''}"
)
- # Stream to both graphs
- send_data(y_now, r, g, b, ylum, hard_tiles, soft_tiles)
- send_elapsed()
-
except Exception as e:
report(f"[SCAN_SAMPLE_BOUNDS] Y={y_now:.3f} → measurement failed: {e}", True)
@@ -1112,15 +316,14 @@ def refine_and_measure(y_now: float) -> None:
# 3) Return to start **without drawing a connecting line**
if abs(y - start_y) > 1e-9:
- send_break() # prevents the line from connecting the last +Y point to start
self._exec_gcode(f"G0 Y{start_y:.3f} Z{start_z:.3f}")
self.pause_point()
report("[SCAN_SAMPLE_BOUNDS] Running autofocus_macro at start position…")
self.autofocus_descent_macro(cmd)
+
# Measure at start after autofocus_macro (with skip logic inside refine_and_measure)
refine_and_measure(start_y)
-
# 4) Sweep -Y until sample end or 0
y = start_y
sample_done = False
@@ -1147,13 +350,7 @@ def refine_and_measure(y_now: float) -> None:
total_time = time.time() - start_time
report(f"[SCAN_SAMPLE_BOUNDS] Scan complete. Total time: {total_time:.2f} seconds")
- send_elapsed() # final elapsed push so titles show the final time
- if plot_ok[0] and plot_queue is not None:
- try:
- plot_queue.put(("done",))
- except Exception:
- pass
def start_scan_sample_bounds(self, folder_name: str | None = None) -> None:
"""
@@ -1168,32 +365,6 @@ def start_scan_sample_bounds(self, folder_name: str | None = None) -> None:
))
- def start_autofocus(self) -> None:
- """Start the automation process"""
-
- self.reset_after_stop()
-
- # Enqueue the macro like any other command
- self.enqueue_cmd(command(
- kind="AUTOFOCUS",
- value="",
- message= "Beginning Autofocus Macro",
- log=True
- ))
-
- def start_fine_autofocus(self) -> None:
- """Start the automation process"""
-
- self.reset_after_stop()
-
- # Enqueue the macro like any other command
- self.enqueue_cmd(command(
- kind="FINE_AUTOFOCUS",
- value="",
- message= "Beginning Fine Autofocus Macro",
- log=True
- ))
-
def start_automation(self) -> None:
"""Home, then iterate enabled samples and scan each with progress messaging."""
self.reset_after_stop()
@@ -1252,17 +423,6 @@ def start_automation(self) -> None:
# 4) Enqueue the macro
self.enqueue_cmd(macro)
- '''
- def setPosition1(self) -> None:
- self.automation_config.x_start = self.position.x
- self.automation_config.y_start = self.position.y
- self.automation_config.z_start = self.position.z
-
- def setPosition2(self) -> None:
- self.automation_config.x_end = self.position.x
- self.automation_config.y_end = self.position.y
- self.automation_config.z_end = self.position.z
- '''
def _get_range(self, start: int, end: int, step: int) -> range:
"""Get appropriate range based on start and end positions"""
if start < end:
diff --git a/printer/automation/autofocus_mixin.py b/printer/automation/autofocus_mixin.py
new file mode 100644
index 0000000..a654beb
--- /dev/null
+++ b/printer/automation/autofocus_mixin.py
@@ -0,0 +1,709 @@
+"""
+Autofocus functionality for automated 3D printer control.
+
+This module contains all autofocus-related methods that can be mixed into
+the main AutomatedPrinter controller class.
+"""
+
+import time
+from typing import Optional, Callable
+
+from printer.base_controller import command
+
+
+# Autofocus constants (ticks per mm = 100, meaning 0.01 mm units)
+_AFTPM = 100 # ticks/mm (0.01 mm units)
+_AFSTEP = 4 # 0.04 mm (printer min step)
+_AF_ZFLOOR = 0 # 0.00 mm -> 0 ticks
+
+
+class AutofocusMixin:
+ """
+ Mixin class containing autofocus functionality.
+
+ This class assumes it will be mixed into a controller that has:
+ - self.machine_vision (MachineVision instance)
+ - self.camera (camera instance with capture_image, get_last_frame, is_taking_image)
+ - self._exec_gcode(gcode, wait=False) method
+ - self.status(message, log=True) method
+ - self.pause_point() method that returns True if stopped
+ - self.register_handler(kind, function) method
+ """
+
+ def _init_autofocus_handlers(self):
+ """Register autofocus command handlers. Call this from __init__."""
+ self.register_handler("AUTOFOCUS_DESCENT", self.autofocus_descent_macro)
+ self.register_handler("AUTOFOCUS", self.autofocus_macro)
+ self.register_handler("FINE_AUTOFOCUS", self.fine_autofocus)
+
+ # ========================================================================
+ # Core autofocus helper methods
+ # ========================================================================
+
+ def _af_quantize(self, z_ticks: int) -> int:
+ """Quantize Z position to printer's minimum step size."""
+ return int(round(z_ticks / _AFSTEP) * _AFSTEP)
+
+ def _af_move_to_ticks(self, z_ticks: int) -> None:
+ """Move Z axis to specified position in ticks."""
+ z_ticks = max(z_ticks, _AF_ZFLOOR)
+ z_mm = z_ticks / _AFTPM
+ self._exec_gcode(f"G0 Z{z_mm:.2f}", wait=True)
+
+ def _af_score_still(self) -> float:
+ """Capture a STILL image and return its focus score."""
+ self._exec_gcode("M400", wait=True)
+ time.sleep(0.1) # vibration settle for stills
+ self.camera.capture_image()
+ while self.camera.is_taking_image:
+ time.sleep(0.01)
+ if self.machine_vision.is_black(source="still"):
+ return float("-inf")
+ try:
+ img = self.camera.get_last_frame(prefer="still", wait_for_still=False)
+ res = self.machine_vision.analyze_focus()
+ return float(getattr(res, "focus_score", float("-inf")))
+ except Exception:
+ return float("-inf")
+
+ def _af_score_preview(self) -> float:
+ """Score the live preview/stream (no still capture). Much faster."""
+ self._exec_gcode("M400", wait=True)
+ time.sleep(0.05) # tiny settle is enough for stream
+ if self.machine_vision.is_black(source="stream"):
+ return float("-inf")
+ try:
+ img = self.camera.get_last_frame(prefer="stream", wait_for_still=False)
+ res = self.machine_vision.analyze_focus()
+ return float(getattr(res, "focus_score", float("-inf")))
+ except Exception:
+ return float("-inf")
+
+ def _af_score_at(
+ self,
+ zt: int,
+ cache: dict[int, float],
+ bounds_ok: Optional[Callable[[int], bool]] = None,
+ scorer: Optional[Callable[[], float]] = None,
+ ) -> float:
+ """
+ Quantize, check bounds, check cache, move, and score using the provided scorer.
+ Defaults to STILL scorer if not provided.
+ """
+ scorer = scorer or self._af_score_still
+ zt = self._af_quantize(zt)
+ if zt < _AF_ZFLOOR:
+ return float("-inf")
+ if bounds_ok and not bounds_ok(zt):
+ return float("-inf")
+ if zt in cache:
+ return cache[zt]
+ self._af_move_to_ticks(zt)
+ s = scorer(zt, cache, bounds_ok)
+ cache[zt] = s
+ return s
+
+ def _af_climb_fine(
+ self,
+ start: int,
+ step_ticks: int,
+ cache: dict[int, float],
+ bounds_ok: Optional[Callable[[int], bool]] = None,
+ no_improve_limit: int = 2,
+ scorer: Optional[Callable[[], float]] = None,
+ baseline: Optional[float] = None,
+ ) -> tuple[int, float]:
+ """
+ Climb in one direction with fine steps until no improvement is found.
+ Returns (best_z, best_score).
+ """
+ scorer = scorer or self._af_score_still
+ zt = start
+ best_z = start
+ best_s = cache.get(start, self._af_score_at(start, cache, bounds_ok, scorer))
+ no_imp = 0
+
+ while True:
+ nxt = self._af_quantize(zt + step_ticks)
+ if nxt < _AF_ZFLOOR or (bounds_ok and not bounds_ok(nxt)):
+ break
+ s = self._af_score_at(nxt, cache, bounds_ok, scorer)
+ delta = f" Δbase={s - baseline:+.1f}" if baseline is not None else ""
+ self.status(
+ f"[AF-Fine] {step_ticks/_AFTPM:.2f}mm step {'up' if step_ticks>0 else 'down'}: "
+ f"Z={nxt / _AFTPM:.2f} score={s:.1f}{delta}",
+ False
+ )
+ if s > best_s + 1e-6:
+ best_z, best_s = nxt, s
+ zt = nxt
+ no_imp = 0
+ else:
+ no_imp += 1
+ zt = nxt
+ if no_imp >= no_improve_limit:
+ break
+ return best_z, best_s
+
+ def _af_refine_around(
+ self,
+ center: int,
+ cache: dict[int, float],
+ bounds_ok: Optional[Callable[[int], bool]] = None,
+ fine_step_ticks: int = _AFSTEP,
+ no_improve_limit: int = 2,
+ scorer: Optional[Callable[[], float]] = None,
+ baseline: Optional[float] = None,
+ ) -> tuple[int, float]:
+ """
+ Refine focus by climbing both up and down from center position.
+ Returns (best_z, best_score) from both directions.
+ """
+ scorer = scorer or self._af_score_still
+ up_z, up_s = self._af_climb_fine(
+ center, fine_step_ticks, cache, bounds_ok, no_improve_limit, scorer, baseline
+ )
+ down_z, down_s = self._af_climb_fine(
+ center, -fine_step_ticks, cache, bounds_ok, no_improve_limit, scorer, baseline
+ )
+ return (up_z, up_s) if up_s >= down_s else (down_z, down_s)
+
+ # ========================================================================
+ # Main autofocus macros
+ # ========================================================================
+
+ def autofocus_descent_macro(self, cmd: command) -> None:
+ """
+ Descent-only autofocus with configurable envelope, step sizes, and scoring.
+ Coarse: fixed downward march from the start position toward Z floor.
+ Refine: fine polish around the best coarse Z.
+ """
+ # Tunables
+ FOCUS_PREVIEW_THRESHOLD = 90000.0
+ Z_FLOOR_MM = 0.00
+ COARSE_STEP_MM = 0.20
+ FINE_STEP_MM = 0.04
+ MAX_OFFSET_MM = 5.60
+ DROP_STOP_PEAK = 5000.0
+ DROP_STOP_BASE = 3000.0
+ SETTLE_STILL_S = 0.4
+ SETTLE_PREVIEW_S = 0.4
+ FINE_NO_IMPROVE_LIMIT = 2
+ FINE_ALLOW_PREVIEW = False
+ LOG_VERBOSE = True
+
+ # Derived constants
+ _AF_ZFLOOR = int(round(Z_FLOOR_MM * _AFTPM))
+ COARSE_STEP = int(round(COARSE_STEP_MM * _AFTPM))
+ _AFSTEP = int(round(FINE_STEP_MM * _AFTPM))
+ MAX_OFFSET = int(round(MAX_OFFSET_MM * _AFTPM))
+
+ def quantize(zt: int) -> int:
+ step = 4
+ return (zt // step) * step
+
+ def within_env(zt: int) -> bool:
+ return (start - MAX_OFFSET) <= zt <= start and zt >= _AF_ZFLOOR
+
+ # Scorers
+ def score_still_lambda(_z, _c, _b) -> float:
+ self._exec_gcode("M400", wait=True)
+ if SETTLE_STILL_S > 0:
+ time.sleep(SETTLE_STILL_S)
+ self.camera.capture_image()
+ while self.camera.is_taking_image:
+ time.sleep(0.01)
+ if self.machine_vision.is_black(source="still"):
+ return float("-inf")
+ try:
+ img = self.camera.get_last_frame(prefer="still", wait_for_still=False)
+ res = self.machine_vision.analyze_focus()
+ return float(res.focus_score)
+ except Exception:
+ return float("-inf")
+
+ def score_preview_lambda(_z, _c, _b) -> float:
+ self._exec_gcode("M400", wait=True)
+ if SETTLE_PREVIEW_S > 0:
+ time.sleep(SETTLE_PREVIEW_S)
+ if self.machine_vision.is_black(source="stream"):
+ return float("-inf")
+ try:
+ img = self.camera.get_last_frame(prefer="stream", wait_for_still=False)
+ res = self.machine_vision.analyze_focus()
+ return float(res.focus_score)
+ except Exception:
+ return float("-inf")
+
+ # Start
+ self.status(cmd.message or "Autofocus (descent) starting...", cmd.log)
+ if self.pause_point():
+ return
+
+ pos = self.get_position()
+ start = quantize(int(round(getattr(pos, "z", 1600))))
+ self.status(f"Start @ Z={start / _AFTPM:.2f} mm (descent expected)", cmd.log)
+
+ scores: dict[int, float] = {}
+
+ # Baseline STILL
+ self._af_move_to_ticks(start)
+ baseline = self._af_score_at(start, scores, within_env, scorer=score_still_lambda)
+ scores[start] = baseline
+ best_z = start
+ best_s = baseline
+ self.status(f"[AF-Descent] Baseline Z={start / _AFTPM:.2f} score={baseline:.1f}", LOG_VERBOSE)
+
+ # Choose coarse scorer
+ coarse_scorer = (
+ score_preview_lambda if (baseline < FOCUS_PREVIEW_THRESHOLD) else score_still_lambda
+ )
+ self.status(
+ f"[AF-Descent] Coarse scorer: "
+ f"{'PREVIEW' if coarse_scorer is score_preview_lambda else 'STILL'} "
+ f"(baseline={baseline:.1f} < thresh={FOCUS_PREVIEW_THRESHOLD:.1f})",
+ LOG_VERBOSE
+ )
+
+ # Coarse descent
+ peak_s = baseline
+ peak_z = start
+ steps = min(MAX_OFFSET // COARSE_STEP, (start - _AF_ZFLOOR) // COARSE_STEP)
+
+ for k in range(1, steps + 1):
+ if self.pause_point():
+ self.status("Autofocus paused/stopped.", True)
+ return
+
+ target = quantize(start - k * COARSE_STEP)
+ if target <= _AF_ZFLOOR:
+ target = _AF_ZFLOOR
+
+ s = self._af_score_at(target, scores, within_env, scorer=coarse_scorer)
+ d_base = s - baseline
+ self.status(
+ f"[AF-Descent] ↓{COARSE_STEP_MM:.2f}mm Z={target / _AFTPM:.2f}"
+ f"{' (FLOOR)' if target == _AF_ZFLOOR else ''} score={s:.1f} Δbase={d_base:+.1f}",
+ LOG_VERBOSE
+ )
+
+ if s > best_s:
+ best_s, best_z = s, target
+ if s > peak_s:
+ peak_s, peak_z = s, target
+
+ if best_z == start and (baseline - s) >= DROP_STOP_BASE:
+ self.status("[AF-Descent] Early stop (baseline-drop)", LOG_VERBOSE)
+ break
+ if (peak_s - s) >= DROP_STOP_PEAK:
+ self.status("[AF-Descent] Early stop (peak-drop)", LOG_VERBOSE)
+ break
+ if target == _AF_ZFLOOR:
+ break
+
+ # Fine polish
+ if self.pause_point():
+ self.status("Autofocus paused/stopped.", True)
+ return
+
+ if FINE_ALLOW_PREVIEW and baseline < FOCUS_PREVIEW_THRESHOLD:
+ fine_scorer = score_preview_lambda
+ scorer_name = "PREVIEW"
+ else:
+ fine_scorer = score_still_lambda
+ scorer_name = "STILL"
+
+ self.status(
+ f"[AF-Descent] Fine search using {scorer_name} (step={FINE_STEP_MM:.2f}mm)",
+ LOG_VERBOSE
+ )
+
+ local_z, local_s = self._af_refine_around(
+ center=best_z,
+ cache=scores,
+ bounds_ok=within_env,
+ fine_step_ticks=_AFSTEP,
+ no_improve_limit=FINE_NO_IMPROVE_LIMIT,
+ scorer=fine_scorer,
+ baseline=baseline
+ )
+ if local_s > best_s:
+ best_z, best_s = local_z, local_s
+
+ if self.pause_point():
+ return
+ self._af_move_to_ticks(best_z)
+ self.status(
+ f"Autofocus (descent) complete: Best Z={best_z / _AFTPM:.2f} mm "
+ f"Score={best_s:.1f} Δbase={(best_s - baseline):+.1f} "
+ f"(coarse={'PREVIEW' if coarse_scorer is score_preview_lambda else 'STILL'}, "
+ f"fine={scorer_name}, step={FINE_STEP_MM:.2f}mm, max_offset={MAX_OFFSET_MM:.2f}mm)",
+ True
+ )
+
+ def fine_autofocus(self, cmd: command) -> None:
+ """
+ Fine autofocus around current Z with configurable window, step, and scoring.
+ """
+ # Tunables
+ WINDOW_MM = 0.16
+ FINE_STEP_MM = 0.04
+ NO_IMPROVE_LIMIT = 1
+ USE_PREVIEW_IF_BELOW = False
+ FOCUS_PREVIEW_THRESHOLD = 90000.0
+ LOG_VERBOSE = True
+
+ # Derived constants
+ _AF_ZFLOOR = 0
+ FINE_STEP_TICKS = int(round(FINE_STEP_MM * _AFTPM))
+ WINDOW_TICKS = int(round(WINDOW_MM * _AFTPM))
+
+ def within_window(zt: int, center: int) -> bool:
+ return (center - WINDOW_TICKS) <= zt <= (center + WINDOW_TICKS) and zt >= _AF_ZFLOOR
+
+ # Start
+ self.status(cmd.message or "Fine autofocus...", cmd.log)
+
+ pos = self.get_position()
+ center = self._af_quantize(int(round(getattr(pos, "z", 1600))))
+ self.status(
+ f"[AF-Fine] Center Z={center / _AFTPM:.2f} mm Window=±{WINDOW_MM:.2f} mm "
+ f"Step={FINE_STEP_MM:.2f} mm",
+ LOG_VERBOSE
+ )
+
+ scores: dict[int, float] = {}
+
+ # Baseline with STILL
+ baseline = self._af_score_at(
+ center, scores, lambda z: within_window(z, center),
+ scorer=lambda _z, _c, _b: self._af_score_still()
+ )
+
+ # Choose scorer
+ if USE_PREVIEW_IF_BELOW and baseline < FOCUS_PREVIEW_THRESHOLD:
+ fine_scorer = lambda _z, _c, _b: self._af_score_preview()
+ scorer_name = "PREVIEW"
+ else:
+ fine_scorer = lambda _z, _c, _b: self._af_score_still()
+ scorer_name = "STILL"
+
+ self.status(
+ f"[AF-Fine] Using {scorer_name} scorer for search "
+ f"(baseline={baseline:.1f} thresh={FOCUS_PREVIEW_THRESHOLD:.1f})",
+ LOG_VERBOSE
+ )
+
+ # Fine search
+ if self.pause_point():
+ return
+
+ best_z, best_s = self._af_refine_around(
+ center=center,
+ cache=scores,
+ bounds_ok=lambda z: within_window(z, center),
+ fine_step_ticks=FINE_STEP_TICKS,
+ no_improve_limit=NO_IMPROVE_LIMIT,
+ scorer=fine_scorer,
+ baseline=baseline
+ )
+
+ if self.pause_point():
+ return
+
+ self._af_move_to_ticks(best_z)
+ self.status(
+ f"[AF-Fine] Best Z={best_z / _AFTPM:.2f} mm "
+ f"Score={best_s:.1f} Δbase={(best_s - baseline):+.1f} "
+ f"(search={scorer_name}, step={FINE_STEP_MM:.2f}mm, window=±{WINDOW_MM:.2f}mm, "
+ f"no_improve_limit={NO_IMPROVE_LIMIT})",
+ True
+ )
+
+ def autofocus_macro(self, cmd: command) -> None:
+ """
+ Coarse (0.40 mm) alternating with bias, then 0.20 mm refine march,
+ then 0.04 mm fine polish.
+ """
+ # Tunables
+ FOCUS_PREVIEW_THRESHOLD = 90000.0
+ COARSE_IMPROVE_THRESH = 1000.0
+ COARSE_DROP_STOP_PEAK = 2000.0
+ COARSE_DROP_STOP_BASE = 3000.0
+ Z_FLOOR_MM = 0.00
+ COARSE_STEP_MM = 0.20
+ REFINE_COARSE_MM = 0.12
+ FINE_STEP_MM = 0.04
+ MAX_OFFSET_MM = 5.60
+ SETTLE_STILL_S = 0.4
+ SETTLE_PREVIEW_S = 0.4
+ FINE_NO_IMPROVE_LIMIT = 2
+ LOG_VERBOSE = True
+
+ # Derived constants
+ _AF_ZFLOOR = int(round(Z_FLOOR_MM * _AFTPM))
+ COARSE_STEP = int(round(COARSE_STEP_MM * _AFTPM))
+ REFINE_COARSE = int(round(REFINE_COARSE_MM * _AFTPM))
+ _AFSTEP = int(round(FINE_STEP_MM * _AFTPM))
+ MAX_OFFSET = int(round(MAX_OFFSET_MM * _AFTPM))
+
+ def quantize(zt: int) -> int:
+ step = 4
+ return (zt // step) * step
+
+ def within_env(zt: int) -> bool:
+ return (start - MAX_OFFSET) <= zt <= (start + MAX_OFFSET) and zt >= _AF_ZFLOOR
+
+ def score_still() -> float:
+ self._exec_gcode("M400", wait=True)
+ if SETTLE_STILL_S > 0:
+ time.sleep(SETTLE_STILL_S)
+ self.camera.capture_image()
+ while self.camera.is_taking_image:
+ time.sleep(0.01)
+ if self.machine_vision.is_black(source="still"):
+ return float("-inf")
+ img = self.camera.get_last_frame(prefer="still", wait_for_still=False)
+ res = self.machine_vision.analyze_focus()
+ return float(res.focus_score)
+
+ def score_preview() -> float:
+ self._exec_gcode("M400", wait=True)
+ if SETTLE_PREVIEW_S > 0:
+ time.sleep(SETTLE_PREVIEW_S)
+ if self.machine_vision.is_black(source="stream"):
+ return float("-inf")
+ img = self.camera.get_last_frame(prefer="stream", wait_for_still=False)
+ res = self.machine_vision.analyze_focus()
+ return float(res.focus_score)
+
+ def score_at(zt: int, cache: dict, scorer) -> float:
+ zt = quantize(zt)
+ if zt < _AF_ZFLOOR or not within_env(zt):
+ return float("-inf")
+ if zt in cache:
+ return cache[zt]
+ self._af_move_to_ticks(zt)
+ s = scorer()
+ cache[zt] = s
+ return s
+
+ # Start
+ self.status(cmd.message or "Autofocus starting...", cmd.log)
+ if self.pause_point():
+ return
+
+ pos = self.get_position()
+ start = quantize(int(round(getattr(pos, "z", 1600))))
+ self.status(f"Start @ Z={start / _AFTPM:.2f} mm", cmd.log)
+
+ scores: dict[int, float] = {}
+
+ # Baseline STILL
+ self._af_move_to_ticks(start)
+ baseline = score_still()
+ scores[start] = baseline
+ best_z = start
+ best_s = baseline
+ self.status(f"[AF] Baseline Z={start / _AFTPM:.2f} score={baseline:.1f}", LOG_VERBOSE)
+
+ coarse_scorer = score_preview if (baseline < FOCUS_PREVIEW_THRESHOLD) else score_still
+ self.status(
+ f"[AF] Coarse scorer: "
+ f"{'PREVIEW' if coarse_scorer is score_preview else 'STILL'} "
+ f"(baseline={baseline:.1f} < thresh={FOCUS_PREVIEW_THRESHOLD:.1f})",
+ LOG_VERBOSE
+ )
+
+ # Coarse alternating with bias
+ k_right = 1
+ k_left = 1
+ max_k = MAX_OFFSET // COARSE_STEP
+ left_max_safe = min(max_k, (start - _AF_ZFLOOR) // COARSE_STEP)
+ right_max_safe = max_k
+ bias_side = None
+ last_side = None
+ peak_on_bias = baseline
+
+ while True:
+ if self.pause_point():
+ self.status("Autofocus paused/stopped.", True)
+ return
+
+ right_has = k_right <= right_max_safe
+ left_has = k_left <= left_max_safe
+ if not right_has and not left_has:
+ break
+
+ # Choose side
+ if bias_side:
+ if bias_side == 'right' and right_has:
+ side = 'right'
+ elif bias_side == 'left' and left_has:
+ side = 'left'
+ else:
+ side = 'right' if right_has else 'left'
+ else:
+ if last_side == 'left' and right_has:
+ side = 'right'
+ elif last_side == 'right' and left_has:
+ side = 'left'
+ elif right_has:
+ side = 'right'
+ else:
+ side = 'left'
+
+ target = quantize(
+ start + (k_right * COARSE_STEP if side == 'right' else -k_left * COARSE_STEP)
+ )
+ if side == 'left' and target < _AF_ZFLOOR:
+ self.status("[AF-Coarse] Reached Z floor; stop left.", LOG_VERBOSE)
+ k_left = left_max_safe + 1
+ last_side = side
+ continue
+
+ s = score_at(target, scores, coarse_scorer)
+ if s > best_s:
+ best_s, best_z = s, target
+
+ improv = s - baseline
+ self.status(
+ f"[AF-Coarse] side={side:<5} Z={target / _AFTPM:.2f} "
+ f"score={s:.1f} Δbase={improv:+.1f}",
+ LOG_VERBOSE
+ )
+
+ if best_z == start and (baseline - s) >= COARSE_DROP_STOP_BASE:
+ self.status("[AF-Coarse] Early stop (baseline-drop)", LOG_VERBOSE)
+ break
+
+ if not bias_side and improv >= COARSE_IMPROVE_THRESH:
+ bias_side = side
+ peak_on_bias = s
+ self.status(
+ f"[AF-Coarse] Bias → {bias_side.upper()} (≥+{COARSE_IMPROVE_THRESH:.0f})",
+ LOG_VERBOSE
+ )
+
+ if bias_side and side == bias_side:
+ if s > peak_on_bias:
+ peak_on_bias = s
+ elif (peak_on_bias - s) >= COARSE_DROP_STOP_PEAK:
+ self.status("[AF-Coarse] Early stop (peak-drop)", LOG_VERBOSE)
+ break
+
+ if side == 'right':
+ k_right += 1
+ else:
+ k_left += 1
+ last_side = side
+
+ if bias_side and ((bias_side == 'right' and not (k_right <= max_k)) or
+ (bias_side == 'left' and not (k_left <= max_k))):
+ break
+
+ # Refine march (0.20 mm)
+ if self.pause_point():
+ self.status("Autofocus paused/stopped.", True)
+ return
+
+ up_zt = quantize(best_z + REFINE_COARSE)
+ down_zt = quantize(best_z - REFINE_COARSE)
+ up_s = score_at(up_zt, scores, coarse_scorer)
+ down_s = score_at(down_zt, scores, coarse_scorer)
+ dir1, z1, s1 = (('up', up_zt, up_s) if up_s >= down_s else ('down', down_zt, down_s))
+ self.status(
+ f"[AF-Refine] Probe {REFINE_COARSE_MM:.2f}mm {dir1}: Z={z1 / _AFTPM:.2f} score={s1:.1f}",
+ LOG_VERBOSE
+ )
+ if s1 > best_s:
+ best_s, best_z = s1, z1
+
+ current, prev = z1, s1
+ while True:
+ if self.pause_point():
+ self.status("Autofocus paused/stopped.", True)
+ return
+ step = REFINE_COARSE if dir1 == 'up' else -REFINE_COARSE
+ nxt = quantize(current + step)
+ if nxt < _AF_ZFLOOR or not within_env(nxt):
+ break
+ s = score_at(nxt, scores, coarse_scorer)
+ self.status(
+ f"[AF-Refine] {REFINE_COARSE_MM:.2f}mm step {dir1}: Z={nxt / _AFTPM:.2f} score={s:.1f}",
+ LOG_VERBOSE
+ )
+ if s > best_s:
+ best_s, best_z = s, nxt
+ if s + 1e-6 >= prev:
+ current, prev = nxt, s
+ else:
+ break
+
+ # Fine polish (ALWAYS STILLs)
+ def climb_fine(start_zt: int, step_ticks: int) -> tuple[int, float]:
+ zt = start_zt
+ best_local_z = start_zt
+ best_local_s = scores.get(start_zt, score_at(start_zt, scores, score_still))
+ no_imp = 0
+ while True:
+ nxt = quantize(zt + step_ticks)
+ if nxt < _AF_ZFLOOR or not within_env(nxt):
+ break
+ s = score_at(nxt, scores, score_still)
+ self.status(
+ f"[AF-Fine] {FINE_STEP_MM:.2f}mm step {'up' if step_ticks>0 else 'down'}: "
+ f"Z={nxt / _AFTPM:.2f} score={s:.1f}",
+ LOG_VERBOSE
+ )
+ if s > best_local_s + 1e-6:
+ best_local_z, best_local_s = nxt, s
+ zt = nxt
+ no_imp = 0
+ else:
+ no_imp += 1
+ zt = nxt
+ if no_imp >= FINE_NO_IMPROVE_LIMIT:
+ break
+ return best_local_z, best_local_s
+
+ up_z, up_s = climb_fine(best_z, _AFSTEP)
+ down_z, down_s = climb_fine(best_z, -_AFSTEP)
+ if (up_s, up_z) >= (down_s, down_z):
+ local_z, local_s = up_z, up_s
+ else:
+ local_z, local_s = down_z, down_s
+ if local_s > best_s:
+ best_z, best_s = local_z, local_s
+
+ if self.pause_point():
+ return
+ self._af_move_to_ticks(best_z)
+ self.status(
+ f"Autofocus complete: Best Z={best_z / _AFTPM:.2f} mm Score={best_s:.1f}",
+ True
+ )
+
+ # ========================================================================
+ # Public convenience methods
+ # ========================================================================
+
+ def start_autofocus(self) -> None:
+ """Start the autofocus macro."""
+ self.reset_after_stop()
+ self.enqueue_cmd(command(
+ kind="AUTOFOCUS",
+ value="",
+ message="Beginning Autofocus Macro",
+ log=True
+ ))
+
+ def start_fine_autofocus(self) -> None:
+ """Start the fine autofocus macro."""
+ self.reset_after_stop()
+ self.enqueue_cmd(command(
+ kind="FINE_AUTOFOCUS",
+ value="",
+ message="Beginning Fine Autofocus Macro",
+ log=True
+ ))
\ No newline at end of file
diff --git a/printer/automation/camera_calibration_mixin.py b/printer/automation/camera_calibration_mixin.py
new file mode 100644
index 0000000..f23dca6
--- /dev/null
+++ b/printer/automation/camera_calibration_mixin.py
@@ -0,0 +1,639 @@
+"""
+Camera calibration and vision-guided movement for automated 3D printer control.
+
+This module contains camera calibration and vision-based positioning methods
+that can be mixed into the main AutomatedPrinter controller class.
+"""
+
+import time
+import numpy as np
+import cv2
+from typing import Optional, Tuple
+
+from printer.base_controller import command
+from printer.models import Position
+
+
+class CameraCalibrationMixin:
+ """
+ Mixin class containing camera calibration and vision-guided movement functionality.
+
+ This class assumes it will be mixed into a controller that has:
+ - self.camera (camera instance with capture_image, get_last_frame, is_taking_image)
+ - self._exec_gcode(gcode, wait=False) method
+ - self.status(message, log=True) method
+ - self.pause_point() method that returns True if stopped
+ - self.register_handler(kind, function) method
+ - self.get_position() -> Position method
+ - self.get_max_x/y/z() methods
+ - self.enqueue_cmd(command) method
+ """
+
+ def _init_camera_calibration_handlers(self):
+ """Register camera calibration command handlers. Call this from __init__."""
+ self.register_handler("CAMERA_CALIBRATE", self._handle_camera_calibrate)
+ self.register_handler("MOVE_TO_VISION_POINT", self._handle_move_to_vision_point)
+
+ # Initialize calibration state
+ self.M_est = None # 2x2 estimated mapping matrix (pixels = M * world_delta)
+ self.M_inv = None # Inverse mapping (world_delta = M_inv * pixel_delta)
+ self._cal_ref_pos = None # Position where calibration was performed
+ self._cal_image_width = None # Image width used during calibration
+ self._cal_image_height = None # Image height used during calibration
+ self._cal_dpi = None # Computed DPI (dots per inch) from calibration
+
+ # Calibration parameters (can be overridden)
+ self._cal_move_x_ticks = 100 # 1.00mm in 0.01mm units
+ self._cal_move_y_ticks = 100 # 1.00mm in 0.01mm units
+
+ # Try to load saved calibration
+ self._load_camera_calibration()
+
+ # ========================================================================
+ # Save/Load calibration methods
+ # ========================================================================
+
+ def _save_camera_calibration(self) -> None:
+ """Save the current calibration matrix to printer config."""
+ if self.M_est is None or self.M_inv is None:
+ return
+
+ calibration_data = {
+ 'M_est': self.M_est.tolist(),
+ 'M_inv': self.M_inv.tolist(),
+ 'ref_pos_x': int(self._cal_ref_pos.x) if self._cal_ref_pos else None,
+ 'ref_pos_y': int(self._cal_ref_pos.y) if self._cal_ref_pos else None,
+ 'ref_pos_z': int(self._cal_ref_pos.z) if self._cal_ref_pos else None,
+ 'image_width': self._cal_image_width,
+ 'image_height': self._cal_image_height,
+ 'move_x_ticks': self._cal_move_x_ticks,
+ 'move_y_ticks': self._cal_move_y_ticks,
+ 'dpi': float(self._cal_dpi) if self._cal_dpi is not None else None,
+ }
+
+ # Save to printer config
+ self.config.camera_calibration = calibration_data
+
+ # Persist to disk using the PrinterSettingsManager
+ from printer.printerConfig import PrinterSettingsManager
+ PrinterSettingsManager.save(self.CONFIG_SUBDIR, self.config)
+
+ self.status("Camera calibration saved to config", True)
+
+ def _load_camera_calibration(self) -> bool:
+ """
+ Load saved calibration from printer config.
+ Returns True if calibration was loaded successfully.
+ """
+ if not hasattr(self.config, 'camera_calibration'):
+ return False
+
+ cal_data = self.config.camera_calibration
+ if not cal_data or not isinstance(cal_data, dict):
+ return False
+
+ try:
+ # Load matrices
+ M_est_list = cal_data.get('M_est')
+ M_inv_list = cal_data.get('M_inv')
+
+ if M_est_list is None or M_inv_list is None:
+ return False
+
+ self.M_est = np.array(M_est_list, dtype=np.float64)
+ self.M_inv = np.array(M_inv_list, dtype=np.float64)
+
+ # Load reference position
+ ref_x = cal_data.get('ref_pos_x')
+ ref_y = cal_data.get('ref_pos_y')
+ ref_z = cal_data.get('ref_pos_z')
+
+ if ref_x is not None and ref_y is not None and ref_z is not None:
+ self._cal_ref_pos = Position(x=ref_x, y=ref_y, z=ref_z)
+
+ # Load image dimensions
+ self._cal_image_width = cal_data.get('image_width')
+ self._cal_image_height = cal_data.get('image_height')
+
+ # Load calibration parameters
+ self._cal_move_x_ticks = cal_data.get('move_x_ticks', 100)
+ self._cal_move_y_ticks = cal_data.get('move_y_ticks', 100)
+
+ # Load or calculate DPI
+ self._cal_dpi = cal_data.get('dpi')
+ if self._cal_dpi is None:
+ # Calculate DPI if not saved
+ self._calculate_dpi()
+
+ dpi_str = f" (DPI: {self._cal_dpi:.1f})" if self._cal_dpi is not None else ""
+ self.status(f"Camera calibration loaded from config{dpi_str}", True)
+ return True
+
+ except Exception as e:
+ self.status(f"Failed to load camera calibration: {e}", True)
+ self.M_est = None
+ self.M_inv = None
+ self._cal_ref_pos = None
+ self._cal_image_width = None
+ self._cal_image_height = None
+ self._cal_dpi = None
+ return False
+
+ def clear_camera_calibration(self) -> None:
+ """Clear the saved camera calibration from config and memory."""
+ self.M_est = None
+ self.M_inv = None
+ self._cal_ref_pos = None
+ self._cal_image_width = None
+ self._cal_image_height = None
+ self._cal_dpi = None
+
+ # Clear from config
+ self.config.camera_calibration = {}
+
+ # Persist to disk
+ from printer.printerConfig import PrinterSettingsManager
+ PrinterSettingsManager.save(self.CONFIG_SUBDIR, self.config)
+
+ self.status("Camera calibration cleared", True)
+
+ # ========================================================================
+ # Camera calibration helper methods
+ # ========================================================================
+
+ def _surface_to_gray_cv(self, arr: np.ndarray) -> np.ndarray:
+ """Convert RGB numpy array to grayscale for OpenCV."""
+ if arr.ndim == 2:
+ return arr
+ gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
+ return gray
+
+ def _edges_canny(self, gray_u8: np.ndarray) -> np.ndarray:
+ """Compute normalized Canny edges."""
+ g = cv2.GaussianBlur(gray_u8, (5, 5), 0)
+ e = cv2.Canny(g, 60, 180)
+ ef = e.astype(np.float32)
+ ef -= ef.mean()
+ ef /= (ef.std() + 1e-6)
+ return ef
+
+ def _phase_corr_shift(
+ self,
+ img_a_f32: np.ndarray,
+ img_b_f32: np.ndarray
+ ) -> Tuple[float, float, float]:
+ """Compute phase correlation shift between two images."""
+ win = cv2.createHanningWindow(
+ (img_a_f32.shape[1], img_a_f32.shape[0]),
+ cv2.CV_32F
+ )
+ (dx, dy), response = cv2.phaseCorrelate(img_a_f32, img_b_f32, win)
+ return float(dx), float(dy), float(response)
+
+ def _calculate_dpi(self) -> None:
+ """
+ Calculate DPI (dots per inch) from the calibration matrix.
+ DPI represents the average resolution of the camera image.
+
+ The calculation uses the calibration matrix M_est to determine how many
+ pixels correspond to physical movement. We compute the average scaling
+ factor from both X and Y axes and convert to DPI (pixels per inch).
+ """
+ if self.M_est is None:
+ self._cal_dpi = None
+ return
+
+ try:
+ # M_est maps world deltas (in 0.01mm ticks) to pixel deltas
+ # M_est[0,0] and M_est[1,1] are the diagonal elements (x->px, y->py)
+ # Extract pixels per tick for both axes
+ px_per_tick_x = abs(self.M_est[0, 0]) # pixels per 0.01mm in X
+ px_per_tick_y = abs(self.M_est[1, 1]) # pixels per 0.01mm in Y
+
+ # Average the two axes
+ px_per_tick_avg = (px_per_tick_x + px_per_tick_y) / 2.0
+
+ # Convert to pixels per mm (1 tick = 0.01mm)
+ px_per_mm = px_per_tick_avg * 100.0
+
+ # Convert to DPI (1 inch = 25.4 mm)
+ dpi = px_per_mm * 25.4
+
+ self._cal_dpi = dpi
+
+ except Exception as e:
+ self.status(f"Failed to calculate DPI: {e}", False)
+ self._cal_dpi = None
+
+ def _capture_and_process_edges(self) -> Optional[np.ndarray]:
+ """Capture a still image and return its edge map."""
+ try:
+ # Capture still
+ self.camera.capture_image()
+ while self.camera.is_taking_image:
+ time.sleep(0.01)
+
+ # Get frame as numpy array
+ arr = self.camera.get_last_frame(prefer="still", wait_for_still=False)
+ if arr is None:
+ return None
+
+ # Store the calibration image resolution
+ if self._cal_image_height is None:
+ self._cal_image_height = arr.shape[0]
+ self._cal_image_width = arr.shape[1]
+ self.status(
+ f"Calibration using image resolution: "
+ f"{self._cal_image_width}x{self._cal_image_height}",
+ True
+ )
+
+ # Convert to grayscale and compute edges
+ gray = self._surface_to_gray_cv(arr)
+ edges = self._edges_canny(gray)
+ return edges
+ except Exception as e:
+ self.status(f"Edge capture failed: {e}", True)
+ return None
+
+ # ========================================================================
+ # Main calibration routine
+ # ========================================================================
+
+ def _handle_camera_calibrate(self, cmd: command) -> None:
+ """
+ Run the calibration routine to determine the mapping between
+ image pixels and world coordinates using phase correlation.
+ """
+ self.status("Starting camera calibration...", True)
+
+ # Reset calibration state
+ self.M_est = None
+ self.M_inv = None
+ self._cal_image_width = None
+ self._cal_image_height = None
+
+ # Allow pausing/stopping
+ if self.pause_point():
+ self.status("Calibration cancelled.", True)
+ return
+
+ # Step 1: Capture base image at current position
+ self.status("Capturing base image...", True)
+ self._exec_gcode("M400", wait=True)
+ time.sleep(0.6) # Settle time
+
+ cal_base_pos = self.get_position()
+ edges_base = self._capture_and_process_edges()
+
+ if edges_base is None:
+ self.status("Failed to capture base image.", True)
+ return
+
+ if self.pause_point():
+ self.status("Calibration cancelled.", True)
+ return
+
+ # Step 2: Move +X and capture
+ dx_mm = self._cal_move_x_ticks / 100.0
+ self.status(f"Moving +X by {dx_mm:.2f}mm...", True)
+ self._exec_gcode(f"G91", wait=True) # Relative mode
+ self._exec_gcode(f"G0 X{dx_mm:.2f}", wait=True)
+ self._exec_gcode(f"G90", wait=True) # Back to absolute
+ time.sleep(0.6)
+
+ edges_x = self._capture_and_process_edges()
+ if edges_x is None:
+ self.status("Failed to capture +X image.", True)
+ return
+
+ # Compute phase correlation for +X move
+ dpx_x, dpy_x, resp_x = self._phase_corr_shift(edges_base, edges_x)
+ self.status(
+ f"+X move: pixel shift=({dpx_x:.2f}, {dpy_x:.2f}), "
+ f"response={resp_x:.3f}",
+ True
+ )
+
+ if self.pause_point():
+ self.status("Calibration cancelled.", True)
+ return
+
+ # Step 3: Return to base, then move +Y and capture
+ self.status("Returning to base position...", True)
+ self._exec_gcode(
+ f"G0 X{cal_base_pos.x/100:.2f} Y{cal_base_pos.y/100:.2f}",
+ wait=True
+ )
+ time.sleep(0.6)
+
+ dy_mm = self._cal_move_y_ticks / 100.0
+ self.status(f"Moving +Y by {dy_mm:.2f}mm...", True)
+ self._exec_gcode(f"G91", wait=True)
+ self._exec_gcode(f"G0 Y{dy_mm:.2f}", wait=True)
+ self._exec_gcode(f"G90", wait=True)
+ time.sleep(0.6)
+
+ edges_y = self._capture_and_process_edges()
+ if edges_y is None:
+ self.status("Failed to capture +Y image.", True)
+ return
+
+ # Compute phase correlation for +Y move
+ dpx_y, dpy_y, resp_y = self._phase_corr_shift(edges_base, edges_y)
+ self.status(
+ f"+Y move: pixel shift=({dpx_y:.2f}, {dpy_y:.2f}), "
+ f"response={resp_y:.3f}",
+ True
+ )
+
+ # Step 4: Return to base
+ self.status("Returning to base position...", True)
+ self._exec_gcode(
+ f"G0 X{cal_base_pos.x/100:.2f} Y{cal_base_pos.y/100:.2f}",
+ wait=True
+ )
+
+ # Step 5: Build calibration matrix
+ # M * [dx_world, dy_world] = [dpx_pixel, dpy_pixel]
+ # We have two observations:
+ # M * [dx_ticks, 0] = [dpx_x, dpy_x]
+ # M * [0, dy_ticks] = [dpx_y, dpy_y]
+
+ world_x = np.array([[self._cal_move_x_ticks], [0.0]])
+ world_y = np.array([[0.0], [self._cal_move_y_ticks]])
+ pixel_x = np.array([[dpx_x], [dpy_x]])
+ pixel_y = np.array([[dpx_y], [dpy_y]])
+
+ # M = [pixel_x, pixel_y] * [world_x, world_y]^-1
+ world_mat = np.hstack([world_x, world_y])
+ pixel_mat = np.hstack([pixel_x, pixel_y])
+
+ try:
+ world_inv = np.linalg.inv(world_mat)
+ self.M_est = pixel_mat @ world_inv
+ self.M_inv = np.linalg.inv(self.M_est)
+
+ # Store calibration reference position (center of image at cal_base_pos)
+ self._cal_ref_pos = cal_base_pos
+
+ # Calculate DPI from the calibration matrix
+ self._calculate_dpi()
+
+ dpi_str = f", DPI: {self._cal_dpi:.1f}" if self._cal_dpi is not None else ""
+ self.status(
+ f"Calibration complete. Matrix M_est:\n{self.M_est}\n"
+ f"Inverse M_inv:\n{self.M_inv}{dpi_str}",
+ True
+ )
+
+ # Save calibration to config
+ self._save_camera_calibration()
+
+ except np.linalg.LinAlgError:
+ self.status("Calibration failed: singular matrix.", True)
+ return
+
+ # ========================================================================
+ # Vision-guided movement
+ # ========================================================================
+
+ def _pixel_to_world_delta(
+ self,
+ pixel_x: float,
+ pixel_y: float,
+ image_center_x: Optional[float] = None,
+ image_center_y: Optional[float] = None
+ ) -> Optional[Tuple[float, float]]:
+ """
+ Convert pixel coordinates to world coordinate delta from calibration reference.
+
+ Args:
+ pixel_x: X coordinate in image pixels
+ pixel_y: Y coordinate in image pixels
+ image_center_x: Center X of image (defaults to _cal_image_width/2)
+ image_center_y: Center Y of image (defaults to _cal_image_height/2)
+
+ Returns:
+ (dx_ticks, dy_ticks) relative to calibration reference position,
+ or None if calibration is not available
+ """
+ if self.M_inv is None:
+ return None
+
+ if image_center_x is None:
+ image_center_x = (self._cal_image_width or 0) / 2.0
+ if image_center_y is None:
+ image_center_y = (self._cal_image_height or 0) / 2.0
+
+ # Pixel delta from image center
+ pixel_delta = np.array([[pixel_x - image_center_x], [pixel_y - image_center_y]])
+
+ # Convert to world delta
+ world_delta = self.M_inv @ pixel_delta
+
+ # NOTE: We negate both X and Y to convert from image coordinates to stage coordinates.
+ # Image coords: origin at top-left, X increases right, Y increases down
+ # Stage coords: X and Y both increase in positive directions
+ # The calibration matrix M is built to map stage deltas to pixel deltas,
+ # so M_inv maps pixel deltas to stage deltas, but we need to flip signs
+ # to account for the image coordinate system.
+ dx_ticks = -float(world_delta[0, 0])
+ dy_ticks = -float(world_delta[1, 0])
+
+ return dx_ticks, dy_ticks
+
+ def _handle_move_to_vision_point(self, cmd: command) -> None:
+ """
+ Move to a position specified by vision coordinates.
+
+ cmd.value should be a dict with:
+ - 'pixel_x': X coordinate in image
+ - 'pixel_y': Y coordinate in image
+ - 'relative': bool, if True move relative to current position,
+ if False move relative to calibration reference
+ """
+ if self.M_inv is None:
+ self.status("Cannot move: calibration required first", True)
+ return
+
+ try:
+ params = cmd.value
+ pixel_x = float(params['pixel_x'])
+ pixel_y = float(params['pixel_y'])
+ relative = params.get('relative', True)
+ except (TypeError, KeyError, ValueError) as e:
+ self.status(f"Invalid MOVE_TO_VISION_POINT parameters: {e}", True)
+ return
+
+ # Convert pixel coords to world delta
+ result = self._pixel_to_world_delta(pixel_x, pixel_y)
+ if result is None:
+ self.status("Pixel-to-world conversion failed", True)
+ return
+
+ dx_ticks, dy_ticks = result
+
+ # Determine target position
+ if relative:
+ # Move relative to current position
+ current_pos = self.get_position()
+ new_x_ticks = current_pos.x + int(round(dx_ticks))
+ new_y_ticks = current_pos.y + int(round(dy_ticks))
+ else:
+ # Move relative to calibration reference
+ if self._cal_ref_pos is None:
+ self.status("No calibration reference position available", True)
+ return
+ new_x_ticks = self._cal_ref_pos.x + int(round(dx_ticks))
+ new_y_ticks = self._cal_ref_pos.y + int(round(dy_ticks))
+
+ # Convert to mm
+ new_x_mm = new_x_ticks / 100.0
+ new_y_mm = new_y_ticks / 100.0
+
+ # Bounds check
+ max_x = self.get_max_x()
+ max_y = self.get_max_y()
+
+ if not (0 <= new_x_mm <= max_x and 0 <= new_y_mm <= max_y):
+ self.status(
+ f"Vision target out of bounds: ({new_x_mm:.2f}, {new_y_mm:.2f})",
+ True
+ )
+ return
+
+ # Execute move
+ self._exec_gcode(
+ f"G0 X{new_x_mm:.2f} Y{new_y_mm:.2f}",
+ wait=True,
+ message=f"Moving to vision point: X={new_x_mm:.2f}, Y={new_y_mm:.2f}",
+ log=True
+ )
+
+ # ========================================================================
+ # Public convenience methods
+ # ========================================================================
+
+ def set_calibration_moves(self, x_ticks: int, y_ticks: int) -> None:
+ """
+ Set the calibration move distances in ticks (0.01mm units).
+
+ Args:
+ x_ticks: Distance to move in X during calibration
+ y_ticks: Distance to move in Y during calibration
+ """
+ self._cal_move_x_ticks = x_ticks
+ self._cal_move_y_ticks = y_ticks
+
+ def start_camera_calibration(self) -> None:
+ """Enqueue a camera calibration command."""
+ self.reset_after_stop()
+ self.enqueue_cmd(command(
+ kind="CAMERA_CALIBRATE",
+ value="",
+ message="Starting camera calibration",
+ log=True
+ ))
+
+ def go_to_calibration_pattern(self, position: Optional[Position] = None) -> None:
+ """
+ Move to a known calibration pattern position.
+
+ This is useful for setting up before running calibration, allowing you to
+ position the camera over a calibration target (e.g., a grid or known feature).
+
+ Args:
+ position: Position to move to (in 0.01mm ticks).
+ If None, looks for 'calibration_pattern_position' in printer config.
+ """
+ if position is None:
+ # Try to load from printer config (same pattern as get_sample_position)
+ if hasattr(self.config, 'calibration_pattern_position'):
+ try:
+ entry = self.config.calibration_pattern_position
+ x_mm = float(entry["x"])
+ y_mm = float(entry["y"])
+ z_mm = float(entry["z"])
+ position = Position(
+ x=int(x_mm * 100),
+ y=int(y_mm * 100),
+ z=int(z_mm * 100),
+ )
+ except (KeyError, ValueError, TypeError) as e:
+ self.status(
+ f"Invalid calibration_pattern_position in printer config: {e}",
+ True
+ )
+ return
+ else:
+ self.status(
+ "No calibration pattern position provided or configured in printer config",
+ True
+ )
+ return
+
+ # Move to the position
+ x_mm = position.x / 100.0
+ y_mm = position.y / 100.0
+ z_mm = position.z / 100.0
+
+ self.enqueue_printer(
+ f"G0 X{x_mm:.2f} Y{y_mm:.2f} Z{z_mm:.2f}",
+ message=f"Moving to calibration pattern at X={x_mm:.2f}, Y={y_mm:.2f}, Z={z_mm:.2f}",
+ log=True
+ )
+
+ def move_to_vision_point(
+ self,
+ pixel_x: float,
+ pixel_y: float,
+ relative: bool = True
+ ) -> None:
+ """
+ Move to a point identified by vision coordinates.
+
+ Args:
+ pixel_x: X coordinate in image pixels
+ pixel_y: Y coordinate in image pixels
+ relative: If True, move relative to current position;
+ if False, move relative to calibration reference
+ """
+ self.enqueue_cmd(command(
+ kind="MOVE_TO_VISION_POINT",
+ value={
+ 'pixel_x': pixel_x,
+ 'pixel_y': pixel_y,
+ 'relative': relative
+ },
+ message=f"Moving to vision point ({pixel_x:.1f}, {pixel_y:.1f})",
+ log=True
+ ))
+
+ def get_calibration_status(self) -> dict:
+ """
+ Get current calibration status.
+
+ Returns:
+ Dict with calibration state information
+ """
+ return {
+ 'calibrated': self.M_inv is not None,
+ 'image_width': self._cal_image_width,
+ 'image_height': self._cal_image_height,
+ 'reference_position': self._cal_ref_pos,
+ 'matrix_M': self.M_est.tolist() if self.M_est is not None else None,
+ 'matrix_M_inv': self.M_inv.tolist() if self.M_inv is not None else None,
+ 'dpi': self._cal_dpi,
+ }
+
+ def is_calibrated(self) -> bool:
+ """
+ Check if camera is calibrated and ready for vision-guided movement.
+
+ Returns:
+ True if calibration matrices are loaded and valid
+ """
+ return (self.M_est is not None and
+ self.M_inv is not None and
+ self._cal_ref_pos is not None)
\ No newline at end of file
diff --git a/printer/automation_config.py b/printer/automation_config.py
index 7d4b4e8..7e602d6 100644
--- a/printer/automation_config.py
+++ b/printer/automation_config.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from generic_config import ConfigManager, DEFAULT_FILENAME, ACTIVE_FILENAME
+from common.generic_config import ConfigManager, DEFAULT_FILENAME, ACTIVE_FILENAME
@dataclass
class AutomationSettings:
diff --git a/printer/base_controller.py b/printer/base_controller.py
index f53a414..9ce608d 100644
--- a/printer/base_controller.py
+++ b/printer/base_controller.py
@@ -15,8 +15,8 @@
PrinterSettings,
PrinterSettingsManager
)
-from forgeConfig import (
- ForgeSettings,
+from common.fieldweaveConfig import (
+ FieldWeaveSettings,
)
def _probe_port(port_device, baud, indicators, request=b"M115\r\n", read_window_s=10, min_lines=3):
@@ -98,7 +98,7 @@ class command:
class BasePrinterController:
CONFIG_SUBDIR = "Ender3"
"""Base class for 3D printer control"""
- def __init__(self, forgeConfig: ForgeSettings):
+ def __init__(self, fieldweaveConfig: FieldWeaveSettings):
self.config = PrinterSettings()
PrinterSettingsManager.scope_dir(self.CONFIG_SUBDIR)
self.config = PrinterSettingsManager.load(self.CONFIG_SUBDIR)
@@ -127,13 +127,13 @@ def __init__(self, forgeConfig: ForgeSettings):
# Initialize serial connection
- self._initialize_printer(forgeConfig)
+ self._initialize_printer(fieldweaveConfig)
# Start command processing thread
self._processing_thread = threading.Thread(target=self._process_commands, daemon=True)
self._processing_thread.start()
- def _initialize_printer(self, forgeConfig):
+ def _initialize_printer(self, fieldweaveConfig):
"""Initialize printer serial connection"""
baud = self.config.baud_rate
indicators = getattr(self.config, "valid_response_indicators", None) or [
@@ -146,7 +146,7 @@ def _initialize_printer(self, forgeConfig):
raise RuntimeError("No serial ports found. Is the printer connected?")
preferred = []
- cfg_port = getattr(forgeConfig, "serial_port", None)
+ cfg_port = getattr(fieldweaveConfig, "serial_port", None)
if cfg_port:
preferred = [cfg_port]
remaining = [p for p in detected if p not in set(preferred)]
diff --git a/printer/printerConfig.py b/printer/printerConfig.py
index 12fcd2a..5e97547 100644
--- a/printer/printerConfig.py
+++ b/printer/printerConfig.py
@@ -2,7 +2,7 @@
from dataclasses import dataclass, field
-from generic_config import ConfigManager, DEFAULT_FILENAME, ACTIVE_FILENAME
+from common.generic_config import ConfigManager, DEFAULT_FILENAME, ACTIVE_FILENAME
@dataclass
class PrinterSettings():
@@ -14,6 +14,14 @@ class PrinterSettings():
max_z: int = 6000 # Maximum Z dimension in steps
step_size: int = 4 # minimum distance that can be moved in 0.01mm
sample_positions: dict[int, dict[str, float]] = field(default_factory=dict)
+ calibration_pattern_position: dict[str, float] = field(default_factory=dict) # X, Y, Z in mm
+
+ # Sample calibration positions (for verifying X positions)
+ calibration_y: float = 220.0 # Y position for calibration checks (mm)
+ calibration_z: float = 26.0 # Z position for calibration checks (mm)
+
+ # Camera calibration data
+ camera_calibration: dict[str, any] = field(default_factory=dict) # Stores M_est, M_inv, reference position, etc.
def make_printer_settings_manager(