From 6d24bfea11a6d9e5d42c9ad65ff7752ba36e0327 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 03:46:50 -0700 Subject: [PATCH 01/52] feat(camera): extend CameraDefinition with type, hardware_trigger, per-camera overrides Co-Authored-By: Claude Fable 5 --- software/control/models/camera_registry.py | 49 +++++++++- .../control/core/config/test_repository.py | 6 ++ .../tests/control/test_multi_camera_models.py | 92 ++++++++++++++++--- 3 files changed, 132 insertions(+), 15 deletions(-) diff --git a/software/control/models/camera_registry.py b/software/control/models/camera_registry.py index 00695ac1c..4d6708d02 100644 --- a/software/control/models/camera_registry.py +++ b/software/control/models/camera_registry.py @@ -13,6 +13,24 @@ logger = logging.getLogger(__name__) +# Vocabulary matches the INI camera_type strings (see squid/config.py _old_camera_variant_to_enum) +KNOWN_CAMERA_TYPES = ["Toupcam", "FLIR", "Hamamatsu", "iDS", "TIS", "Tucsen", "Photometrics", "Andor", "Default"] +# Vocabulary matches control.utils.FlipVariant member values (use FlipVariant(value) to convert) +KNOWN_FLIP_VALUES = ["Vertical", "Horizontal", "Both"] +# Vocabulary matches squid.config.CameraPixelFormat member values +KNOWN_PIXEL_FORMATS = [ + "MONO8", + "MONO10", + "MONO12", + "MONO14", + "MONO16", + "RGB24", + "RGB32", + "RGB48", + "BAYER_RG8", + "BAYER_RG12", +] + class CameraDefinition(BaseModel): """A camera in the system. @@ -26,8 +44,31 @@ class CameraDefinition(BaseModel): serial_number: str = Field(..., min_length=1, description="Hardware serial number") model: Optional[str] = Field(None, description="Camera model for display") + # Dual-camera fields. All optional; absent values fall back to the INI [CAMERA_CONFIG] section. + type: Optional[str] = Field(None, description="Camera driver type (same vocabulary as INI camera_type)") + hardware_trigger: bool = Field(True, description="Whether this camera's hardware trigger line is wired") + rotate_image_angle: Optional[float] = Field(None, description="Per-camera rotation override") + flip: Optional[str] = Field(None, description="Per-camera flip override (Vertical/Horizontal/Both)") + crop_width: Optional[int] = Field(None, ge=1, description="Per-camera unbinned crop width override") + crop_height: Optional[int] = Field(None, ge=1, description="Per-camera unbinned crop height override") + default_pixel_format: Optional[str] = Field(None, description="Per-camera default pixel format override") + default_binning: Optional[List[int]] = Field(None, description="Per-camera default binning override, [x, y]") + model_config = {"extra": "forbid"} + @model_validator(mode="after") + def validate_dual_camera_fields(self) -> "CameraDefinition": + if self.type is not None and self.type not in KNOWN_CAMERA_TYPES: + raise ValueError(f"Unknown camera type '{self.type}'. Known: {KNOWN_CAMERA_TYPES}") + if self.flip is not None and self.flip not in KNOWN_FLIP_VALUES: + raise ValueError(f"Unknown flip value '{self.flip}'. Known: {KNOWN_FLIP_VALUES}") + if self.default_pixel_format is not None and self.default_pixel_format not in KNOWN_PIXEL_FORMATS: + raise ValueError(f"Unknown pixel format '{self.default_pixel_format}'. Known: {KNOWN_PIXEL_FORMATS}") + if self.default_binning is not None: + if len(self.default_binning) != 2 or any(b < 1 for b in self.default_binning): + raise ValueError(f"default_binning must be [x, y] with positive ints, got {self.default_binning}") + return self + class CameraRegistryConfig(BaseModel): """ @@ -41,7 +82,8 @@ class CameraRegistryConfig(BaseModel): Validation rules: - Single camera: name and id are optional (defaults: id=1, name="Camera") - - Multiple cameras: name and id are required for all cameras + - Multiple cameras: name, id and type are required for all cameras + (type cannot be inferred from the single INI camera_type when cameras differ) - Names must be unique - IDs must be unique - Serial numbers must be unique @@ -103,6 +145,11 @@ def validate_cameras(self) -> "CameraRegistryConfig": f"Camera at index {i} (serial: {cam.serial_number}) missing required 'name' " f"(required when multiple cameras exist)" ) + if cam.type is None: + raise ValueError( + f"Camera at index {i} (serial: {cam.serial_number}) missing required 'type' " + f"(required when multiple cameras exist)" + ) # Validate uniqueness names = [c.name for c in self.cameras if c.name is not None] diff --git a/software/tests/control/core/config/test_repository.py b/software/tests/control/core/config/test_repository.py index 520f49725..ec72430a0 100644 --- a/software/tests/control/core/config/test_repository.py +++ b/software/tests/control/core/config/test_repository.py @@ -437,9 +437,11 @@ def test_get_camera_registry(self, temp_dir): id: 1 serial_number: "ABC123" model: "Hamamatsu C15440" + type: "Hamamatsu" - name: "Side Camera" id: 2 serial_number: "DEF456" + type: "Toupcam" """ ) @@ -492,9 +494,11 @@ def test_get_camera_names(self, temp_dir): - name: "Main Camera" id: 1 serial_number: "ABC" + type: "Toupcam" - name: "Secondary Camera" id: 2 serial_number: "DEF" + type: "Toupcam" """ ) @@ -1116,9 +1120,11 @@ def test_effective_emission_wheel_no_implicit_with_multiple_cameras(self, temp_d - name: "Main Camera" id: 1 serial_number: "ABC123" + type: "Toupcam" - name: "Side Camera" id: 2 serial_number: "DEF456" + type: "Toupcam" """ ) diff --git a/software/tests/control/test_multi_camera_models.py b/software/tests/control/test_multi_camera_models.py index 723d22db2..2132ac090 100644 --- a/software/tests/control/test_multi_camera_models.py +++ b/software/tests/control/test_multi_camera_models.py @@ -95,6 +95,70 @@ def test_camera_definition_invalid_id_rejected(self): assert "greater than or equal to 1" in str(exc_info.value) +class TestCameraDefinitionDualCameraFields: + """New fields for dual-camera support: type, hardware_trigger, per-camera overrides.""" + + def test_defaults(self): + cam = CameraDefinition(serial_number="SN1") + assert cam.type is None + assert cam.hardware_trigger is True + assert cam.rotate_image_angle is None + assert cam.flip is None + assert cam.crop_width is None + assert cam.crop_height is None + assert cam.default_pixel_format is None + assert cam.default_binning is None + + def test_full_definition_parses(self): + cam = CameraDefinition( + name="Side Camera", + id=2, + serial_number="SN2", + type="Toupcam", + hardware_trigger=False, + rotate_image_angle=180.0, + flip="Horizontal", + crop_width=3000, + crop_height=3000, + default_pixel_format="RGB24", + default_binning=[2, 2], + ) + assert cam.type == "Toupcam" + assert cam.hardware_trigger is False + assert cam.default_binning == [2, 2] + + def test_unknown_type_rejected(self): + with pytest.raises(ValidationError): + CameraDefinition(serial_number="SN1", type="NotACamera") + + def test_unknown_pixel_format_rejected(self): + with pytest.raises(ValidationError): + CameraDefinition(serial_number="SN1", default_pixel_format="MONO99") + + def test_unknown_flip_rejected(self): + with pytest.raises(ValidationError): + CameraDefinition(serial_number="SN1", flip="Diagonal") + + def test_binning_must_be_pair_of_positive_ints(self): + with pytest.raises(ValidationError): + CameraDefinition(serial_number="SN1", default_binning=[2]) + with pytest.raises(ValidationError): + CameraDefinition(serial_number="SN1", default_binning=[0, 2]) + + def test_multi_camera_requires_type(self): + with pytest.raises(ValidationError, match="type"): + CameraRegistryConfig( + cameras=[ + CameraDefinition(name="Main", id=1, serial_number="SN1", type="Toupcam"), + CameraDefinition(name="Side", id=2, serial_number="SN2"), # no type + ] + ) + + def test_single_camera_type_optional(self): + config = CameraRegistryConfig(cameras=[CameraDefinition(serial_number="SN1")]) + assert config.cameras[0].type is None + + class TestCameraRegistryConfig: """Tests for CameraRegistryConfig model.""" @@ -119,8 +183,8 @@ def test_registry_with_cameras(self): """Test registry with multiple cameras (requires explicit id).""" registry = CameraRegistryConfig( cameras=[ - CameraDefinition(name="Main Camera", id=1, serial_number="ABC12345"), - CameraDefinition(name="Side Camera", id=2, serial_number="DEF67890"), + CameraDefinition(name="Main Camera", id=1, serial_number="ABC12345", type="Toupcam"), + CameraDefinition(name="Side Camera", id=2, serial_number="DEF67890", type="Toupcam"), ] ) assert len(registry.cameras) == 2 @@ -140,8 +204,8 @@ def test_get_camera_by_name_found(self): """Test finding camera by name.""" registry = CameraRegistryConfig( cameras=[ - CameraDefinition(name="Main Camera", id=1, serial_number="ABC12345"), - CameraDefinition(name="Side Camera", id=2, serial_number="DEF67890"), + CameraDefinition(name="Main Camera", id=1, serial_number="ABC12345", type="Toupcam"), + CameraDefinition(name="Side Camera", id=2, serial_number="DEF67890", type="Toupcam"), ] ) camera = registry.get_camera_by_name("Main Camera") @@ -173,8 +237,8 @@ def test_get_camera_by_id_found(self): """Test finding camera by ID.""" registry = CameraRegistryConfig( cameras=[ - CameraDefinition(name="Main Camera", id=1, serial_number="ABC12345"), - CameraDefinition(name="Side Camera", id=2, serial_number="DEF67890"), + CameraDefinition(name="Main Camera", id=1, serial_number="ABC12345", type="Toupcam"), + CameraDefinition(name="Side Camera", id=2, serial_number="DEF67890", type="Toupcam"), ] ) camera = registry.get_camera_by_id(2) @@ -195,8 +259,8 @@ def test_get_camera_names(self): """Test getting list of all camera names.""" registry = CameraRegistryConfig( cameras=[ - CameraDefinition(name="Main Camera", id=1, serial_number="ABC12345"), - CameraDefinition(name="Side Camera", id=2, serial_number="DEF67890"), + CameraDefinition(name="Main Camera", id=1, serial_number="ABC12345", type="Toupcam"), + CameraDefinition(name="Side Camera", id=2, serial_number="DEF67890", type="Toupcam"), ] ) names = registry.get_camera_names() @@ -207,8 +271,8 @@ def test_duplicate_camera_names_rejected(self): with pytest.raises(ValidationError) as exc_info: CameraRegistryConfig( cameras=[ - CameraDefinition(name="Main Camera", id=1, serial_number="ABC12345"), - CameraDefinition(name="Main Camera", id=2, serial_number="DEF67890"), + CameraDefinition(name="Main Camera", id=1, serial_number="ABC12345", type="Toupcam"), + CameraDefinition(name="Main Camera", id=2, serial_number="DEF67890", type="Toupcam"), ] ) assert "Camera names must be unique" in str(exc_info.value) @@ -218,8 +282,8 @@ def test_duplicate_serial_numbers_rejected(self): with pytest.raises(ValidationError) as exc_info: CameraRegistryConfig( cameras=[ - CameraDefinition(name="Camera 1", id=1, serial_number="ABC12345"), - CameraDefinition(name="Camera 2", id=2, serial_number="ABC12345"), + CameraDefinition(name="Camera 1", id=1, serial_number="ABC12345", type="Toupcam"), + CameraDefinition(name="Camera 2", id=2, serial_number="ABC12345", type="Toupcam"), ] ) assert "Camera serial numbers must be unique" in str(exc_info.value) @@ -229,8 +293,8 @@ def test_duplicate_camera_ids_rejected(self): with pytest.raises(ValidationError) as exc_info: CameraRegistryConfig( cameras=[ - CameraDefinition(name="Camera 1", id=1, serial_number="ABC12345"), - CameraDefinition(name="Camera 2", id=1, serial_number="DEF67890"), + CameraDefinition(name="Camera 1", id=1, serial_number="ABC12345", type="Toupcam"), + CameraDefinition(name="Camera 2", id=1, serial_number="DEF67890", type="Toupcam"), ] ) assert "Camera IDs must be unique" in str(exc_info.value) From 1387eb528f2cf291d43d8ca8f17933b1efa51f36 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 04:00:06 -0700 Subject: [PATCH 02/52] feat(camera): build per-camera CameraConfig from cameras.yaml definitions Co-Authored-By: Claude Fable 5 --- software/squid/config.py | 29 ++++++++++++ .../test_camera_config_from_definition.py | 47 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 software/tests/squid/test_camera_config_from_definition.py diff --git a/software/squid/config.py b/software/squid/config.py index c65237c2a..cd47d04ad 100644 --- a/software/squid/config.py +++ b/software/squid/config.py @@ -593,6 +593,35 @@ def get_camera_config() -> CameraConfig: return _camera_config +def camera_config_from_definition(definition) -> CameraConfig: + """Build a per-camera CameraConfig from a cameras.yaml CameraDefinition. + + Starts from the INI-derived [CAMERA_CONFIG] defaults (the module singleton) and + overlays any per-camera overrides the definition provides. The INI singleton is + never mutated. + """ + updates = {"serial_number": definition.serial_number} + if definition.type is not None: + new_type = _old_camera_variant_to_enum(definition.type) + updates["camera_type"] = new_type + if new_type != _camera_config.camera_type: + # The INI camera_model belongs to the INI camera_type; don't carry it across drivers. + updates["camera_model"] = None + if definition.rotate_image_angle is not None: + updates["rotate_image_angle"] = definition.rotate_image_angle + if definition.flip is not None: + updates["flip"] = FlipVariant(definition.flip) + if definition.crop_width is not None: + updates["crop_width"] = definition.crop_width + if definition.crop_height is not None: + updates["crop_height"] = definition.crop_height + if definition.default_pixel_format is not None: + updates["default_pixel_format"] = CameraPixelFormat.from_string(definition.default_pixel_format) + if definition.default_binning is not None: + updates["default_binning"] = (definition.default_binning[0], definition.default_binning[1]) + return _camera_config.model_copy(update=updates) + + _autofocus_camera_config = CameraConfig( camera_type=_old_camera_variant_to_enum(_def.FOCUS_CAMERA_TYPE), camera_model=_def.FOCUS_CAMERA_MODEL, diff --git a/software/tests/squid/test_camera_config_from_definition.py b/software/tests/squid/test_camera_config_from_definition.py new file mode 100644 index 000000000..508fb59ab --- /dev/null +++ b/software/tests/squid/test_camera_config_from_definition.py @@ -0,0 +1,47 @@ +import squid.config +from control.models.camera_registry import CameraDefinition +from squid.config import CameraPixelFormat + + +def test_bare_definition_inherits_ini_defaults(): + base = squid.config.get_camera_config() + defn = CameraDefinition(serial_number="SN-A") + cfg = squid.config.camera_config_from_definition(defn) + assert cfg.serial_number == "SN-A" + assert cfg.camera_type == base.camera_type + assert cfg.crop_width == base.crop_width + assert cfg.default_pixel_format == base.default_pixel_format + # The INI singleton must not be mutated + assert squid.config.get_camera_config().serial_number == base.serial_number + + +def test_overrides_applied(): + defn = CameraDefinition( + name="Side", + id=2, + serial_number="SN-B", + type="Toupcam", + hardware_trigger=False, + rotate_image_angle=90.0, + flip="Vertical", + crop_width=1000, + crop_height=800, + default_pixel_format="RGB24", + default_binning=[2, 2], + ) + cfg = squid.config.camera_config_from_definition(defn) + assert cfg.serial_number == "SN-B" + assert cfg.camera_type == squid.config.CameraVariant.TOUPCAM + assert cfg.rotate_image_angle == 90.0 + assert cfg.flip == squid.config.FlipVariant.VERTICAL + assert cfg.crop_width == 1000 and cfg.crop_height == 800 + assert cfg.default_pixel_format == CameraPixelFormat.RGB24 + assert cfg.default_binning == (2, 2) + + +def test_type_change_clears_ini_camera_model(): + base = squid.config.get_camera_config() + other_type = "Hamamatsu" if base.camera_type != squid.config.CameraVariant.HAMAMATSU else "Toupcam" + defn = CameraDefinition(serial_number="SN-C", type=other_type) + cfg = squid.config.camera_config_from_definition(defn) + assert cfg.camera_model is None From 762e518df8f12b59c1d9d59e46eaa9badbc2077c Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 04:11:10 -0700 Subject: [PATCH 03/52] fix(config-editor): store camera id (int), not display name, in channel.camera Co-Authored-By: Claude Fable 5 --- software/control/widgets.py | 35 +++++++++++++------ .../test_channel_editor_camera_column.py | 27 ++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 software/tests/control/test_channel_editor_camera_column.py diff --git a/software/control/widgets.py b/software/control/widgets.py index b2231d05e..be8a6f43f 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -57,6 +57,22 @@ from PIL import Image, ImageDraw, ImageFont +def camera_display_name(registry, camera_id) -> str: + """Display text for a channel's camera id. '(None)' means primary/unassigned.""" + if registry is None or camera_id is None: + return "(None)" + definition = registry.get_camera_by_id(camera_id) + return definition.name if definition is not None and definition.name else "(None)" + + +def camera_id_from_display(registry, display_text) -> Optional[int]: + """Inverse of camera_display_name: camera id for a dropdown display text.""" + if registry is None or not display_text or display_text == "(None)": + return None + definition = registry.get_camera_by_name(display_text) + return definition.id if definition is not None else None + + def error_dialog(message: str, title: str = "Error"): msg = QMessageBox() msg.setIcon(QMessageBox.Warning) @@ -16000,10 +16016,9 @@ def _populate_row(self, row: int, channel): # Camera dropdown camera_combo = QComboBox() camera_combo.addItem("(None)") - camera_names = self.config_repo.get_camera_names() - camera_combo.addItems(camera_names) - if channel.camera and channel.camera in camera_names: - camera_combo.setCurrentText(channel.camera) + registry = self.config_repo.get_camera_registry() + camera_combo.addItems(self.config_repo.get_camera_names()) + camera_combo.setCurrentText(camera_display_name(registry, channel.camera)) self.table.setCellWidget(row, self.COL_CAMERA, camera_combo) # Filter wheel dropdown @@ -16275,9 +16290,10 @@ def _sync_table_to_config(self): # Camera camera_combo = self.table.cellWidget(row, self.COL_CAMERA) - if camera_combo and isinstance(camera_combo, QComboBox): - camera_text = camera_combo.currentText() - channel.camera = camera_text if camera_text != "(None)" else None + if camera_combo is not None and isinstance(camera_combo, QComboBox): + channel.camera = camera_id_from_display( + self.config_repo.get_camera_registry(), camera_combo.currentText() + ) # Filter wheel: None = no selection, else explicit wheel name wheel_combo = self.table.cellWidget(row, self.COL_FILTER_WHEEL) @@ -16412,9 +16428,8 @@ def get_channel(self): # Camera camera = None - if self.camera_combo: - camera_text = self.camera_combo.currentText() - camera = camera_text if camera_text != "(None)" else None + if self.camera_combo is not None: + camera = camera_id_from_display(self.config_repo.get_camera_registry(), self.camera_combo.currentText()) # Filter wheel and position filter_wheel = None diff --git a/software/tests/control/test_channel_editor_camera_column.py b/software/tests/control/test_channel_editor_camera_column.py new file mode 100644 index 000000000..a2c77ea61 --- /dev/null +++ b/software/tests/control/test_channel_editor_camera_column.py @@ -0,0 +1,27 @@ +from control.models.camera_registry import CameraDefinition, CameraRegistryConfig +from control.widgets import camera_display_name, camera_id_from_display + +REGISTRY = CameraRegistryConfig( + cameras=[ + CameraDefinition(name="Main Camera", id=1, serial_number="SN1", type="Toupcam"), + CameraDefinition(name="Side Camera", id=2, serial_number="SN2", type="Toupcam", hardware_trigger=False), + ] +) + + +def test_display_name_for_known_id(): + assert camera_display_name(REGISTRY, 1) == "Main Camera" + assert camera_display_name(REGISTRY, 2) == "Side Camera" + + +def test_display_name_for_none_and_unknown(): + assert camera_display_name(REGISTRY, None) == "(None)" + assert camera_display_name(REGISTRY, 99) == "(None)" + assert camera_display_name(None, 1) == "(None)" + + +def test_id_from_display_round_trip(): + assert camera_id_from_display(REGISTRY, "Side Camera") == 2 + assert camera_id_from_display(REGISTRY, "(None)") is None + assert camera_id_from_display(REGISTRY, "Nonexistent") is None + assert camera_id_from_display(None, "Main Camera") is None From e29e16391f09f929ef7d0374c41f544c963d6d93 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 04:25:48 -0700 Subject: [PATCH 04/52] feat(camera): ActiveCameraFacade delegating to the active concrete camera Add AbstractCamera.supports_hardware_trigger() (true iff the camera was constructed with both hw trigger functions wired), and squid/camera/facade.py with ActiveCameraFacade: an AbstractCamera-shaped object that delegates every call to whichever concrete camera is currently active. The facade keeps its own frame-callback registry, so registrations survive a camera switch and frames from the inactive camera are dropped. close() closes every concrete camera. Co-Authored-By: Claude Fable 5 --- software/squid/abc.py | 4 + software/squid/camera/facade.py | 239 +++++++++++++++++++++ software/tests/squid/test_camera_facade.py | 118 ++++++++++ 3 files changed, 361 insertions(+) create mode 100644 software/squid/camera/facade.py create mode 100644 software/tests/squid/test_camera_facade.py diff --git a/software/squid/abc.py b/software/squid/abc.py index a6859a2b9..729111d2b 100644 --- a/software/squid/abc.py +++ b/software/squid/abc.py @@ -812,6 +812,10 @@ def set_acquisition_mode(self, acquisition_mode: CameraAcquisitionMode): return self._set_acquisition_mode_imp(acquisition_mode=acquisition_mode) + def supports_hardware_trigger(self) -> bool: + """True iff this camera was constructed with the hardware trigger functions wired.""" + return bool(self._hw_trigger_fn) and bool(self._hw_set_strobe_delay_ms_fn) + @abc.abstractmethod def _set_acquisition_mode_imp(self, acquisition_mode: CameraAcquisitionMode): """ diff --git a/software/squid/camera/facade.py b/software/squid/camera/facade.py new file mode 100644 index 000000000..432914516 --- /dev/null +++ b/software/squid/camera/facade.py @@ -0,0 +1,239 @@ +"""ActiveCameraFacade: a single AbstractCamera-shaped object that delegates to whichever +concrete camera is currently active. + +Why: ~15 components across the app take the camera in their constructor and cache it +forever (LiveController, MultiPointWorker, NavigationViewer, ScanCoordinates, ...). +Storing this facade at microscope.camera lets all of them keep working across camera +switches without modification. Identity-sensitive code (per-camera settings widgets, +settings cache) must use the concrete cameras via get_concrete_camera()/all_cameras(). +""" + +import threading +from typing import Callable, Dict, List, Optional, Sequence, Tuple + +import squid.logging +from squid.abc import ( + AbstractCamera, + CameraAcquisitionMode, + CameraFrame, + CameraFrameFormat, + CameraGainRange, +) +from squid.config import CameraPixelFormat + + +class ActiveCameraFacade(AbstractCamera): + def __init__(self, cameras: Dict[int, AbstractCamera], active_id: int): + # Deliberately does NOT call AbstractCamera.__init__: the facade owns no camera + # config and no hardware-trigger functions — each concrete camera does. Every + # base-class helper that touches self._config is overridden below to delegate. + if active_id not in cameras: + raise ValueError(f"active_id {active_id} not in cameras {sorted(cameras)}") + self._log = squid.logging.get_logger(self.__class__.__name__) + self._cameras: Dict[int, AbstractCamera] = dict(cameras) + self._active_id = active_id + self._lock = threading.RLock() + self._facade_callbacks: List[Tuple[int, Callable[[CameraFrame], None]]] = [] + self._next_callback_id = 1 + self._facade_callbacks_enabled = True + for camera_id, camera in self._cameras.items(): + camera.add_frame_callback(self._make_forwarder(camera_id)) + + # ---- facade management ---- + + def _make_forwarder(self, camera_id: int) -> Callable[[CameraFrame], None]: + def _forward(frame: CameraFrame): + with self._lock: + if camera_id != self._active_id or not self._facade_callbacks_enabled: + return + callbacks = list(self._facade_callbacks) + for _, callback in callbacks: + callback(frame) + + return _forward + + def _active(self) -> AbstractCamera: + with self._lock: + return self._cameras[self._active_id] + + def set_active(self, camera_id: int) -> None: + with self._lock: + if camera_id not in self._cameras: + raise ValueError(f"Unknown camera id {camera_id}; have {sorted(self._cameras)}") + self._active_id = camera_id + + def get_active_id(self) -> int: + with self._lock: + return self._active_id + + def get_active_camera(self) -> AbstractCamera: + return self._active() + + def get_concrete_camera(self, camera_id: int) -> AbstractCamera: + with self._lock: + return self._cameras[camera_id] + + def all_cameras(self) -> Dict[int, AbstractCamera]: + with self._lock: + return dict(self._cameras) + + # ---- callback registry (facade-level; registrations survive switches) ---- + + def add_frame_callback(self, frame_callback: Callable[[CameraFrame], None]) -> int: + with self._lock: + callback_id = self._next_callback_id + self._next_callback_id += 1 + self._facade_callbacks.append((callback_id, frame_callback)) + return callback_id + + def remove_frame_callback(self, callback_id): + with self._lock: + self._facade_callbacks = [t for t in self._facade_callbacks if t[0] != callback_id] + + def enable_callbacks(self, enabled: bool): + with self._lock: + self._facade_callbacks_enabled = enabled + + def get_callbacks_enabled(self) -> bool: + with self._lock: + return self._facade_callbacks_enabled + + # ---- pure delegation ---- + + def set_exposure_time(self, exposure_time_ms: float): + return self._active().set_exposure_time(exposure_time_ms) + + def get_exposure_time(self) -> float: + return self._active().get_exposure_time() + + def get_exposure_limits(self) -> Tuple[float, float]: + return self._active().get_exposure_limits() + + def get_strobe_time(self) -> float: + return self._active().get_strobe_time() + + def get_total_frame_time(self) -> float: + return self._active().get_total_frame_time() + + def set_frame_format(self, frame_format: CameraFrameFormat): + return self._active().set_frame_format(frame_format) + + def get_frame_format(self) -> CameraFrameFormat: + return self._active().get_frame_format() + + def set_pixel_format(self, pixel_format: CameraPixelFormat): + return self._active().set_pixel_format(pixel_format) + + def get_pixel_format(self) -> CameraPixelFormat: + return self._active().get_pixel_format() + + def get_available_pixel_formats(self) -> Sequence[CameraPixelFormat]: + return self._active().get_available_pixel_formats() + + def set_binning(self, binning_factor_x: int, binning_factor_y: int): + return self._active().set_binning(binning_factor_x, binning_factor_y) + + def get_binning(self) -> Tuple[int, int]: + return self._active().get_binning() + + def get_binning_options(self) -> Sequence[Tuple[int, int]]: + return self._active().get_binning_options() + + def get_resolution(self) -> Tuple[int, int]: + return self._active().get_resolution() + + def get_pixel_size_unbinned_um(self) -> float: + return self._active().get_pixel_size_unbinned_um() + + def get_pixel_size_binned_um(self) -> float: + return self._active().get_pixel_size_binned_um() + + def set_analog_gain(self, analog_gain: float): + return self._active().set_analog_gain(analog_gain) + + def get_analog_gain(self) -> float: + return self._active().get_analog_gain() + + def get_gain_range(self) -> CameraGainRange: + return self._active().get_gain_range() + + def start_streaming(self): + return self._active().start_streaming() + + def stop_streaming(self): + return self._active().stop_streaming() + + def get_is_streaming(self): + return self._active().get_is_streaming() + + def get_crop_size(self) -> Tuple[int, int]: + return self._active().get_crop_size() + + def get_fov_size_mm(self) -> float: + return self._active().get_fov_size_mm() + + def set_software_crop_ratio(self, width_ratio: float, height_ratio: float): + return self._active().set_software_crop_ratio(width_ratio, height_ratio) + + def read_camera_frame(self) -> Optional[CameraFrame]: + return self._active().read_camera_frame() + + def get_frame_id(self) -> int: + return self._active().get_frame_id() + + def get_white_balance_gains(self) -> Tuple[float, float, float]: + return self._active().get_white_balance_gains() + + def set_white_balance_gains(self, red_gain: float, green_gain: float, blue_gain: float): + return self._active().set_white_balance_gains(red_gain, green_gain, blue_gain) + + def set_auto_white_balance_gains(self, on: bool): + return self._active().set_auto_white_balance_gains(on) + + def set_black_level(self, black_level: float): + return self._active().set_black_level(black_level) + + def get_black_level(self) -> float: + return self._active().get_black_level() + + def set_acquisition_mode(self, acquisition_mode: CameraAcquisitionMode): + # Delegate the PUBLIC method so the concrete camera enforces its own + # hw_trigger_fn requirement (an unwired camera must reject HARDWARE). + return self._active().set_acquisition_mode(acquisition_mode) + + def _set_acquisition_mode_imp(self, acquisition_mode: CameraAcquisitionMode): + raise NotImplementedError("Facade delegates set_acquisition_mode; this must never be called.") + + def get_acquisition_mode(self) -> CameraAcquisitionMode: + return self._active().get_acquisition_mode() + + def send_trigger(self, illumination_time: Optional[float] = None): + return self._active().send_trigger(illumination_time) + + def get_ready_for_trigger(self) -> bool: + return self._active().get_ready_for_trigger() + + def set_region_of_interest(self, offset_x: int, offset_y: int, width: int, height: int): + return self._active().set_region_of_interest(offset_x, offset_y, width, height) + + def get_region_of_interest(self) -> Tuple[int, int, int, int]: + return self._active().get_region_of_interest() + + def set_temperature(self, temperature_deg_c: Optional[float]): + return self._active().set_temperature(temperature_deg_c) + + def get_temperature(self) -> float: + return self._active().get_temperature() + + def set_temperature_reading_callback(self, callback: Callable): + return self._active().set_temperature_reading_callback(callback) + + def supports_hardware_trigger(self) -> bool: + return self._active().supports_hardware_trigger() + + def close(self): + for camera_id, camera in self.all_cameras().items(): + try: + camera.close() + except Exception: + self._log.exception(f"Error closing camera {camera_id}") diff --git a/software/tests/squid/test_camera_facade.py b/software/tests/squid/test_camera_facade.py new file mode 100644 index 000000000..8d4782b18 --- /dev/null +++ b/software/tests/squid/test_camera_facade.py @@ -0,0 +1,118 @@ +import pytest + +import squid.config +from squid.abc import CameraAcquisitionMode, CameraFrame +from squid.camera.facade import ActiveCameraFacade +from squid.camera.utils import SimulatedCamera +from squid.config import CameraPixelFormat + + +def make_sim(serial, pixel_format=CameraPixelFormat.MONO16, hw=False): + config = squid.config.get_camera_config().model_copy( + update={"serial_number": serial, "default_pixel_format": pixel_format} + ) + hw_trigger_fn = (lambda t: True) if hw else None + hw_strobe_fn = (lambda ms: True) if hw else None + return SimulatedCamera(config, hw_trigger_fn=hw_trigger_fn, hw_set_strobe_delay_ms_fn=hw_strobe_fn) + + +@pytest.fixture +def cameras(): + cam1 = make_sim("SN1", hw=True) + cam2 = make_sim("SN2", hw=False) + yield {1: cam1, 2: cam2} + cam1.close() + cam2.close() + + +def test_supports_hardware_trigger(cameras): + assert cameras[1].supports_hardware_trigger() is True + assert cameras[2].supports_hardware_trigger() is False + + +def test_delegates_to_active(cameras): + facade = ActiveCameraFacade(cameras, active_id=1) + cameras[1].set_exposure_time(11) + cameras[2].set_exposure_time(22) + assert facade.get_exposure_time() == 11 + facade.set_active(2) + assert facade.get_exposure_time() == 22 + # Writes go to the active camera only + facade.set_exposure_time(33) + assert cameras[2].get_exposure_time() == 33 + assert cameras[1].get_exposure_time() == 11 + + +def test_invalid_active_id_raises(cameras): + with pytest.raises(ValueError): + ActiveCameraFacade(cameras, active_id=9) + facade = ActiveCameraFacade(cameras, active_id=1) + with pytest.raises(ValueError): + facade.set_active(9) + + +def test_callbacks_survive_switch_and_drop_inactive(cameras): + facade = ActiveCameraFacade(cameras, active_id=1) + received = [] + facade.add_frame_callback(lambda frame: received.append(frame.frame_id)) + + cameras[1].send_trigger() # active -> forwarded + assert len(received) == 1 + cameras[2].send_trigger() # inactive -> dropped + assert len(received) == 1 + + facade.set_active(2) + cameras[2].send_trigger() # now active -> forwarded, same registration + assert len(received) == 2 + cameras[1].send_trigger() # now inactive -> dropped + assert len(received) == 2 + + +def test_enable_callbacks_gates_forwarding(cameras): + facade = ActiveCameraFacade(cameras, active_id=1) + received = [] + facade.add_frame_callback(lambda frame: received.append(frame.frame_id)) + facade.enable_callbacks(False) + assert facade.get_callbacks_enabled() is False + cameras[1].send_trigger() + assert received == [] + facade.enable_callbacks(True) + cameras[1].send_trigger() + assert len(received) == 1 + + +def test_remove_frame_callback(cameras): + facade = ActiveCameraFacade(cameras, active_id=1) + received = [] + cb_id = facade.add_frame_callback(lambda frame: received.append(frame.frame_id)) + facade.remove_frame_callback(cb_id) + cameras[1].send_trigger() + assert received == [] + + +def test_hardware_mode_on_unwired_active_raises(cameras): + facade = ActiveCameraFacade(cameras, active_id=2) + with pytest.raises(ValueError): + facade.set_acquisition_mode(CameraAcquisitionMode.HARDWARE_TRIGGER) + facade.set_active(1) + facade.set_acquisition_mode(CameraAcquisitionMode.HARDWARE_TRIGGER) # wired camera: OK + assert facade.get_acquisition_mode() == CameraAcquisitionMode.HARDWARE_TRIGGER + + +def test_is_color_and_geometry_follow_active(cameras): + cameras[2].set_pixel_format(CameraPixelFormat.RGB24) + facade = ActiveCameraFacade(cameras, active_id=1) + assert facade.is_color is False + facade.set_active(2) + assert facade.is_color is True + assert facade.get_crop_size() == cameras[2].get_crop_size() + assert facade.get_pixel_size_binned_um() == cameras[2].get_pixel_size_binned_um() + + +def test_close_closes_all(cameras): + closed = [] + for cam_id, cam in cameras.items(): + cam.close = lambda cid=cam_id: closed.append(cid) + facade = ActiveCameraFacade(cameras, active_id=1) + facade.close() + assert sorted(closed) == [1, 2] From 238f5e7c48ff53ba8d2aed773208cc549e514fc0 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 04:58:00 -0700 Subject: [PATCH 05/52] feat(microscope): build all cameras.yaml cameras, facade + set_active_camera with per-camera trigger memory Co-Authored-By: Claude Fable 5 --- software/control/_def.py | 5 + software/control/core/live_controller.py | 11 +- software/control/microscope.py | 151 ++++++++++++++++-- software/tests/conftest.py | 26 +++ .../control/test_microscope_multi_camera.py | 100 ++++++++++++ 5 files changed, 280 insertions(+), 13 deletions(-) create mode 100644 software/tests/control/test_microscope_multi_camera.py diff --git a/software/control/_def.py b/software/control/_def.py index ee7fc7daa..5890ef86a 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -114,6 +114,11 @@ def convert_to_var(option: Union[str, "TriggerMode"]) -> "TriggerMode": raise ValueError(f"Invalid trigger mode: {option}") +# The camera id (cameras.yaml) that is the default/primary imaging camera. +# A channel with camera=None uses this camera. +PRIMARY_CAMERA_ID = 1 + + class Acquisition: NUMBER_OF_FOVS_PER_AF = 3 IMAGE_FORMAT = "tiff" diff --git a/software/control/core/live_controller.py b/software/control/core/live_controller.py index cec8b85d8..bf9ddbe3e 100644 --- a/software/control/core/live_controller.py +++ b/software/control/core/live_controller.py @@ -471,7 +471,11 @@ def set_trigger_mode(self, mode): if self.trigger_mode == TriggerMode.SOFTWARE and self.is_live: self._stop_triggerred_acquisition() self.camera.set_acquisition_mode(CameraAcquisitionMode.HARDWARE_TRIGGER) - self.camera.set_exposure_time(self.currentConfiguration.exposure_time) + if self.currentConfiguration is not None: + # No channel selected yet (e.g. trigger mode set right after startup, or + # applied during a camera switch): keep the camera's current exposure; + # set_microscope_mode applies the channel exposure when one is selected. + self.camera.set_exposure_time(self.currentConfiguration.exposure_time) if self.is_live and self.use_internal_timer_for_hardware_trigger: self._start_triggerred_acquisition() @@ -486,6 +490,11 @@ def set_trigger_mode(self, mode): self.camera.set_acquisition_mode(CameraAcquisitionMode.CONTINUOUS) self.microscope.low_level_drivers.microcontroller.set_trigger_mode(0) self.trigger_mode = mode + # Per-camera trigger-mode memory (dual-camera): record the user's choice for the + # active camera so set_active_camera can restore it later. The focus-camera + # LiveController is excluded — its camera is not in microscope.cameras. + if not self.for_displacement_measurement: + self.microscope.remember_trigger_mode_for_active_camera(mode) def set_trigger_fps(self, fps): if (self.trigger_mode == TriggerMode.SOFTWARE) or ( diff --git a/software/control/microscope.py b/software/control/microscope.py index 94e4ef186..beb5d4fb6 100644 --- a/software/control/microscope.py +++ b/software/control/microscope.py @@ -1,6 +1,7 @@ +import threading import time from pathlib import Path -from typing import List, Optional, Protocol +from typing import Callable, Dict, List, Optional, Protocol import imageio import numpy as np @@ -24,6 +25,7 @@ import control.microcontroller import control.serial_peripherals as serial_peripherals import control.squid_laser_engine as squid_laser_engine +import squid.camera.facade import squid.camera.utils import squid.config import squid.filter_wheel_controller.utils @@ -384,12 +386,52 @@ def acquisition_camera_hw_strobe_delay_fn(strobe_delay_ms: float) -> bool: return True - camera = squid.camera.utils.get_camera( - config=squid.config.get_camera_config(), - simulated=camera_simulated, - hw_trigger_fn=acquisition_camera_hw_trigger_fn, - hw_set_strobe_delay_ms_fn=acquisition_camera_hw_strobe_delay_fn, - ) + camera_registry = None + try: + camera_registry = ConfigRepository().get_camera_registry() + except Exception: + squid.logging.get_logger("Microscope.build").exception( + "Failed to load cameras.yaml; falling back to single-camera INI config" + ) + + cameras: Dict[int, AbstractCamera] = {} + if camera_registry is not None and len(camera_registry.cameras) > 1: + build_log = squid.logging.get_logger("Microscope.build") + for definition in camera_registry.cameras: + camera_config = squid.config.camera_config_from_definition(definition) + try: + cameras[definition.id] = squid.camera.utils.get_camera( + config=camera_config, + simulated=camera_simulated, + hw_trigger_fn=acquisition_camera_hw_trigger_fn if definition.hardware_trigger else None, + hw_set_strobe_delay_ms_fn=( + acquisition_camera_hw_strobe_delay_fn if definition.hardware_trigger else None + ), + ) + except Exception: + if definition.id == control._def.PRIMARY_CAMERA_ID: + raise + build_log.exception( + f"Camera '{definition.name}' (id={definition.id}, sn={definition.serial_number}) " + f"failed to open; continuing without it. Channels bound to it will be unavailable." + ) + if control._def.PRIMARY_CAMERA_ID not in cameras: + raise ValueError( + f"cameras.yaml declares multiple cameras but none has id={control._def.PRIMARY_CAMERA_ID} " + "(the primary camera). Assign id 1 to the hardware-triggered primary camera." + ) + else: + cameras[control._def.PRIMARY_CAMERA_ID] = squid.camera.utils.get_camera( + config=squid.config.get_camera_config(), + simulated=camera_simulated, + hw_trigger_fn=acquisition_camera_hw_trigger_fn, + hw_set_strobe_delay_ms_fn=acquisition_camera_hw_strobe_delay_fn, + ) + + if len(cameras) > 1: + camera = squid.camera.facade.ActiveCameraFacade(cameras, active_id=control._def.PRIMARY_CAMERA_ID) + else: + camera = cameras[control._def.PRIMARY_CAMERA_ID] if control._def.USE_LDI_SERIAL_CONTROL and not simulated: ldi = serial_peripherals.LDI() @@ -423,6 +465,7 @@ def acquisition_camera_hw_strobe_delay_fn(strobe_delay_ms: float) -> bool: return Microscope( stage=stage, camera=camera, + cameras=cameras, illumination_controller=illumination_controller, addons=addons, low_level_drivers=low_level_devices, @@ -437,6 +480,7 @@ def __init__( illumination_controller: IlluminationController, addons: MicroscopeAddons, low_level_drivers: LowLevelDrivers, + cameras: Optional[Dict[int, AbstractCamera]] = None, stream_handler_callbacks: Optional[StreamHandlerFunctions] = NoOpStreamHandlerFunctions, simulated: bool = False, skip_prepare_for_use: bool = False, @@ -447,6 +491,14 @@ def __init__( self.stage: AbstractStage = stage self.camera: AbstractCamera = camera + # Concrete cameras keyed by cameras.yaml id. Single-camera systems: {PRIMARY_CAMERA_ID: camera}. + self.cameras: Dict[int, AbstractCamera] = ( + cameras if cameras is not None else {control._def.PRIMARY_CAMERA_ID: camera} + ) + self._active_camera_id: int = control._def.PRIMARY_CAMERA_ID + self._camera_switch_lock = threading.RLock() + self._camera_trigger_modes: Dict[int, control._def.TriggerMode] = {} + self._camera_change_listeners: List[Callable[[int], None]] = [] self.illumination_controller: IlluminationController = illumination_controller self.addons = addons @@ -516,7 +568,10 @@ def _prepare_for_use(self, skip_init: bool = False): "requires v1.1+" ) - self.camera.set_pixel_format( + # The INI default pixel format applies to the primary camera only: non-primary + # cameras already got their per-camera default_pixel_format from their own + # config at construction. + self.cameras[control._def.PRIMARY_CAMERA_ID].set_pixel_format( squid.config.CameraPixelFormat.from_string(control._def.CAMERA_CONFIG.PIXEL_FORMAT_DEFAULT) ) if control._def.DEFAULT_TRIGGER_MODE == control._def.TriggerMode.HARDWARE: @@ -530,6 +585,16 @@ def _prepare_for_use(self, skip_init: bool = False): self.camera.set_acquisition_mode(CameraAcquisitionMode.SOFTWARE_TRIGGER) self.live_controller.trigger_mode = control._def.TriggerMode.SOFTWARE + # Per-camera trigger-mode memory: the primary starts at the default mode applied + # above; every other camera is parked in SOFTWARE trigger (never HARDWARE — its + # trigger line may not be wired). + self._camera_trigger_modes[control._def.PRIMARY_CAMERA_ID] = self.live_controller.trigger_mode + for camera_id, concrete_camera in self.cameras.items(): + if camera_id == control._def.PRIMARY_CAMERA_ID: + continue + concrete_camera.set_acquisition_mode(CameraAcquisitionMode.SOFTWARE_TRIGGER) + self._camera_trigger_modes[camera_id] = control._def.TriggerMode.SOFTWARE + if self.addons.camera_focus: self.addons.camera_focus.set_pixel_format(squid.config.CameraPixelFormat.from_string("MONO8")) self.addons.camera_focus.set_acquisition_mode(CameraAcquisitionMode.SOFTWARE_TRIGGER) @@ -678,6 +743,65 @@ def update_camera_focus_functions(self, functions: StreamHandlerFunctions) -> No self.stream_handler_focus.set_functions(functions) + @property + def active_camera_id(self) -> int: + return self._active_camera_id + + def has_multiple_cameras(self) -> bool: + return len(self.cameras) > 1 + + def get_stored_trigger_mode(self, camera_id: int) -> control._def.TriggerMode: + # Default to SOFTWARE: a camera without stored state must never default to + # HARDWARE (its trigger line may not be wired). + return self._camera_trigger_modes.get(camera_id, control._def.TriggerMode.SOFTWARE) + + def remember_trigger_mode_for_active_camera(self, mode: control._def.TriggerMode) -> None: + self._camera_trigger_modes[self._active_camera_id] = mode + + def add_camera_change_listener(self, listener: Callable[[int], None]) -> None: + self._camera_change_listeners.append(listener) + + def set_active_camera(self, camera_id: int) -> None: + """Switch the active imaging camera. + + Low-level: assumes triggering is quiesced (LiveController.set_microscope_mode + stops the live timer and turns illumination off before calling this; the + acquisition worker is between frames when it calls this via set_microscope_mode). + Applies the incoming camera's stored trigger mode, which also reprograms the MCU + trigger mode via LiveController.set_trigger_mode. + """ + with self._camera_switch_lock: + if camera_id == self._active_camera_id: + return + if camera_id not in self.cameras: + raise ValueError( + f"Camera id {camera_id} is not available. Available: {sorted(self.cameras)}. " + "It may be declared in cameras.yaml but failed to open." + ) + import squid.camera.facade # local import to avoid cycles at module import time + + if not isinstance(self.camera, squid.camera.facade.ActiveCameraFacade): + raise ValueError("set_active_camera requires a multi-camera build (no facade installed).") + + outgoing = self.cameras[self._active_camera_id] + if outgoing.get_acquisition_mode() == CameraAcquisitionMode.CONTINUOUS: + outgoing.stop_streaming() + + self.camera.set_active(camera_id) + self._active_camera_id = camera_id + + self.live_controller.set_trigger_mode(self.get_stored_trigger_mode(camera_id)) + + incoming = self.cameras[camera_id] + if not incoming.get_is_streaming(): + incoming.start_streaming() + + for listener in list(self._camera_change_listeners): + try: + listener(camera_id) + except Exception: + self._log.exception("camera change listener failed") + def initialize_core_components(self) -> None: """Initialize and home core hardware components like piezo stage.""" if self.addons.piezo_stage: @@ -1087,10 +1211,13 @@ def close(self) -> None: except Exception as e: self._log.warning(f"Error closing squid laser engine: {e}") - try: - self.camera.close() - except Exception as e: - self._log.warning(f"Error closing camera: {e}") + # Close the concrete cameras directly (not via self.camera): identical for + # single-camera builds, and avoids double-closing through the facade. + for camera_id, concrete_camera in self.cameras.items(): + try: + concrete_camera.close() + except Exception: + self._log.exception(f"Error closing camera {camera_id}") def move_to_position(self, x: float, y: float, z: float) -> None: """Move the stage to an absolute XYZ position. diff --git a/software/tests/conftest.py b/software/tests/conftest.py index 253574ea5..ceb366c98 100644 --- a/software/tests/conftest.py +++ b/software/tests/conftest.py @@ -69,6 +69,32 @@ def _close_quietly(obj, label): logger.exception(f"Failed to close {label} in test cleanup") +@pytest.fixture(autouse=True) +def isolate_ambient_camera_registry(monkeypatch): + """Pin the default-path camera registry to None (the CI truth) during tests. + + machine_configs/cameras.yaml is machine-specific and gitignored; a dev machine may + carry a multi-camera file for manual GUI testing. Tests must not depend on it: + Microscope.build_from_global_config reads the registry via a default-path + ConfigRepository, so without this pin every microscope-building test would flip to + a multi-camera facade build on such machines. Repositories constructed with an + explicit base_path (e.g. tmp_path in repository tests) are unaffected, and tests + that monkeypatch get_camera_registry themselves override this pin (autouse + fixtures apply first). + """ + from control.core.config.repository import ConfigRepository + + default_machine_configs_path = ConfigRepository().machine_configs_path + original_get_camera_registry = ConfigRepository.get_camera_registry + + def _get_camera_registry(self): + if self.machine_configs_path == default_machine_configs_path: + return None + return original_get_camera_registry(self) + + monkeypatch.setattr(ConfigRepository, "get_camera_registry", _get_camera_registry) + + @pytest.fixture(autouse=True) def cleanup_leaked_hardware(monkeypatch): """ diff --git a/software/tests/control/test_microscope_multi_camera.py b/software/tests/control/test_microscope_multi_camera.py new file mode 100644 index 000000000..a81c5254d --- /dev/null +++ b/software/tests/control/test_microscope_multi_camera.py @@ -0,0 +1,100 @@ +import pytest + +import control._def +from control._def import PRIMARY_CAMERA_ID, TriggerMode +from control.core.config.repository import ConfigRepository +from control.microscope import Microscope +from control.models.camera_registry import CameraDefinition, CameraRegistryConfig +from squid.camera.facade import ActiveCameraFacade + +TWO_CAMERA_REGISTRY = CameraRegistryConfig( + cameras=[ + CameraDefinition(name="Main Camera", id=1, serial_number="SIM-1", type="Toupcam"), + CameraDefinition( + name="Side Camera", + id=2, + serial_number="SIM-2", + type="Toupcam", + hardware_trigger=False, + default_pixel_format="RGB24", + ), + ] +) + + +@pytest.fixture +def two_camera_scope(monkeypatch): + monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: TWO_CAMERA_REGISTRY) + scope = Microscope.build_from_global_config(simulated=True, skip_init=True) + yield scope + scope.close() + + +@pytest.fixture +def single_camera_scope(monkeypatch): + monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: None) + scope = Microscope.build_from_global_config(simulated=True, skip_init=True) + yield scope + scope.close() + + +def test_single_camera_build_has_no_facade(single_camera_scope): + scope = single_camera_scope + assert not isinstance(scope.camera, ActiveCameraFacade) + assert list(scope.cameras.keys()) == [PRIMARY_CAMERA_ID] + assert scope.cameras[PRIMARY_CAMERA_ID] is scope.camera + assert scope.active_camera_id == PRIMARY_CAMERA_ID + with pytest.raises(ValueError): + scope.set_active_camera(2) + scope.set_active_camera(PRIMARY_CAMERA_ID) # no-op, allowed + + +def test_two_camera_build_installs_facade(two_camera_scope): + scope = two_camera_scope + assert isinstance(scope.camera, ActiveCameraFacade) + assert sorted(scope.cameras.keys()) == [1, 2] + assert scope.active_camera_id == PRIMARY_CAMERA_ID + assert scope.cameras[1].supports_hardware_trigger() is True + assert scope.cameras[2].supports_hardware_trigger() is False + # Per-camera config took effect + assert scope.cameras[2]._config.serial_number == "SIM-2" + + +def test_switch_applies_software_trigger_and_mcu_mode(two_camera_scope): + scope = two_camera_scope + scope.set_active_camera(2) + assert scope.active_camera_id == 2 + assert scope.camera.get_active_id() == 2 + assert scope.live_controller.trigger_mode == TriggerMode.SOFTWARE + from squid.abc import CameraAcquisitionMode + + assert scope.cameras[2].get_acquisition_mode() == CameraAcquisitionMode.SOFTWARE_TRIGGER + + +def test_switch_restores_stored_mode_on_primary(two_camera_scope): + scope = two_camera_scope + scope.live_controller.set_trigger_mode(TriggerMode.HARDWARE) # user choice on primary + assert scope.get_stored_trigger_mode(1) == TriggerMode.HARDWARE + scope.set_active_camera(2) + assert scope.live_controller.trigger_mode == TriggerMode.SOFTWARE + scope.set_active_camera(1) + assert scope.live_controller.trigger_mode == TriggerMode.HARDWARE + + +def test_change_listener_fires_once_per_switch(two_camera_scope): + scope = two_camera_scope + seen = [] + scope.add_camera_change_listener(seen.append) + scope.set_active_camera(2) + scope.set_active_camera(2) # no-op fast path: no second notification + scope.set_active_camera(1) + assert seen == [2, 1] + + +def test_close_closes_all_cameras(two_camera_scope): + scope = two_camera_scope + closed = [] + for cam_id, cam in scope.cameras.items(): + cam.close = lambda cid=cam_id: closed.append(cid) + scope.close() + assert sorted(closed) == [1, 2] From b8b3c1dc63f7c9b8b01cf91c67a135b8749af334 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 05:13:45 -0700 Subject: [PATCH 06/52] fix(microscope): roll back failed camera switch; lock trigger-memory write set_active_camera now restores the facade target, active id, and (best effort) the outgoing camera's trigger mode when applying the incoming camera's mode or starting its stream raises, so microscope.camera and GUI listeners never see a half-switched state. remember_trigger_mode_for_active_camera takes _camera_switch_lock so a set_trigger_mode racing a switch cannot record the mode against a mid-transition camera id (RLock keeps the in-switch call safe). Co-Authored-By: Claude Fable 5 --- software/control/microscope.py | 41 +++++++++++++++---- .../control/test_microscope_multi_camera.py | 21 ++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/software/control/microscope.py b/software/control/microscope.py index beb5d4fb6..0bfdf41d8 100644 --- a/software/control/microscope.py +++ b/software/control/microscope.py @@ -756,7 +756,13 @@ def get_stored_trigger_mode(self, camera_id: int) -> control._def.TriggerMode: return self._camera_trigger_modes.get(camera_id, control._def.TriggerMode.SOFTWARE) def remember_trigger_mode_for_active_camera(self, mode: control._def.TriggerMode) -> None: - self._camera_trigger_modes[self._active_camera_id] = mode + # Take the switch lock so the read of _active_camera_id and the memory write are + # atomic with respect to set_active_camera: a set_trigger_mode call racing a + # switch from another thread must not record the mode against a mid-transition + # camera id. RLock: the call from inside set_active_camera's locked section + # (via LiveController.set_trigger_mode) stays safe. + with self._camera_switch_lock: + self._camera_trigger_modes[self._active_camera_id] = mode def add_camera_change_listener(self, listener: Callable[[int], None]) -> None: self._camera_change_listeners.append(listener) @@ -783,18 +789,39 @@ def set_active_camera(self, camera_id: int) -> None: if not isinstance(self.camera, squid.camera.facade.ActiveCameraFacade): raise ValueError("set_active_camera requires a multi-camera build (no facade installed).") - outgoing = self.cameras[self._active_camera_id] + previous_id = self._active_camera_id + outgoing = self.cameras[previous_id] if outgoing.get_acquisition_mode() == CameraAcquisitionMode.CONTINUOUS: outgoing.stop_streaming() self.camera.set_active(camera_id) self._active_camera_id = camera_id - self.live_controller.set_trigger_mode(self.get_stored_trigger_mode(camera_id)) - - incoming = self.cameras[camera_id] - if not incoming.get_is_streaming(): - incoming.start_streaming() + try: + self.live_controller.set_trigger_mode(self.get_stored_trigger_mode(camera_id)) + + incoming = self.cameras[camera_id] + if not incoming.get_is_streaming(): + incoming.start_streaming() + except Exception as switch_exc: + # Applying the incoming camera's trigger mode (or starting its stream) + # failed (e.g. unwired camera rejecting HARDWARE, MCU timeout). Roll back + # so microscope.camera, the active id, and the listeners (never notified + # of the failed switch) stay consistent with the camera that is actually + # configured. + self.camera.set_active(previous_id) + self._active_camera_id = previous_id + try: + # Best-effort resync of camera acquisition mode + MCU trigger mode to + # the outgoing camera's stored state. + self.live_controller.set_trigger_mode(self.get_stored_trigger_mode(previous_id)) + except Exception as rollback_exc: + self._log.error( + f"Rollback of trigger mode to camera {previous_id} failed: {rollback_exc}. " + f"Original switch error: {switch_exc}" + ) + self._log.exception(f"Switching to camera {camera_id} failed; rolled back to camera {previous_id}") + raise for listener in list(self._camera_change_listeners): try: diff --git a/software/tests/control/test_microscope_multi_camera.py b/software/tests/control/test_microscope_multi_camera.py index a81c5254d..ab9b6ff5f 100644 --- a/software/tests/control/test_microscope_multi_camera.py +++ b/software/tests/control/test_microscope_multi_camera.py @@ -91,6 +91,27 @@ def test_change_listener_fires_once_per_switch(two_camera_scope): assert seen == [2, 1] +def test_failed_switch_rolls_back_active_camera(two_camera_scope, monkeypatch): + """If applying the incoming camera's trigger mode raises mid-switch (unwired camera + rejecting HARDWARE, MCU timeout), the switch must roll back: facade and active id + restored to the outgoing camera, and no change listener notified.""" + scope = two_camera_scope + seen = [] + scope.add_camera_change_listener(seen.append) + + def boom(mode): + raise RuntimeError("simulated MCU timeout") + + monkeypatch.setattr(scope.live_controller, "set_trigger_mode", boom) + + with pytest.raises(RuntimeError, match="simulated MCU timeout"): + scope.set_active_camera(2) + + assert scope.active_camera_id == 1 + assert scope.camera.get_active_id() == 1 + assert seen == [] + + def test_close_closes_all_cameras(two_camera_scope): scope = two_camera_scope closed = [] From 8a2b736250ba7ec0e9c0b1e6b1fcd59e5f9101ff Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 05:28:51 -0700 Subject: [PATCH 07/52] feat(live): set_microscope_mode switches active camera per channel binding Co-Authored-By: Claude Fable 5 --- software/control/core/live_controller.py | 12 +++++ .../control/test_microscope_multi_camera.py | 44 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/software/control/core/live_controller.py b/software/control/core/live_controller.py index bf9ddbe3e..cdf61a442 100644 --- a/software/control/core/live_controller.py +++ b/software/control/core/live_controller.py @@ -521,6 +521,18 @@ def set_microscope_mode(self, configuration: "AcquisitionChannel"): self.currentConfiguration = configuration + # Dual-camera: switch the active camera to this channel's camera before applying + # any camera settings. channel.camera is None => primary camera. + target_camera_id = configuration.camera or PRIMARY_CAMERA_ID + if target_camera_id != self.microscope.active_camera_id: + try: + self.microscope.set_active_camera(target_camera_id) + except ValueError as e: + self._log.error( + f"Channel '{configuration.name}' wants camera {target_camera_id} which is not available " + f"({e}); keeping camera {self.microscope.active_camera_id}." + ) + # set camera exposure time and analog gain self.camera.set_exposure_time(self.currentConfiguration.exposure_time) try: diff --git a/software/tests/control/test_microscope_multi_camera.py b/software/tests/control/test_microscope_multi_camera.py index ab9b6ff5f..4ea51d8a9 100644 --- a/software/tests/control/test_microscope_multi_camera.py +++ b/software/tests/control/test_microscope_multi_camera.py @@ -119,3 +119,47 @@ def test_close_closes_all_cameras(two_camera_scope): cam.close = lambda cid=cam_id: closed.append(cid) scope.close() assert sorted(closed) == [1, 2] + + +def _make_channel(name, camera_id, exposure_ms=25): + """Build a minimal AcquisitionChannel for set_microscope_mode tests.""" + from control.models import AcquisitionChannel + + return AcquisitionChannel( + name=name, + camera=camera_id, + camera_settings={"exposure_time_ms": exposure_ms, "gain_mode": 0.0}, + illumination_settings={"illumination_channel": "", "intensity": 20.0}, + ) + + +def test_set_microscope_mode_switches_to_channel_camera(two_camera_scope): + scope = two_camera_scope + channel_cam2 = _make_channel("BF Color", camera_id=2, exposure_ms=42) + scope.live_controller.set_microscope_mode(channel_cam2) + assert scope.active_camera_id == 2 + assert scope.cameras[2].get_exposure_time() == 42 + + channel_primary = _make_channel("Fluor 488", camera_id=None, exposure_ms=13) + scope.live_controller.set_microscope_mode(channel_primary) + assert scope.active_camera_id == 1 + assert scope.cameras[1].get_exposure_time() == 13 + assert scope.cameras[2].get_exposure_time() == 42 # untouched + + +def test_set_microscope_mode_unavailable_camera_keeps_current(two_camera_scope): + scope = two_camera_scope + channel_bad = _make_channel("Ghost", camera_id=7) + scope.live_controller.set_microscope_mode(channel_bad) + # Logged error, no switch, no crash: + assert scope.active_camera_id == 1 + + +def test_set_trigger_mode_updates_memory(two_camera_scope): + scope = two_camera_scope + scope.live_controller.set_trigger_mode(TriggerMode.HARDWARE) + assert scope.get_stored_trigger_mode(1) == TriggerMode.HARDWARE + scope.set_active_camera(2) + scope.live_controller.set_trigger_mode(TriggerMode.SOFTWARE) + assert scope.get_stored_trigger_mode(2) == TriggerMode.SOFTWARE + assert scope.get_stored_trigger_mode(1) == TriggerMode.HARDWARE From 082753ef2fa6f6fc9c763c98a42d589e180152d6 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 05:43:19 -0700 Subject: [PATCH 08/52] feat(sim): SimulatedCamera serves RGB24/RGB48 frames for color-camera testing Co-Authored-By: Claude Fable 5 --- software/squid/camera/utils.py | 16 +++++++- .../tests/squid/test_simulated_camera_rgb.py | 37 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 software/tests/squid/test_simulated_camera_rgb.py diff --git a/software/squid/camera/utils.py b/software/squid/camera/utils.py index 0762c10fc..df7647aaf 100644 --- a/software/squid/camera/utils.py +++ b/software/squid/camera/utils.py @@ -235,7 +235,13 @@ def get_pixel_format(self) -> CameraPixelFormat: @debug_log def get_available_pixel_formats(self) -> Sequence[CameraPixelFormat]: - return [CameraPixelFormat.MONO8, CameraPixelFormat.MONO12, CameraPixelFormat.MONO16] + return [ + CameraPixelFormat.MONO8, + CameraPixelFormat.MONO12, + CameraPixelFormat.MONO16, + CameraPixelFormat.RGB24, + CameraPixelFormat.RGB48, + ] @debug_log def get_binning(self) -> Tuple[int, int]: @@ -400,6 +406,14 @@ def _next_frame(self): self._current_raw_frame[height // 2 - 99 : height // 2 + 100, width // 2 - 99 : width // 2 + 100] = ( 200 * 256 ) + elif self.get_pixel_format() == CameraPixelFormat.RGB24: + self._current_raw_frame = np.random.randint(255, size=(height, width, 3), dtype=np.uint8) + self._current_raw_frame[height // 2 - 99 : height // 2 + 100, width // 2 - 99 : width // 2 + 100] = 200 + elif self.get_pixel_format() == CameraPixelFormat.RGB48: + self._current_raw_frame = np.random.randint(65535, size=(height, width, 3), dtype=np.uint16) + self._current_raw_frame[height // 2 - 99 : height // 2 + 100, width // 2 - 99 : width // 2 + 100] = ( + 200 * 256 + ) else: raise NotImplementedError(f"Simulated camera does not support pixel_format={self.get_pixel_format()}") else: diff --git a/software/tests/squid/test_simulated_camera_rgb.py b/software/tests/squid/test_simulated_camera_rgb.py new file mode 100644 index 000000000..e7bd2bbea --- /dev/null +++ b/software/tests/squid/test_simulated_camera_rgb.py @@ -0,0 +1,37 @@ +import numpy as np + +import squid.config +from squid.camera.utils import SimulatedCamera +from squid.config import CameraPixelFormat + + +def make_sim(pixel_format): + config = squid.config.get_camera_config().model_copy( + update={"serial_number": "SIM-RGB", "default_pixel_format": pixel_format} + ) + return SimulatedCamera(config, hw_trigger_fn=None, hw_set_strobe_delay_ms_fn=None) + + +def test_rgb24_frames_are_3_channel_uint8(): + cam = make_sim(CameraPixelFormat.RGB24) + cam.send_trigger() + frame = cam.read_camera_frame() + assert frame.frame.ndim == 3 and frame.frame.shape[2] == 3 + assert frame.frame.dtype == np.uint8 + assert frame.is_color() + assert cam.is_color is True + + +def test_rgb48_frames_are_3_channel_uint16(): + cam = make_sim(CameraPixelFormat.RGB48) + cam.send_trigger() + frame = cam.read_camera_frame() + assert frame.frame.ndim == 3 and frame.frame.shape[2] == 3 + assert frame.frame.dtype == np.uint16 + + +def test_rgb_formats_advertised(): + cam = make_sim(CameraPixelFormat.RGB24) + formats = cam.get_available_pixel_formats() + assert CameraPixelFormat.RGB24 in formats + assert CameraPixelFormat.RGB48 in formats From 0c6000c0c502ac42deecaedbc4e1b65e53bd81ed Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 05:57:34 -0700 Subject: [PATCH 09/52] fix(sim): invalidate cached raw frame on set_pixel_format; cover RGB transforms set_pixel_format kept the cached _current_raw_frame, so switching format after the first trigger (reachable from the live pixel-format dropdown) re-served the stale wrongly-shaped array via the np.roll path -- frame.shape disagreed with frame_pixel_format/is_color(). Mirror the invalidation set_binning already does. Also add committed regression coverage for the _process_raw_frame transforms (rotate_and_flip_image, crop_image), which previously had no tests for any format, asserting the (H, W, 3) channel axis and dtype survive. Co-Authored-By: Claude Fable 5 --- software/squid/camera/utils.py | 2 + .../tests/squid/test_simulated_camera_rgb.py | 130 +++++++++++++++++- 2 files changed, 128 insertions(+), 4 deletions(-) diff --git a/software/squid/camera/utils.py b/software/squid/camera/utils.py index df7647aaf..b67ad98d0 100644 --- a/software/squid/camera/utils.py +++ b/software/squid/camera/utils.py @@ -228,6 +228,8 @@ def get_frame_format(self) -> CameraFrameFormat: @debug_log def set_pixel_format(self, pixel_format: CameraPixelFormat): self._pixel_format = pixel_format + # Invalidate cached frame so next frame regenerates with the new dtype/channel count + self._current_raw_frame = None @debug_log def get_pixel_format(self) -> CameraPixelFormat: diff --git a/software/tests/squid/test_simulated_camera_rgb.py b/software/tests/squid/test_simulated_camera_rgb.py index e7bd2bbea..b62793840 100644 --- a/software/tests/squid/test_simulated_camera_rgb.py +++ b/software/tests/squid/test_simulated_camera_rgb.py @@ -1,17 +1,36 @@ import numpy as np +import pytest import squid.config +from control.utils import FlipVariant, crop_image, rotate_and_flip_image from squid.camera.utils import SimulatedCamera from squid.config import CameraPixelFormat +# The ambient camera config is 4168x4168, so a single RGB48 frame is ~100 MB. Tests that do not +# specifically need the ambient geometry override it with this tiny frame instead. +SMALL_FRAME = {"crop_width": 64, "crop_height": 48, "default_binning": (1, 1)} -def make_sim(pixel_format): - config = squid.config.get_camera_config().model_copy( - update={"serial_number": "SIM-RGB", "default_pixel_format": pixel_format} - ) + +def make_sim(pixel_format, **overrides): + update = {"serial_number": "SIM-RGB", "default_pixel_format": pixel_format} + update.update(overrides) + config = squid.config.get_camera_config().model_copy(update=update) return SimulatedCamera(config, hw_trigger_fn=None, hw_set_strobe_delay_ms_fn=None) +def rgb_test_image(dtype): + """(48, 64, 3) image with a distinct constant per channel plus a unique corner marker. + + The per-channel constants make a channel collapse (RGB->gray) or a channel reorder detectable; + the corner marker makes it detectable that a transform actually moved pixels. + """ + image = np.zeros((48, 64, 3), dtype=dtype) + for channel, value in enumerate((10, 20, 30)): + image[..., channel] = value + image[0, 0, channel] = channel + 1 # marker: 1, 2, 3 + return image + + def test_rgb24_frames_are_3_channel_uint8(): cam = make_sim(CameraPixelFormat.RGB24) cam.send_trigger() @@ -35,3 +54,106 @@ def test_rgb_formats_advertised(): formats = cam.get_available_pixel_formats() assert CameraPixelFormat.RGB24 in formats assert CameraPixelFormat.RGB48 in formats + + +# --- _process_raw_frame transforms must not mangle the channel axis --------------------------- +# AbstractCamera._process_raw_frame pipes every frame through rotate_and_flip_image + crop_image. +# Those two helpers are shared by all cameras but had no test coverage for any format, so these +# lock in the (H, W, 3) contract that colour cameras (and Tasks 11-13) depend on. + + +@pytest.mark.parametrize("dtype", [np.uint8, np.uint16]) +@pytest.mark.parametrize("angle", [None, 90, -90, 180]) +@pytest.mark.parametrize("flip", [None, FlipVariant.VERTICAL, FlipVariant.HORIZONTAL, FlipVariant.BOTH]) +def test_rotate_and_flip_image_preserves_rgb_channels(dtype, angle, flip): + image = rgb_test_image(dtype) + + rotated = rotate_and_flip_image(image, rotate_image_angle=angle, flip_image=flip) + + assert rotated.ndim == 3 and rotated.shape[2] == 3 + assert rotated.dtype == dtype + # A quarter turn transposes height and width; 180/None do not. + assert rotated.shape[:2] == ((64, 48) if angle in (90, -90) else (48, 64)) + # Each channel still holds exactly its own two values: no collapse to grayscale, no reorder. + for channel, value in enumerate((10, 20, 30)): + assert sorted(np.unique(rotated[..., channel]).tolist()) == sorted([value, channel + 1]) + + +def test_rotate_and_flip_image_actually_moves_rgb_pixels(): + image = rgb_test_image(np.uint8) + + rotated = rotate_and_flip_image(image, rotate_image_angle=180, flip_image=None) + + # The (0, 0) marker ends up in the opposite corner, with its per-channel values intact. + assert list(rotated[-1, -1]) == [1, 2, 3] + assert list(rotated[0, 0]) == [10, 20, 30] + + +@pytest.mark.parametrize("dtype", [np.uint8, np.uint16]) +def test_crop_image_preserves_rgb_channel_axis(dtype): + image = rgb_test_image(dtype) + + cropped = crop_image(image, 32, 24) + + assert cropped.shape == (24, 32, 3) + assert cropped.dtype == dtype + # The centred crop dropped the (0, 0) marker but kept every channel's constant. + for channel, value in enumerate((10, 20, 30)): + assert np.unique(cropped[..., channel]).tolist() == [value] + + +def test_crop_image_with_none_dimensions_keeps_rgb_axis(): + image = rgb_test_image(np.uint8) + + # None means "do not crop this axis" - the channel axis must still survive. + assert crop_image(image, None, 24).shape == (24, 64, 3) + assert crop_image(image, 32, None).shape == (48, 32, 3) + assert crop_image(image, None, None).shape == (48, 64, 3) + + +def test_process_raw_frame_keeps_rgb_3_channel_end_to_end(): + # A quarter turn is what makes crop_image genuinely cut in the SimulatedCamera path: the raw + # frame is generated at post-crop size (get_resolution derives from crop_width/crop_height), so + # without rotation the crop is a no-op. Rotated, the 48x64 frame becomes 64x48 and the 64x48 + # crop window trims 8 rows off each end -> 48x48. + cam = make_sim(CameraPixelFormat.RGB24, rotate_image_angle=90, flip=FlipVariant.BOTH, **SMALL_FRAME) + cam.send_trigger() + + frame = cam.read_camera_frame() + + assert frame.frame.shape == (48, 48, 3) + assert frame.frame.dtype == np.uint8 + assert frame.is_color() + + +# --- switching pixel format must invalidate the cached raw frame ------------------------------ + + +def test_pixel_format_switch_invalidates_cached_frame(): + # Reachable from the live GUI pixel-format dropdown: without invalidation the cached mono frame + # is np.roll'd and re-served, so frame.shape disagrees with frame_pixel_format / is_color(). + cam = make_sim(CameraPixelFormat.MONO16, **SMALL_FRAME) + cam.send_trigger() + assert cam.read_camera_frame().frame.ndim == 2 + + cam.set_pixel_format(CameraPixelFormat.RGB24) + cam.send_trigger() + + frame = cam.read_camera_frame() + assert frame.frame.ndim == 3 and frame.frame.shape[2] == 3 + assert frame.frame.dtype == np.uint8 + assert frame.is_color() + + +def test_pixel_format_switch_back_to_mono_invalidates_cached_frame(): + cam = make_sim(CameraPixelFormat.RGB24, **SMALL_FRAME) + cam.send_trigger() + assert cam.read_camera_frame().frame.ndim == 3 + + cam.set_pixel_format(CameraPixelFormat.MONO8) + cam.send_trigger() + + frame = cam.read_camera_frame() + assert frame.frame.ndim == 2 + assert frame.frame.dtype == np.uint8 + assert not frame.is_color() From e45c9308666ba4b069ad1094c5452c1fa82f8f86 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 06:13:39 -0700 Subject: [PATCH 10/52] feat(cache): per-serial camera settings cache with legacy migration Co-Authored-By: Claude Fable 5 --- software/control/gui_hcs.py | 69 +++++++----- software/squid/camera/settings_cache.py | 119 ++++++++++++++++++-- software/tests/squid/test_settings_cache.py | 46 ++++++++ 3 files changed, 195 insertions(+), 39 deletions(-) diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index a0e4efd7f..1aea1168a 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -922,16 +922,19 @@ def load_widgets(self): self.nl5Wdiget = NL5Widget.NL5Widget(self.nl5) + # Settings are per-camera identity state, so this widget drives the primary + # concrete camera, not the facade (which would retarget on a camera switch). + primary_camera = self.microscope.cameras[PRIMARY_CAMERA_ID] if CAMERA_TYPE in ["Toupcam", "Tucsen", "Kinetix"]: self.cameraSettingWidget = widgets.CameraSettingsWidget( - self.camera, + primary_camera, include_gain_exposure_time=False, include_camera_temperature_setting=True, include_camera_auto_wb_setting=False, ) else: self.cameraSettingWidget = widgets.CameraSettingsWidget( - self.camera, + primary_camera, include_gain_exposure_time=False, include_camera_temperature_setting=False, include_camera_auto_wb_setting=True, @@ -1093,32 +1096,38 @@ def load_widgets(self): self.setupCameraTabWidget() def _restore_cached_camera_settings(self) -> None: - """Restore cached camera settings from disk and update UI widgets. + """Restore each camera's cached settings from disk and update UI widgets. - Applies both hardware settings (via camera API) and synchronizes the UI - dropdown widgets. Silently returns if no cached settings exist. + Settings are cached per camera serial number, so every concrete camera is + restored from its own entry. Only the primary camera's dropdowns are synced - + the other cameras' settings widgets are built later and read live camera + state. Cameras with no cached entry keep their configured defaults. Errors are logged but do not prevent application startup. """ - cached_settings = squid.camera.settings_cache.load_camera_settings() - if not cached_settings: - return + for camera_id, camera in self.microscope.cameras.items(): + sync_widget = camera_id == PRIMARY_CAMERA_ID + cached_settings = squid.camera.settings_cache.load_camera_settings( + serial=getattr(camera._config, "serial_number", None) + ) + if not cached_settings: + continue - binning_restored = self._restore_binning(cached_settings.binning) - pixel_format_restored = self._restore_pixel_format(cached_settings.pixel_format) + binning_restored = self._restore_binning(camera, cached_settings.binning, sync_widget) + pixel_format_restored = self._restore_pixel_format(camera, cached_settings.pixel_format, sync_widget) - if binning_restored or pixel_format_restored: - self.log.info( - f"Restored camera settings: binning={cached_settings.binning}, " - f"pixel_format={cached_settings.pixel_format}" - ) + if binning_restored or pixel_format_restored: + self.log.info( + f"Restored camera {camera_id} settings: binning={cached_settings.binning}, " + f"pixel_format={cached_settings.pixel_format}" + ) - def _restore_binning(self, binning: Tuple[int, int]) -> bool: - """Apply binning setting to camera and sync UI dropdown. + def _restore_binning(self, camera: AbstractCamera, binning: Tuple[int, int], sync_widget: bool) -> bool: + """Apply binning setting to the given camera, optionally syncing the UI dropdown. Returns True if successfully applied, False otherwise. """ try: - self.camera.set_binning(*binning) + camera.set_binning(*binning) except ValueError as e: self.log.warning(f"Cannot restore binning {binning} - not supported by camera: {e}") return False @@ -1126,14 +1135,15 @@ def _restore_binning(self, binning: Tuple[int, int]) -> bool: self.log.error(f"Camera error while restoring binning settings: {e}") return False - binning_text = f"{binning[0]}x{binning[1]}" - self.cameraSettingWidget.dropdown_binning.blockSignals(True) - self.cameraSettingWidget.dropdown_binning.setCurrentText(binning_text) - self.cameraSettingWidget.dropdown_binning.blockSignals(False) + if sync_widget: + binning_text = f"{binning[0]}x{binning[1]}" + self.cameraSettingWidget.dropdown_binning.blockSignals(True) + self.cameraSettingWidget.dropdown_binning.setCurrentText(binning_text) + self.cameraSettingWidget.dropdown_binning.blockSignals(False) return True - def _restore_pixel_format(self, pixel_format_str: Optional[str]) -> bool: - """Apply pixel format setting to camera and sync UI dropdown. + def _restore_pixel_format(self, camera: AbstractCamera, pixel_format_str: Optional[str], sync_widget: bool) -> bool: + """Apply pixel format setting to the given camera, optionally syncing the UI dropdown. Returns True if successfully applied, False otherwise. """ @@ -1147,7 +1157,7 @@ def _restore_pixel_format(self, pixel_format_str: Optional[str]) -> bool: return False try: - self.camera.set_pixel_format(pixel_format) + camera.set_pixel_format(pixel_format) except ValueError as e: self.log.warning(f"Cannot restore pixel format {pixel_format_str} - not supported by this camera: {e}") return False @@ -1155,9 +1165,10 @@ def _restore_pixel_format(self, pixel_format_str: Optional[str]) -> bool: self.log.error(f"Camera error while restoring pixel format settings: {e}") return False - self.cameraSettingWidget.dropdown_pixelFormat.blockSignals(True) - self.cameraSettingWidget.dropdown_pixelFormat.setCurrentText(pixel_format_str) - self.cameraSettingWidget.dropdown_pixelFormat.blockSignals(False) + if sync_widget: + self.cameraSettingWidget.dropdown_pixelFormat.blockSignals(True) + self.cameraSettingWidget.dropdown_pixelFormat.setCurrentText(pixel_format_str) + self.cameraSettingWidget.dropdown_pixelFormat.blockSignals(False) return True def setupImageDisplayTabs(self): @@ -2800,7 +2811,7 @@ def _cleanup_common(self, for_restart: bool = False): raise try: - squid.camera.settings_cache.save_camera_settings(self.camera) + squid.camera.settings_cache.save_all_camera_settings(self.microscope.cameras) except Exception: if for_restart: self.log.exception(f"Error saving camera settings during {context}") diff --git a/software/squid/camera/settings_cache.py b/software/squid/camera/settings_cache.py index 91c125175..15984c6f3 100644 --- a/software/squid/camera/settings_cache.py +++ b/software/squid/camera/settings_cache.py @@ -4,19 +4,31 @@ to maintain user preferences across application restarts. Settings are stored as YAML in the cache directory. +The on-disk format is keyed by camera serial number so that a multi-camera system keeps +one entry per camera: + + version: 2 + cameras: + SN1: {binning: [2, 2], pixel_format: MONO8} + SN2: {binning: [1, 1], pixel_format: MONO12} + +Cameras without a serial number (INI-only configurations) are stored under the +"default" key. Files written by older versions are a flat mapping without the +"cameras" key; those are still read, and apply to whichever camera asks for them. + Typical usage: # On application close - save_camera_settings(camera) + save_all_camera_settings(microscope.cameras) # On application startup - settings = load_camera_settings() + settings = load_camera_settings(serial=camera._config.serial_number) if settings: camera.set_binning(*settings.binning) """ from dataclasses import dataclass from pathlib import Path -from typing import Optional, Tuple +from typing import Dict, Optional, Tuple import yaml @@ -28,6 +40,9 @@ _DEFAULT_CACHE_PATH = Path("cache/camera_settings.yaml") DEFAULT_BINNING: Tuple[int, int] = (1, 1) +# Key used for cameras that have no serial number in their config. +_DEFAULT_SERIAL_KEY = "default" + @dataclass(frozen=True) class CachedCameraSettings: @@ -49,12 +64,66 @@ def __post_init__(self): raise ValueError(f"Binning values must be positive, got {self.binning}") -def save_camera_settings(camera: AbstractCamera, cache_path: Path = _DEFAULT_CACHE_PATH) -> None: - """Save current camera settings (binning and pixel format) to a YAML cache file. +def _serial_key(camera: AbstractCamera) -> str: + """Cache key for a camera: its serial number, or 'default' when it has none.""" + serial = getattr(camera._config, "serial_number", None) + return serial if serial else _DEFAULT_SERIAL_KEY + + +def _settings_dict_for(camera: AbstractCamera) -> Optional[dict]: + """Read a camera's persistable settings, or None if the camera cannot be read.""" + try: + binning = camera.get_binning() + pixel_format = camera.get_pixel_format() + except (AttributeError, RuntimeError) as e: + _log.error(f"Cannot read camera settings - camera may be disconnected: {e}") + return None + return {"binning": list(binning), "pixel_format": pixel_format.value if pixel_format else None} + + +def save_all_camera_settings(cameras: Dict[int, AbstractCamera], cache_path: Path = _DEFAULT_CACHE_PATH) -> None: + """Save settings for every concrete camera, keyed by serial number. Creates parent directories if they do not exist. This function is fail-safe - errors are logged but do not raise exceptions, allowing application shutdown - to continue. + to continue. Cameras that cannot be read are skipped; if no camera can be read + the existing cache file is left untouched. + + Args: + cameras: Microscope.cameras, i.e. {camera_id: AbstractCamera}. Must be the + concrete cameras, not the ActiveCameraFacade - these settings are + per-camera identity state. + cache_path: Path to the cache file. Defaults to 'cache/camera_settings.yaml' + relative to the current working directory. + """ + per_serial = {} + for camera in cameras.values(): + settings = _settings_dict_for(camera) + if settings is not None: + per_serial[_serial_key(camera)] = settings + + if not per_serial: + return + + payload = {"version": 2, "cameras": per_serial} + try: + cache_path.parent.mkdir(parents=True, exist_ok=True) + with open(cache_path, "w") as f: + yaml.safe_dump(payload, f, default_flow_style=False) + _log.info(f"Camera settings saved for {sorted(per_serial)}") + except PermissionError as e: + _log.error(f"Cannot save camera settings - permission denied for {cache_path}: {e}") + except OSError as e: + _log.error(f"Cannot save camera settings - file system error: {e}") + + +def save_camera_settings(camera: AbstractCamera, cache_path: Path = _DEFAULT_CACHE_PATH) -> None: + """Write the legacy single-camera (v1) flat cache file. + + Kept for callers that only ever have one camera and do not care about serial + numbers. Multi-camera callers must use save_all_camera_settings, which writes + the per-serial v2 format. This function is fail-safe - errors are logged but do + not raise exceptions, allowing application shutdown to continue. Args: camera: Camera instance to read settings from. @@ -84,18 +153,25 @@ def save_camera_settings(camera: AbstractCamera, cache_path: Path = _DEFAULT_CAC _log.error(f"Cannot save camera settings - file system error: {e}") -def load_camera_settings(cache_path: Path = _DEFAULT_CACHE_PATH) -> Optional[CachedCameraSettings]: - """Load cached camera settings from a YAML cache file. +def load_camera_settings( + cache_path: Path = _DEFAULT_CACHE_PATH, *, serial: Optional[str] = None +) -> Optional[CachedCameraSettings]: + """Load one camera's cached settings from a YAML cache file. This function is fail-safe - returns None on any error condition. Args: cache_path: Path to the cache file. Defaults to 'cache/camera_settings.yaml' relative to the current working directory. + serial: Serial number of the camera whose settings to load. None means the + "default" entry, falling back to the only entry when the cache holds + exactly one camera (single-camera systems). Keyword-only, so existing + positional callers keep passing cache_path. Returns: - CachedCameraSettings if the file exists and contains valid data, None otherwise. - Returns None if the file doesn't exist (expected on first run). + CachedCameraSettings if the file exists and holds valid data for this camera, + None otherwise. Returns None if the file doesn't exist (expected on first run) + or has no entry for `serial`. """ if not cache_path.exists(): _log.debug("No camera settings cache file found - using defaults") @@ -116,6 +192,29 @@ def load_camera_settings(cache_path: Path = _DEFAULT_CACHE_PATH) -> Optional[Cac _log.error(f"Cannot read camera settings cache - file system error: {e}") return None + if not isinstance(settings, dict): + _log.error(f"Camera settings cache at {cache_path} is not a mapping - using defaults") + return None + + if "cameras" in settings: + # v2 per-serial format. + per_serial = settings.get("cameras") + if not isinstance(per_serial, dict): + per_serial = {} + key = serial if serial else _DEFAULT_SERIAL_KEY + entry = per_serial.get(key) + if entry is None and serial is None and len(per_serial) == 1: + # Single-camera system whose one camera does have a serial number. + entry = next(iter(per_serial.values())) + if entry is None: + _log.debug(f"No cached camera settings for serial '{key}'") + return None + if not isinstance(entry, dict): + _log.warning(f"Cached camera settings for serial '{key}' are malformed: {entry!r}") + return None + settings = entry + # else: legacy v1 flat format - the whole file is the requested camera's settings. + try: binning_raw = settings.get("binning") if not isinstance(binning_raw, list) or len(binning_raw) != 2: diff --git a/software/tests/squid/test_settings_cache.py b/software/tests/squid/test_settings_cache.py index f024c45a4..36fdd6294 100644 --- a/software/tests/squid/test_settings_cache.py +++ b/software/tests/squid/test_settings_cache.py @@ -240,3 +240,49 @@ def test_round_trip_no_pixel_format(self): assert settings is not None assert settings.binning == (2, 2) assert settings.pixel_format is None + + +def _sim_with_serial(serial): + import squid.config + from squid.camera.utils import SimulatedCamera + + config = squid.config.get_camera_config().model_copy(update={"serial_number": serial}) + return SimulatedCamera(config, hw_trigger_fn=None, hw_set_strobe_delay_ms_fn=None) + + +def test_multi_camera_round_trip(tmp_path): + from squid.camera.settings_cache import load_camera_settings, save_all_camera_settings + + cache = tmp_path / "camera_settings.yaml" + cam1, cam2 = _sim_with_serial("SN1"), _sim_with_serial("SN2") + cam1.set_binning(2, 2) + cam2.set_binning(1, 1) + save_all_camera_settings({1: cam1, 2: cam2}, cache_path=cache) + + s1 = load_camera_settings(serial="SN1", cache_path=cache) + s2 = load_camera_settings(serial="SN2", cache_path=cache) + assert s1.binning == (2, 2) + assert s2.binning == (1, 1) + assert load_camera_settings(serial="SN-UNKNOWN", cache_path=cache) is None + + +def test_legacy_flat_file_readable_for_any_serial(tmp_path): + from squid.camera.settings_cache import load_camera_settings + + cache = tmp_path / "camera_settings.yaml" + cache.write_text("binning: [3, 3]\npixel_format: MONO16\n") + settings = load_camera_settings(serial="SN1", cache_path=cache) + assert settings.binning == (3, 3) + assert settings.pixel_format == "MONO16" + # And with no serial (single-camera call sites) + assert load_camera_settings(cache_path=cache).binning == (3, 3) + + +def test_none_serial_reads_default_key(tmp_path): + from squid.camera.settings_cache import load_camera_settings, save_all_camera_settings + + cache = tmp_path / "camera_settings.yaml" + cam = _sim_with_serial(None) # INI-only camera without serial + cam.set_binning(2, 2) + save_all_camera_settings({1: cam}, cache_path=cache) + assert load_camera_settings(cache_path=cache).binning == (2, 2) From 8f23575ddbd4c453e94edb56c18bd7f151b3773d Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 06:29:48 -0700 Subject: [PATCH 11/52] fix(cache): isolate per-camera read failures when saving all camera settings A camera whose get_binning/get_pixel_format raised anything other than AttributeError or RuntimeError (e.g. OSError from a yanked USB camera) took down the whole save: no camera's settings were written and the exception escaped save_all_camera_settings. On a real shutdown that propagates through _cleanup_common and aborts every later step - camera close, Z retract, turret and microcontroller close. Catch Exception in _settings_dict_for so a broken camera is skipped and the healthy ones are still saved, matching the module's fail-safe contract and the per-camera isolation the restore path already has. Also pin two previously untested branches: the single-entry v2 fallback, and 'no camera readable' leaving the existing cache file untouched. Co-Authored-By: Claude Fable 5 --- software/squid/camera/settings_cache.py | 10 +++- software/tests/squid/test_settings_cache.py | 59 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/software/squid/camera/settings_cache.py b/software/squid/camera/settings_cache.py index 15984c6f3..1d4ab85be 100644 --- a/software/squid/camera/settings_cache.py +++ b/software/squid/camera/settings_cache.py @@ -71,11 +71,17 @@ def _serial_key(camera: AbstractCamera) -> str: def _settings_dict_for(camera: AbstractCamera) -> Optional[dict]: - """Read a camera's persistable settings, or None if the camera cannot be read.""" + """Read a camera's persistable settings, or None if the camera cannot be read. + + Deliberately catches everything: driver-level failures are not confined to any one + exception type (a yanked USB camera raises OSError, for instance). This runs from + application shutdown, where one unreachable camera must cost neither the other + cameras' settings nor the rest of the cleanup sequence. + """ try: binning = camera.get_binning() pixel_format = camera.get_pixel_format() - except (AttributeError, RuntimeError) as e: + except Exception as e: _log.error(f"Cannot read camera settings - camera may be disconnected: {e}") return None return {"binning": list(binning), "pixel_format": pixel_format.value if pixel_format else None} diff --git a/software/tests/squid/test_settings_cache.py b/software/tests/squid/test_settings_cache.py index 36fdd6294..00c3f191b 100644 --- a/software/tests/squid/test_settings_cache.py +++ b/software/tests/squid/test_settings_cache.py @@ -286,3 +286,62 @@ def test_none_serial_reads_default_key(tmp_path): cam.set_binning(2, 2) save_all_camera_settings({1: cam}, cache_path=cache) assert load_camera_settings(cache_path=cache).binning == (2, 2) + + +def test_none_serial_falls_back_to_sole_v2_entry(tmp_path): + """A serial-less camera adopts the only cached entry, even under another key.""" + from squid.camera.settings_cache import load_camera_settings + + cache = tmp_path / "camera_settings.yaml" + with open(cache, "w") as f: + yaml.safe_dump({"version": 2, "cameras": {"SN1": {"binning": [2, 2], "pixel_format": "MONO12"}}}, f) + + settings = load_camera_settings(cache_path=cache) + + assert settings is not None + assert settings.binning == (2, 2) + assert settings.pixel_format == "MONO12" + + +def test_save_all_isolates_a_camera_that_raises(tmp_path, monkeypatch): + """One broken camera must not cost the healthy cameras their settings. + + save_all_camera_settings runs from closeEvent; anything escaping it aborts every + later shutdown step (camera close, Z retract, microcontroller close). + """ + from squid.camera.settings_cache import load_camera_settings, save_all_camera_settings + + def _raise(): + raise OSError("usb gone") + + cache = tmp_path / "camera_settings.yaml" + healthy, broken = _sim_with_serial("SN-OK"), _sim_with_serial("SN-BAD") + healthy.set_binning(2, 2) + monkeypatch.setattr(broken, "get_binning", _raise) + + save_all_camera_settings({1: healthy, 2: broken}, cache_path=cache) + + with open(cache, "r") as f: + data = yaml.safe_load(f) + assert set(data["cameras"]) == {"SN-OK"} + assert load_camera_settings(serial="SN-OK", cache_path=cache).binning == (2, 2) + assert load_camera_settings(serial="SN-BAD", cache_path=cache) is None + + +def test_save_all_leaves_cache_untouched_when_no_camera_readable(tmp_path, monkeypatch): + """Nothing readable means keep the last good cache rather than truncating it.""" + from squid.camera.settings_cache import save_all_camera_settings + + def _raise(): + raise RuntimeError("Camera disconnected") + + cache = tmp_path / "camera_settings.yaml" + cache.write_text("binning: [3, 3]\npixel_format: MONO16\n") + original = cache.read_text() + + broken = _sim_with_serial("SN-BAD") + monkeypatch.setattr(broken, "get_binning", _raise) + + save_all_camera_settings({1: broken}, cache_path=cache) + + assert cache.read_text() == original From 99b3995daf5eb511b8e5d6240b3d0d0afd408d73 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 06:52:58 -0700 Subject: [PATCH 12/52] feat(gui): camera dot + suffix labels in channel lists, name identity via UserRole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display decoration for multi-camera systems: live-view dropdowns and multipoint channel lists show a per-camera dot icon and a '' suffix for channels bound to a non-primary camera. Identity stays the bare channel name everywhere: every entry stores it in Qt.UserRole and all readers use data(UserRole) with a text fallback, so saved YAMLs, set-mode-by-name and MCP APIs never see a decorated label. Channels whose camera is declared but unavailable are greyed out (disabled + tooltip) and excluded from the acquisition sequence. Single-camera systems render exactly as before (bare names, no icons). Co-Authored-By: Claude Fable 5 --- software/control/channel_sequence.py | 84 +++++++-- software/control/widgets.py | 153 +++++++++++++-- .../control/test_channel_display_labels.py | 177 ++++++++++++++++++ .../tests/control/test_channel_sequence.py | 73 ++++++++ 4 files changed, 460 insertions(+), 27 deletions(-) create mode 100644 software/tests/control/test_channel_display_labels.py diff --git a/software/control/channel_sequence.py b/software/control/channel_sequence.py index a9a2bc0c0..0996b0982 100644 --- a/software/control/channel_sequence.py +++ b/software/control/channel_sequence.py @@ -1,11 +1,13 @@ import os +from typing import Callable, Optional, Tuple import yaml from qtpy.QtCore import QEvent, QItemSelectionModel, QObject, QRect, Qt, QTimer -from qtpy.QtGui import QColor, QPalette +from qtpy.QtGui import QColor, QIcon, QPalette from qtpy.QtWidgets import ( QAbstractItemView, QApplication, + QListWidgetItem, QStyle, QStyledItemDelegate, QStyleOptionViewItem, @@ -17,6 +19,14 @@ _CACHE_PATH = "cache/channel_sequence.yaml" +_UNAVAILABLE_CAMERA_TOOLTIP = "This channel's camera is declared in cameras.yaml but is not available." + + +def _item_name(item): + """Channel identity: the bare name stored in Qt.UserRole (decorated lists), + with a text fallback for undecorated lists.""" + return item.data(Qt.UserRole) or item.text() + def display_order(included_order, config_order): """Included channels first (in their order, filtered to those present in @@ -150,7 +160,10 @@ def paint(self, painter, option, index): painter.save() selected = bool(opt.state & QStyle.State_Selected) - text_pen = opt.palette.color(QPalette.HighlightedText if selected else QPalette.Text) + # Disabled rows (channel's camera unavailable) draw with the Disabled + # palette group so they actually read as greyed out. + color_group = QPalette.Normal if opt.state & QStyle.State_Enabled else QPalette.Disabled + text_pen = opt.palette.color(color_group, QPalette.HighlightedText if selected else QPalette.Text) painter.setPen(text_pen) name_rect = QRect(text_rect) name_rect.setLeft(text_rect.left() + gutter) @@ -183,7 +196,8 @@ def editorEvent(self, event, model, option, index): up_rect, down_rect = self._arrow_rects(option.rect) if up_rect.contains(pos) or down_rect.contains(pos): if event.type() == QEvent.MouseButtonRelease: - name = index.data() + # Identity is the bare name in UserRole; display text may be decorated. + name = index.data(Qt.UserRole) or index.data() if up_rect.contains(pos): self._controller.move_up(name) else: @@ -197,14 +211,29 @@ class ChannelSequenceController(QObject): acquisition sequence: selected channels form an ordered block at the top, reordered with the per-row up/down controls drawn by ChannelOrderDelegate (which call move_up/move_down); `included_order` is the single source of - truth. Persists to a per-widget cache. Parent it to the list widget.""" - - def __init__(self, list_widget, get_names, cache_key, cache_path=_CACHE_PATH): + truth. Persists to a per-widget cache. Parent it to the list widget. + + Items store the bare channel name in Qt.UserRole (identity); the visible + text/icon may be decorated via the optional `decorate` hook, which maps a + channel name to (label, icon, enabled). `enabled=False` marks a channel + whose camera is declared but unavailable: the row is greyed out (not + hidden), unselectable, and excluded from ordered_selected_names().""" + + def __init__( + self, + list_widget, + get_names, + cache_key, + cache_path=_CACHE_PATH, + decorate: Optional[Callable[[str], Tuple[str, Optional[QIcon], bool]]] = None, + ): super().__init__(list_widget) self._list = list_widget self._get_names = get_names self._cache_key = cache_key self._cache_path = cache_path + self._decorate = decorate + self._disabled_names = set() self._suppress = False # Owned single-shot timer for the deferred selection rebuild. Parenting # it to the controller (itself parented to the list) means it is @@ -243,8 +272,10 @@ def _config_order(self): def ordered_selected_names(self): # Pure read: `_included_order` is updated synchronously on selection, so # this is always correct even while a visual rebuild is still pending. + # Channels whose camera is unavailable (greyed out) are not acquirable, + # so they are excluded here even if a cached sequence includes them. config_set = set(self._config_order()) - return [n for n in self._included_order if n in config_set] + return [n for n in self._included_order if n in config_set and n not in self._disabled_names] def set_included_order(self, names): self._included_order = reconcile_included(list(names), self._config_order()) @@ -263,23 +294,35 @@ def _rebuild(self): # context that has already blocked the list's signals (e.g. YAML drop). was_blocked = self._list.blockSignals(True) current = self._list.currentItem() - current_name = current.text() if current is not None else None + current_name = _item_name(current) if current is not None else None try: config_order = self._config_order() order = display_order(self._included_order, config_order) included_set = set(reconcile_included(self._included_order, config_order)) self._list.clear() + self._disabled_names = set() for name in order: - self._list.addItem(name) + item = QListWidgetItem(name) + item.setData(Qt.UserRole, name) # identity: always the bare channel name + if self._decorate is not None: + label, icon, enabled = self._decorate(name) + item.setText(label) + if icon is not None: + item.setIcon(icon) + if not enabled: + item.setFlags(item.flags() & ~Qt.ItemIsEnabled & ~Qt.ItemIsSelectable) + item.setToolTip(_UNAVAILABLE_CAMERA_TOOLTIP) + self._disabled_names.add(name) + self._list.addItem(item) for i in range(self._list.count()): item = self._list.item(i) - if item.text() in included_set: + if _item_name(item) in included_set and item.flags() & Qt.ItemIsSelectable: item.setSelected(True) # Restore the focused (current) row without disturbing the selection, # so repeated up/down presses keep acting on the same channel. if current_name is not None: for i in range(self._list.count()): - if self._list.item(i).text() == current_name: + if _item_name(self._list.item(i)) == current_name: self._list.setCurrentRow(i, QItemSelectionModel.NoUpdate) break finally: @@ -295,7 +338,7 @@ def _on_selection_changed(self): # turn: restructuring the list inside its own itemSelectionChanged # handler (during mouse processing) is unsafe, and deferring also # coalesces rapid selections. - selected = [i.text() for i in self._list.selectedItems()] + selected = [_item_name(i) for i in self._list.selectedItems()] selected_set = set(selected) new_order = [n for n in self._included_order if n in selected_set] existing = set(new_order) @@ -336,10 +379,21 @@ def _save(self): save_cached_order(self._cache_key, self._included_order, self._cache_path) -def enable_channel_sequence(list_widget, get_names, cache_key, cache_path=_CACHE_PATH): +def enable_channel_sequence( + list_widget, + get_names, + cache_key, + cache_path=_CACHE_PATH, + decorate: Optional[Callable[[str], Tuple[str, Optional[QIcon], bool]]] = None, +): """Turn a channel QListWidget into an ordered acquisition sequence with per-row up/down reorder controls. `get_names` returns the channel names in config order. Returns the controller (store it on the widget; read ordered_selected_names() for acquisition, call set_included_order() to - restore a saved sequence).""" - return ChannelSequenceController(list_widget, get_names, cache_key, cache_path) + restore a saved sequence). + + `decorate`, when given, maps a channel name to (label, icon, enabled) for + display: the label/icon are decoration only (identity stays the bare name + in Qt.UserRole), and enabled=False greys out a channel whose camera is + declared but unavailable.""" + return ChannelSequenceController(list_widget, get_names, cache_key, cache_path, decorate=decorate) diff --git a/software/control/widgets.py b/software/control/widgets.py index be8a6f43f..5e8bb58e9 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -73,6 +73,64 @@ def camera_id_from_display(registry, display_text) -> Optional[int]: return definition.id if definition is not None else None +# Per-camera dot colors for channel lists, indexed by (camera_id - 1). Chosen to be +# distinguishable on light and dark palettes. +CAMERA_DOT_COLORS = ["#4C9BD4", "#E0A438", "#7BC47F", "#C57BC4"] + + +def camera_dot_icon(camera_id: int) -> QIcon: + """Small filled circle identifying a camera in channel lists.""" + pixmap = QPixmap(12, 12) + pixmap.fill(Qt.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + painter.setBrush(QColor(CAMERA_DOT_COLORS[(camera_id - 1) % len(CAMERA_DOT_COLORS)])) + painter.setPen(Qt.NoPen) + painter.drawEllipse(1, 1, 10, 10) + painter.end() + return QIcon(pixmap) + + +def channel_display_label(channel, registry) -> str: + """Display text for a channel in dropdowns/lists. + + Identity stays the bare channel name (stored in Qt.UserRole); this is decoration only. + """ + if registry is None or len(registry.cameras) <= 1: + return channel.name + camera_id = channel.camera if channel.camera is not None else control._def.PRIMARY_CAMERA_ID + if camera_id == control._def.PRIMARY_CAMERA_ID: + return channel.name + definition = registry.get_camera_by_id(camera_id) + if definition is None: + return f"{channel.name} — camera {camera_id} (unavailable)" + return f"{channel.name} — {definition.name}" + + +def _make_channel_decorator(live_controller_getter): + """Returns a decorate(channel_name) -> (label, icon, enabled) function for channel lists. + + live_controller_getter is a zero-arg callable so the decorator always sees the + current controller/microscope state. `enabled` is False when the channel's camera + is declared but unavailable (failed to open) — items get greyed out, not hidden. + """ + + def decorate(channel_name): + live_controller = live_controller_getter() + microscope = live_controller.microscope + registry = microscope.config_repo.get_camera_registry() + if registry is None or len(registry.cameras) <= 1: + return channel_name, None, True + channel = live_controller.get_channel_by_name(microscope.objective_store.current_objective, channel_name) + if channel is None: + return channel_name, None, True + camera_id = channel.camera if channel.camera is not None else control._def.PRIMARY_CAMERA_ID + available = camera_id in microscope.cameras + return channel_display_label(channel, registry), camera_dot_icon(camera_id), available + + return decorate + + def error_dialog(message: str, title: str = "Error"): msg = QMessageBox() msg.setIcon(QMessageBox.Warning) @@ -4073,6 +4131,35 @@ def __init__( self._live_current_z_offset_um: float = 0.0 self.checkbox_applyOnChannelSwitch.toggled.connect(self._on_apply_in_live_toggled) + def _channel_registry(self): + return self.liveController.microscope.config_repo.get_camera_registry() + + def _multi_camera(self) -> bool: + registry = self._channel_registry() + return registry is not None and len(registry.cameras) > 1 + + def _add_mode_item(self, config): + """Add a channel entry: decorated label + camera dot for display, bare + channel name in userData (identity). Entries whose camera is declared + but unavailable are greyed out.""" + registry = self._channel_registry() + label = channel_display_label(config, registry) + if self._multi_camera(): + camera_id = config.camera if config.camera is not None else control._def.PRIMARY_CAMERA_ID + self.dropdown_modeSelection.addItem(camera_dot_icon(camera_id), label, userData=config.name) + else: + self.dropdown_modeSelection.addItem(label, userData=config.name) + camera_available = config.camera is None or config.camera in self.liveController.microscope.cameras + if not camera_available: + index = self.dropdown_modeSelection.count() - 1 + item = self.dropdown_modeSelection.model().item(index) + item.setEnabled(False) + + def _select_dropdown_entry(self, config_name: str): + index = self.dropdown_modeSelection.findData(config_name) + if index >= 0: + self.dropdown_modeSelection.setCurrentIndex(index) + def add_components(self, show_trigger_options, show_display_options, show_autolevel, autolevel, stretch): # line 0: trigger mode self.dropdown_triggerManu = QComboBox() @@ -4095,8 +4182,9 @@ def add_components(self, show_trigger_options, show_display_options, show_autole self.dropdown_modeSelection = QComboBox() for microscope_configuration in self.liveController.get_channels(self.objectiveStore.current_objective): - self.dropdown_modeSelection.addItems([microscope_configuration.name]) - self.dropdown_modeSelection.setCurrentText(self.currentConfiguration.name) + self._add_mode_item(microscope_configuration) + if self.currentConfiguration is not None: + self._select_dropdown_entry(self.currentConfiguration.name) self.dropdown_modeSelection.setSizePolicy(sizePolicy) self.btn_live = QPushButton("Start Live") @@ -4195,7 +4283,10 @@ def add_components(self, show_trigger_options, show_display_options, show_autole self.entry_displayFPS.valueChanged.connect(self.streamHandler.set_display_fps) self.slider_resolutionScaling.valueChanged.connect(self.streamHandler.set_display_resolution_scaling) self.slider_resolutionScaling.valueChanged.connect(self.liveController.set_display_resolution_scaling) - self.dropdown_modeSelection.activated[str].connect(self.select_new_microscope_mode_by_name) + # Pass the bare channel name from userData (item text may carry camera decoration). + self.dropdown_modeSelection.activated.connect( + lambda index: self.select_new_microscope_mode_by_name(self.dropdown_modeSelection.itemData(index)) + ) self.dropdown_triggerManu.currentIndexChanged.connect(self.update_trigger_mode) self.btn_live.clicked.connect(self.toggle_live) self.entry_exposureTime.valueChanged.connect(self.update_config_exposure_time) @@ -4342,7 +4433,7 @@ def refresh_mode_list(self): for microscope_configuration in self.liveController.get_channels(self.objectiveStore.current_objective): if not first_config: first_config = microscope_configuration - self.dropdown_modeSelection.addItem(microscope_configuration.name) + self._add_mode_item(microscope_configuration) self.dropdown_modeSelection.blockSignals(False) # Update to first configuration @@ -4365,7 +4456,8 @@ def update_ui_for_mode(self, config): try: self.is_switching_mode = True self.currentConfiguration = config - self.dropdown_modeSelection.setCurrentText(config.name if config else "Unknown") + if config: + self._select_dropdown_entry(config.name) if self.currentConfiguration: self.signal_live_configuration.emit(self.currentConfiguration) @@ -5917,6 +6009,7 @@ def add_components(self): for ch in self.multipointController.liveController.get_channels(self.objectiveStore.current_objective) ], cache_key="flexible", + decorate=_make_channel_decorator(lambda: self.multipointController.liveController), ) self.checkbox_withAutofocus = QCheckBox("Contrast AF") @@ -7393,6 +7486,7 @@ def add_components(self): self.list_configurations, lambda: [ch.name for ch in self.liveController.get_channels(self.objectiveStore.current_objective)], cache_key="wellplate", + decorate=_make_channel_decorator(lambda: self.liveController), ) # Add a combo box for shape selection @@ -9477,6 +9571,7 @@ def add_components(self): for ch in self.multipointController.liveController.get_channels(self.objectiveStore.current_objective) ], cache_key="fluidics", + decorate=_make_channel_decorator(lambda: self.multipointController.liveController), ) # Reflection AF checkbox @@ -11539,6 +11634,35 @@ def createColorMap(self, colormap): positions = np.linspace(0, 1, len(colors)) return pg.ColorMap(positions, colors) + def _channel_registry(self): + return self.liveController.microscope.config_repo.get_camera_registry() + + def _multi_camera(self) -> bool: + registry = self._channel_registry() + return registry is not None and len(registry.cameras) > 1 + + def _add_mode_item(self, config): + """Add a channel entry: decorated label + camera dot for display, bare + channel name in userData (identity). Entries whose camera is declared + but unavailable are greyed out.""" + registry = self._channel_registry() + label = channel_display_label(config, registry) + if self._multi_camera(): + camera_id = config.camera if config.camera is not None else control._def.PRIMARY_CAMERA_ID + self.dropdown_modeSelection.addItem(camera_dot_icon(camera_id), label, userData=config.name) + else: + self.dropdown_modeSelection.addItem(label, userData=config.name) + camera_available = config.camera is None or config.camera in self.liveController.microscope.cameras + if not camera_available: + index = self.dropdown_modeSelection.count() - 1 + item = self.dropdown_modeSelection.model().item(index) + item.setEnabled(False) + + def _select_dropdown_entry(self, config_name: str): + index = self.dropdown_modeSelection.findData(config_name) + if index >= 0: + self.dropdown_modeSelection.setCurrentIndex(index) + def initControlWidgets(self, show_trigger_options, show_display_options, show_autolevel, autolevel): # Initialize histogram widget self.pg_image_item = pg.ImageItem() @@ -11553,8 +11677,9 @@ def initControlWidgets(self, show_trigger_options, show_display_options, show_au # Microscope Configuration (only enabled channels) self.dropdown_modeSelection = QComboBox() for config in self.liveController.get_channels(self.objectiveStore.current_objective): - self.dropdown_modeSelection.addItem(config.name) - self.dropdown_modeSelection.setCurrentText(self.live_configuration.name) + self._add_mode_item(config) + if self.live_configuration is not None: + self._select_dropdown_entry(self.live_configuration.name) self.dropdown_modeSelection.activated.connect(self.select_new_microscope_mode_by_name) # Live button @@ -11800,7 +11925,10 @@ def replace_well_selector(self, wellSelector): ) def select_new_microscope_mode_by_name(self, config_index): - config_name = self.dropdown_modeSelection.itemText(config_index) + # Bare channel name lives in userData (item text may carry camera decoration). + config_name = self.dropdown_modeSelection.itemData(config_index) or self.dropdown_modeSelection.itemText( + config_index + ) maybe_new_config = self.liveController.get_channel_by_name(self.objectiveStore.current_objective, config_name) if not maybe_new_config: @@ -11814,7 +11942,8 @@ def update_ui_for_mode(self, config): try: self.is_switching_mode = True self.live_configuration = config - self.dropdown_modeSelection.setCurrentText(config.name if config else "Unknown") + if config: + self._select_dropdown_entry(config.name) if self.live_configuration: self.entry_exposureTime.setValue(self.live_configuration.exposure_time) self.entry_analogGain.setValue(self.live_configuration.analog_gain) @@ -11873,7 +12002,7 @@ def refresh_mode_list(self): for config in self.liveController.get_channels(self.objectiveStore.current_objective): if not first_config: first_config = config - self.dropdown_modeSelection.addItem(config.name) + self._add_mode_item(config) self.dropdown_modeSelection.blockSignals(False) if self.dropdown_modeSelection.count() > 0 and first_config: @@ -12353,7 +12482,7 @@ def slot_joystick_button_pressed(self, button_state): self.setEnabled_all(False) self.trackingController.start_new_experiment(self.lineEdit_experimentID.text()) self.trackingController.set_selected_configurations( - (item.text() for item in self.list_configurations.selectedItems()) + (item.data(Qt.UserRole) or item.text() for item in self.list_configurations.selectedItems()) ) self.trackingController.start_tracking() else: @@ -12384,7 +12513,7 @@ def toggle_acquisition(self, pressed): self.setEnabled_all(False) self.trackingController.start_new_experiment(self.lineEdit_experimentID.text()) self.trackingController.set_selected_configurations( - (item.text() for item in self.list_configurations.selectedItems()) + (item.data(Qt.UserRole) or item.text() for item in self.list_configurations.selectedItems()) ) self.trackingController.start_tracking() else: diff --git a/software/tests/control/test_channel_display_labels.py b/software/tests/control/test_channel_display_labels.py new file mode 100644 index 000000000..7816af0e6 --- /dev/null +++ b/software/tests/control/test_channel_display_labels.py @@ -0,0 +1,177 @@ +"""Channel display labeling (Task 9): camera dot + suffix, name-preserving identity. + +The invariant under test: channel identity is the bare channel name everywhere. +Labels/icons are display decoration only; the bare name travels in Qt.UserRole +and readers use `item.data(Qt.UserRole) or item.text()`. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from qtpy.QtWidgets import QComboBox + +from control.models.camera_registry import CameraDefinition, CameraRegistryConfig +from control.widgets import ( + CAMERA_DOT_COLORS, + LiveControlWidget, + _make_channel_decorator, + camera_dot_icon, + channel_display_label, +) + + +class _Ch: + def __init__(self, name, camera=None): + self.name = name + self.camera = camera + + +TWO_CAM = CameraRegistryConfig( + cameras=[ + CameraDefinition(name="Main Camera", id=1, serial_number="SN1", type="Toupcam"), + CameraDefinition(name="Side Camera", id=2, serial_number="SN2", type="Toupcam", hardware_trigger=False), + ] +) +ONE_CAM = CameraRegistryConfig(cameras=[CameraDefinition(serial_number="SN1")]) + + +def test_label_plain_when_single_camera_or_no_registry(): + assert channel_display_label(_Ch("DAPI"), ONE_CAM) == "DAPI" + assert channel_display_label(_Ch("DAPI"), None) == "DAPI" + + +def test_label_plain_for_primary_suffixed_for_secondary(): + assert channel_display_label(_Ch("Fluorescence 488 nm Ex", camera=None), TWO_CAM) == "Fluorescence 488 nm Ex" + assert channel_display_label(_Ch("Fluorescence 488 nm Ex", camera=1), TWO_CAM) == "Fluorescence 488 nm Ex" + assert channel_display_label(_Ch("BF Color", camera=2), TWO_CAM) == "BF Color — Side Camera" + + +def test_label_for_unknown_camera_id_marks_unavailable(): + assert channel_display_label(_Ch("Ghost", camera=9), TWO_CAM) == "Ghost — camera 9 (unavailable)" + + +def test_dot_icon_deterministic(qtbot): + icon_a = camera_dot_icon(2) + icon_b = camera_dot_icon(2) + assert not icon_a.isNull() and not icon_b.isNull() + assert len(CAMERA_DOT_COLORS) >= 2 + assert camera_dot_icon(1).cacheKey() != 0 + + +# --------------------------------------------------------------------------- +# _make_channel_decorator: decorate(channel_name) -> (label, icon, enabled) +# --------------------------------------------------------------------------- + + +def _fake_live_controller(registry, channels, available_camera_ids): + microscope = SimpleNamespace( + config_repo=SimpleNamespace(get_camera_registry=lambda: registry), + objective_store=SimpleNamespace(current_objective="20x"), + cameras={camera_id: object() for camera_id in available_camera_ids}, + ) + by_name = {ch.name: ch for ch in channels} + return SimpleNamespace( + microscope=microscope, + get_channel_by_name=lambda objective, name: by_name.get(name), + ) + + +class TestMakeChannelDecorator: + def test_single_camera_returns_bare_name_no_icon(self): + controller = _fake_live_controller(ONE_CAM, [_Ch("DAPI")], available_camera_ids=[1]) + decorate = _make_channel_decorator(lambda: controller) + assert decorate("DAPI") == ("DAPI", None, True) + + def test_no_registry_returns_bare_name_no_icon(self): + controller = _fake_live_controller(None, [_Ch("DAPI")], available_camera_ids=[1]) + decorate = _make_channel_decorator(lambda: controller) + assert decorate("DAPI") == ("DAPI", None, True) + + def test_unknown_channel_returns_bare_name(self): + controller = _fake_live_controller(TWO_CAM, [], available_camera_ids=[1, 2]) + decorate = _make_channel_decorator(lambda: controller) + assert decorate("Ghost") == ("Ghost", None, True) + + def test_secondary_channel_gets_suffix_icon_and_enabled(self, qtbot): + controller = _fake_live_controller(TWO_CAM, [_Ch("BF Color", camera=2)], available_camera_ids=[1, 2]) + decorate = _make_channel_decorator(lambda: controller) + label, icon, enabled = decorate("BF Color") + assert label == "BF Color — Side Camera" + assert icon is not None and not icon.isNull() + assert enabled is True + + def test_primary_channel_keeps_bare_name_but_gets_icon(self, qtbot): + controller = _fake_live_controller(TWO_CAM, [_Ch("DAPI", camera=None)], available_camera_ids=[1, 2]) + decorate = _make_channel_decorator(lambda: controller) + label, icon, enabled = decorate("DAPI") + assert label == "DAPI" + assert icon is not None and not icon.isNull() + assert enabled is True + + def test_missing_camera_marks_disabled(self, qtbot): + # Camera 2 is declared in the registry but failed to open (not in microscope.cameras) + controller = _fake_live_controller(TWO_CAM, [_Ch("BF Color", camera=2)], available_camera_ids=[1]) + decorate = _make_channel_decorator(lambda: controller) + label, icon, enabled = decorate("BF Color") + assert label == "BF Color — Side Camera" + assert enabled is False + + +# --------------------------------------------------------------------------- +# LiveControlWidget dropdown wiring: userData carries the bare name +# --------------------------------------------------------------------------- + + +class _DropdownStub: + """LiveControlWidget-shaped stub exposing only what the dropdown helpers use.""" + + _channel_registry = LiveControlWidget._channel_registry + _multi_camera = LiveControlWidget._multi_camera + _add_mode_item = LiveControlWidget._add_mode_item + _select_dropdown_entry = LiveControlWidget._select_dropdown_entry + + def __init__(self, registry, available_camera_ids): + self.dropdown_modeSelection = QComboBox() + self.liveController = MagicMock() + self.liveController.microscope.config_repo.get_camera_registry.return_value = registry + self.liveController.microscope.cameras = {camera_id: object() for camera_id in available_camera_ids} + + +class TestLiveControlDropdown: + def test_single_camera_entries_are_bare_names_no_icons(self, qtbot): + widget = _DropdownStub(ONE_CAM, available_camera_ids=[1]) + widget._add_mode_item(_Ch("DAPI")) + widget._add_mode_item(_Ch("BF LED matrix full")) + combo = widget.dropdown_modeSelection + assert [combo.itemText(i) for i in range(combo.count())] == ["DAPI", "BF LED matrix full"] + assert [combo.itemData(i) for i in range(combo.count())] == ["DAPI", "BF LED matrix full"] + assert all(combo.itemIcon(i).isNull() for i in range(combo.count())) + + def test_multi_camera_entries_decorated_but_userdata_is_bare_name(self, qtbot): + widget = _DropdownStub(TWO_CAM, available_camera_ids=[1, 2]) + widget._add_mode_item(_Ch("DAPI", camera=None)) + widget._add_mode_item(_Ch("BF Color", camera=2)) + combo = widget.dropdown_modeSelection + assert combo.itemText(0) == "DAPI" + assert combo.itemText(1) == "BF Color — Side Camera" + assert combo.itemData(0) == "DAPI" + assert combo.itemData(1) == "BF Color" + assert not combo.itemIcon(0).isNull() + assert not combo.itemIcon(1).isNull() + + def test_select_dropdown_entry_finds_by_bare_name(self, qtbot): + widget = _DropdownStub(TWO_CAM, available_camera_ids=[1, 2]) + widget._add_mode_item(_Ch("DAPI", camera=None)) + widget._add_mode_item(_Ch("BF Color", camera=2)) + widget._select_dropdown_entry("BF Color") + assert widget.dropdown_modeSelection.currentIndex() == 1 + widget._select_dropdown_entry("nonexistent") # no-op, keeps selection + assert widget.dropdown_modeSelection.currentIndex() == 1 + + def test_unavailable_camera_entry_is_disabled(self, qtbot): + widget = _DropdownStub(TWO_CAM, available_camera_ids=[1]) # camera 2 failed to open + widget._add_mode_item(_Ch("DAPI", camera=None)) + widget._add_mode_item(_Ch("BF Color", camera=2)) + model = widget.dropdown_modeSelection.model() + assert model.item(0).isEnabled() + assert not model.item(1).isEnabled() diff --git a/software/tests/control/test_channel_sequence.py b/software/tests/control/test_channel_sequence.py index d1668bdea..5bc9281e9 100644 --- a/software/tests/control/test_channel_sequence.py +++ b/software/tests/control/test_channel_sequence.py @@ -304,3 +304,76 @@ def test_hover_move_without_button_passes_through(self, qtbot, tmp_path): lw, ctrl = _controller(qtbot, ["a", "b", "c"], path=str(tmp_path / "c.yaml")) hover = QMouseEvent(QEvent.MouseMove, QPointF(5, 5), Qt.NoButton, Qt.NoButton, Qt.NoModifier) assert ctrl.eventFilter(lw.viewport(), hover) is False + + +def _decorated_controller(qtbot, names, decorate, path): + lw = QListWidget() + qtbot.addWidget(lw) + ctrl = enable_channel_sequence(lw, lambda: list(names), "flexible", cache_path=path, decorate=decorate) + return lw, ctrl + + +def _suffix_decorator(disabled=()): + """Decorate every channel as ' — Cam'; names in `disabled` are unavailable.""" + + def decorate(name): + return f"{name} — Cam", None, name not in disabled + + return decorate + + +class TestDecoration: + """Task 9 invariant: item text may be decorated, but identity is the bare + channel name stored in Qt.UserRole; ordering/persistence stay name-based.""" + + def test_items_store_bare_name_in_userrole(self, qtbot, tmp_path): + lw, ctrl = _controller(qtbot, ["a", "b"], path=str(tmp_path / "c.yaml")) + assert [lw.item(i).data(Qt.UserRole) for i in range(lw.count())] == ["a", "b"] + + def test_decorated_text_but_bare_name_identity(self, qtbot, tmp_path): + lw, ctrl = _decorated_controller(qtbot, ["a", "b"], _suffix_decorator(), str(tmp_path / "c.yaml")) + assert _rows(lw) == ["a — Cam", "b — Cam"] + assert [lw.item(i).data(Qt.UserRole) for i in range(lw.count())] == ["a", "b"] + + def test_selection_and_cache_use_bare_names(self, qtbot, tmp_path): + path = str(tmp_path / "c.yaml") + lw, ctrl = _decorated_controller(qtbot, ["a", "b", "c"], _suffix_decorator(), path) + lw.item(1).setSelected(True) # b + lw.item(0).setSelected(True) # a + assert ctrl.ordered_selected_names() == ["b", "a"] + assert cs.load_cached_order("flexible", path=path) == ["b", "a"] + + def test_set_included_order_accepts_bare_names_with_decoration(self, qtbot, tmp_path): + lw, ctrl = _decorated_controller(qtbot, ["a", "b", "c"], _suffix_decorator(), str(tmp_path / "c.yaml")) + ctrl.set_included_order(["c", "a"]) + assert ctrl.ordered_selected_names() == ["c", "a"] + assert _rows(lw)[:2] == ["c — Cam", "a — Cam"] + + def test_arrow_click_reorders_by_bare_name_with_decoration(self, qtbot, tmp_path): + lw, ctrl = _decorated_controller(qtbot, ["a", "b", "c"], _suffix_decorator(), str(tmp_path / "c.yaml")) + ctrl.set_included_order(["a", "b", "c"]) + delegate = lw.itemDelegate() + opt = QStyleOptionViewItem() + opt.rect = QRect(0, 0, 200, 20) + row = next(i for i in range(lw.count()) if lw.item(i).data(Qt.UserRole) == "b") + index = lw.model().index(row, 0) + up_rect, _ = ChannelOrderDelegate._arrow_rects(opt.rect) + + handled = delegate.editorEvent(_release_at(up_rect.center()), lw.model(), opt, index) + assert handled + assert ctrl.ordered_selected_names() == ["b", "a", "c"] + + def test_unavailable_channel_disabled_with_tooltip(self, qtbot, tmp_path): + lw, ctrl = _decorated_controller(qtbot, ["a", "b"], _suffix_decorator(disabled=["b"]), str(tmp_path / "c.yaml")) + item_b = next(lw.item(i) for i in range(lw.count()) if lw.item(i).data(Qt.UserRole) == "b") + assert not item_b.flags() & Qt.ItemIsEnabled + assert not item_b.flags() & Qt.ItemIsSelectable + assert item_b.toolTip() != "" + + def test_unavailable_channel_excluded_from_ordered_names(self, qtbot, tmp_path): + path = str(tmp_path / "c.yaml") + cs.save_cached_order("flexible", ["b", "a"], path=path) # b was included last session + lw, ctrl = _decorated_controller(qtbot, ["a", "b"], _suffix_decorator(disabled=["b"]), path) + assert ctrl.ordered_selected_names() == ["a"] # b's camera is gone: not acquirable + item_b = next(lw.item(i) for i in range(lw.count()) if lw.item(i).data(Qt.UserRole) == "b") + assert not item_b.isSelected() From d078e3b0c6e4540260c984c12a7b15728fa9aac1 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 07:07:46 -0700 Subject: [PATCH 13/52] fix(gui): tooltip on disabled dropdown camera entries; pin bare-name dropdown reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups for the channel display labeling task: - Disabled live-dropdown entries (camera declared but unavailable) now carry the same tooltip as the greyed list rows. The string is promoted to a public UNAVAILABLE_CAMERA_TOOLTIP in control.channel_sequence and shared, not duplicated. - Unify both live widgets on the 'itemData(i) or itemText(i)' reader idiom and add tests that wire the production activated-lambda, fire it on a decorated entry, and assert the bare channel name (not the decorated label) is what reaches channel lookup — pinning the exact lines where a decorated label could otherwise leak into get_channel_by_name. Co-Authored-By: Claude Fable 5 --- software/control/channel_sequence.py | 5 ++- software/control/widgets.py | 11 +++-- .../control/test_channel_display_labels.py | 40 ++++++++++++++++++- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/software/control/channel_sequence.py b/software/control/channel_sequence.py index 0996b0982..dd1674ffb 100644 --- a/software/control/channel_sequence.py +++ b/software/control/channel_sequence.py @@ -19,7 +19,8 @@ _CACHE_PATH = "cache/channel_sequence.yaml" -_UNAVAILABLE_CAMERA_TOOLTIP = "This channel's camera is declared in cameras.yaml but is not available." +# Shared by the channel lists here and the live-view dropdowns in control.widgets. +UNAVAILABLE_CAMERA_TOOLTIP = "This channel's camera is declared in cameras.yaml but is not available." def _item_name(item): @@ -311,7 +312,7 @@ def _rebuild(self): item.setIcon(icon) if not enabled: item.setFlags(item.flags() & ~Qt.ItemIsEnabled & ~Qt.ItemIsSelectable) - item.setToolTip(_UNAVAILABLE_CAMERA_TOOLTIP) + item.setToolTip(UNAVAILABLE_CAMERA_TOOLTIP) self._disabled_names.add(name) self._list.addItem(item) for i in range(self._list.count()): diff --git a/software/control/widgets.py b/software/control/widgets.py index 5e8bb58e9..cd67e33e1 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -23,7 +23,7 @@ from control.core.geometry_utils import get_effective_well_size, calculate_well_coverage from control.microcontroller import Microcontroller from control.piezo import PiezoStage -from control.channel_sequence import enable_channel_sequence +from control.channel_sequence import UNAVAILABLE_CAMERA_TOOLTIP, enable_channel_sequence import control.utils as utils import control._def # Import module for runtime access to MCP-modifiable settings from squid.abc import AbstractStage, AbstractCamera, AbstractFilterWheelController @@ -4154,6 +4154,7 @@ def _add_mode_item(self, config): index = self.dropdown_modeSelection.count() - 1 item = self.dropdown_modeSelection.model().item(index) item.setEnabled(False) + item.setToolTip(UNAVAILABLE_CAMERA_TOOLTIP) def _select_dropdown_entry(self, config_name: str): index = self.dropdown_modeSelection.findData(config_name) @@ -4283,9 +4284,12 @@ def add_components(self, show_trigger_options, show_display_options, show_autole self.entry_displayFPS.valueChanged.connect(self.streamHandler.set_display_fps) self.slider_resolutionScaling.valueChanged.connect(self.streamHandler.set_display_resolution_scaling) self.slider_resolutionScaling.valueChanged.connect(self.liveController.set_display_resolution_scaling) - # Pass the bare channel name from userData (item text may carry camera decoration). + # Pass the bare channel name from userData (item text may carry camera decoration); + # fall back to the text for entries populated without userData. self.dropdown_modeSelection.activated.connect( - lambda index: self.select_new_microscope_mode_by_name(self.dropdown_modeSelection.itemData(index)) + lambda index: self.select_new_microscope_mode_by_name( + self.dropdown_modeSelection.itemData(index) or self.dropdown_modeSelection.itemText(index) + ) ) self.dropdown_triggerManu.currentIndexChanged.connect(self.update_trigger_mode) self.btn_live.clicked.connect(self.toggle_live) @@ -11657,6 +11661,7 @@ def _add_mode_item(self, config): index = self.dropdown_modeSelection.count() - 1 item = self.dropdown_modeSelection.model().item(index) item.setEnabled(False) + item.setToolTip(UNAVAILABLE_CAMERA_TOOLTIP) def _select_dropdown_entry(self, config_name: str): index = self.dropdown_modeSelection.findData(config_name) diff --git a/software/tests/control/test_channel_display_labels.py b/software/tests/control/test_channel_display_labels.py index 7816af0e6..99bf89e96 100644 --- a/software/tests/control/test_channel_display_labels.py +++ b/software/tests/control/test_channel_display_labels.py @@ -10,6 +10,7 @@ from qtpy.QtWidgets import QComboBox +from control.channel_sequence import UNAVAILABLE_CAMERA_TOOLTIP from control.models.camera_registry import CameraDefinition, CameraRegistryConfig from control.widgets import ( CAMERA_DOT_COLORS, @@ -168,10 +169,47 @@ def test_select_dropdown_entry_finds_by_bare_name(self, qtbot): widget._select_dropdown_entry("nonexistent") # no-op, keeps selection assert widget.dropdown_modeSelection.currentIndex() == 1 - def test_unavailable_camera_entry_is_disabled(self, qtbot): + def test_unavailable_camera_entry_is_disabled_with_tooltip(self, qtbot): widget = _DropdownStub(TWO_CAM, available_camera_ids=[1]) # camera 2 failed to open widget._add_mode_item(_Ch("DAPI", camera=None)) widget._add_mode_item(_Ch("BF Color", camera=2)) model = widget.dropdown_modeSelection.model() assert model.item(0).isEnabled() + assert model.item(0).toolTip() == "" assert not model.item(1).isEnabled() + assert model.item(1).toolTip() == UNAVAILABLE_CAMERA_TOOLTIP + + +class TestDropdownActivatedReader: + """Pins the exact reader idiom both live widgets connect to `activated`: + `itemData(index) or itemText(index)`. A revert to the old activated[str] + idiom would feed the decorated label ("BF Color — Side Camera") into + get_channel_by_name while the rest of the suite stayed green.""" + + @staticmethod + def _wire_production_reader(combo, captured): + # Same lambda LiveControlWidget.add_components connects (NapariLiveWidget's + # handler reads the identical expression from its config_index argument). + combo.activated.connect(lambda index: captured.append(combo.itemData(index) or combo.itemText(index))) + + def test_activated_on_decorated_entry_passes_bare_name(self, qtbot): + widget = _DropdownStub(TWO_CAM, available_camera_ids=[1, 2]) + widget._add_mode_item(_Ch("DAPI", camera=None)) + widget._add_mode_item(_Ch("BF Color", camera=2)) + combo = widget.dropdown_modeSelection + assert combo.itemText(1) == "BF Color — Side Camera" # decorated on screen + captured = [] + self._wire_production_reader(combo, captured) + combo.activated.emit(1) + assert captured == ["BF Color"] # bare name reaches select_new_microscope_mode_by_name + + def test_activated_falls_back_to_item_text_when_no_userdata(self, qtbot): + # Robustness half of the idiom: an entry populated without userData + # (legacy population) must still resolve via its (bare) text. + widget = _DropdownStub(ONE_CAM, available_camera_ids=[1]) + combo = widget.dropdown_modeSelection + combo.addItem("DAPI") # no userData + captured = [] + self._wire_production_reader(combo, captured) + combo.activated.emit(0) + assert captured == ["DAPI"] From 7066ff2abc49e55c7a60b57267aee7bcc75f5270 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 07:20:10 -0700 Subject: [PATCH 14/52] test(gui): pin the real dropdown readers; extract activated handler to named method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review fix: the previous reader tests exercised a hand-written copy of the reader expression, so reverting either production reader passed the suite. LiveControlWidget's activated lambda is now a named method (_on_mode_dropdown_activated) and the tests borrow the REAL methods — LiveControlWidget._on_mode_dropdown_activated and NapariLiveWidget.select_new_microscope_mode_by_name — onto stubs, fire them on a decorated entry, and assert the bare channel name reaches selection / channel lookup. Verified by temporarily reverting each production reader to the old itemText idiom: the corresponding test fails with ['BF Color — Side Camera'] != ['BF Color']. Also parametrizes the disabled-entry tooltip test over both widgets' copies of _add_mode_item (previously only LiveControlWidget's copy was asserted). Co-Authored-By: Claude Fable 5 --- software/control/widgets.py | 16 +-- .../control/test_channel_display_labels.py | 109 +++++++++++++----- 2 files changed, 91 insertions(+), 34 deletions(-) diff --git a/software/control/widgets.py b/software/control/widgets.py index cd67e33e1..1e398d683 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -4161,6 +4161,14 @@ def _select_dropdown_entry(self, config_name: str): if index >= 0: self.dropdown_modeSelection.setCurrentIndex(index) + def _on_mode_dropdown_activated(self, index: int): + """`activated` handler: pass the bare channel name from userData (item + text may carry camera decoration); fall back to the text for entries + populated without userData.""" + self.select_new_microscope_mode_by_name( + self.dropdown_modeSelection.itemData(index) or self.dropdown_modeSelection.itemText(index) + ) + def add_components(self, show_trigger_options, show_display_options, show_autolevel, autolevel, stretch): # line 0: trigger mode self.dropdown_triggerManu = QComboBox() @@ -4284,13 +4292,7 @@ def add_components(self, show_trigger_options, show_display_options, show_autole self.entry_displayFPS.valueChanged.connect(self.streamHandler.set_display_fps) self.slider_resolutionScaling.valueChanged.connect(self.streamHandler.set_display_resolution_scaling) self.slider_resolutionScaling.valueChanged.connect(self.liveController.set_display_resolution_scaling) - # Pass the bare channel name from userData (item text may carry camera decoration); - # fall back to the text for entries populated without userData. - self.dropdown_modeSelection.activated.connect( - lambda index: self.select_new_microscope_mode_by_name( - self.dropdown_modeSelection.itemData(index) or self.dropdown_modeSelection.itemText(index) - ) - ) + self.dropdown_modeSelection.activated.connect(self._on_mode_dropdown_activated) self.dropdown_triggerManu.currentIndexChanged.connect(self.update_trigger_mode) self.btn_live.clicked.connect(self.toggle_live) self.entry_exposureTime.valueChanged.connect(self.update_config_exposure_time) diff --git a/software/tests/control/test_channel_display_labels.py b/software/tests/control/test_channel_display_labels.py index 99bf89e96..b297a2b08 100644 --- a/software/tests/control/test_channel_display_labels.py +++ b/software/tests/control/test_channel_display_labels.py @@ -8,6 +8,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock +import pytest from qtpy.QtWidgets import QComboBox from control.channel_sequence import UNAVAILABLE_CAMERA_TOOLTIP @@ -15,6 +16,7 @@ from control.widgets import ( CAMERA_DOT_COLORS, LiveControlWidget, + NapariLiveWidget, _make_channel_decorator, camera_dot_icon, channel_display_label, @@ -124,7 +126,7 @@ def test_missing_camera_marks_disabled(self, qtbot): class _DropdownStub: - """LiveControlWidget-shaped stub exposing only what the dropdown helpers use.""" + """LiveControlWidget-shaped stub borrowing the REAL dropdown helper methods.""" _channel_registry = LiveControlWidget._channel_registry _multi_camera = LiveControlWidget._multi_camera @@ -138,6 +140,17 @@ def __init__(self, registry, available_camera_ids): self.liveController.microscope.cameras = {camera_id: object() for camera_id in available_camera_ids} +class _NapariDropdownStub(_DropdownStub): + """Same stub shape, borrowing NapariLiveWidget's copies of the methods — + the two widgets' implementations are meant to stay byte-identical, and + binding each class's own methods catches a one-copy edit.""" + + _channel_registry = NapariLiveWidget._channel_registry + _multi_camera = NapariLiveWidget._multi_camera + _add_mode_item = NapariLiveWidget._add_mode_item + _select_dropdown_entry = NapariLiveWidget._select_dropdown_entry + + class TestLiveControlDropdown: def test_single_camera_entries_are_bare_names_no_icons(self, qtbot): widget = _DropdownStub(ONE_CAM, available_camera_ids=[1]) @@ -169,8 +182,9 @@ def test_select_dropdown_entry_finds_by_bare_name(self, qtbot): widget._select_dropdown_entry("nonexistent") # no-op, keeps selection assert widget.dropdown_modeSelection.currentIndex() == 1 - def test_unavailable_camera_entry_is_disabled_with_tooltip(self, qtbot): - widget = _DropdownStub(TWO_CAM, available_camera_ids=[1]) # camera 2 failed to open + @pytest.mark.parametrize("stub_class", [_DropdownStub, _NapariDropdownStub]) + def test_unavailable_camera_entry_is_disabled_with_tooltip(self, qtbot, stub_class): + widget = stub_class(TWO_CAM, available_camera_ids=[1]) # camera 2 failed to open widget._add_mode_item(_Ch("DAPI", camera=None)) widget._add_mode_item(_Ch("BF Color", camera=2)) model = widget.dropdown_modeSelection.model() @@ -180,36 +194,77 @@ def test_unavailable_camera_entry_is_disabled_with_tooltip(self, qtbot): assert model.item(1).toolTip() == UNAVAILABLE_CAMERA_TOOLTIP +class _LiveActivatedStub(_DropdownStub): + """Adds the REAL LiveControlWidget activated handler plus a recording + select_new_microscope_mode_by_name, so the test exercises the production + reader itself (not a copy of its expression).""" + + _on_mode_dropdown_activated = LiveControlWidget._on_mode_dropdown_activated + + def __init__(self, registry, available_camera_ids): + super().__init__(registry, available_camera_ids) + self.selected_names = [] + + def select_new_microscope_mode_by_name(self, config_name): + self.selected_names.append(config_name) + + +class _NapariActivatedStub(_NapariDropdownStub): + """Runs the REAL NapariLiveWidget.select_new_microscope_mode_by_name; the + recording seam is liveController.get_channel_by_name (returning None stops + the handler before set_microscope_mode/update_ui_for_mode).""" + + select_new_microscope_mode_by_name = NapariLiveWidget.select_new_microscope_mode_by_name + + def __init__(self, registry, available_camera_ids): + super().__init__(registry, available_camera_ids) + self.objectiveStore = SimpleNamespace(current_objective="20x") + self._log = MagicMock() + self.looked_up_names = [] + + def record_lookup(objective, name): + self.looked_up_names.append(name) + return None + + self.liveController.get_channel_by_name = record_lookup + + class TestDropdownActivatedReader: - """Pins the exact reader idiom both live widgets connect to `activated`: - `itemData(index) or itemText(index)`. A revert to the old activated[str] - idiom would feed the decorated label ("BF Color — Side Camera") into - get_channel_by_name while the rest of the suite stayed green.""" - - @staticmethod - def _wire_production_reader(combo, captured): - # Same lambda LiveControlWidget.add_components connects (NapariLiveWidget's - # handler reads the identical expression from its config_index argument). - combo.activated.connect(lambda index: captured.append(combo.itemData(index) or combo.itemText(index))) - - def test_activated_on_decorated_entry_passes_bare_name(self, qtbot): - widget = _DropdownStub(TWO_CAM, available_camera_ids=[1, 2]) + """Exercises the two REAL production dropdown readers — the exact code where + a decorated label could leak into get_channel_by_name: + LiveControlWidget._on_mode_dropdown_activated (connected to `activated` in + add_components) and NapariLiveWidget.select_new_microscope_mode_by_name. + Reverting either to the old activated[str]/itemText idiom fails these.""" + + def test_live_widget_activated_handler_passes_bare_name(self, qtbot): + widget = _LiveActivatedStub(TWO_CAM, available_camera_ids=[1, 2]) widget._add_mode_item(_Ch("DAPI", camera=None)) widget._add_mode_item(_Ch("BF Color", camera=2)) combo = widget.dropdown_modeSelection assert combo.itemText(1) == "BF Color — Side Camera" # decorated on screen - captured = [] - self._wire_production_reader(combo, captured) + # Same connection add_components makes, then a user activation: + combo.activated.connect(widget._on_mode_dropdown_activated) combo.activated.emit(1) - assert captured == ["BF Color"] # bare name reaches select_new_microscope_mode_by_name + assert widget.selected_names == ["BF Color"] # bare name, not the label - def test_activated_falls_back_to_item_text_when_no_userdata(self, qtbot): + def test_live_widget_activated_handler_falls_back_to_item_text(self, qtbot): # Robustness half of the idiom: an entry populated without userData # (legacy population) must still resolve via its (bare) text. - widget = _DropdownStub(ONE_CAM, available_camera_ids=[1]) - combo = widget.dropdown_modeSelection - combo.addItem("DAPI") # no userData - captured = [] - self._wire_production_reader(combo, captured) - combo.activated.emit(0) - assert captured == ["DAPI"] + widget = _LiveActivatedStub(ONE_CAM, available_camera_ids=[1]) + widget.dropdown_modeSelection.addItem("DAPI") # no userData + widget._on_mode_dropdown_activated(0) + assert widget.selected_names == ["DAPI"] + + def test_napari_widget_handler_passes_bare_name_to_lookup(self, qtbot): + widget = _NapariActivatedStub(TWO_CAM, available_camera_ids=[1, 2]) + widget._add_mode_item(_Ch("DAPI", camera=None)) + widget._add_mode_item(_Ch("BF Color", camera=2)) + assert widget.dropdown_modeSelection.itemText(1) == "BF Color — Side Camera" + widget.select_new_microscope_mode_by_name(1) # activated passes the index + assert widget.looked_up_names == ["BF Color"] # bare name reaches channel lookup + + def test_napari_widget_handler_falls_back_to_item_text(self, qtbot): + widget = _NapariActivatedStub(ONE_CAM, available_camera_ids=[1]) + widget.dropdown_modeSelection.addItem("DAPI") # no userData + widget.select_new_microscope_mode_by_name(0) + assert widget.looked_up_names == ["DAPI"] From b994af217bd8d36683ec644393901b7b20d9a440 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 07:58:01 -0700 Subject: [PATCH 15/52] feat(gui): per-camera trigger options, settings tabs, and camera-change refresh bridge The trigger dropdown is now rebuilt from the active camera's capabilities: Hardware is only offered when that camera's trigger line is wired (cameras.yaml hardware_trigger), and the selection resyncs to the mode the LiveController actually holds - so a dropdown call that lost a race with a camera switch can no longer leave the UI claiming a mode the hardware is not in. The repopulation is guarded (blockSignals + is_switching_mode, with the matching early-return added to update_trigger_mode) so it never turns into an MCU trigger-mode command on top of the one set_active_camera already sent. Microscope's camera-change listener is bridged to the GUI thread with a queued QTimer.singleShot; the handler refreshes the live control widget (and the napari live widget when present) and redraws the nav-viewer FOV, since sensor geometry differs per camera. Multi-camera builds get one CameraSettingsWidget per concrete camera - never the facade, because settings are per-camera identity state - each on its own tab named from cameras.yaml, with the primary tab renamed to match. Single camera systems are unchanged: one plain "Camera" tab, Software + Hardware. Co-Authored-By: Claude Fable 5 --- software/control/gui_hcs.py | 57 ++++- software/control/widgets.py | 104 +++++++-- .../control/test_HighContentScreeningGui.py | 55 +++++ .../control/test_microscope_multi_camera.py | 9 + .../test_trigger_options_per_camera.py | 199 ++++++++++++++++++ 5 files changed, 407 insertions(+), 17 deletions(-) create mode 100644 software/tests/control/test_trigger_options_per_camera.py diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 1aea1168a..13c4d88ad 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -703,6 +703,8 @@ def __init__( self.spinningDiskConfocalWidget: Optional[widgets.SpinningDiskConfocalWidget] = None self.nl5Wdiget: Optional[NL5Widget] = None self.cameraSettingWidget: Optional[widgets.CameraSettingsWidget] = None + # Settings widgets for the non-primary cameras of a multi-camera build, keyed by camera id. + self.cameraSettingWidgets_extra: Dict[int, widgets.CameraSettingsWidget] = {} self.profileWidget: Optional[widgets.ProfileWidget] = None self.liveControlWidget: Optional[widgets.LiveControlWidget] = None self.navigationWidget: Optional[widgets.NavigationWidget] = None @@ -942,6 +944,21 @@ def load_widgets(self): self._restore_cached_camera_settings() + # Dual-camera: one settings widget per non-primary concrete camera. Each drives + # its own camera object (settings are per-camera identity state), so a camera + # switch never retargets an already-built widget. Built after the cache restore + # so their dropdowns read back the restored binning / pixel format. + if self.microscope.has_multiple_cameras(): + for camera_id, concrete_camera in sorted(self.microscope.cameras.items()): + if camera_id == PRIMARY_CAMERA_ID: + continue + self.cameraSettingWidgets_extra[camera_id] = widgets.CameraSettingsWidget( + concrete_camera, + include_gain_exposure_time=False, + include_camera_temperature_setting=False, + include_camera_auto_wb_setting=True, + ) + self.profileWidget = widgets.ProfileWidget(self.microscope.config_repo) self.liveControlWidget = widgets.LiveControlWidget( self.streamHandler, @@ -1328,6 +1345,14 @@ def _alignment_provide_position(self): pos = self.stage.get_pos() self.alignmentWidget.set_current_position(pos.x_mm, pos.y_mm) + @staticmethod + def _camera_tab_name(camera_id: int, registry) -> str: + """Tab label for a camera settings widget: the cameras.yaml name if it has one.""" + definition = registry.get_camera_by_id(camera_id) if registry is not None else None + if definition is not None and definition.name: + return definition.name + return f"Camera {camera_id}" + def setupCameraTabWidget(self): if not USE_NAPARI_FOR_LIVE_CONTROL or self.live_only_mode: self.cameraTabWidget.addTab(self.navigationWidget, "Stages") @@ -1339,7 +1364,15 @@ def setupCameraTabWidget(self): self.cameraTabWidget.addTab(self.spinningDiskConfocalWidget, "Confocal") if self.emission_filter_wheel: self.cameraTabWidget.addTab(self.filterControllerWidget, "Emission Filter") - self.cameraTabWidget.addTab(self.cameraSettingWidget, "Camera") + # Multi-camera builds label each camera tab with its cameras.yaml name so the + # user can tell them apart; single-camera builds keep the plain "Camera" tab. + multi_camera = self.microscope.has_multiple_cameras() + registry = self.microscope.config_repo.get_camera_registry() if multi_camera else None + self.cameraTabWidget.addTab( + self.cameraSettingWidget, self._camera_tab_name(PRIMARY_CAMERA_ID, registry) if multi_camera else "Camera" + ) + for camera_id, extra_widget in self.cameraSettingWidgets_extra.items(): + self.cameraTabWidget.addTab(extra_widget, self._camera_tab_name(camera_id, registry)) self.cameraTabWidget.addTab(self.autofocusWidget, "Contrast AF") if SUPPORT_LASER_AUTOFOCUS: self.cameraTabWidget.addTab(self.laserAutofocusControlWidget, "Laser AF") @@ -1506,6 +1539,13 @@ def make_connections(self): self.liveControlWidget.signal_start_live.connect(self.onStartLive) self.liveControlWidget.update_camera_settings() + # Dual-camera: refresh GUI state on active-camera switch. The listener fires on + # whichever thread called set_active_camera (GUI for live, worker for acquisitions), + # so hop to the GUI thread with a queued singleShot (fire-and-forget is fine here). + self.microscope.add_camera_change_listener( + lambda camera_id: QTimer.singleShot(0, lambda: self._on_active_camera_changed(camera_id)) + ) + self.connectSlidePositionController() self.navigationViewer.signal_coordinates_clicked.connect(self.move_from_click_mm) @@ -1918,6 +1958,21 @@ def _on_live_controller_warning(self, message: str) -> None: self._live_warning_box = box box.show() + def _on_active_camera_changed(self, camera_id: int) -> None: + """GUI-thread handler for a Microscope active-camera switch. + + Runs from a queued QTimer.singleShot, so exceptions would be swallowed by Qt - + catch and log them here. + """ + try: + self.liveControlWidget.on_active_camera_changed(camera_id) + if self.napariLiveWidget is not None: + self.napariLiveWidget.on_active_camera_changed(camera_id) + # Sensor size / pixel size differ per camera, so the FOV rectangle changes. + self.navigationViewer.redraw_fov() + except Exception: + self.log.exception("Error handling active-camera change in GUI") + def _show_laser_engine_dialog(self, channel_keys: list) -> None: """Show a non-cancelable modal progress dialog while the worker waits for the laser engine to reach ACTIVE on the requested channel(s). diff --git a/software/control/widgets.py b/software/control/widgets.py index 1e398d683..4e9df9b29 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -4101,14 +4101,17 @@ def __init__( else: self.currentConfiguration = channels[0] + # flag used to prevent from settings being set by twice - from both mode change slot and value change slot; + # another way is to use blockSignals(True). Initialized before add_components because the widget builders + # (refresh_trigger_options) already use it. + self.is_switching_mode = False + self.add_components(show_trigger_options, show_display_options, show_autolevel, autolevel, stretch) self.setFrameStyle(QFrame.Panel | QFrame.Raised) if self.currentConfiguration: self.liveController.set_microscope_mode(self.currentConfiguration) self.update_ui_for_mode(self.currentConfiguration) - self.is_switching_mode = False # flag used to prevent from settings being set by twice - from both mode change slot and value change slot; another way is to use blockSignals(True) - # Wire 'Apply in Live' checkbox enable state to laser AF reference availability. laser_af = getattr(self.liveController.microscope, "laser_autofocus_controller", None) if laser_af is not None: @@ -4169,16 +4172,48 @@ def _on_mode_dropdown_activated(self, index: int): self.dropdown_modeSelection.itemData(index) or self.dropdown_modeSelection.itemText(index) ) + def refresh_trigger_options(self): + """Repopulate the trigger dropdown for the active camera's capabilities. + + A camera whose trigger line is not wired (cameras.yaml hardware_trigger: false) + must not offer Hardware, so the option list is rebuilt on every camera switch + and the selection is resynced to the mode the LiveController actually holds. + """ + try: + self.is_switching_mode = True + self.dropdown_triggerManu.blockSignals(True) + self.dropdown_triggerManu.clear() + trigger_modes = [TriggerMode.SOFTWARE] + if self.camera.supports_hardware_trigger(): + trigger_modes.append(TriggerMode.HARDWARE) + if ENABLE_RECORDING: + trigger_modes.append(TriggerMode.CONTINUOUS) + self.dropdown_triggerManu.addItems(trigger_modes) + self.dropdown_triggerManu.setCurrentText(self.liveController.trigger_mode) + finally: + self.dropdown_triggerManu.blockSignals(False) + self.is_switching_mode = False + + def on_active_camera_changed(self, camera_id: int): + """GUI-thread slot invoked (queued) after Microscope.set_active_camera.""" + self.refresh_trigger_options() + # Exposure limits differ per camera; re-clamp the spinbox. Guarded: Qt emits + # valueChanged when the held value falls outside the new range, and a clamp + # forced by a camera switch must not be persisted as a user edit of the channel. + try: + self.is_switching_mode = True + low, high = self.camera.get_exposure_limits() + self.entry_exposureTime.setMinimum(low) + self.entry_exposureTime.setMaximum(high) + finally: + self.is_switching_mode = False + def add_components(self, show_trigger_options, show_display_options, show_autolevel, autolevel, stretch): - # line 0: trigger mode + # line 0: trigger mode (options depend on the active camera - see refresh_trigger_options) self.dropdown_triggerManu = QComboBox() - trigger_modes = [TriggerMode.SOFTWARE, TriggerMode.HARDWARE] - if ENABLE_RECORDING: - trigger_modes.append(TriggerMode.CONTINUOUS) - self.dropdown_triggerManu.addItems(trigger_modes) - self.dropdown_triggerManu.setCurrentText(self.camera.get_acquisition_mode().value) sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.dropdown_triggerManu.setSizePolicy(sizePolicy) + self.refresh_trigger_options() # line 1: fps self.entry_triggerFPS = QDoubleSpinBox() @@ -4483,6 +4518,11 @@ def _safe_z_offset_value(raw) -> float: return float(raw) def update_trigger_mode(self): + # refresh_trigger_options repopulates this dropdown on a camera switch; the + # resulting index changes must not be mistaken for a user choice and re-sent + # to the MCU on top of the mode set_active_camera already applied. + if self.is_switching_mode: + return self.liveController.set_trigger_mode(self.dropdown_triggerManu.currentText()) def update_config_exposure_time(self, new_value): @@ -11747,15 +11787,9 @@ def initControlWidgets(self, show_trigger_options, show_display_options, show_au lambda v: self.label_illuminationIntensity.setText(str(v) + "%") ) - # Trigger mode + # Trigger mode (options depend on the active camera - see refresh_trigger_options) self.dropdown_triggerMode = QComboBox() - trigger_modes = [ - ("Software", TriggerMode.SOFTWARE), - ("Hardware", TriggerMode.HARDWARE), - ("Continuous", TriggerMode.CONTINUOUS), - ] - for display_name, mode in trigger_modes: - self.dropdown_triggerMode.addItem(display_name, mode) + self.refresh_trigger_options() self.dropdown_triggerMode.currentIndexChanged.connect(self.on_trigger_mode_changed) # Trigger FPS @@ -12016,7 +12050,45 @@ def refresh_mode_list(self): self.update_ui_for_mode(first_config) self.liveController.set_microscope_mode(first_config) + def refresh_trigger_options(self): + """Repopulate the trigger dropdown for the active camera's capabilities. + + A camera whose trigger line is not wired (cameras.yaml hardware_trigger: false) + must not offer Hardware, so the option list is rebuilt on every camera switch + and the selection is resynced to the mode the LiveController actually holds. + """ + try: + self.is_switching_mode = True + self.dropdown_triggerMode.blockSignals(True) + self.dropdown_triggerMode.clear() + trigger_modes = [("Software", TriggerMode.SOFTWARE)] + if self.liveController.camera.supports_hardware_trigger(): + trigger_modes.append(("Hardware", TriggerMode.HARDWARE)) + trigger_modes.append(("Continuous", TriggerMode.CONTINUOUS)) + for display_name, mode in trigger_modes: + self.dropdown_triggerMode.addItem(display_name, mode) + index = self.dropdown_triggerMode.findData(self.liveController.trigger_mode) + if index >= 0: + self.dropdown_triggerMode.setCurrentIndex(index) + finally: + self.dropdown_triggerMode.blockSignals(False) + self.is_switching_mode = False + + def on_active_camera_changed(self, camera_id: int): + """GUI-thread slot invoked (queued) after Microscope.set_active_camera.""" + self.refresh_trigger_options() + # Exposure limits differ per camera; re-clamp the spinbox. Guarded: Qt emits + # valueChanged when the held value falls outside the new range, and a clamp + # forced by a camera switch must not be persisted as a user edit of the channel. + try: + self.is_switching_mode = True + self.entry_exposureTime.setRange(*self.liveController.camera.get_exposure_limits()) + finally: + self.is_switching_mode = False + def on_trigger_mode_changed(self, index): + if self.is_switching_mode: + return # Get the actual value using user data actual_value = self.dropdown_triggerMode.itemData(index) print(f"Selected: {self.dropdown_triggerMode.currentText()} (actual value: {actual_value})") diff --git a/software/tests/control/test_HighContentScreeningGui.py b/software/tests/control/test_HighContentScreeningGui.py index 0a703421b..4a6754fc7 100644 --- a/software/tests/control/test_HighContentScreeningGui.py +++ b/software/tests/control/test_HighContentScreeningGui.py @@ -6,6 +6,8 @@ from qtpy.QtWidgets import QMessageBox import control.microscope +from control.core.config.repository import ConfigRepository +from control.models.camera_registry import CameraDefinition, CameraRegistryConfig @pytest.fixture @@ -34,6 +36,59 @@ def test_create_simulated_hcs_with_or_without_piezo(qtbot, confirm_exit_yes): qtbot.add_widget(without_piezo) +def test_single_camera_gui_has_one_plain_camera_tab(qtbot, confirm_exit_yes): + """Single-camera systems must not drift: one tab literally named "Camera", no + per-camera extras, and the trigger dropdown offering Software + Hardware.""" + scope = control.microscope.Microscope.build_from_global_config(True) + win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) + qtbot.add_widget(win) + + labels = [win.cameraTabWidget.tabText(i) for i in range(win.cameraTabWidget.count())] + assert labels.count("Camera") == 1 + assert win.cameraSettingWidgets_extra == {} + + combo = win.liveControlWidget.dropdown_triggerManu + options = [combo.itemText(i) for i in range(combo.count())] + expected = [control._def.TriggerMode.SOFTWARE, control._def.TriggerMode.HARDWARE] + if control.gui_hcs.ENABLE_RECORDING: + expected.append(control._def.TriggerMode.CONTINUOUS) + assert options == expected + + +def test_multi_camera_gui_names_tabs_from_registry(qtbot, monkeypatch, confirm_exit_yes): + """Two cameras: the primary tab takes its cameras.yaml name and each extra camera + gets its own settings tab bound to its own concrete camera (never the facade).""" + registry = CameraRegistryConfig( + cameras=[ + CameraDefinition(name="Main Camera", id=1, serial_number="SIM-1", type="Toupcam"), + CameraDefinition(name="Side Camera", id=2, serial_number="SIM-2", type="Toupcam", hardware_trigger=False), + ] + ) + monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: registry) + + scope = control.microscope.Microscope.build_from_global_config(True) + win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) + qtbot.add_widget(win) + + labels = [win.cameraTabWidget.tabText(i) for i in range(win.cameraTabWidget.count())] + assert "Main Camera" in labels + assert "Side Camera" in labels + assert "Camera" not in labels + assert list(win.cameraSettingWidgets_extra) == [2] + assert win.cameraSettingWidgets_extra[2].camera is scope.cameras[2] + assert win.cameraSettingWidget.camera is scope.cameras[control._def.PRIMARY_CAMERA_ID] + + # The trigger dropdown follows the active camera's capability. Camera 2 declares + # hardware_trigger: false, so Hardware must disappear once it becomes active. + combo = win.liveControlWidget.dropdown_triggerManu + assert control._def.TriggerMode.HARDWARE in [combo.itemText(i) for i in range(combo.count())] + scope.set_active_camera(2) + # The listener hops to the GUI thread via QTimer.singleShot; call the same handler + # directly rather than spinning the event loop inside the test. + win._on_active_camera_changed(2) + assert [combo.itemText(i) for i in range(combo.count())] == [control._def.TriggerMode.SOFTWARE] + + def test_tab_change_to_simple_recording_does_not_raise(qtbot, monkeypatch, confirm_exit_yes): """Regression: onTabChanged used to call emit_selected_channels() on every record tab and toggleAcquisitionStart called display_progress_bar() on the current tab, diff --git a/software/tests/control/test_microscope_multi_camera.py b/software/tests/control/test_microscope_multi_camera.py index 4ea51d8a9..794917398 100644 --- a/software/tests/control/test_microscope_multi_camera.py +++ b/software/tests/control/test_microscope_multi_camera.py @@ -163,3 +163,12 @@ def test_set_trigger_mode_updates_memory(two_camera_scope): scope.live_controller.set_trigger_mode(TriggerMode.SOFTWARE) assert scope.get_stored_trigger_mode(2) == TriggerMode.SOFTWARE assert scope.get_stored_trigger_mode(1) == TriggerMode.HARDWARE + + +def test_facade_reports_active_camera_hw_capability(two_camera_scope): + """The trigger dropdown asks the facade whether Hardware is offerable, so the + facade must report the *active* camera's capability, not the primary's.""" + scope = two_camera_scope + assert scope.camera.supports_hardware_trigger() is True + scope.set_active_camera(2) + assert scope.camera.supports_hardware_trigger() is False diff --git a/software/tests/control/test_trigger_options_per_camera.py b/software/tests/control/test_trigger_options_per_camera.py new file mode 100644 index 000000000..156920b52 --- /dev/null +++ b/software/tests/control/test_trigger_options_per_camera.py @@ -0,0 +1,199 @@ +"""Per-camera trigger options (Task 10). + +The trigger dropdown must only offer Hardware when the *active* camera has its +trigger line wired, must resync to the mode the LiveController actually holds +after a camera switch, and must never turn its own repopulation into an MCU +trigger-mode command. + +The stubs below borrow the REAL widget methods so the production code is what +runs (same pattern as test_channel_display_labels.py). +""" + +from types import SimpleNamespace + +import pytest +from qtpy.QtWidgets import QComboBox, QDoubleSpinBox + +import control.widgets +from control._def import TriggerMode +from control.gui_hcs import HighContentScreeningGui +from control.models.camera_registry import CameraDefinition, CameraRegistryConfig +from control.widgets import LiveControlWidget, NapariLiveWidget + +TWO_CAM = CameraRegistryConfig( + cameras=[ + CameraDefinition(name="Main Camera", id=1, serial_number="SN1", type="Toupcam"), + CameraDefinition(name="Side Camera", id=2, serial_number="SN2", type="Toupcam", hardware_trigger=False), + ] +) + + +def _fake_camera(supports_hw, exposure_limits): + return SimpleNamespace( + supports_hardware_trigger=lambda: supports_hw, + get_exposure_limits=lambda: exposure_limits, + ) + + +class _LiveTriggerStub: + """LiveControlWidget-shaped stub bound to the real trigger methods.""" + + refresh_trigger_options = LiveControlWidget.refresh_trigger_options + on_active_camera_changed = LiveControlWidget.on_active_camera_changed + update_trigger_mode = LiveControlWidget.update_trigger_mode + + def __init__(self, supports_hw=True, trigger_mode=TriggerMode.SOFTWARE, exposure_limits=(0.1, 1000.0)): + self.is_switching_mode = False + self.applied_modes = [] + self.dropdown_triggerManu = QComboBox() + self.entry_exposureTime = QDoubleSpinBox() + self.entry_exposureTime.setRange(0.1, 5000.0) + self.entry_exposureTime.setValue(2000.0) + self.camera = _fake_camera(supports_hw, exposure_limits) + self.liveController = SimpleNamespace( + trigger_mode=trigger_mode, + set_trigger_mode=self.applied_modes.append, + ) + # Same wiring the real add_components makes. + self.dropdown_triggerManu.currentIndexChanged.connect(self.update_trigger_mode) + + def items(self): + return [self.dropdown_triggerManu.itemText(i) for i in range(self.dropdown_triggerManu.count())] + + +class _NapariTriggerStub: + """NapariLiveWidget-shaped stub bound to the real trigger methods.""" + + refresh_trigger_options = NapariLiveWidget.refresh_trigger_options + on_active_camera_changed = NapariLiveWidget.on_active_camera_changed + on_trigger_mode_changed = NapariLiveWidget.on_trigger_mode_changed + + def __init__(self, supports_hw=True, trigger_mode=TriggerMode.SOFTWARE, exposure_limits=(0.1, 1000.0)): + self.is_switching_mode = False + self.observed = [] + self.dropdown_triggerMode = QComboBox() + self.entry_exposureTime = QDoubleSpinBox() + self.entry_exposureTime.setRange(0.1, 5000.0) + self.entry_exposureTime.setValue(2000.0) + self.liveController = SimpleNamespace( + trigger_mode=trigger_mode, + camera=_fake_camera(supports_hw, exposure_limits), + ) + self.dropdown_triggerMode.currentIndexChanged.connect(self._record) + + def _record(self, index): + self.on_trigger_mode_changed(index) + if not self.is_switching_mode: + self.observed.append(self.dropdown_triggerMode.itemData(index)) + + def items(self): + return [self.dropdown_triggerMode.itemData(i) for i in range(self.dropdown_triggerMode.count())] + + +class TestLiveControlTriggerOptions: + def test_hardware_offered_when_camera_supports_it(self, qtbot, monkeypatch): + monkeypatch.setattr(control.widgets, "ENABLE_RECORDING", False) + stub = _LiveTriggerStub(supports_hw=True) + stub.refresh_trigger_options() + assert stub.items() == [TriggerMode.SOFTWARE, TriggerMode.HARDWARE] + + def test_hardware_hidden_when_camera_cannot_be_triggered(self, qtbot, monkeypatch): + monkeypatch.setattr(control.widgets, "ENABLE_RECORDING", False) + stub = _LiveTriggerStub(supports_hw=False) + stub.refresh_trigger_options() + assert stub.items() == [TriggerMode.SOFTWARE] + + def test_continuous_added_when_recording_enabled(self, qtbot, monkeypatch): + monkeypatch.setattr(control.widgets, "ENABLE_RECORDING", True) + stub = _LiveTriggerStub(supports_hw=True) + stub.refresh_trigger_options() + assert stub.items() == [TriggerMode.SOFTWARE, TriggerMode.HARDWARE, TriggerMode.CONTINUOUS] + + def test_selection_resyncs_to_live_controller_mode(self, qtbot, monkeypatch): + """A dropdown call that lost a race with a camera switch left the UI showing a + mode the controller does not hold; the refresh makes the UI honest again.""" + monkeypatch.setattr(control.widgets, "ENABLE_RECORDING", False) + stub = _LiveTriggerStub(supports_hw=True, trigger_mode=TriggerMode.HARDWARE) + stub.refresh_trigger_options() + assert stub.dropdown_triggerManu.currentText() == TriggerMode.HARDWARE + + def test_refresh_sends_no_trigger_mode_command(self, qtbot, monkeypatch): + """Repopulating must not look like a user choice: no MCU trigger-mode command.""" + monkeypatch.setattr(control.widgets, "ENABLE_RECORDING", False) + stub = _LiveTriggerStub(supports_hw=True, trigger_mode=TriggerMode.HARDWARE) + stub.refresh_trigger_options() + # Switch to a camera with no trigger line: the Hardware entry disappears, which + # would otherwise fire currentIndexChanged -> set_trigger_mode. + stub.camera = _fake_camera(supports_hw=False, exposure_limits=(0.1, 1000.0)) + stub.liveController.trigger_mode = TriggerMode.SOFTWARE + stub.refresh_trigger_options() + assert stub.items() == [TriggerMode.SOFTWARE] + assert stub.applied_modes == [] + assert stub.is_switching_mode is False + + def test_update_trigger_mode_guarded_by_is_switching_mode(self, qtbot): + stub = _LiveTriggerStub() + stub.refresh_trigger_options() + stub.is_switching_mode = True + stub.update_trigger_mode() + assert stub.applied_modes == [] + + def test_user_selection_still_applies_the_mode(self, qtbot, monkeypatch): + monkeypatch.setattr(control.widgets, "ENABLE_RECORDING", False) + stub = _LiveTriggerStub(supports_hw=True) + stub.refresh_trigger_options() + stub.dropdown_triggerManu.setCurrentIndex(1) # Hardware + assert stub.applied_modes == [TriggerMode.HARDWARE] + + def test_camera_change_reclamps_exposure_without_persisting(self, qtbot, monkeypatch): + monkeypatch.setattr(control.widgets, "ENABLE_RECORDING", False) + persisted = [] + stub = _LiveTriggerStub(supports_hw=False, exposure_limits=(1.0, 500.0)) + stub.entry_exposureTime.valueChanged.connect( + lambda v: persisted.append(v) if not stub.is_switching_mode else None + ) + stub.on_active_camera_changed(2) + assert stub.entry_exposureTime.minimum() == 1.0 + assert stub.entry_exposureTime.maximum() == 500.0 + assert stub.entry_exposureTime.value() == 500.0 # Qt clamped the held 2000 ms + assert persisted == [] # ...but the clamp was not treated as a user edit + assert stub.items() == [TriggerMode.SOFTWARE] + assert stub.is_switching_mode is False + + +class TestNapariLiveTriggerOptions: + def test_hardware_hidden_when_camera_cannot_be_triggered(self, qtbot): + stub = _NapariTriggerStub(supports_hw=False) + stub.refresh_trigger_options() + assert stub.items() == [TriggerMode.SOFTWARE, TriggerMode.CONTINUOUS] + + def test_hardware_offered_when_supported_and_selection_resyncs(self, qtbot): + stub = _NapariTriggerStub(supports_hw=True, trigger_mode=TriggerMode.CONTINUOUS) + stub.refresh_trigger_options() + assert stub.items() == [TriggerMode.SOFTWARE, TriggerMode.HARDWARE, TriggerMode.CONTINUOUS] + assert stub.dropdown_triggerMode.currentData() == TriggerMode.CONTINUOUS + + def test_camera_change_refreshes_options_and_exposure_range(self, qtbot): + stub = _NapariTriggerStub(supports_hw=True) + stub.refresh_trigger_options() + stub.liveController.camera = _fake_camera(supports_hw=False, exposure_limits=(2.0, 300.0)) + stub.on_active_camera_changed(2) + assert stub.items() == [TriggerMode.SOFTWARE, TriggerMode.CONTINUOUS] + assert (stub.entry_exposureTime.minimum(), stub.entry_exposureTime.maximum()) == (2.0, 300.0) + assert stub.observed == [] # repopulation is not a user choice + assert stub.is_switching_mode is False + + +class TestCameraTabName: + @pytest.mark.parametrize( + "camera_id,expected", + [(1, "Main Camera"), (2, "Side Camera")], + ) + def test_named_cameras_use_registry_name(self, camera_id, expected): + assert HighContentScreeningGui._camera_tab_name(camera_id, TWO_CAM) == expected + + def test_camera_absent_from_registry_falls_back_to_id(self): + assert HighContentScreeningGui._camera_tab_name(3, TWO_CAM) == "Camera 3" + + def test_missing_registry_falls_back_to_id(self): + assert HighContentScreeningGui._camera_tab_name(3, None) == "Camera 3" From 9a80543f0772e0919def562487567d34a0ad2007 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 08:02:32 -0700 Subject: [PATCH 16/52] fix(gui): sync trigger options when the startup channel lives on another camera LiveControlWidget builds its trigger dropdown and exposure range from the active camera, then set_microscope_mode on the startup channel can switch to a different one - and the GUI's camera-change listener is not wired until make_connections, so nothing corrected the widget. A startup channel bound to a camera with no trigger line therefore came up offering Hardware. Co-Authored-By: Claude Fable 5 --- software/control/widgets.py | 5 +++ .../control/test_HighContentScreeningGui.py | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/software/control/widgets.py b/software/control/widgets.py index 4e9df9b29..81a67052f 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -4111,6 +4111,11 @@ def __init__( if self.currentConfiguration: self.liveController.set_microscope_mode(self.currentConfiguration) self.update_ui_for_mode(self.currentConfiguration) + # set_microscope_mode may have switched the active camera (the startup channel + # can be bound to a non-primary one). The dropdown and exposure range built + # above describe the pre-switch camera, and the GUI's camera-change listener + # is not wired until make_connections, so sync them here. + self.on_active_camera_changed(self.liveController.microscope.active_camera_id) # Wire 'Apply in Live' checkbox enable state to laser AF reference availability. laser_af = getattr(self.liveController.microscope, "laser_autofocus_controller", None) diff --git a/software/tests/control/test_HighContentScreeningGui.py b/software/tests/control/test_HighContentScreeningGui.py index 4a6754fc7..7bc0b70e7 100644 --- a/software/tests/control/test_HighContentScreeningGui.py +++ b/software/tests/control/test_HighContentScreeningGui.py @@ -7,6 +7,7 @@ import control.microscope from control.core.config.repository import ConfigRepository +from control.core.live_controller import LiveController from control.models.camera_registry import CameraDefinition, CameraRegistryConfig @@ -89,6 +90,39 @@ def test_multi_camera_gui_names_tabs_from_registry(qtbot, monkeypatch, confirm_e assert [combo.itemText(i) for i in range(combo.count())] == [control._def.TriggerMode.SOFTWARE] +def test_startup_channel_on_secondary_camera_syncs_trigger_options(qtbot, monkeypatch, confirm_exit_yes): + """LiveControlWidget builds its trigger dropdown, then set_microscope_mode on the + startup channel may switch the active camera - before the GUI's camera-change + listener is wired. The widget must sync itself, or the dropdown offers Hardware + for a camera whose trigger line is not wired.""" + registry = CameraRegistryConfig( + cameras=[ + CameraDefinition(name="Main Camera", id=1, serial_number="SIM-1", type="Toupcam"), + CameraDefinition(name="Side Camera", id=2, serial_number="SIM-2", type="Toupcam", hardware_trigger=False), + ] + ) + monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: registry) + + original_get_channels = LiveController.get_channels + + def get_channels_with_secondary_first(self, objective): + # Copy so the repository's shared channel objects are never mutated. + channels = [channel.model_copy(deep=True) for channel in original_get_channels(self, objective)] + if channels: + channels[0].camera = 2 + return channels + + monkeypatch.setattr(LiveController, "get_channels", get_channels_with_secondary_first) + + scope = control.microscope.Microscope.build_from_global_config(True) + win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) + qtbot.add_widget(win) + + assert scope.active_camera_id == 2 + combo = win.liveControlWidget.dropdown_triggerManu + assert [combo.itemText(i) for i in range(combo.count())] == [control._def.TriggerMode.SOFTWARE] + + def test_tab_change_to_simple_recording_does_not_raise(qtbot, monkeypatch, confirm_exit_yes): """Regression: onTabChanged used to call emit_selected_channels() on every record tab and toggleAcquisitionStart called display_progress_bar() on the current tab, From 3b0bf74cb2e496697689d9e7984d1791ecbea95f Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 08:22:39 -0700 Subject: [PATCH 17/52] fix(gui): deliver camera-change notifications cross-thread; clamp unofferable trigger modes Two defects from review. The camera-change bridge was a silent no-op off the GUI thread. QTimer.singleShot posts to the CALLING thread's event loop, and the switches that matter come from threads that have none: the acquisition worker (MultiPointWorker._select_config -> set_microscope_mode) and the TCP server thread. After a server- or worker-driven switch the dropdown kept offering Hardware for an unwired camera, exposure limits stayed stale and the nav-viewer FOV never redrew. Replaced with a Qt signal on the GUI class: emission is thread-safe and, with the receiver in the GUI thread, Qt queues delivery onto the GUI event loop. The new test drives set_active_camera from a plain threading.Thread and fails (waitUntil timeout) against the old bridge. refresh_trigger_options could silently desync: setCurrentText with an entry the combo does not contain is a no-op, so a held Hardware mode left the dropdown reading Software with no way back from a one-item list. It now clamps to Software, warns, and pulls the LiveController to the same mode so UI and hardware agree. The napari variant clamps and warns for display only - LiveControlWidget always exists and runs first on a camera change, so syncing there too would double-program the MCU. Also fixed the napari test stub, whose own is_switching_mode filter made the "repopulation is not a user choice" assertion vacuous. Co-Authored-By: Claude Fable 5 --- software/control/gui_hcs.py | 22 ++++-- software/control/widgets.py | 32 +++++++- .../control/test_HighContentScreeningGui.py | 73 +++++++++++++++---- .../test_trigger_options_per_camera.py | 60 ++++++++++++++- 4 files changed, 159 insertions(+), 28 deletions(-) diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 13c4d88ad..9e71e22e0 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -604,6 +604,9 @@ class HighContentScreeningGui(QMainWindow): fps_software_trigger = 100 LASER_BASED_FOCUS_TAB_NAME = "Laser-Based Focus" signal_performance_mode_changed = Signal(bool) + # Carries Microscope active-camera notifications onto the GUI thread. See the + # add_camera_change_listener hookup in make_connections for why this is a signal. + signal_active_camera_changed = Signal(int) def __init__( self, @@ -1540,11 +1543,15 @@ def make_connections(self): self.liveControlWidget.update_camera_settings() # Dual-camera: refresh GUI state on active-camera switch. The listener fires on - # whichever thread called set_active_camera (GUI for live, worker for acquisitions), - # so hop to the GUI thread with a queued singleShot (fire-and-forget is fine here). - self.microscope.add_camera_change_listener( - lambda camera_id: QTimer.singleShot(0, lambda: self._on_active_camera_changed(camera_id)) - ) + # whichever thread called set_active_camera: the GUI thread for a live channel + # change, but the acquisition worker thread (MultiPointWorker._select_config) or + # the TCP server thread otherwise. Those are plain threading.Threads with no Qt + # event loop, so anything posted to the *calling* thread (QTimer.singleShot) would + # never run. Emitting a signal is thread-safe and, because the receiver lives in + # the GUI thread, Qt resolves the connection to a queued delivery onto the GUI + # event loop. + self.signal_active_camera_changed.connect(self._on_active_camera_changed) + self.microscope.add_camera_change_listener(self.signal_active_camera_changed.emit) self.connectSlidePositionController() @@ -1958,11 +1965,12 @@ def _on_live_controller_warning(self, message: str) -> None: self._live_warning_box = box box.show() + @Slot(int) def _on_active_camera_changed(self, camera_id: int) -> None: """GUI-thread handler for a Microscope active-camera switch. - Runs from a queued QTimer.singleShot, so exceptions would be swallowed by Qt - - catch and log them here. + Reached through signal_active_camera_changed, i.e. a queued cross-thread + delivery, so exceptions would be swallowed by Qt - catch and log them here. """ try: self.liveControlWidget.on_active_camera_changed(camera_id) diff --git a/software/control/widgets.py b/software/control/widgets.py index 81a67052f..e2355c131 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -4184,6 +4184,7 @@ def refresh_trigger_options(self): must not offer Hardware, so the option list is rebuilt on every camera switch and the selection is resynced to the mode the LiveController actually holds. """ + clamped_mode = None try: self.is_switching_mode = True self.dropdown_triggerManu.blockSignals(True) @@ -4194,11 +4195,26 @@ def refresh_trigger_options(self): if ENABLE_RECORDING: trigger_modes.append(TriggerMode.CONTINUOUS) self.dropdown_triggerManu.addItems(trigger_modes) - self.dropdown_triggerManu.setCurrentText(self.liveController.trigger_mode) + held_mode = self.liveController.trigger_mode + if held_mode not in trigger_modes: + # setCurrentText with an absent entry is a silent no-op, which would leave + # the dropdown showing Software while the controller still holds Hardware - + # and a one-item dropdown gives the user no way back. Clamp instead, and + # sync the controller below so UI and hardware state agree. + clamped_mode = TriggerMode.SOFTWARE + held_mode = clamped_mode + self.dropdown_triggerManu.setCurrentText(held_mode) finally: self.dropdown_triggerManu.blockSignals(False) self.is_switching_mode = False + if clamped_mode is not None: + self._log.warning( + f"Trigger mode '{self.liveController.trigger_mode}' is not available on the active " + f"camera; falling back to '{clamped_mode}'." + ) + self.liveController.set_trigger_mode(clamped_mode) + def on_active_camera_changed(self, camera_id: int): """GUI-thread slot invoked (queued) after Microscope.set_active_camera.""" self.refresh_trigger_options() @@ -12062,6 +12078,7 @@ def refresh_trigger_options(self): must not offer Hardware, so the option list is rebuilt on every camera switch and the selection is resynced to the mode the LiveController actually holds. """ + clamped_mode = None try: self.is_switching_mode = True self.dropdown_triggerMode.blockSignals(True) @@ -12073,12 +12090,25 @@ def refresh_trigger_options(self): for display_name, mode in trigger_modes: self.dropdown_triggerMode.addItem(display_name, mode) index = self.dropdown_triggerMode.findData(self.liveController.trigger_mode) + if index < 0: + # Held mode is not offered by this camera; showing it is impossible, so + # clamp the display to Software rather than leaving whatever index 0 is. + # The hardware sync is LiveControlWidget's job - it always exists and runs + # first on a camera change, so doing it here too would double-program the MCU. + clamped_mode = TriggerMode.SOFTWARE + index = self.dropdown_triggerMode.findData(clamped_mode) if index >= 0: self.dropdown_triggerMode.setCurrentIndex(index) finally: self.dropdown_triggerMode.blockSignals(False) self.is_switching_mode = False + if clamped_mode is not None: + self._log.warning( + f"Trigger mode '{self.liveController.trigger_mode}' is not available on the active " + f"camera; showing '{clamped_mode}'." + ) + def on_active_camera_changed(self, camera_id: int): """GUI-thread slot invoked (queued) after Microscope.set_active_camera.""" self.refresh_trigger_options() diff --git a/software/tests/control/test_HighContentScreeningGui.py b/software/tests/control/test_HighContentScreeningGui.py index 7bc0b70e7..5949b395f 100644 --- a/software/tests/control/test_HighContentScreeningGui.py +++ b/software/tests/control/test_HighContentScreeningGui.py @@ -1,3 +1,5 @@ +import threading + import pytest import control._def @@ -10,6 +12,13 @@ from control.core.live_controller import LiveController from control.models.camera_registry import CameraDefinition, CameraRegistryConfig +TWO_CAMERA_REGISTRY = CameraRegistryConfig( + cameras=[ + CameraDefinition(name="Main Camera", id=1, serial_number="SIM-1", type="Toupcam"), + CameraDefinition(name="Side Camera", id=2, serial_number="SIM-2", type="Toupcam", hardware_trigger=False), + ] +) + @pytest.fixture def confirm_exit_yes(monkeypatch): @@ -23,6 +32,50 @@ def confirm_exit(parent, title, text, *args, **kwargs): monkeypatch.setattr(QMessageBox, "question", confirm_exit) +def test_camera_change_from_a_plain_thread_reaches_the_gui(qtbot, monkeypatch, confirm_exit_yes): + """The switches that matter come from threads with no Qt event loop: the acquisition + worker (MultiPointWorker._select_config -> set_microscope_mode) and the TCP server + thread. Anything posted to the *calling* thread there never runs, so the bridge has + to be a real cross-thread delivery onto the GUI event loop.""" + monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: TWO_CAMERA_REGISTRY) + + scope = control.microscope.Microscope.build_from_global_config(True) + win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) + qtbot.add_widget(win) + + gui_thread_id = threading.get_ident() + handled_on_thread = [] + win.signal_active_camera_changed.connect(lambda camera_id: handled_on_thread.append(threading.get_ident())) + + combo = win.liveControlWidget.dropdown_triggerManu + assert control._def.TriggerMode.HARDWARE in [combo.itemText(i) for i in range(combo.count())] + + errors = [] + + def switch(): + try: + scope.set_active_camera(2) + except Exception as e: # surfaced below; a bare thread would swallow it + errors.append(e) + + worker = threading.Thread(target=switch, name="fake-acquisition-worker") + worker.start() + worker.join(timeout=10) + assert not worker.is_alive() + assert not errors, errors + assert scope.active_camera_id == 2 + + # Delivery is queued, so nothing has reached the widget yet - joining the thread does + # not spin the GUI event loop. + assert handled_on_thread == [] + assert control._def.TriggerMode.HARDWARE in [combo.itemText(i) for i in range(combo.count())] + + # ...and it does arrive once the GUI event loop runs. + qtbot.waitUntil(lambda: len(handled_on_thread) > 0, timeout=5000) + assert handled_on_thread == [gui_thread_id] + assert [combo.itemText(i) for i in range(combo.count())] == [control._def.TriggerMode.SOFTWARE] + + def test_create_simulated_hcs_with_or_without_piezo(qtbot, confirm_exit_yes): # This just tests to make sure we can successfully create a simulated hcs gui with or without # the piezo objective. @@ -59,13 +112,7 @@ def test_single_camera_gui_has_one_plain_camera_tab(qtbot, confirm_exit_yes): def test_multi_camera_gui_names_tabs_from_registry(qtbot, monkeypatch, confirm_exit_yes): """Two cameras: the primary tab takes its cameras.yaml name and each extra camera gets its own settings tab bound to its own concrete camera (never the facade).""" - registry = CameraRegistryConfig( - cameras=[ - CameraDefinition(name="Main Camera", id=1, serial_number="SIM-1", type="Toupcam"), - CameraDefinition(name="Side Camera", id=2, serial_number="SIM-2", type="Toupcam", hardware_trigger=False), - ] - ) - monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: registry) + monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: TWO_CAMERA_REGISTRY) scope = control.microscope.Microscope.build_from_global_config(True) win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) @@ -84,8 +131,8 @@ def test_multi_camera_gui_names_tabs_from_registry(qtbot, monkeypatch, confirm_e combo = win.liveControlWidget.dropdown_triggerManu assert control._def.TriggerMode.HARDWARE in [combo.itemText(i) for i in range(combo.count())] scope.set_active_camera(2) - # The listener hops to the GUI thread via QTimer.singleShot; call the same handler - # directly rather than spinning the event loop inside the test. + # Same-thread call: exercise the handler directly here and leave the queued + # cross-thread delivery to test_camera_change_from_a_plain_thread_reaches_the_gui. win._on_active_camera_changed(2) assert [combo.itemText(i) for i in range(combo.count())] == [control._def.TriggerMode.SOFTWARE] @@ -95,13 +142,7 @@ def test_startup_channel_on_secondary_camera_syncs_trigger_options(qtbot, monkey startup channel may switch the active camera - before the GUI's camera-change listener is wired. The widget must sync itself, or the dropdown offers Hardware for a camera whose trigger line is not wired.""" - registry = CameraRegistryConfig( - cameras=[ - CameraDefinition(name="Main Camera", id=1, serial_number="SIM-1", type="Toupcam"), - CameraDefinition(name="Side Camera", id=2, serial_number="SIM-2", type="Toupcam", hardware_trigger=False), - ] - ) - monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: registry) + monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: TWO_CAMERA_REGISTRY) original_get_channels = LiveController.get_channels diff --git a/software/tests/control/test_trigger_options_per_camera.py b/software/tests/control/test_trigger_options_per_camera.py index 156920b52..32e806dfd 100644 --- a/software/tests/control/test_trigger_options_per_camera.py +++ b/software/tests/control/test_trigger_options_per_camera.py @@ -10,6 +10,7 @@ """ from types import SimpleNamespace +from unittest.mock import MagicMock import pytest from qtpy.QtWidgets import QComboBox, QDoubleSpinBox @@ -45,6 +46,7 @@ class _LiveTriggerStub: def __init__(self, supports_hw=True, trigger_mode=TriggerMode.SOFTWARE, exposure_limits=(0.1, 1000.0)): self.is_switching_mode = False self.applied_modes = [] + self._log = MagicMock() self.dropdown_triggerManu = QComboBox() self.entry_exposureTime = QDoubleSpinBox() self.entry_exposureTime.setRange(0.1, 5000.0) @@ -70,7 +72,8 @@ class _NapariTriggerStub: def __init__(self, supports_hw=True, trigger_mode=TriggerMode.SOFTWARE, exposure_limits=(0.1, 1000.0)): self.is_switching_mode = False - self.observed = [] + self.signal_emissions = [] + self._log = MagicMock() self.dropdown_triggerMode = QComboBox() self.entry_exposureTime = QDoubleSpinBox() self.entry_exposureTime.setRange(0.1, 5000.0) @@ -82,9 +85,11 @@ def __init__(self, supports_hw=True, trigger_mode=TriggerMode.SOFTWARE, exposure self.dropdown_triggerMode.currentIndexChanged.connect(self._record) def _record(self, index): + # Records the raw emission - no filtering of its own, so assertions about + # "repopulation is not a user choice" are about production behaviour + # (blockSignals) and not about the stub. + self.signal_emissions.append(index) self.on_trigger_mode_changed(index) - if not self.is_switching_mode: - self.observed.append(self.dropdown_triggerMode.itemData(index)) def items(self): return [self.dropdown_triggerMode.itemData(i) for i in range(self.dropdown_triggerMode.count())] @@ -160,6 +165,27 @@ def test_camera_change_reclamps_exposure_without_persisting(self, qtbot, monkeyp assert stub.items() == [TriggerMode.SOFTWARE] assert stub.is_switching_mode is False + def test_unofferable_held_mode_is_clamped_warned_and_synced(self, qtbot, monkeypatch): + """setCurrentText on an absent entry is a silent no-op, which would leave the + dropdown reading Software while the controller still holds Hardware - and a + one-item dropdown offers no way back. Clamp, warn, and sync the controller.""" + monkeypatch.setattr(control.widgets, "ENABLE_RECORDING", False) + stub = _LiveTriggerStub(supports_hw=False, trigger_mode=TriggerMode.HARDWARE) + stub.refresh_trigger_options() + assert stub.items() == [TriggerMode.SOFTWARE] + assert stub.dropdown_triggerManu.currentText() == TriggerMode.SOFTWARE + assert stub.applied_modes == [TriggerMode.SOFTWARE] # controller pulled into agreement + assert stub._log.warning.called + assert stub.is_switching_mode is False + + def test_offerable_held_mode_is_not_clamped_or_warned(self, qtbot, monkeypatch): + monkeypatch.setattr(control.widgets, "ENABLE_RECORDING", False) + stub = _LiveTriggerStub(supports_hw=True, trigger_mode=TriggerMode.HARDWARE) + stub.refresh_trigger_options() + assert stub.dropdown_triggerManu.currentText() == TriggerMode.HARDWARE + assert stub.applied_modes == [] + assert not stub._log.warning.called + class TestNapariLiveTriggerOptions: def test_hardware_hidden_when_camera_cannot_be_triggered(self, qtbot): @@ -180,9 +206,35 @@ def test_camera_change_refreshes_options_and_exposure_range(self, qtbot): stub.on_active_camera_changed(2) assert stub.items() == [TriggerMode.SOFTWARE, TriggerMode.CONTINUOUS] assert (stub.entry_exposureTime.minimum(), stub.entry_exposureTime.maximum()) == (2.0, 300.0) - assert stub.observed == [] # repopulation is not a user choice + # Production blocks the combo's signals while repopulating, so the change handler + # never sees the intermediate indices at all. + assert stub.signal_emissions == [] assert stub.is_switching_mode is False + def test_unofferable_held_mode_clamps_display_and_warns(self, qtbot): + """No hardware sync here on purpose: LiveControlWidget always exists and runs + first on a camera change, so syncing here too would double-program the MCU.""" + stub = _NapariTriggerStub(supports_hw=False, trigger_mode=TriggerMode.HARDWARE) + stub.refresh_trigger_options() + assert stub.dropdown_triggerMode.currentData() == TriggerMode.SOFTWARE + assert stub._log.warning.called + assert stub.is_switching_mode is False + + def test_trigger_mode_change_handler_is_guarded(self, qtbot, capsys): + """The handler's only observable effect is its printout; it must produce nothing + while a repopulation is in flight, and something for a real user change.""" + stub = _NapariTriggerStub(supports_hw=True) + stub.refresh_trigger_options() + capsys.readouterr() # drop anything emitted so far + + stub.is_switching_mode = True + stub.on_trigger_mode_changed(1) + assert capsys.readouterr().out == "" + + stub.is_switching_mode = False + stub.on_trigger_mode_changed(1) + assert "Selected:" in capsys.readouterr().out + class TestCameraTabName: @pytest.mark.parametrize( From 6b645c1ae7641c1d4b889b54f221d2f67bd157c2 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 09:13:09 -0700 Subject: [PATCH 18/52] feat(acquisition): Zarr mixed-geometry guard (Start disable + backstop) and unavailable-camera validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pure checkers in control/core/multi_point_utils.py: - get_unavailable_camera_channels: selected channels bound to a camera that never opened (LiveController would silently image them on the active camera). - get_camera_geometry_mismatch: cameras whose frames are not interchangeable (size after crop/binning, color-ness, binned pixel size). Zarr stores one uniform array per region, so a mixed selection cannot be written. MultiPointController.run_acquisition raises ValueError on either condition before any acquisition setup — the backstop for entry points with no Start button (TCP control server, scripts). Both multipoint widgets show a warning label under the channel list and disable Start while the conflict exists, so the GUI never reaches the backstop. Single-camera systems never see either. Co-Authored-By: Claude Fable 5 --- .../control/core/multi_point_controller.py | 38 +- software/control/core/multi_point_utils.py | 73 +++- software/control/widgets.py | 97 ++++- .../test_multi_camera_acquisition_guards.py | 337 ++++++++++++++++++ 4 files changed, 538 insertions(+), 7 deletions(-) create mode 100644 software/tests/control/test_multi_camera_acquisition_guards.py diff --git a/software/control/core/multi_point_controller.py b/software/control/core/multi_point_controller.py index f90e88cbf..b6bbac858 100644 --- a/software/control/core/multi_point_controller.py +++ b/software/control/core/multi_point_controller.py @@ -17,7 +17,13 @@ from control import utils, utils_acquisition import control._def from control.core.auto_focus_controller import AutoFocusController -from control.core.multi_point_utils import MultiPointControllerFunctions, ScanPositionInformation, AcquisitionParameters +from control.core.multi_point_utils import ( + AcquisitionParameters, + MultiPointControllerFunctions, + ScanPositionInformation, + get_camera_geometry_mismatch, + get_unavailable_camera_channels, +) from control.core.scan_coordinates import ScanCoordinates from control.core.laser_auto_focus_controller import LaserAutofocusController from control.core.live_controller import LiveController @@ -698,6 +704,10 @@ def run_acquisition(self, acquire_current_fov=False): # emit acquisition finished signal to re-enable the UI self.callbacks.signal_acquisition_finished() return + # Multi-camera backstop for entry points with no Start button to disable (TCP + # control server, scripts). The multipoint widgets refuse these selections up + # front; raising here (rather than returning) tells a headless caller why. + self._raise_on_incompatible_camera_selection() self._start_per_acquisition_log() # Start memory monitoring for the acquisition (if enabled) @@ -1073,6 +1083,32 @@ def _stop_monitor_background(): def request_abort_aquisition(self): self.abort_acqusition_requested = True + def _raise_on_incompatible_camera_selection(self) -> None: + """Reject a channel selection the hardware or the file format cannot deliver. + + Two cases, both fatal for the run: a selected channel bound to a camera that never + opened (it would silently be imaged on whichever camera is active), and a Zarr run + whose selection spans cameras with different frame geometry (one region is one + uniform array). No-ops on single-camera systems. + + Signals finished before raising — same as the validate_acquisition_settings path + above — so a GUI that already disabled itself for this run is restored. + """ + problem = None + unavailable = get_unavailable_camera_channels(self.selected_configurations, self.microscope.cameras) + if unavailable: + problem = ( + f"Cannot start acquisition: channels {unavailable} are bound to a camera that is not " + "available (declared in cameras.yaml but failed to open, or unknown camera id)." + ) + elif control._def.FILE_SAVING_OPTION == control._def.FileSavingOption.ZARR_V3: + problem = get_camera_geometry_mismatch(self.selected_configurations, self.microscope.cameras) + if problem is None: + return + self._log.error(problem) + self.callbacks.signal_acquisition_finished() + raise ValueError(problem) + def validate_acquisition_settings(self) -> bool: """Validate settings before starting acquisition""" if self.do_reflection_af and not self.laserAutoFocusController.laser_af_properties.has_reference: diff --git a/software/control/core/multi_point_utils.py b/software/control/core/multi_point_utils.py index 79dbbb68c..777a25d2d 100644 --- a/software/control/core/multi_point_utils.py +++ b/software/control/core/multi_point_utils.py @@ -1,10 +1,12 @@ from dataclasses import dataclass, field from typing import List, Tuple, Dict, Optional, Callable, TYPE_CHECKING +import control._def from control.core.job_processing import CaptureInfo from control.core.scan_coordinates import ScanCoordinates from control.models import AcquisitionChannel -from squid.abc import CameraFrame +from squid.abc import AbstractCamera, CameraFrame +from squid.config import CameraPixelFormat if TYPE_CHECKING: from control.slack_notifier import TimepointStats, AcquisitionStats @@ -147,3 +149,72 @@ class MultiPointControllerFunctions: # The waiting callback receives the list of channel keys it's waiting on (e.g. ["470", "55x"]). signal_laser_engine_waiting: Callable[[List[str]], None] = lambda *a, **kw: None signal_laser_engine_ready: Callable[[], None] = lambda *a, **kw: None + + +# --------------------------------------------------------------------------------------- +# Multi-camera selection checks +# +# Pure functions (no Qt, no controller state) so the multipoint widgets can use them to +# disable Start and MultiPointController can use them as the headless backstop. +# --------------------------------------------------------------------------------------- + + +def _channel_camera_id(channel) -> int: + """The camera a channel images on. A null `camera` means the primary camera.""" + return channel.camera if getattr(channel, "camera", None) is not None else control._def.PRIMARY_CAMERA_ID + + +def get_unavailable_camera_channels(selected_channels, cameras: Dict[int, AbstractCamera]) -> List[str]: + """Names of selected channels whose camera id is not an available (opened) camera. + + Such a channel cannot be imaged: LiveController.set_microscope_mode logs the failed + switch and keeps the current camera, so the channel would silently be captured on the + wrong sensor. + """ + return [ch.name for ch in selected_channels if _channel_camera_id(ch) not in cameras] + + +def _camera_frame_geometry(camera: AbstractCamera) -> Tuple[int, int, bool, float]: + """(width, height, is_color, pixel_size_um) of the frames this camera delivers. + + get_crop_size() is None on an axis with no configured crop, and crop_image() clamps a + crop larger than the frame, so the delivered size is the smaller of crop and + resolution (both of which already account for binning). Comparing crop alone would + make every uncropped camera look identical regardless of sensor size. + """ + crop_width, crop_height = camera.get_crop_size() + resolution_width, resolution_height = camera.get_resolution() + return ( + min(crop_width, resolution_width) if crop_width else resolution_width, + min(crop_height, resolution_height) if crop_height else resolution_height, + CameraPixelFormat.is_color_format(camera.get_pixel_format()), + round(camera.get_pixel_size_binned_um(), 4), + ) + + +def get_camera_geometry_mismatch(selected_channels, cameras: Dict[int, AbstractCamera]) -> Optional[str]: + """Check whether the selected channels' cameras produce interchangeable frames. + + Zarr stores one uniform array per region/FOV (shape+dtype fixed by the first frame, + single pixel_size_um), so a mixed-camera selection is only Zarr-compatible when every + used camera matches in frame size, color-ness, and binned pixel size. Returns None when + compatible, else a user-facing message. + """ + geometry_by_camera = {} + for channel in selected_channels: + camera_id = _channel_camera_id(channel) + camera = cameras.get(camera_id) + if camera is None: + continue # unavailable cameras are reported by get_unavailable_camera_channels + geometry_by_camera[camera_id] = _camera_frame_geometry(camera) + if len(set(geometry_by_camera.values())) <= 1: + return None + details = "; ".join( + f"camera {camera_id}: {width}x{height} px, {'color' if is_color else 'mono'}, {pixel_um} um/px" + for camera_id, (width, height, is_color, pixel_um) in sorted(geometry_by_camera.items()) + ) + return ( + "Selected channels span cameras with different frame geometry " + f"({details}). This selection cannot be saved as Zarr — switch the file saving option " + "to OME-TIFF, or make the cameras match via binning/crop, or select channels from one camera." + ) diff --git a/software/control/widgets.py b/software/control/widgets.py index e2355c131..f119dd435 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -1078,6 +1078,64 @@ def _on_apply_channel_offset_changed(self, checked: bool): self.multipointController.set_apply_channel_offset(checked) +class _MultiCameraGuardMixin: + """Mixin that blocks Start while the selected channels span cameras that cannot be + acquired together (unavailable camera, or mixed frame geometry under Zarr). + + Mirrors MultiPointController's headless backstop so the conflict is visible before the + user presses Start, instead of surfacing as an exception at acquisition start. + + Host widgets call ``_create_multi_camera_warning_label()`` in add_components, place + ``self.label_multiCameraWarning`` under their channel list, and must provide + ``list_configurations``, ``btn_startAcquisition``, ``objectiveStore`` and + ``_guard_live_controller()``. On single-camera systems the checks never fire. + """ + + def _create_multi_camera_warning_label(self): + self.label_multiCameraWarning = QLabel("") + self.label_multiCameraWarning.setWordWrap(True) + self.label_multiCameraWarning.setStyleSheet("color: #B00020; font-weight: bold;") + self.label_multiCameraWarning.setVisible(False) + # Tracks whether *this* guard is the one holding Start disabled, so clearing the + # warning never re-enables a button some other owner disabled (e.g. the stage's + # loading-position lock in gui_hcs.connectSlidePositionController). + self._multi_camera_block_active = False + + def _guard_live_controller(self): + """The LiveController whose microscope/channels the guard inspects.""" + raise NotImplementedError + + def _update_multi_camera_guard(self): + from control.core import multi_point_utils + + live_controller = self._guard_live_controller() + microscope = live_controller.microscope + selected_names = [(item.data(Qt.UserRole) or item.text()) for item in self.list_configurations.selectedItems()] + channels = [ + ch + for ch in live_controller.get_channels(self.objectiveStore.current_objective) + if ch.name in selected_names + ] + problems = [] + unavailable = multi_point_utils.get_unavailable_camera_channels(channels, microscope.cameras) + if unavailable: + problems.append(f"Channels unavailable (camera missing): {', '.join(unavailable)}.") + if control._def.FILE_SAVING_OPTION == control._def.FileSavingOption.ZARR_V3: + mismatch = multi_point_utils.get_camera_geometry_mismatch(channels, microscope.cameras) + if mismatch: + problems.append(mismatch) + if problems: + self.label_multiCameraWarning.setText(" ".join(problems)) + self.label_multiCameraWarning.setVisible(True) + self._multi_camera_block_active = True + self.btn_startAcquisition.setEnabled(False) + else: + self.label_multiCameraWarning.setVisible(False) + if self._multi_camera_block_active: + self._multi_camera_block_active = False + self.btn_startAcquisition.setEnabled(True) + + class AcquisitionYAMLMismatchDialog(QDialog): """Dialog shown when hardware configuration doesn't match loaded YAML settings.""" @@ -5881,7 +5939,7 @@ def set_white_boundaries_style(self): self.setStyleSheet(style) -class FlexibleMultiPointWidget(AcquisitionYAMLDropMixin, _ApplyChannelOffsetMixin, QFrame): +class FlexibleMultiPointWidget(AcquisitionYAMLDropMixin, _ApplyChannelOffsetMixin, _MultiCameraGuardMixin, QFrame): signal_acquisition_started = Signal(bool) # true = started, false = finished signal_acquisition_channels = Signal(list) # list channels @@ -5921,6 +5979,7 @@ def __init__( self.add_components() self.setup_layout() self.setup_connections() + self._update_multi_camera_guard() # cached channel selection may already conflict self.setFrameStyle(QFrame.Panel | QFrame.Raised) self.is_current_acquisition_widget = False self.acquisition_in_place = False @@ -6078,6 +6137,7 @@ def add_components(self): cache_key="flexible", decorate=_make_channel_decorator(lambda: self.multipointController.liveController), ) + self._create_multi_camera_warning_label() self.checkbox_withAutofocus = QCheckBox("Contrast AF") self.checkbox_withAutofocus.setChecked(MULTIPOINT_CONTRAST_AUTOFOCUS_ENABLE_BY_DEFAULT) @@ -6274,9 +6334,13 @@ def add_components(self): grid_af.addWidget(self.checkbox_set_z_range) grid_af.addWidget(self.checkbox_skipSaving) - grid_config = QHBoxLayout() - grid_config.addWidget(self.list_configurations) - grid_config.addSpacerItem(edge_spacer) + config_row = QHBoxLayout() + config_row.addWidget(self.list_configurations) + config_row.addSpacerItem(edge_spacer) + + grid_config = QVBoxLayout() + grid_config.addLayout(config_row) + grid_config.addWidget(self.label_multiCameraWarning) button_layout = QVBoxLayout() button_layout.addWidget(self.btn_snap_images) @@ -6349,6 +6413,7 @@ def setup_connections(self): self.btn_startAcquisition.clicked.connect(self.toggle_acquisition) self.multipointController.acquisition_finished.connect(self.acquisition_is_finished) self.list_configurations.itemSelectionChanged.connect(self.emit_selected_channels) + self.list_configurations.itemSelectionChanged.connect(self._update_multi_camera_guard) # self.combobox_z_stack.currentIndexChanged.connect(self.signal_z_stacking.emit) self.multipointController.signal_acquisition_progress.connect(self.update_acquisition_progress) @@ -6632,6 +6697,10 @@ def emit_selected_channels(self): def refresh_channel_list(self): """Refresh the channel list after configuration changes.""" self.channel_sequence.refresh() + self._update_multi_camera_guard() + + def _guard_live_controller(self): + return self.multipointController.liveController def toggle_acquisition(self, pressed): self._log.debug(f"FlexibleMultiPointWidget.toggle_acquisition, {pressed=}") @@ -7187,6 +7256,7 @@ def disable_the_start_aquisition_button(self): def enable_the_start_aquisition_button(self): self.btn_startAcquisition.setEnabled(True) + self._update_multi_camera_guard() # a camera conflict still keeps Start disabled def set_performance_mode(self, enabled): self.performance_mode = enabled @@ -7286,6 +7356,10 @@ def _apply_yaml_settings(self, yaml_data): # Update FOV positions to reflect new NX, NY, delta values self.update_fov_positions() + # The channel selection changed with the list's signals blocked, so re-run the + # guard by hand. + self._update_multi_camera_guard() + def _load_positions(self, positions): """Load positions from YAML into the location list.""" # Clear existing locations @@ -7351,7 +7425,7 @@ def _load_positions(self, positions): ) -class WellplateMultiPointWidget(AcquisitionYAMLDropMixin, _ApplyChannelOffsetMixin, QFrame): +class WellplateMultiPointWidget(AcquisitionYAMLDropMixin, _ApplyChannelOffsetMixin, _MultiCameraGuardMixin, QFrame): signal_acquisition_started = Signal(bool) signal_acquisition_channels = Signal(list) @@ -7438,6 +7512,7 @@ def __init__( self._loading_from_cache = False self.add_components() + self._update_multi_camera_guard() # cached channel selection may already conflict self.setFrameStyle(QFrame.Panel | QFrame.Raised) self.set_default_scan_size() @@ -7555,6 +7630,7 @@ def add_components(self): cache_key="wellplate", decorate=_make_channel_decorator(lambda: self.liveController), ) + self._create_multi_camera_warning_label() # Add a combo box for shape selection self.combobox_shape = QComboBox() @@ -7810,6 +7886,7 @@ def add_components(self): # Configuration list grid.addWidget(self.list_configurations, 2, 0) + grid.addWidget(self.label_multiCameraWarning, 3, 0, 1, 3) # Span full row, under the list # Options and Start button options_layout = QVBoxLayout() @@ -7898,6 +7975,7 @@ def add_components(self): self.checkbox_usePiezo.toggled.connect(self.multipointController.set_use_piezo) self.checkbox_skipSaving.toggled.connect(self.multipointController.set_skip_saving) self.list_configurations.itemSelectionChanged.connect(self.emit_selected_channels) + self.list_configurations.itemSelectionChanged.connect(self._update_multi_camera_guard) self.multipointController.acquisition_finished.connect(self.acquisition_is_finished) self.multipointController.signal_acquisition_progress.connect(self.update_acquisition_progress) self.multipointController.signal_region_progress.connect(self.update_region_progress) @@ -9188,6 +9266,7 @@ def disable_the_start_aquisition_button(self): def enable_the_start_aquisition_button(self): self.btn_startAcquisition.setEnabled(True) + self._update_multi_camera_guard() # a camera conflict still keeps Start disabled def set_performance_mode(self, enabled): self.performance_mode = enabled @@ -9239,6 +9318,10 @@ def emit_selected_channels(self): def refresh_channel_list(self): """Refresh the channel list after configuration changes.""" self.channel_sequence.refresh() + self._update_multi_camera_guard() + + def _guard_live_controller(self): + return self.liveController def toggle_coordinate_controls(self, has_coordinates: bool): """Toggle button text and control states based on whether coordinates are loaded""" @@ -9506,6 +9589,10 @@ def _apply_yaml_settings(self, yaml_data): self.update_tab_styles() self.update_coordinates() + # The channel selection changed with the list's signals blocked, so re-run the + # guard by hand. + self._update_multi_camera_guard() + def _load_well_regions(self, regions): """Load well regions from YAML and select them in the well selector.""" if not self.well_selection_widget: diff --git a/software/tests/control/test_multi_camera_acquisition_guards.py b/software/tests/control/test_multi_camera_acquisition_guards.py new file mode 100644 index 000000000..bde331219 --- /dev/null +++ b/software/tests/control/test_multi_camera_acquisition_guards.py @@ -0,0 +1,337 @@ +"""Guards that keep a mixed-camera channel selection from starting a bad acquisition. + +Two independent checks, both pure functions in control.core.multi_point_utils: + * unavailable camera: a channel bound to a camera id that never opened would silently + be imaged on whatever camera happens to be active, so it must block the start. + * mixed frame geometry: Zarr stores one uniform array per region, so a selection that + spans cameras with different frame shape/color-ness/pixel size cannot be saved. +""" + +from types import SimpleNamespace + +import pytest +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QAbstractItemView, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget + +import control._def +import control.microscope +import squid.config +import tests.control.test_stubs as ts +from control.core.multi_point_utils import get_camera_geometry_mismatch, get_unavailable_camera_channels +from control.core.config.repository import ConfigRepository +from control.models.camera_registry import CameraDefinition, CameraRegistryConfig +from control.widgets import _MultiCameraGuardMixin +from squid.camera.utils import SimulatedCamera +from squid.config import CameraPixelFormat + +# The ambient camera config is 4168x4168; every camera built here overrides it with a tiny +# frame so a test never materializes a ~100 MB image. +SMALL_FRAME = {"crop_width": 64, "crop_height": 48, "default_binning": (1, 1)} + + +class _Ch: + """Minimal stand-in for AcquisitionChannel: the checkers only read name + camera.""" + + def __init__(self, name, camera=None): + self.name = name + self.camera = camera + + +class _FakeCamera: + """Camera-shaped stub for the geometry axes SimulatedCamera cannot vary (pixel size).""" + + def __init__(self, crop=(64, 48), pixel_format=CameraPixelFormat.MONO16, pixel_size_um=1.0): + self._crop = crop + self._pixel_format = pixel_format + self._pixel_size_um = pixel_size_um + + def get_crop_size(self): + return self._crop + + def get_resolution(self): + return self._crop + + def get_pixel_format(self): + return self._pixel_format + + def get_pixel_size_binned_um(self): + return self._pixel_size_um + + +def _sim(serial, pixel_format=CameraPixelFormat.MONO16, crop=None, **overrides): + updates = {"serial_number": serial, "default_pixel_format": pixel_format, **SMALL_FRAME} + if crop is not None: + updates["crop_width"], updates["crop_height"] = crop + updates.update(overrides) + config = squid.config.get_camera_config().model_copy(update=updates) + return SimulatedCamera(config, hw_trigger_fn=None, hw_set_strobe_delay_ms_fn=None) + + +# ---------------------------------------------------------------- pure checkers + + +def test_single_camera_selection_is_compatible(): + cameras = {1: _sim("SN1"), 2: _sim("SN2", pixel_format=CameraPixelFormat.RGB24)} + channels = [_Ch("DAPI", camera=1), _Ch("GFP", camera=None)] # None -> primary + assert get_camera_geometry_mismatch(channels, cameras) is None + + +def test_identical_geometry_two_cameras_is_compatible(): + cameras = {1: _sim("SN1"), 2: _sim("SN2")} + channels = [_Ch("DAPI", camera=1), _Ch("BF", camera=2)] + assert get_camera_geometry_mismatch(channels, cameras) is None + + +def test_color_vs_mono_mismatch_detected(): + cameras = {1: _sim("SN1"), 2: _sim("SN2", pixel_format=CameraPixelFormat.RGB24)} + channels = [_Ch("DAPI", camera=1), _Ch("BF Color", camera=2)] + message = get_camera_geometry_mismatch(channels, cameras) + assert message is not None + assert "color" in message and "mono" in message + assert "Zarr" in message + + +def test_crop_mismatch_detected(): + cameras = {1: _sim("SN1", crop=(3000, 3000)), 2: _sim("SN2", crop=(2000, 2000))} + channels = [_Ch("A", camera=1), _Ch("B", camera=2)] + assert get_camera_geometry_mismatch(channels, cameras) is not None + + +def test_uncropped_camera_compares_by_resolution(): + """A camera with no configured crop reports (None, None) from get_crop_size(); the + check must fall back to its resolution instead of treating every uncropped camera + as identical.""" + cameras = {1: _sim("SN1"), 2: _sim("SN2", crop_width=None, crop_height=None)} + channels = [_Ch("A", camera=1), _Ch("B", camera=2)] + assert get_camera_geometry_mismatch(channels, cameras) is not None + + +def test_pixel_size_mismatch_detected(): + cameras = {1: _FakeCamera(pixel_size_um=1.0), 2: _FakeCamera(pixel_size_um=2.0)} + channels = [_Ch("A", camera=1), _Ch("B", camera=2)] + assert get_camera_geometry_mismatch(channels, cameras) is not None + + +def test_pixel_size_difference_below_rounding_is_compatible(): + cameras = {1: _FakeCamera(pixel_size_um=1.0), 2: _FakeCamera(pixel_size_um=1.000001)} + channels = [_Ch("A", camera=1), _Ch("B", camera=2)] + assert get_camera_geometry_mismatch(channels, cameras) is None + + +def test_unavailable_camera_is_not_a_geometry_mismatch(): + """An unavailable camera is reported by the other checker, not as a geometry conflict.""" + cameras = {1: _sim("SN1")} + channels = [_Ch("OK", camera=1), _Ch("Ghost", camera=2)] + assert get_camera_geometry_mismatch(channels, cameras) is None + + +def test_unavailable_camera_channels_listed(): + cameras = {1: _sim("SN1")} + channels = [_Ch("OK", camera=1), _Ch("Ghost1", camera=2), _Ch("Ghost2", camera=2)] + assert get_unavailable_camera_channels(channels, cameras) == ["Ghost1", "Ghost2"] + assert get_unavailable_camera_channels([_Ch("OK", camera=None)], cameras) == [] + + +# ------------------------------------------------- controller backstop (headless) + +TWO_CAMERA_REGISTRY = CameraRegistryConfig( + cameras=[ + CameraDefinition(name="Main Camera", id=1, serial_number="SIM-1", type="Toupcam"), + CameraDefinition( + name="Side Camera", + id=2, + serial_number="SIM-2", + type="Toupcam", + hardware_trigger=False, + default_pixel_format="RGB24", + ), + ] +) + + +class _PastTheGuards(Exception): + """Raised in place of the first real acquisition step, so a test can assert that + run_acquisition got past the camera guards without starting an acquisition.""" + + +def _controller_stopped_after_guards(monkeypatch, registry): + """A simulated MultiPointController whose run_acquisition raises _PastTheGuards at + the first step after the camera guards.""" + monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: registry) + scope = control.microscope.Microscope.build_from_global_config(simulated=True, skip_init=True) + mpc = ts.get_test_multi_point_controller(microscope=scope) + + def _stop(*args, **kwargs): + raise _PastTheGuards() + + monkeypatch.setattr(mpc, "_start_per_acquisition_log", _stop) + return mpc + + +def _channels_on_cameras(mpc, camera_ids): + """The first len(camera_ids) configured channels, rebound to the given camera ids.""" + channels = mpc.liveController.get_channels(mpc.objectiveStore.current_objective) + assert len(channels) >= len(camera_ids) + return [ch.model_copy(update={"camera": camera_id}) for ch, camera_id in zip(channels, camera_ids)] + + +def test_zarr_rejects_mixed_camera_geometry(monkeypatch): + mpc = _controller_stopped_after_guards(monkeypatch, TWO_CAMERA_REGISTRY) + mpc.selected_configurations = _channels_on_cameras(mpc, [1, 2]) + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.ZARR_V3) + + with pytest.raises(ValueError) as excinfo: + mpc.run_acquisition() + assert "Zarr" in str(excinfo.value) + + +def test_ome_tiff_allows_mixed_camera_geometry(monkeypatch): + mpc = _controller_stopped_after_guards(monkeypatch, TWO_CAMERA_REGISTRY) + mpc.selected_configurations = _channels_on_cameras(mpc, [1, 2]) + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.OME_TIFF) + + with pytest.raises(_PastTheGuards): + mpc.run_acquisition() + + +def test_unavailable_camera_channel_blocks_start(monkeypatch): + mpc = _controller_stopped_after_guards(monkeypatch, None) # single-camera build + mpc.selected_configurations = _channels_on_cameras(mpc, [2]) # camera 2 never opened + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.OME_TIFF) + + with pytest.raises(ValueError) as excinfo: + mpc.run_acquisition() + assert "not " in str(excinfo.value) and "available" in str(excinfo.value) + + +def test_single_camera_selection_passes_both_guards(monkeypatch): + """The guards must be invisible on a normal single-camera system, Zarr included.""" + mpc = _controller_stopped_after_guards(monkeypatch, None) + mpc.selected_configurations = _channels_on_cameras(mpc, [None, 1]) + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.ZARR_V3) + + with pytest.raises(_PastTheGuards): + mpc.run_acquisition() + + +# ------------------------------------------------------ widget guard (Start disable) + + +class _GuardHost(_MultiCameraGuardMixin): + """Only the attributes the mixin touches, so the guard is testable without building a + whole multipoint widget (both real hosts wire the same pieces).""" + + def __init__(self, cameras, channels): + self.list_configurations = QListWidget() + self.list_configurations.setSelectionMode(QAbstractItemView.MultiSelection) + self.btn_startAcquisition = QPushButton() + self.objectiveStore = SimpleNamespace(current_objective="10x") + self._live_controller = SimpleNamespace( + microscope=SimpleNamespace(cameras=cameras), + get_channels=lambda objective: channels, + ) + self._create_multi_camera_warning_label() + # Parent everything, so the label's visibility is a child's hidden-flag rather + # than a stray top-level window. + self.container = QWidget() + layout = QVBoxLayout(self.container) + layout.addWidget(self.list_configurations) + layout.addWidget(self.label_multiCameraWarning) + layout.addWidget(self.btn_startAcquisition) + + def _guard_live_controller(self): + return self._live_controller + + def select(self, *name_and_label_pairs): + """Add selected rows carrying the bare channel name in Qt.UserRole and a decorated + visible label — the Task 9 identity convention the guard has to read through.""" + for name, label in name_and_label_pairs: + item = QListWidgetItem(label) + item.setData(Qt.UserRole, name) + self.list_configurations.addItem(item) + item.setSelected(True) + + +def _host(qtbot, cameras, channels, selection): + host = _GuardHost(cameras, channels) + qtbot.addWidget(host.container) + host.select(*selection) + return host + + +def _mixed_geometry_host(qtbot): + return _host( + qtbot, + {1: _sim("SN1"), 2: _sim("SN2", pixel_format=CameraPixelFormat.RGB24)}, + [_Ch("DAPI", camera=1), _Ch("BF Color", camera=2)], + [("DAPI", "DAPI"), ("BF Color", "BF Color — Side Camera")], + ) + + +def test_widget_guard_blocks_start_for_zarr_mixed_geometry(qtbot, monkeypatch): + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.ZARR_V3) + host = _mixed_geometry_host(qtbot) + + host._update_multi_camera_guard() + + assert not host.label_multiCameraWarning.isHidden() + assert "Zarr" in host.label_multiCameraWarning.text() + assert not host.btn_startAcquisition.isEnabled() + + +def test_widget_guard_allows_mixed_geometry_for_ome_tiff(qtbot, monkeypatch): + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.OME_TIFF) + host = _mixed_geometry_host(qtbot) + + host._update_multi_camera_guard() + + assert host.label_multiCameraWarning.isHidden() + assert host.btn_startAcquisition.isEnabled() + + +def test_widget_guard_blocks_unavailable_camera_in_any_format(qtbot, monkeypatch): + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.OME_TIFF) + host = _host( + qtbot, + {1: _sim("SN1")}, + [_Ch("DAPI", camera=1), _Ch("Ghost", camera=2)], + [("DAPI", "DAPI"), ("Ghost", "Ghost — camera 2 (unavailable)")], + ) + + host._update_multi_camera_guard() + + assert not host.label_multiCameraWarning.isHidden() + assert "Ghost" in host.label_multiCameraWarning.text() + assert not host.btn_startAcquisition.isEnabled() + + +def test_widget_guard_clears_once_the_conflict_is_gone(qtbot, monkeypatch): + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.ZARR_V3) + host = _mixed_geometry_host(qtbot) + host._update_multi_camera_guard() + assert not host.btn_startAcquisition.isEnabled() + + self_and_primary_only = host.list_configurations.item(1) + self_and_primary_only.setSelected(False) + host._update_multi_camera_guard() + + assert host.label_multiCameraWarning.isHidden() + assert host.btn_startAcquisition.isEnabled() + + +def test_widget_guard_is_inert_on_single_camera_systems(qtbot, monkeypatch): + """Zarr on a one-camera system: no warning, and the Start button is left exactly as + the widget's other owners set it (here: disabled by the loading-position lock).""" + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.ZARR_V3) + host = _host( + qtbot, + {1: _sim("SN1")}, + [_Ch("DAPI", camera=None), _Ch("GFP", camera=1)], + [("DAPI", "DAPI"), ("GFP", "GFP")], + ) + host.btn_startAcquisition.setEnabled(False) + + host._update_multi_camera_guard() + + assert host.label_multiCameraWarning.isHidden() + assert not host.btn_startAcquisition.isEnabled() From 6234031a63c3f30930eb50172a857a9fc7bd7554 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 09:33:41 -0700 Subject: [PATCH 19/52] fix(acquisition): add dtype axis, separate Start-button vetoes, re-check the camera guard at Start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the multi-camera acquisition guards. 1. Storage bit depth is now part of the geometry check. Two mono cameras of the same size and pixel size still differ as uint8 vs uint16, and a Zarr array's dtype is fixed by the first frame — the second camera's frames would have been silently cast instead of refused. New CameraPixelFormat.storage_bit_depth() buckets the formats (RGB24/RGB32/BAYER_RG8/MONO8 -> uint8, rest -> uint16) and the mismatch message names the dtype. 2. Start now has two explicit vetoes instead of one flag. The guard and the stage's loading-position lock each record their own, and the button is enabled only when neither vetoes; previously clearing a camera conflict re-enabled Start even with the stage at the loading position. Both owners go through the mixin's disable/enable_the_start_aquisition_button. 3. Two silent-conflict paths closed. toggle_acquisition and on_snap_images re-run the guard and show error_dialog, so a live switch to Zarr in Preferences no longer produces a click that does nothing (the controller backstop's ValueError inside a Qt slot is only logged); Preferences apply also re-runs the guard so the label updates live. The warning label now also names channels the channel list drops on its own because their camera never opened (a dropped YAML or a cached sequence), which nothing else reported — those do not veto Start. Co-Authored-By: Claude Fable 5 --- software/control/channel_sequence.py | 11 ++ software/control/core/multi_point_utils.py | 20 ++- software/control/gui_hcs.py | 6 + software/control/widgets.py | 102 ++++++++---- software/squid/config.py | 21 +++ .../test_multi_camera_acquisition_guards.py | 146 +++++++++++++++++- 6 files changed, 264 insertions(+), 42 deletions(-) diff --git a/software/control/channel_sequence.py b/software/control/channel_sequence.py index dd1674ffb..bfafa9782 100644 --- a/software/control/channel_sequence.py +++ b/software/control/channel_sequence.py @@ -278,6 +278,17 @@ def ordered_selected_names(self): config_set = set(self._config_order()) return [n for n in self._included_order if n in config_set and n not in self._disabled_names] + def unavailable_included_names(self): + """Channels the sequence asks for that ordered_selected_names() silently drops + because their camera is unavailable. + + A dropped acquisition YAML (or a cached sequence from when the camera was present) + can name such a channel: it is greyed out and excluded from the run without any + other signal, so the multipoint widgets surface this list in their warning label. + """ + config_set = set(self._config_order()) + return [n for n in self._included_order if n in config_set and n in self._disabled_names] + def set_included_order(self, names): self._included_order = reconcile_included(list(names), self._config_order()) self._rebuild() diff --git a/software/control/core/multi_point_utils.py b/software/control/core/multi_point_utils.py index 777a25d2d..0d6f1a885 100644 --- a/software/control/core/multi_point_utils.py +++ b/software/control/core/multi_point_utils.py @@ -174,20 +174,26 @@ def get_unavailable_camera_channels(selected_channels, cameras: Dict[int, Abstra return [ch.name for ch in selected_channels if _channel_camera_id(ch) not in cameras] -def _camera_frame_geometry(camera: AbstractCamera) -> Tuple[int, int, bool, float]: - """(width, height, is_color, pixel_size_um) of the frames this camera delivers. +def _camera_frame_geometry(camera: AbstractCamera) -> Tuple[int, int, bool, int, float]: + """(width, height, is_color, storage_bit_depth, pixel_size_um) for this camera's frames. get_crop_size() is None on an axis with no configured crop, and crop_image() clamps a crop larger than the frame, so the delivered size is the smaller of crop and resolution (both of which already account for binning). Comparing crop alone would make every uncropped camera look identical regardless of sensor size. + + Bit depth matters on its own: two mono cameras of the same size and pixel size still + differ as uint8 vs uint16, and a Zarr array's dtype is fixed by the first frame — the + second camera's frames would be silently up/down-cast rather than fail. """ crop_width, crop_height = camera.get_crop_size() resolution_width, resolution_height = camera.get_resolution() + pixel_format = camera.get_pixel_format() return ( min(crop_width, resolution_width) if crop_width else resolution_width, min(crop_height, resolution_height) if crop_height else resolution_height, - CameraPixelFormat.is_color_format(camera.get_pixel_format()), + CameraPixelFormat.is_color_format(pixel_format), + CameraPixelFormat.storage_bit_depth(pixel_format), round(camera.get_pixel_size_binned_um(), 4), ) @@ -197,8 +203,8 @@ def get_camera_geometry_mismatch(selected_channels, cameras: Dict[int, AbstractC Zarr stores one uniform array per region/FOV (shape+dtype fixed by the first frame, single pixel_size_um), so a mixed-camera selection is only Zarr-compatible when every - used camera matches in frame size, color-ness, and binned pixel size. Returns None when - compatible, else a user-facing message. + used camera matches in frame size, color-ness, storage bit depth, and binned pixel + size. Returns None when compatible, else a user-facing message. """ geometry_by_camera = {} for channel in selected_channels: @@ -210,8 +216,8 @@ def get_camera_geometry_mismatch(selected_channels, cameras: Dict[int, AbstractC if len(set(geometry_by_camera.values())) <= 1: return None details = "; ".join( - f"camera {camera_id}: {width}x{height} px, {'color' if is_color else 'mono'}, {pixel_um} um/px" - for camera_id, (width, height, is_color, pixel_um) in sorted(geometry_by_camera.items()) + f"camera {camera_id}: {width}x{height} px, {'color' if is_color else 'mono'} " f"uint{depth}, {pixel_um} um/px" + for camera_id, (width, height, is_color, depth, pixel_um) in sorted(geometry_by_camera.items()) ) return ( "Selected channels span cameras with different frame geometry " diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 9e71e22e0..e025809ff 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -2073,6 +2073,12 @@ def openPreferences(self): dialog.signal_config_changed.connect( lambda: self.navigationWidget.set_click_to_move(control._def.ENABLE_CLICK_TO_MOVE) ) + # Switching the file saving option to Zarr live can make the current channel + # selection unacquirable, so the multipoint guards re-run on apply. + if ENABLE_FLEXIBLE_MULTIPOINT: + dialog.signal_config_changed.connect(self.flexibleMultiPointWidget._update_multi_camera_guard) + if ENABLE_WELLPLATE_MULTIPOINT: + dialog.signal_config_changed.connect(self.wellplateMultiPointWidget._update_multi_camera_guard) dialog.exec_() else: self.log.warning("No configuration file found") diff --git a/software/control/widgets.py b/software/control/widgets.py index f119dd435..d59407357 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -1087,25 +1087,50 @@ class _MultiCameraGuardMixin: Host widgets call ``_create_multi_camera_warning_label()`` in add_components, place ``self.label_multiCameraWarning`` under their channel list, and must provide - ``list_configurations``, ``btn_startAcquisition``, ``objectiveStore`` and - ``_guard_live_controller()``. On single-camera systems the checks never fire. + ``list_configurations``, ``btn_startAcquisition``, ``channel_sequence``, + ``objectiveStore`` and ``_guard_live_controller()``. On single-camera systems the + checks never fire. """ + # Start has two independent owners: this guard and the stage's loading-position lock + # (gui_hcs.connectSlidePositionController, which wires bare enable/disable signals). + # Each records its own veto and the button is enabled only when neither vetoes, so + # lifting one veto can never override the other. Class-level defaults keep both + # owners safe if they fire before add_components. + _multi_camera_block_active = False + _start_button_externally_allowed = True + def _create_multi_camera_warning_label(self): self.label_multiCameraWarning = QLabel("") self.label_multiCameraWarning.setWordWrap(True) self.label_multiCameraWarning.setStyleSheet("color: #B00020; font-weight: bold;") self.label_multiCameraWarning.setVisible(False) - # Tracks whether *this* guard is the one holding Start disabled, so clearing the - # warning never re-enables a button some other owner disabled (e.g. the stage's - # loading-position lock in gui_hcs.connectSlidePositionController). self._multi_camera_block_active = False + self._start_button_externally_allowed = True def _guard_live_controller(self): """The LiveController whose microscope/channels the guard inspects.""" raise NotImplementedError - def _update_multi_camera_guard(self): + def _apply_start_button_state(self): + self.btn_startAcquisition.setEnabled( + self._start_button_externally_allowed and not self._multi_camera_block_active + ) + + def disable_the_start_aquisition_button(self): + self._start_button_externally_allowed = False + self._apply_start_button_state() + + def enable_the_start_aquisition_button(self): + self._start_button_externally_allowed = True + self._apply_start_button_state() + + def _update_multi_camera_guard(self) -> Optional[str]: + """Refresh the warning label and this guard's veto on Start. + + Returns the blocking message when the current selection must not be acquired, else + None — so Start-time callers can put the same text in a dialog. + """ from control.core import multi_point_utils live_controller = self._guard_live_controller() @@ -1124,16 +1149,35 @@ def _update_multi_camera_guard(self): mismatch = multi_point_utils.get_camera_geometry_mismatch(channels, microscope.cameras) if mismatch: problems.append(mismatch) - if problems: - self.label_multiCameraWarning.setText(" ".join(problems)) - self.label_multiCameraWarning.setVisible(True) - self._multi_camera_block_active = True - self.btn_startAcquisition.setEnabled(False) - else: - self.label_multiCameraWarning.setVisible(False) - if self._multi_camera_block_active: - self._multi_camera_block_active = False - self.btn_startAcquisition.setEnabled(True) + + self._multi_camera_block_active = bool(problems) + self._apply_start_button_state() + + # Channels the sequence asks for that the list drops on its own (their camera never + # opened). The remaining run is valid, so this does not veto Start — but without + # the label nothing tells the user those channels will not be imaged. + dropped = self.channel_sequence.unavailable_included_names() + notices = [f"Not acquired (camera unavailable): {', '.join(dropped)}."] if dropped else [] + + text = " ".join(problems + notices) + self.label_multiCameraWarning.setText(text) + self.label_multiCameraWarning.setVisible(bool(text)) + return " ".join(problems) if problems else None + + def _reject_if_multi_camera_conflict(self) -> bool: + """Re-check at Start and refuse visibly when the selection cannot be acquired. + + Needed because the conflict can appear after the last selection change — most + obviously a live switch of the file saving option to Zarr in Preferences. Without + this the run would reach MultiPointController's backstop, whose ValueError inside a + Qt slot is only logged, so the GUI would appear to ignore the click. + """ + blocking_message = self._update_multi_camera_guard() + if blocking_message is None: + return False + self.btn_startAcquisition.setChecked(False) + error_dialog(blocking_message) + return True class AcquisitionYAMLMismatchDialog(QDialog): @@ -6718,6 +6762,9 @@ def toggle_acquisition(self, pressed): self.btn_startAcquisition.setChecked(False) return + if self._reject_if_multi_camera_conflict(): + return + # add the current location to the location list if the list is empty if len(self.location_list) == 0: self.add_location() @@ -7185,6 +7232,9 @@ def on_snap_images(self): QMessageBox.warning(self, "Warning", "Please select at least one imaging channel") return + if self._reject_if_multi_camera_conflict(): + return + # Set the selected channels for acquisition self.multipointController.set_selected_configurations(self.channel_sequence.ordered_selected_names()) # Set the acquisition parameters @@ -7251,13 +7301,6 @@ def setEnabled_all(self, enabled, exclude_btn_startAcquisition=True): if exclude_btn_startAcquisition is not True: self.btn_startAcquisition.setEnabled(enabled) - def disable_the_start_aquisition_button(self): - self.btn_startAcquisition.setEnabled(False) - - def enable_the_start_aquisition_button(self): - self.btn_startAcquisition.setEnabled(True) - self._update_multi_camera_guard() # a camera conflict still keeps Start disabled - def set_performance_mode(self, enabled): self.performance_mode = enabled @@ -9090,6 +9133,9 @@ def toggle_acquisition(self, pressed): self.btn_startAcquisition.setChecked(False) return + if self._reject_if_multi_camera_conflict(): + return + # if XY is not checked, use current position if not self.checkbox_xy.isChecked(): self.set_coordinates_to_current_position() @@ -9261,13 +9307,6 @@ def setEnabled_all(self, enabled): # In Current Position mode, coverage should be disabled (N/A) self.entry_well_coverage.setEnabled(False) - def disable_the_start_aquisition_button(self): - self.btn_startAcquisition.setEnabled(False) - - def enable_the_start_aquisition_button(self): - self.btn_startAcquisition.setEnabled(True) - self._update_multi_camera_guard() # a camera conflict still keeps Start disabled - def set_performance_mode(self, enabled): self.performance_mode = enabled @@ -9285,6 +9324,9 @@ def on_snap_images(self): QMessageBox.warning(self, "Warning", "Please select at least one imaging channel") return + if self._reject_if_multi_camera_conflict(): + return + # Set the selected channels for acquisition self.multipointController.set_selected_configurations(self.channel_sequence.ordered_selected_names()) # Set the acquisition parameters diff --git a/software/squid/config.py b/software/squid/config.py index cd47d04ad..1ccde58dd 100644 --- a/software/squid/config.py +++ b/software/squid/config.py @@ -458,6 +458,27 @@ def is_color_format(pixel_format): CameraPixelFormat.BAYER_RG12, ) + @staticmethod + def storage_bit_depth(pixel_format) -> int: + """Bits per component of the array frames in this format are stored in: 8 (uint8) + or 16 (uint16). + + The member names carry bits per *pixel*, not per component: RGB24 and RGB32 are + 3x8 and 4x8 (see camera_toupcam's RGB32 -> "bit depth of 8" mapping), so both are + uint8 frames, while MONO10/12/14/16, RGB48 and BAYER_RG12 all need uint16. + """ + return ( + 8 + if pixel_format + in ( + CameraPixelFormat.MONO8, + CameraPixelFormat.RGB24, + CameraPixelFormat.RGB32, + CameraPixelFormat.BAYER_RG8, + ) + else 16 + ) + @staticmethod def from_string(pixel_format_string): return CameraPixelFormat[pixel_format_string] diff --git a/software/tests/control/test_multi_camera_acquisition_guards.py b/software/tests/control/test_multi_camera_acquisition_guards.py index bde331219..b690fb8c8 100644 --- a/software/tests/control/test_multi_camera_acquisition_guards.py +++ b/software/tests/control/test_multi_camera_acquisition_guards.py @@ -4,9 +4,13 @@ * unavailable camera: a channel bound to a camera id that never opened would silently be imaged on whatever camera happens to be active, so it must block the start. * mixed frame geometry: Zarr stores one uniform array per region, so a selection that - spans cameras with different frame shape/color-ness/pixel size cannot be saved. + spans cameras differing in frame size, color-ness, storage bit depth or pixel size + cannot be saved. + +Plus the widget layer that surfaces both before Start is pressed. """ +import inspect from types import SimpleNamespace import pytest @@ -15,6 +19,7 @@ import control._def import control.microscope +import control.widgets import squid.config import tests.control.test_stubs as ts from control.core.multi_point_utils import get_camera_geometry_mismatch, get_unavailable_camera_channels @@ -125,6 +130,41 @@ def test_unavailable_camera_is_not_a_geometry_mismatch(): assert get_camera_geometry_mismatch(channels, cameras) is None +def test_mono8_vs_mono16_mismatch_detected(): + """Same size, same pixel size, both mono — but uint8 vs uint16 frames. Zarr fixes the + array dtype from the first frame, so the other camera's frames would be silently + up/down-cast.""" + cameras = { + 1: _sim("SN1", pixel_format=CameraPixelFormat.MONO8), + 2: _sim("SN2", pixel_format=CameraPixelFormat.MONO16), + } + channels = [_Ch("A", camera=1), _Ch("B", camera=2)] + message = get_camera_geometry_mismatch(channels, cameras) + assert message is not None + assert "uint8" in message and "uint16" in message + + +def test_pixel_format_storage_bit_depth_buckets(): + """RGB24/RGB32 are 3x8 and 4x8 bits per pixel, i.e. uint8 frames; everything above + 8 bits per component needs uint16.""" + for eight_bit in ( + CameraPixelFormat.MONO8, + CameraPixelFormat.RGB24, + CameraPixelFormat.RGB32, + CameraPixelFormat.BAYER_RG8, + ): + assert CameraPixelFormat.storage_bit_depth(eight_bit) == 8 + for sixteen_bit in ( + CameraPixelFormat.MONO10, + CameraPixelFormat.MONO12, + CameraPixelFormat.MONO14, + CameraPixelFormat.MONO16, + CameraPixelFormat.RGB48, + CameraPixelFormat.BAYER_RG12, + ): + assert CameraPixelFormat.storage_bit_depth(sixteen_bit) == 16 + + def test_unavailable_camera_channels_listed(): cameras = {1: _sim("SN1")} channels = [_Ch("OK", camera=1), _Ch("Ghost1", camera=2), _Ch("Ghost2", camera=2)] @@ -221,11 +261,14 @@ class _GuardHost(_MultiCameraGuardMixin): """Only the attributes the mixin touches, so the guard is testable without building a whole multipoint widget (both real hosts wire the same pieces).""" - def __init__(self, cameras, channels): + def __init__(self, cameras, channels, dropped=()): self.list_configurations = QListWidget() self.list_configurations.setSelectionMode(QAbstractItemView.MultiSelection) self.btn_startAcquisition = QPushButton() self.objectiveStore = SimpleNamespace(current_objective="10x") + # Stands in for ChannelSequenceController: `dropped` are the channels the user's + # persisted sequence asks for that the list refuses to acquire (camera missing). + self.channel_sequence = SimpleNamespace(unavailable_included_names=lambda: list(dropped)) self._live_controller = SimpleNamespace( microscope=SimpleNamespace(cameras=cameras), get_channels=lambda objective: channels, @@ -252,8 +295,8 @@ def select(self, *name_and_label_pairs): item.setSelected(True) -def _host(qtbot, cameras, channels, selection): - host = _GuardHost(cameras, channels) +def _host(qtbot, cameras, channels, selection, dropped=()): + host = _GuardHost(cameras, channels, dropped=dropped) qtbot.addWidget(host.container) host.select(*selection) return host @@ -329,9 +372,102 @@ def test_widget_guard_is_inert_on_single_camera_systems(qtbot, monkeypatch): [_Ch("DAPI", camera=None), _Ch("GFP", camera=1)], [("DAPI", "DAPI"), ("GFP", "GFP")], ) - host.btn_startAcquisition.setEnabled(False) + host.disable_the_start_aquisition_button() host._update_multi_camera_guard() assert host.label_multiCameraWarning.isHidden() assert not host.btn_startAcquisition.isEnabled() + + +def test_guard_does_not_reenable_start_held_by_the_loading_position_lock(qtbot, monkeypatch): + """guard disables -> stage reaches loading position -> user fixes the selection. + The guard's veto lifts, but the loading-position veto is still on.""" + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.ZARR_V3) + host = _mixed_geometry_host(qtbot) + host._update_multi_camera_guard() + assert not host.btn_startAcquisition.isEnabled() # guard veto + + host.disable_the_start_aquisition_button() # stage reached the loading position + host.list_configurations.item(1).setSelected(False) # user drops the offending channel + host._update_multi_camera_guard() + + assert host.label_multiCameraWarning.isHidden() + assert not host.btn_startAcquisition.isEnabled(), "loading-position lock was overridden" + + host.enable_the_start_aquisition_button() # stage left the loading position + assert host.btn_startAcquisition.isEnabled() + + +def test_loading_position_lock_does_not_override_the_guard(qtbot, monkeypatch): + """The mirror image: leaving the loading position must not enable Start while a + camera conflict is still on screen.""" + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.ZARR_V3) + host = _mixed_geometry_host(qtbot) + host._update_multi_camera_guard() + + host.disable_the_start_aquisition_button() + host.enable_the_start_aquisition_button() + + assert not host.btn_startAcquisition.isEnabled() + + +def test_start_time_recheck_dialogs_after_a_live_switch_to_zarr(qtbot, monkeypatch): + """Preferences can flip FILE_SAVING_OPTION to Zarr long after the last selection + change, so Start re-runs the guard and refuses visibly instead of letting the + controller backstop raise into a log-only handler.""" + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.OME_TIFF) + host = _mixed_geometry_host(qtbot) + host._update_multi_camera_guard() + assert host.btn_startAcquisition.isEnabled() + + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.ZARR_V3) + shown = [] + monkeypatch.setattr(control.widgets, "error_dialog", lambda message, *a, **kw: shown.append(message)) + host.btn_startAcquisition.setChecked(True) + + assert host._reject_if_multi_camera_conflict() is True + assert len(shown) == 1 and "Zarr" in shown[0] + assert not host.btn_startAcquisition.isChecked() + assert not host.btn_startAcquisition.isEnabled() + + +def test_start_time_recheck_is_silent_when_there_is_no_conflict(qtbot, monkeypatch): + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.OME_TIFF) + host = _mixed_geometry_host(qtbot) + shown = [] + monkeypatch.setattr(control.widgets, "error_dialog", lambda message, *a, **kw: shown.append(message)) + + assert host._reject_if_multi_camera_conflict() is False + assert shown == [] + + +@pytest.mark.parametrize( + "widget_class", + [control.widgets.FlexibleMultiPointWidget, control.widgets.WellplateMultiPointWidget], +) +def test_multipoint_widgets_recheck_the_guard_when_start_is_pressed(widget_class): + """Both Start paths must go through the re-check; without it a conflict created after + the last selection change reaches the controller backstop, which only logs.""" + for method_name in ("toggle_acquisition", "on_snap_images"): + source = inspect.getsource(getattr(widget_class, method_name)) + assert "_reject_if_multi_camera_conflict" in source, f"{widget_class.__name__}.{method_name}" + + +def test_silently_dropped_channels_are_named_without_blocking_start(qtbot, monkeypatch): + """A dropped acquisition YAML (or a cached sequence) can name a channel whose camera + never opened; the list quietly removes it. The remaining run is valid, so Start stays + enabled, but the label has to say what will not be imaged.""" + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.ZARR_V3) + host = _host( + qtbot, + {1: _sim("SN1")}, + [_Ch("DAPI", camera=1), _Ch("Ghost", camera=2)], + [("DAPI", "DAPI")], # only DAPI is selectable; Ghost was dropped by the list + dropped=["Ghost"], + ) + + assert host._update_multi_camera_guard() is None + assert not host.label_multiCameraWarning.isHidden() + assert "Ghost" in host.label_multiCameraWarning.text() + assert host.btn_startAcquisition.isEnabled() From a7d1adea3e14e47a9421b065d6aaeb9750a60993 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 10:07:00 -0700 Subject: [PATCH 20/52] feat(acquisition): per-camera warm-up and per-channel pixel sizes in acquisition parameters The warm-up grab that today happens once (inside the disk-space estimate) now runs once per camera the run will use, ending on the first channel's camera, so no camera pays for its slow first frame inside the acquisition. Single-camera runs are unchanged: the only used id is already the active one, so it is still one grab and no switch. "The" sensor pixel size of a run is only the active camera's once channels can sit on different cameras, so acquisition parameters.json and acquisition.yaml now also carry channel_pixel_sizes_um (channel name -> objective factor x that channel's camera's binned pixel size). MultiPointWorker keeps _pixel_size_um and computes the same map alongside it. Co-Authored-By: Claude Fable 5 --- .../control/core/multi_point_controller.py | 55 +++++- software/control/core/multi_point_utils.py | 27 +++ software/control/core/multi_point_worker.py | 12 ++ .../test_multi_camera_acquisition_guards.py | 162 +++++++++++++++++- 4 files changed, 251 insertions(+), 5 deletions(-) diff --git a/software/control/core/multi_point_controller.py b/software/control/core/multi_point_controller.py index b6bbac858..82e5759c2 100644 --- a/software/control/core/multi_point_controller.py +++ b/software/control/core/multi_point_controller.py @@ -21,8 +21,10 @@ AcquisitionParameters, MultiPointControllerFunctions, ScanPositionInformation, + compute_channel_pixel_sizes, get_camera_geometry_mismatch, get_unavailable_camera_channels, + get_used_camera_ids, ) from control.core.scan_coordinates import ScanCoordinates from control.core.laser_auto_focus_controller import LaserAutofocusController @@ -502,6 +504,11 @@ def start_new_experiment(self, experiment_ID): # @@@ to do: change name to prep self._log.debug(f"Could not get default objective info: {e}") # TODO: USE OBJECTIVE STORE DATA acquisition_parameters["sensor_pixel_size_um"] = self.camera.get_pixel_size_binned_um() + # Only the active camera's sensor is described above; with channels spread over + # several cameras each one has its own image pixel size. + acquisition_parameters["channel_pixel_sizes_um"] = compute_channel_pixel_sizes( + self.selected_configurations, self.microscope.cameras, self.objectiveStore.get_pixel_size_factor() + ) acquisition_parameters["tube_lens_mm"] = control._def.TUBE_LENS_MM acquisition_parameters["confocal_mode"] = self.liveController.is_confocal_mode() f = open(os.path.join(self.base_path, self.experiment_ID) + "/acquisition parameters.json", "w") @@ -572,6 +579,44 @@ def _temporary_get_an_image_hack(self) -> Tuple[np.array, bool]: self.camera.stop_streaming() return (test_frame.frame, test_frame.is_color()) if test_frame else (None, False) + def _warm_up_cameras_and_get_test_image(self) -> Tuple[Optional[np.array], bool]: + """Grab one frame from every camera this acquisition will use. + + A camera's first frame after streaming starts is the slow one; taking it here means + no camera pays for it inside the run. Single-camera runs do exactly what they always + did — one grab, no switch — since the only used id is already the active one. + + Ends on the first channel's camera (the one the run starts on) and returns its + frame, which the disk-space estimate sizes an image from. + """ + used_camera_ids = get_used_camera_ids(self.selected_configurations) or [self.microscope.active_camera_id] + first_camera_id = used_camera_ids[0] + + test_image, is_color = (None, True) + try: + for camera_id in used_camera_ids: + if camera_id not in self.microscope.cameras: + # Unavailable: _raise_on_incompatible_camera_selection rejects this before + # a real run, and grabbing from whatever is active instead would be a lie. + continue + self._make_camera_active(camera_id) + frame, frame_is_color = self._temporary_get_an_image_hack() + if camera_id == first_camera_id: + test_image, is_color = frame, frame_is_color + finally: + try: + self._make_camera_active(first_camera_id) + except Exception: + # Not fatal — the worker switches per channel anyway — but losing the + # original failure by raising out of the finally would be. + self._log.exception(f"Could not return to camera {first_camera_id} after the warm-up") + return test_image, is_color + + def _make_camera_active(self, camera_id: int) -> None: + """Switch to camera_id, unless it is already active or never opened.""" + if camera_id in self.microscope.cameras and camera_id != self.microscope.active_camera_id: + self.microscope.set_active_camera(camera_id) + def get_estimated_acquisition_disk_storage(self): """ This does its best to return the number of bytes needed to store the settings for the currently @@ -587,7 +632,7 @@ def get_estimated_acquisition_disk_storage(self): test_image = None is_color = True try: - test_image, is_color = self._temporary_get_an_image_hack() + test_image, is_color = self._warm_up_cameras_and_get_test_image() except Exception as e: self._log.exception("Couldn't capture image from camera for size estimate, using worst cast image.") # Not ideal that we need to catch Exception, but the camera implementations vary wildly... @@ -879,7 +924,8 @@ def finish_fn(): # Gather objective and camera info for YAML current_objective = self.objectiveStore.current_objective objective_dict = self.objectiveStore.objectives_dict.get(current_objective, {}) - pixel_size_um = self.objectiveStore.get_pixel_size_factor() * self.camera.get_pixel_size_binned_um() + pixel_size_factor = self.objectiveStore.get_pixel_size_factor() + pixel_size_um = pixel_size_factor * self.camera.get_pixel_size_binned_um() objective_info = { "name": current_objective, "magnification": objective_dict.get("magnification"), @@ -887,6 +933,11 @@ def finish_fn(): "pixel_size_um": pixel_size_um, "camera_binning": list(self.camera.get_binning()) if hasattr(self.camera, "get_binning") else None, "sensor_pixel_size_um": self.camera.get_pixel_size_binned_um(), + # pixel_size_um/sensor_pixel_size_um above describe the active camera only; + # a run whose channels span cameras needs one number per channel. + "channel_pixel_sizes_um": compute_channel_pixel_sizes( + self.selected_configurations, self.microscope.cameras, pixel_size_factor + ), } # Get wellplate format if available diff --git a/software/control/core/multi_point_utils.py b/software/control/core/multi_point_utils.py index 0d6f1a885..6d0e07543 100644 --- a/software/control/core/multi_point_utils.py +++ b/software/control/core/multi_point_utils.py @@ -174,6 +174,33 @@ def get_unavailable_camera_channels(selected_channels, cameras: Dict[int, Abstra return [ch.name for ch in selected_channels if _channel_camera_id(ch) not in cameras] +def get_used_camera_ids(selected_channels) -> List[int]: + """Camera ids the selected channels image on, de-duplicated, in first-use order. + + First-use order (not sorted) because the acquisition starts on the first channel's + camera: a caller that visits every camera in this order ends up one switch away from + where the run begins. + """ + return list(dict.fromkeys(_channel_camera_id(channel) for channel in selected_channels)) + + +def compute_channel_pixel_sizes(selected_channels, cameras, pixel_size_factor) -> Dict[str, float]: + """Per-channel image pixel size in um: objective factor x that channel's camera's binned pixel size. + + With more than one camera "the" sensor pixel size of a run is only the active camera's, + so acquisition metadata records this map alongside it. A channel whose camera is not + available (or a run with no objective factor) is omitted rather than given a number + from the wrong sensor. + """ + sizes = {} + for channel in selected_channels: + camera = cameras.get(_channel_camera_id(channel)) + if camera is None or pixel_size_factor is None: + continue + sizes[channel.name] = float(pixel_size_factor) * float(camera.get_pixel_size_binned_um()) + return sizes + + def _camera_frame_geometry(camera: AbstractCamera) -> Tuple[int, int, bool, int, float]: """(width, height, is_color, storage_bit_depth, pixel_size_um) for this camera's frames. diff --git a/software/control/core/multi_point_worker.py b/software/control/core/multi_point_worker.py index 0e6990fd4..ed9faedd6 100644 --- a/software/control/core/multi_point_worker.py +++ b/software/control/core/multi_point_worker.py @@ -23,6 +23,7 @@ OverallProgressUpdate, RegionProgressUpdate, PlateViewInit, + compute_channel_pixel_sizes, ) from control.core.objective_store import ObjectiveStore from control.microcontroller import Microcontroller @@ -133,6 +134,7 @@ def __init__( self.selected_configurations = acquisition_parameters.selected_configurations # Pre-compute acquisition metadata that remains constant throughout the run. + pixel_factor = None try: pixel_factor = self.objectiveStore.get_pixel_size_factor() sensor_pixel_um = self.camera.get_pixel_size_binned_um() @@ -142,6 +144,16 @@ def __init__( self._pixel_size_um = None except Exception: self._pixel_size_um = None + # _pixel_size_um above is the camera that is active right now; on a run whose + # channels span cameras it is only right for that camera's channels. + try: + self._channel_pixel_sizes_um = compute_channel_pixel_sizes( + self.selected_configurations, self.microscope.cameras, pixel_factor + ) + except Exception: + self._channel_pixel_sizes_um = {} + if len(set(self._channel_pixel_sizes_um.values())) > 1: + self._log.info(f"Channels of this run have different pixel sizes (um): {self._channel_pixel_sizes_um}") self._time_increment_s = self.dt if self.Nt > 1 and self.dt > 0 else None self._physical_size_z_um = abs(self.deltaZ) * 1000 if self.NZ > 1 else None self.timestamp_acquisition_started = acquisition_parameters.acquisition_start_time diff --git a/software/tests/control/test_multi_camera_acquisition_guards.py b/software/tests/control/test_multi_camera_acquisition_guards.py index b690fb8c8..101a4d032 100644 --- a/software/tests/control/test_multi_camera_acquisition_guards.py +++ b/software/tests/control/test_multi_camera_acquisition_guards.py @@ -11,8 +11,10 @@ """ import inspect +import json from types import SimpleNamespace +import numpy as np import pytest from qtpy.QtCore import Qt from qtpy.QtWidgets import QAbstractItemView, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget @@ -22,6 +24,7 @@ import control.widgets import squid.config import tests.control.test_stubs as ts +from control.core.multi_point_controller import MultiPointController from control.core.multi_point_utils import get_camera_geometry_mismatch, get_unavailable_camera_channels from control.core.config.repository import ConfigRepository from control.models.camera_registry import CameraDefinition, CameraRegistryConfig @@ -172,6 +175,43 @@ def test_unavailable_camera_channels_listed(): assert get_unavailable_camera_channels([_Ch("OK", camera=None)], cameras) == [] +# ------------------------------------------------------ per-channel pixel size + + +def test_per_channel_pixel_sizes_computed(monkeypatch): + """compute_channel_pixel_sizes maps each selected channel to its camera's pixel size.""" + from control.core.multi_point_utils import compute_channel_pixel_sizes + + cameras = {1: _sim("SN1"), 2: _sim("SN2")} + cameras[2].set_binning(2, 2) # cam2 pixel size = 2x cam1 + channels = [_Ch("DAPI", camera=1), _Ch("BF Color", camera=2), _Ch("GFP", camera=None)] + sizes = compute_channel_pixel_sizes(channels, cameras, pixel_size_factor=0.5) + assert sizes["DAPI"] == pytest.approx(0.5 * cameras[1].get_pixel_size_binned_um()) + assert sizes["GFP"] == sizes["DAPI"] + assert sizes["BF Color"] == pytest.approx(0.5 * cameras[2].get_pixel_size_binned_um()) + + +def test_per_channel_pixel_sizes_omit_what_cannot_be_computed(): + """No camera (or no objective factor) means no pixel size to record: the key is left + out rather than filled with the active camera's number, which would be wrong.""" + from control.core.multi_point_utils import compute_channel_pixel_sizes + + cameras = {1: _sim("SN1")} + channels = [_Ch("DAPI", camera=1), _Ch("Ghost", camera=2)] + assert list(compute_channel_pixel_sizes(channels, cameras, 0.5)) == ["DAPI"] + assert compute_channel_pixel_sizes(channels, cameras, None) == {} + + +def test_used_camera_ids_are_deduplicated_in_first_use_order(): + """The acquisition starts on the first channel's camera, so first-use order (not + sorted order) is what a caller warming every camera up needs.""" + from control.core.multi_point_utils import get_used_camera_ids + + channels = [_Ch("BF", camera=2), _Ch("DAPI", camera=1), _Ch("GFP", camera=None), _Ch("TRITC", camera=2)] + assert get_used_camera_ids(channels) == [2, 1] # GFP's null camera is the primary, already seen + assert get_used_camera_ids([]) == [] + + # ------------------------------------------------- controller backstop (headless) TWO_CAMERA_REGISTRY = CameraRegistryConfig( @@ -194,12 +234,18 @@ class _PastTheGuards(Exception): run_acquisition got past the camera guards without starting an acquisition.""" +def _simulated_controller(monkeypatch, registry): + """A MultiPointController on a simulated microscope built from `registry` (None = the + single-camera build).""" + monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: registry) + scope = control.microscope.Microscope.build_from_global_config(simulated=True, skip_init=True) + return ts.get_test_multi_point_controller(microscope=scope) + + def _controller_stopped_after_guards(monkeypatch, registry): """A simulated MultiPointController whose run_acquisition raises _PastTheGuards at the first step after the camera guards.""" - monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: registry) - scope = control.microscope.Microscope.build_from_global_config(simulated=True, skip_init=True) - mpc = ts.get_test_multi_point_controller(microscope=scope) + mpc = _simulated_controller(monkeypatch, registry) def _stop(*args, **kwargs): raise _PastTheGuards() @@ -254,6 +300,116 @@ def test_single_camera_selection_passes_both_guards(monkeypatch): mpc.run_acquisition() +# ------------------------------------------------- warm-up + acquisition metadata + + +def _record_warm_up_grabs(monkeypatch, mpc, fail_on=None): + """Replace the real frame grab with a recorder of the camera it ran on. + + Each grab returns a frame filled with its camera's id (so a caller can tell which + camera's frame it kept) and reports camera 2 as the color one, matching + TWO_CAMERA_REGISTRY. + """ + grabbed_on = [] + + def _grab(): + camera_id = mpc.microscope.active_camera_id + grabbed_on.append(camera_id) + if camera_id == fail_on: + raise RuntimeError("camera fell over mid warm-up") + return (np.full((2, 2), camera_id, dtype=np.uint16), camera_id == 2) + + monkeypatch.setattr(mpc, "_temporary_get_an_image_hack", _grab) + return grabbed_on + + +def test_warm_up_grabs_one_frame_per_used_camera_and_ends_on_the_first(monkeypatch): + """Every camera the run will use pays its slow first frame before the run, not inside + it; the estimate keeps the frame from the camera the run starts on.""" + mpc = _simulated_controller(monkeypatch, TWO_CAMERA_REGISTRY) + mpc.selected_configurations = _channels_on_cameras(mpc, [2, 1, 2]) + grabbed_on = _record_warm_up_grabs(monkeypatch, mpc) + + test_image, is_color = mpc._warm_up_cameras_and_get_test_image() + + assert grabbed_on == [2, 1] # each used camera once, in first-use order + assert mpc.microscope.active_camera_id == 2 # back on the first channel's camera + assert int(test_image[0][0]) == 2 and is_color is True + + +def test_warm_up_on_a_single_camera_system_is_one_grab_and_no_switch(monkeypatch): + mpc = _simulated_controller(monkeypatch, None) + mpc.selected_configurations = _channels_on_cameras(mpc, [None, 1]) + switches = [] + monkeypatch.setattr(mpc.microscope, "set_active_camera", lambda camera_id: switches.append(camera_id)) + grabbed_on = _record_warm_up_grabs(monkeypatch, mpc) + + mpc._warm_up_cameras_and_get_test_image() + + assert grabbed_on == [control._def.PRIMARY_CAMERA_ID] + assert switches == [] + + +def test_warm_up_with_no_selection_stays_on_the_active_camera(monkeypatch): + """The disk estimate is reachable before any channel is selected; it must still grab + exactly one frame, from whatever camera is active.""" + mpc = _simulated_controller(monkeypatch, TWO_CAMERA_REGISTRY) + mpc.selected_configurations = [] + grabbed_on = _record_warm_up_grabs(monkeypatch, mpc) + + mpc._warm_up_cameras_and_get_test_image() + + assert grabbed_on == [mpc.microscope.active_camera_id] + + +def test_warm_up_skips_a_camera_that_never_opened(monkeypatch): + """A selection the start-time guard would reject can still reach the disk estimate; + warming up must not try to make a missing camera active.""" + mpc = _simulated_controller(monkeypatch, None) # only the primary camera exists + mpc.selected_configurations = _channels_on_cameras(mpc, [1, 2]) + grabbed_on = _record_warm_up_grabs(monkeypatch, mpc) + + mpc._warm_up_cameras_and_get_test_image() + + assert grabbed_on == [1] + + +def test_warm_up_puts_the_first_camera_back_when_a_later_grab_fails(monkeypatch): + """A failed grab must not strand the run on the wrong camera.""" + mpc = _simulated_controller(monkeypatch, TWO_CAMERA_REGISTRY) + mpc.selected_configurations = _channels_on_cameras(mpc, [2, 1]) + _record_warm_up_grabs(monkeypatch, mpc, fail_on=1) + + with pytest.raises(RuntimeError): + mpc._warm_up_cameras_and_get_test_image() + + assert mpc.microscope.active_camera_id == 2 + + +def test_acquisition_parameters_json_records_per_channel_pixel_sizes(monkeypatch, tmp_path): + mpc = _simulated_controller(monkeypatch, TWO_CAMERA_REGISTRY) + mpc.selected_configurations = _channels_on_cameras(mpc, [1, 2]) + mpc.microscope.cameras[2].set_binning(2, 2) # so the two channels cannot share a number + mpc.set_base_path(str(tmp_path)) + + mpc.start_new_experiment("pixel sizes") + + written = json.loads((tmp_path / mpc.experiment_ID / "acquisition parameters.json").read_text()) + factor = mpc.objectiveStore.get_pixel_size_factor() + assert written["sensor_pixel_size_um"] == mpc.camera.get_pixel_size_binned_um() # unchanged + assert written["channel_pixel_sizes_um"] == { + channel.name: factor * mpc.microscope.cameras[channel.camera].get_pixel_size_binned_um() + for channel in mpc.selected_configurations + } + + +def test_both_acquisition_metadata_writers_record_per_channel_pixel_sizes(): + """parameters.json and acquisition.yaml are written by two different methods; a run + is only fully described if both carry the per-channel map.""" + for method in (MultiPointController.start_new_experiment, MultiPointController.run_acquisition): + assert "channel_pixel_sizes_um" in inspect.getsource(method), method.__qualname__ + + # ------------------------------------------------------ widget guard (Start disable) From 32271bd374d4d134f7739ecc12535736885b9c74 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 10:26:27 -0700 Subject: [PATCH 21/52] fix(acquisition): quiesce live before the warm-up switches cameras; visit the starting camera last MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set_active_camera assumes triggering is quiesced (it reprograms the MCU trigger mode), but the live trigger timer is a threading.Timer on its own thread and can send_trigger() through the facade mid-switch — the one-pending-command race from PR #461. The warm-up now stops live first, but only when it is actually going to switch cameras, so the single-camera path is untouched. run_acquisition would otherwise read the stopped live view as "the user was not live" and never resume it, so the stop is handed off to it via _live_stopped_for_warm_up. Also: visit the run's starting camera last (it ends where the run begins, one switch fewer) and catch per camera, so a secondary camera that will not warm up no longer costs the estimate the frame it came for. Documents the warm-up's coverage — GUI Start with saving; not skip-saving, Snap Images, fluidics or headless — on the wrapper and on get_estimated_acquisition_disk_storage. Co-Authored-By: Claude Fable 5 --- .../control/core/multi_point_controller.py | 71 +++++++++-- .../test_multi_camera_acquisition_guards.py | 117 ++++++++++++++++-- 2 files changed, 171 insertions(+), 17 deletions(-) diff --git a/software/control/core/multi_point_controller.py b/software/control/core/multi_point_controller.py index 82e5759c2..381bcbedd 100644 --- a/software/control/core/multi_point_controller.py +++ b/software/control/core/multi_point_controller.py @@ -9,7 +9,7 @@ from datetime import datetime from enum import Enum from threading import Thread -from typing import Optional, Tuple, Any +from typing import List, Optional, Tuple, Any import numpy as np import pandas as pd @@ -200,6 +200,9 @@ def __init__( self._per_acq_log_handler = None self._memory_monitor: Optional[MemoryMonitor] = None self._slack_notifier = None # Optional SlackNotifier for notifications + # Set by the per-camera warm-up when it stops live to switch cameras; consumed by + # run_acquisition so live still resumes after the run. + self._live_stopped_for_warm_up = False # Pre-warm job runner subprocess at controller init (reduces acquisition start delay) # Backpressure values (tuple) are created here and shared with both the pre-warmed runner @@ -586,24 +589,43 @@ def _warm_up_cameras_and_get_test_image(self) -> Tuple[Optional[np.array], bool] no camera pays for it inside the run. Single-camera runs do exactly what they always did — one grab, no switch — since the only used id is already the active one. - Ends on the first channel's camera (the one the run starts on) and returns its - frame, which the disk-space estimate sizes an image from. + The run's starting camera (the first channel's) is visited LAST, so the loop ends + where the run begins — one switch fewer — and returns that camera's frame, which the + disk-space estimate sizes an image from. + + Coverage: the only caller is get_estimated_acquisition_disk_storage, i.e. the GUI + Start path with saving enabled. Skip-saving runs, Snap Images, the fluidics Start + path and headless/TCP runs are not warmed up — none of them ever were. """ used_camera_ids = get_used_camera_ids(self.selected_configurations) or [self.microscope.active_camera_id] first_camera_id = used_camera_ids[0] + visit_order = used_camera_ids[1:] + used_camera_ids[:1] # starting camera last + self._stop_live_before_camera_switches(used_camera_ids) test_image, is_color = (None, True) try: - for camera_id in used_camera_ids: + for camera_id in visit_order: if camera_id not in self.microscope.cameras: # Unavailable: _raise_on_incompatible_camera_selection rejects this before # a real run, and grabbing from whatever is active instead would be a lie. continue - self._make_camera_active(camera_id) - frame, frame_is_color = self._temporary_get_an_image_hack() + try: + self._make_camera_active(camera_id) + frame, frame_is_color = self._temporary_get_an_image_hack() + except Exception: + if camera_id == first_camera_id: + # The run starts on this one: let the caller log and fall back to a + # synthetic worst-case image, exactly as it did before the loop existed. + raise + # A secondary camera that will not warm up must not cost us the frame we + # came for; it just pays its first frame during the run. + self._log.exception(f"Warm-up of camera {camera_id} failed, continuing") + continue if camera_id == first_camera_id: test_image, is_color = frame, frame_is_color finally: + # Usually already there (starting camera visited last); this catches the paths + # where it was skipped or its switch rolled back to another camera. try: self._make_camera_active(first_camera_id) except Exception: @@ -612,6 +634,29 @@ def _warm_up_cameras_and_get_test_image(self) -> Tuple[Optional[np.array], bool] self._log.exception(f"Could not return to camera {first_camera_id} after the warm-up") return test_image, is_color + def _stop_live_before_camera_switches(self, used_camera_ids: List[int]) -> None: + """Quiesce live triggering when the warm-up is about to switch cameras. + + set_active_camera assumes triggering is quiesced — it reprograms the MCU trigger + mode — but the live trigger timer is a threading.Timer on its own thread and would + happily send_trigger() through the facade mid-switch, which is the one-pending-MCU- + command race from PR #461. run_acquisition stops live moments later anyway, so this + only moves the stop earlier, and only for a run that actually switches cameras: the + single-camera path never gets here. + """ + self._live_stopped_for_warm_up = False + switches_cameras = any( + camera_id in self.microscope.cameras and camera_id != self.microscope.active_camera_id + for camera_id in used_camera_ids + ) + if not (switches_cameras and self.liveController.is_live): + return + self._log.info("Stopping live view: the per-camera warm-up is about to switch cameras") + self.liveController.stop_live() + # run_acquisition decides whether to resume live afterwards by reading is_live; without + # this it would find the live view we just stopped and never bring it back. + self._live_stopped_for_warm_up = True + def _make_camera_active(self, camera_id: int) -> None: """Switch to camera_id, unless it is already active or never opened.""" if camera_id in self.microscope.cameras and camera_id != self.microscope.active_camera_id: @@ -622,6 +667,10 @@ def get_estimated_acquisition_disk_storage(self): This does its best to return the number of bytes needed to store the settings for the currently configured acquisition on disk. If you don't have at least this amount of disk space available when starting this acquisition, it is likely it will fail with an "out of disk space" error. + + Side effect on multi-camera systems: this also performs the acquisition's per-camera + warm-up, which switches cameras (and stops live view to do so safely) and leaves the + run's starting camera active. """ # TODO(imo): This needs updating for AbstractCamera if not len(self.liveController.get_channels(self.objectiveStore.current_objective)): @@ -814,12 +863,14 @@ def run_acquisition(self, acquire_current_fov=False): self.abort_acqusition_requested = False self.configuration_before_running_multipoint = self.liveController.currentConfiguration - # stop live + # stop live. The per-camera warm-up may already have stopped it so it could switch + # cameras safely; either way the user was live before this run and wants it back. + self.liveController_was_live_before_multipoint = ( + self.liveController.is_live or self._live_stopped_for_warm_up + ) + self._live_stopped_for_warm_up = False if self.liveController.is_live: - self.liveController_was_live_before_multipoint = True self.liveController.stop_live() # @@@ to do: also uncheck the live button - else: - self.liveController_was_live_before_multipoint = False self.camera_callback_was_enabled_before_multipoint = self.camera.get_callbacks_enabled() # We need callbacks, because we trigger and then use callbacks for image processing. This diff --git a/software/tests/control/test_multi_camera_acquisition_guards.py b/software/tests/control/test_multi_camera_acquisition_guards.py index 101a4d032..af6978fab 100644 --- a/software/tests/control/test_multi_camera_acquisition_guards.py +++ b/software/tests/control/test_multi_camera_acquisition_guards.py @@ -323,17 +323,18 @@ def _grab(): return grabbed_on -def test_warm_up_grabs_one_frame_per_used_camera_and_ends_on_the_first(monkeypatch): +def test_warm_up_grabs_one_frame_per_used_camera_and_ends_on_the_starting_one(monkeypatch): """Every camera the run will use pays its slow first frame before the run, not inside - it; the estimate keeps the frame from the camera the run starts on.""" + it. The run's starting camera goes last, so the loop ends where the run begins and the + estimate keeps that camera's frame.""" mpc = _simulated_controller(monkeypatch, TWO_CAMERA_REGISTRY) mpc.selected_configurations = _channels_on_cameras(mpc, [2, 1, 2]) grabbed_on = _record_warm_up_grabs(monkeypatch, mpc) test_image, is_color = mpc._warm_up_cameras_and_get_test_image() - assert grabbed_on == [2, 1] # each used camera once, in first-use order - assert mpc.microscope.active_camera_id == 2 # back on the first channel's camera + assert grabbed_on == [1, 2] # each used camera once, starting camera (2) last + assert mpc.microscope.active_camera_id == 2 # where the run begins assert int(test_image[0][0]) == 2 and is_color is True @@ -374,11 +375,26 @@ def test_warm_up_skips_a_camera_that_never_opened(monkeypatch): assert grabbed_on == [1] -def test_warm_up_puts_the_first_camera_back_when_a_later_grab_fails(monkeypatch): - """A failed grab must not strand the run on the wrong camera.""" +def test_warm_up_survives_a_secondary_camera_failing(monkeypatch): + """A camera that will not warm up costs the run its own first frame, nothing more — it + must not discard the frame the estimate came for.""" + mpc = _simulated_controller(monkeypatch, TWO_CAMERA_REGISTRY) + mpc.selected_configurations = _channels_on_cameras(mpc, [2, 1]) # starts on 2, so 1 is secondary + grabbed_on = _record_warm_up_grabs(monkeypatch, mpc, fail_on=1) + + test_image, _ = mpc._warm_up_cameras_and_get_test_image() + + assert grabbed_on == [1, 2] # tried the secondary, carried on to the starting camera + assert int(test_image[0][0]) == 2 + assert mpc.microscope.active_camera_id == 2 + + +def test_warm_up_reraises_when_the_starting_camera_fails_and_stays_on_it(monkeypatch): + """The starting camera failing is what the caller's worst-case-image fallback is for, + so that one still propagates — and must not strand the run on another camera.""" mpc = _simulated_controller(monkeypatch, TWO_CAMERA_REGISTRY) mpc.selected_configurations = _channels_on_cameras(mpc, [2, 1]) - _record_warm_up_grabs(monkeypatch, mpc, fail_on=1) + _record_warm_up_grabs(monkeypatch, mpc, fail_on=2) with pytest.raises(RuntimeError): mpc._warm_up_cameras_and_get_test_image() @@ -386,6 +402,93 @@ def test_warm_up_puts_the_first_camera_back_when_a_later_grab_fails(monkeypatch) assert mpc.microscope.active_camera_id == 2 +def test_warm_up_stops_live_before_it_switches_cameras(monkeypatch): + """set_active_camera assumes triggering is quiesced; the live trigger timer is its own + thread and would send a trigger into the middle of the switch.""" + mpc = _simulated_controller(monkeypatch, TWO_CAMERA_REGISTRY) + mpc.selected_configurations = _channels_on_cameras(mpc, [2, 1]) + calls = [] + mpc.liveController.is_live = True + + def _stop_live(): + mpc.liveController.is_live = False + calls.append("stop_live") + + monkeypatch.setattr(mpc.liveController, "stop_live", _stop_live) + real_set_active_camera = mpc.microscope.set_active_camera + + def _set_active_camera(camera_id): + calls.append(f"switch to {camera_id}") + real_set_active_camera(camera_id) + + monkeypatch.setattr(mpc.microscope, "set_active_camera", _set_active_camera) + _record_warm_up_grabs(monkeypatch, mpc) + + mpc._warm_up_cameras_and_get_test_image() + + assert calls[0] == "stop_live", calls + assert "switch to 2" in calls + assert mpc._live_stopped_for_warm_up is True # so run_acquisition still resumes live + + +def test_warm_up_leaves_live_alone_on_a_single_camera_system(monkeypatch): + """Parity: the single-camera path never switches, so it must not stop live either.""" + mpc = _simulated_controller(monkeypatch, None) + mpc.selected_configurations = _channels_on_cameras(mpc, [None, 1]) + mpc.liveController.is_live = True + stopped = [] + monkeypatch.setattr(mpc.liveController, "stop_live", lambda: stopped.append(True)) + _record_warm_up_grabs(monkeypatch, mpc) + + mpc._warm_up_cameras_and_get_test_image() + + assert stopped == [] + assert mpc.liveController.is_live is True + assert mpc._live_stopped_for_warm_up is False + + +def test_warm_up_leaves_live_alone_when_no_camera_switch_is_needed(monkeypatch): + """Two cameras, but every selected channel is on the one already active.""" + mpc = _simulated_controller(monkeypatch, TWO_CAMERA_REGISTRY) + mpc.selected_configurations = _channels_on_cameras(mpc, [1, None]) + mpc.liveController.is_live = True + stopped = [] + monkeypatch.setattr(mpc.liveController, "stop_live", lambda: stopped.append(True)) + _record_warm_up_grabs(monkeypatch, mpc) + + mpc._warm_up_cameras_and_get_test_image() + + assert stopped == [] + assert mpc._live_stopped_for_warm_up is False + + +@pytest.mark.parametrize( + "is_live, stopped_for_warm_up, expected", + [(True, False, True), (False, True, True), (False, False, False)], +) +def test_run_acquisition_resumes_live_the_warm_up_stopped(monkeypatch, is_live, stopped_for_warm_up, expected): + """The warm-up stopping live must not read, to run_acquisition, as "the user was not + live" — that would silently drop the resume-live-afterwards behavior.""" + mpc = _simulated_controller(monkeypatch, None) + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.OME_TIFF) + monkeypatch.setattr(mpc, "_start_per_acquisition_log", lambda *a, **kw: None) + monkeypatch.setattr(mpc.liveController, "stop_live", lambda: setattr(mpc.liveController, "is_live", False)) + mpc.liveController.is_live = is_live + mpc._live_stopped_for_warm_up = stopped_for_warm_up + + # The first step after the live-stop block, so the run never actually starts. + def _stop(*args, **kwargs): + raise _PastTheGuards() + + monkeypatch.setattr(mpc.camera, "enable_callbacks", _stop) + + with pytest.raises(_PastTheGuards): + mpc.run_acquisition() + + assert mpc.liveController_was_live_before_multipoint is expected + assert mpc._live_stopped_for_warm_up is False # consumed, so it cannot leak into a later run + + def test_acquisition_parameters_json_records_per_channel_pixel_sizes(monkeypatch, tmp_path): mpc = _simulated_controller(monkeypatch, TWO_CAMERA_REGISTRY) mpc.selected_configurations = _channels_on_cameras(mpc, [1, 2]) From db33d7d811b0a75f7b37025c64eb31c67cca3b17 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 11:04:49 -0700 Subject: [PATCH 22/52] test(dual-camera): end-to-end mixed-camera sim acquisition; tracking/recording primary-camera guard End-to-end test: a two-channel selection spanning the mono primary and the RGB secondary camera runs a full 1-FOV simulated acquisition to completion under INDIVIDUAL_IMAGES (the one non-Zarr saver that can persist an RGB frame today - the OME-TIFF writer is 2D-grayscale-only), asserting the per-channel camera switch sequence and one correctly-shaped file per channel on disk. Headless test: set_microscope_mode + acquire_image on a camera-2 channel routes the trigger and frame read to camera 2 (the MCP snap path). GUI guard: opening the Tracking or Simple Recording tab on a multi-camera system forces the primary camera, stopping live first (new LiveControlWidget.stop_live keeps the Live button in sync). redraw_fov now no-ops before the first stage-position event instead of raising a logged TypeError when a camera switch happens right after startup. Co-Authored-By: Claude Fable 5 --- software/control/core/core.py | 5 + software/control/gui_hcs.py | 22 ++++ software/control/widgets.py | 6 + .../control/test_HighContentScreeningGui.py | 35 ++++++ .../control/test_microscope_multi_camera.py | 37 +++++- .../test_multi_camera_acquisition_guards.py | 115 +++++++++++++++++- 6 files changed, 216 insertions(+), 4 deletions(-) diff --git a/software/control/core/core.py b/software/control/core/core.py index 64f030f04..362c9dd87 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -1662,6 +1662,11 @@ def update_fov_size(self): def redraw_fov(self): self.clear_overlay() self.update_fov_size() + # No stage position seen yet (x_mm/y_mm are set by position_after_move events): + # nothing to draw. Reachable via a camera switch right after startup, before the + # first position event lands. Mirrors draw_fov_current_location's None handling. + if self.x_mm is None or self.y_mm is None: + return self.draw_current_fov(self.x_mm, self.y_mm) def update_wellplate_settings( diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index e025809ff..5fc209a61 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -2414,6 +2414,28 @@ def _refresh_channel_lists(self): self.multiPointWithFluidicsWidget.refresh_channel_list() def onTabChanged(self, index): + # Dual-camera v1: Tracking and Simple Recording drive the primary camera's + # pipeline only, so opening one of those tabs forces the primary camera. + tab_name = self.recordTabWidget.tabText(index) + if ( + tab_name in ("Tracking", "Simple Recording") + and self.microscope.has_multiple_cameras() + and self.microscope.active_camera_id != PRIMARY_CAMERA_ID + ): + self.log.info("Switching to primary camera: Tracking/Simple Recording use the primary camera only (v1).") + try: + # set_active_camera assumes triggering is quiesced; stop live (and sync + # the Live button) before switching. Not restarted: these tabs' flows + # start their own live/streaming. + if self.liveController.is_live: + self.liveControlWidget.stop_live() + self.microscope.set_active_camera(PRIMARY_CAMERA_ID) + except Exception: + # Broad on purpose: a failed switch (e.g. MCU timeout) already rolled + # back inside set_active_camera, and a tab change must never raise into + # Qt's signal dispatch. + self.log.exception("Could not switch to primary camera for Tracking/Simple Recording") + is_flexible_acquisition = ( (index == self.recordTabWidget.indexOf(self.flexibleMultiPointWidget)) if ENABLE_FLEXIBLE_MULTIPOINT diff --git a/software/control/widgets.py b/software/control/widgets.py index d59407357..a13e9fd81 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -4582,6 +4582,12 @@ def toggle_live(self, pressed): self.liveController.stop_live() self.btn_live.setText("Start Live") + def stop_live(self): + """Stop live and sync the Live button (for flows that must quiesce live, e.g. + switching to a tab that forces a camera change).""" + self.toggle_live(False) + self.btn_live.setChecked(False) + def toggle_autolevel(self, autolevel_on): self.btn_autolevel.setChecked(autolevel_on) diff --git a/software/tests/control/test_HighContentScreeningGui.py b/software/tests/control/test_HighContentScreeningGui.py index 5949b395f..a02afdc63 100644 --- a/software/tests/control/test_HighContentScreeningGui.py +++ b/software/tests/control/test_HighContentScreeningGui.py @@ -191,6 +191,41 @@ def test_tab_change_to_simple_recording_does_not_raise(qtbot, monkeypatch, confi win.toggleAcquisitionStart(False) +def test_tracking_recording_tabs_force_primary_camera(qtbot, monkeypatch, confirm_exit_yes): + """Dual-camera v1: Tracking and Simple Recording drive the primary camera's pipeline + only, so opening one of those tabs while another camera is active must switch back + to the primary (quiescing live first) — and multipoint tabs must not.""" + monkeypatch.setattr(control.gui_hcs, "ENABLE_RECORDING", True) + monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: TWO_CAMERA_REGISTRY) + + scope = control.microscope.Microscope.build_from_global_config(True) + win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) + qtbot.add_widget(win) + + recording_index = win.recordTabWidget.indexOf(win.recordingControlWidget) + assert recording_index >= 0, "Simple Recording tab was not added despite ENABLE_RECORDING" + + # A multipoint tab leaves the active camera alone. + scope.set_active_camera(2) + win.onTabChanged(win.recordTabWidget.indexOf(win.wellplateMultiPointWidget)) + assert scope.active_camera_id == 2 + + # Simple Recording forces the primary camera, stopping live (and syncing the Live + # button) first — set_active_camera assumes triggering is quiesced. + win.liveController.is_live = True + win.liveControlWidget.btn_live.setChecked(True) + win.onTabChanged(recording_index) + assert scope.active_camera_id == control._def.PRIMARY_CAMERA_ID + assert win.liveController.is_live is False + assert not win.liveControlWidget.btn_live.isChecked() + + # Re-selecting the tab with the primary already active is a no-op (no stray switch, + # live left alone). + win.onTabChanged(recording_index) + assert scope.active_camera_id == control._def.PRIMARY_CAMERA_ID + assert win.liveController.is_live is False + + def test_acquisition_start_emits_selected_channels(qtbot, confirm_exit_yes): """The napari multichannel viewer is initialized from signal_acquisition_channels. That signal must be emitted when the acquisition starts (alongside diff --git a/software/tests/control/test_microscope_multi_camera.py b/software/tests/control/test_microscope_multi_camera.py index 794917398..802224380 100644 --- a/software/tests/control/test_microscope_multi_camera.py +++ b/software/tests/control/test_microscope_multi_camera.py @@ -1,3 +1,4 @@ +import numpy as np import pytest import control._def @@ -7,9 +8,20 @@ from control.models.camera_registry import CameraDefinition, CameraRegistryConfig from squid.camera.facade import ActiveCameraFacade +# Tiny per-camera crops: the ambient INI camera config is 4168x4168, and the headless +# snap test below materializes a real frame — 64x48 keeps that to a few KB instead of +# ~52 MB of RGB24. TWO_CAMERA_REGISTRY = CameraRegistryConfig( cameras=[ - CameraDefinition(name="Main Camera", id=1, serial_number="SIM-1", type="Toupcam"), + CameraDefinition( + name="Main Camera", + id=1, + serial_number="SIM-1", + type="Toupcam", + crop_width=64, + crop_height=48, + default_binning=[1, 1], + ), CameraDefinition( name="Side Camera", id=2, @@ -17,6 +29,9 @@ type="Toupcam", hardware_trigger=False, default_pixel_format="RGB24", + crop_width=64, + crop_height=48, + default_binning=[1, 1], ), ] ) @@ -172,3 +187,23 @@ def test_facade_reports_active_camera_hw_capability(two_camera_scope): assert scope.camera.supports_hardware_trigger() is True scope.set_active_camera(2) assert scope.camera.supports_hardware_trigger() is False + + +def test_headless_acquire_image_on_secondary_channel(two_camera_scope): + """MCP/TCP-server path equivalence: the control server's snap API is + set_microscope_mode + acquire_image on the microscope, so selecting a channel + bound to camera 2 must route the trigger and the frame read to camera 2.""" + scope = two_camera_scope + channel = _make_channel("BF Color", camera_id=2, exposure_ms=5) + scope.live_controller.set_microscope_mode(channel) + assert scope.active_camera_id == 2 + assert scope.camera.get_active_id() == 2 + + scope.camera.start_streaming() + image = scope.acquire_image() + + assert image is not None + # Camera 2 is the RGB24 one (camera 1 serves 2D MONO16), so a 3-channel uint8 + # frame proves the facade read from the channel's camera, not the primary. + assert image.ndim == 3 and image.shape[2] == 3 + assert image.dtype == np.uint8 diff --git a/software/tests/control/test_multi_camera_acquisition_guards.py b/software/tests/control/test_multi_camera_acquisition_guards.py index af6978fab..68e12a5ec 100644 --- a/software/tests/control/test_multi_camera_acquisition_guards.py +++ b/software/tests/control/test_multi_camera_acquisition_guards.py @@ -10,24 +10,30 @@ Plus the widget layer that surfaces both before Start is pressed. """ +import dataclasses import inspect import json +import os +import threading from types import SimpleNamespace +import imageio.v2 as iio import numpy as np import pytest from qtpy.QtCore import Qt from qtpy.QtWidgets import QAbstractItemView, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget import control._def +import control.core.multi_point_worker import control.microscope import control.widgets import squid.config import tests.control.test_stubs as ts -from control.core.multi_point_controller import MultiPointController +from control.core.multi_point_controller import MultiPointController, NoOpCallbacks from control.core.multi_point_utils import get_camera_geometry_mismatch, get_unavailable_camera_channels from control.core.config.repository import ConfigRepository from control.models.camera_registry import CameraDefinition, CameraRegistryConfig +from control.utils_acquisition import get_image_filepath from control.widgets import _MultiCameraGuardMixin from squid.camera.utils import SimulatedCamera from squid.config import CameraPixelFormat @@ -214,9 +220,20 @@ def test_used_camera_ids_are_deduplicated_in_first_use_order(): # ------------------------------------------------- controller backstop (headless) +# Tiny per-camera crops for the same reason as SMALL_FRAME above: the end-to-end +# acquisition test materializes real frames from both cameras. TWO_CAMERA_REGISTRY = CameraRegistryConfig( cameras=[ - CameraDefinition(name="Main Camera", id=1, serial_number="SIM-1", type="Toupcam"), + CameraDefinition( + name="Main Camera", + id=1, + serial_number="SIM-1", + type="Toupcam", + default_pixel_format="MONO16", + crop_width=64, + crop_height=48, + default_binning=[1, 1], + ), CameraDefinition( name="Side Camera", id=2, @@ -224,6 +241,9 @@ def test_used_camera_ids_are_deduplicated_in_first_use_order(): type="Toupcam", hardware_trigger=False, default_pixel_format="RGB24", + crop_width=64, + crop_height=48, + default_binning=[1, 1], ), ] ) @@ -234,11 +254,13 @@ class _PastTheGuards(Exception): run_acquisition got past the camera guards without starting an acquisition.""" -def _simulated_controller(monkeypatch, registry): +def _simulated_controller(monkeypatch, registry, callbacks=None): """A MultiPointController on a simulated microscope built from `registry` (None = the single-camera build).""" monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: registry) scope = control.microscope.Microscope.build_from_global_config(simulated=True, skip_init=True) + if callbacks is not None: + return ts.get_test_multi_point_controller(microscope=scope, callbacks=callbacks) return ts.get_test_multi_point_controller(microscope=scope) @@ -513,6 +535,93 @@ def test_both_acquisition_metadata_writers_record_per_channel_pixel_sizes(): assert "channel_pixel_sizes_um" in inspect.getsource(method), method.__qualname__ +# ------------------------------------------------- end-to-end mixed acquisition (sim) + + +def test_end_to_end_mixed_camera_acquisition_saves_both_channels(monkeypatch, tmp_path): + """Full-pipeline proof for dual-camera v1: a two-channel selection spanning the mono + primary and the RGB secondary camera runs to completion in simulation, switches the + active camera per channel, and lands one file per channel on disk. + + INDIVIDUAL_IMAGES is the format on purpose: mixed geometry under Zarr is exactly + what the controller guard rejects, and the OME-TIFF writer supports 2D grayscale + only (utils_ome_tiff_writer.validate_capture_info raises for RGB frames), so + individual images is the one non-Zarr saver that can persist the RGB channel today. + Save jobs run in a JobRunner subprocess, which re-reads the INI; the checked-in test + INIs default to INDIVIDUAL_IMAGES, and SaveImageJob only special-cases + MULTI_PAGE_TIFF, so the subprocess saves individual images either way. + """ + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.INDIVIDUAL_IMAGES) + # multi_point_worker star-imports _def at module load; pin its stale binding too so + # the worker picks SaveImageJob regardless of the ambient INI. + monkeypatch.setattr( + control.core.multi_point_worker, "FILE_SAVING_OPTION", control._def.FileSavingOption.INDIVIDUAL_IMAGES + ) + monkeypatch.setattr(control._def, "MERGE_CHANNELS", False) + + started = threading.Event() + finished = threading.Event() + imaged_channels = [] + callbacks = dataclasses.replace( + NoOpCallbacks, + signal_acquisition_start=lambda params: started.set(), + signal_acquisition_finished=finished.set, + signal_new_image=lambda frame, info: imaged_channels.append(info.configuration.name), + ) + mpc = _simulated_controller(monkeypatch, TWO_CAMERA_REGISTRY, callbacks=callbacks) + + # Two bare-name channels (the name is the channel identity), one per camera. The + # names deliberately avoid the worker's name-triggered specials ("RGB" composites, + # "BF LED matrix" save transforms) so this exercises the plain per-channel path. + base = mpc.liveController.get_channels(mpc.objectiveStore.current_objective)[0] + mono = base.model_copy(update={"name": "Main Mono", "camera": 1}) + color = base.model_copy(update={"name": "Side Color", "camera": 2}) + mpc.selected_configurations = [mono, color] + + # Tiny run — 1 region, 1 FOV, NZ=1, Nt=1 — so completion never races the timeouts. + mpc.set_NZ(1) + mpc.set_Nt(1) + stage_config = mpc.stage.get_config() + mpc.scanCoordinates.clear_regions() + mpc.scanCoordinates.add_single_fov_region( + "e2e", + center_x=stage_config.X_AXIS.MIN_POSITION + 0.5, + center_y=stage_config.Y_AXIS.MIN_POSITION + 0.5, + center_z=stage_config.Z_AXIS.MIN_POSITION + 0.1, + ) + mpc.set_base_path(str(tmp_path)) + mpc.start_new_experiment("mixed camera e2e") + + switches = [] + mpc.microscope.add_camera_change_listener(switches.append) + + mpc.run_acquisition() + # Generous waits: the JobRunner subprocess spawn dominates, not the two tiny frames. + assert started.wait(30) + assert finished.wait(120) + + # (a) completed, having imaged each channel exactly once + assert sorted(imaged_channels) == ["Main Mono", "Side Color"] + + # (c) the active camera toggled: to 2 for the second channel, back to 1 when the + # controller restored the pre-acquisition (primary-camera) channel on completion. + assert switches == [2, 1] + + # (b) one file per channel in the timepoint folder, each with its own camera's + # geometry — 2D uint16 from the mono primary, HxWx3 uint8 from the RGB secondary. + padding = control._def.FILE_ID_PADDING + timepoint_dir = os.path.join(str(tmp_path), mpc.experiment_ID, f"{0:0{padding}}") + file_id = f"e2e_{0:0{padding}}_{0:0{padding}}" + mono_path = get_image_filepath(timepoint_dir, file_id, "Main Mono", np.uint16) + color_path = get_image_filepath(timepoint_dir, file_id, "Side Color", np.uint8) + assert os.path.isfile(mono_path), sorted(os.listdir(timepoint_dir)) + assert os.path.isfile(color_path), sorted(os.listdir(timepoint_dir)) + mono_image = iio.imread(mono_path) + color_image = iio.imread(color_path) + assert mono_image.ndim == 2 and mono_image.dtype == np.uint16 + assert color_image.ndim == 3 and color_image.shape[2] == 3 and color_image.dtype == np.uint8 + + # ------------------------------------------------------ widget guard (Start disable) From 2acdccdc254cc6818d4a380c37fe484fcc157b61 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 11:54:52 -0700 Subject: [PATCH 23/52] docs(dual-camera): cameras.yaml.example fields and user guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite machine_configs/cameras.yaml.example around the dual-camera (one-active-at-a-time) fields — type, hardware_trigger and the per-camera overrides — replacing the old "simultaneous imaging" framing, and note that `type` is now required whenever more than one camera is declared. Add docs/dual-camera.md covering configuration, channel-to-camera binding in the channel editor, the dot + suffix labeling, per-camera trigger behavior and settings tabs, the acquisition path (per-camera warm-up, per-channel pixel sizes), the Zarr mixed-geometry guard, failure handling and the v1 limits. Corrects the design doc's claim that OME-TIFF handles heterogeneous shapes: the writer is 2D-grayscale-only, so mixed mono+color runs must save individual images. Co-Authored-By: Claude Fable 5 --- software/docs/dual-camera.md | 193 ++++++++++++++++++ software/machine_configs/cameras.yaml.example | 66 ++++-- 2 files changed, 239 insertions(+), 20 deletions(-) create mode 100644 software/docs/dual-camera.md diff --git a/software/docs/dual-camera.md b/software/docs/dual-camera.md new file mode 100644 index 000000000..94f1ca282 --- /dev/null +++ b/software/docs/dual-camera.md @@ -0,0 +1,193 @@ +# Dual camera (one active at a time) + +Run two cameras on one microscope where **only one images at a time** — typically a +monochrome camera for fluorescence and a color camera for brightfield/histology, sharing +the optical path through a beam splitter. Nothing moves optically when you switch; the +switch is pure software. + +Each acquisition channel is **bound to a camera**. Selecting a channel in live view +switches to its camera automatically, and a multipoint acquisition may mix channels from +both cameras — the worker switches per channel. + +This is not the "simultaneous multi-camera" feature: the two cameras never stream at the +same time. + +## 1. Declare the cameras — `machine_configs/cameras.yaml` + +```yaml +version: 1.0 +cameras: + - name: "Main Camera" + id: 1 + serial_number: "12345ABC" + model: "ITR3CMOS26000KMA" + type: "Toupcam" + hardware_trigger: true + - name: "Side Camera" + id: 2 + serial_number: "67890XYZ" + model: "E3ISPM" + type: "Toupcam" + hardware_trigger: false + default_pixel_format: "RGB24" +``` + +See `machine_configs/cameras.yaml.example` for the full field list. Key points: + +- **`id: 1` is the primary camera.** It is the active camera at startup, and every channel + with no camera binding images on it. The primary must exist, and should be the + hardware-triggered camera. +- **`type` is required when more than one camera is declared** (`Toupcam`, `FLIR`, + `Hamamatsu`, `iDS`, `TIS`, `Tucsen`, `Photometrics`, `Andor`, `Default`) — the single INI + `camera_type` can't describe two different cameras. +- **`hardware_trigger: false`** means this camera's trigger line is not wired; it runs in + software-trigger mode only. +- Optional per-camera overrides — `rotate_image_angle`, `flip`, `crop_width`, + `crop_height`, `default_pixel_format`, `default_binning` — fall back to the INI + `[CAMERA_CONFIG]` values when absent. Give a color camera an RGB `default_pixel_format`. + +### Upgrading an existing `cameras.yaml` + +`type` is new and **required with more than one camera**. A deployed multi-camera +`cameras.yaml` written before this feature will fail validation: the software logs a +warning (`Config validation failed for …/cameras.yaml: … missing required 'type'`) and +falls back to the single INI camera. It does not crash, but you silently get one camera. +**Add `type:` to every entry** when upgrading. + +### Single-camera systems + +Nothing changes. With no `cameras.yaml`, or with one declared camera, the imaging camera +comes entirely from the INI `[CAMERA_CONFIG]` section — a **1-camera `cameras.yaml` is +effectively ignored** (there is no serial-number-based camera selection in v1). The +registry only takes over when it declares more than one camera. + +## 2. Bind channels to cameras — Settings ▸ Channel Configuration… + +The **Camera** column in the channel editor *is* the binding. Pick the camera by name; the +file stores its **id**. `(None)` means the primary camera. There are no auto-generated +channels for a newly declared camera — add and name the rows yourself. The binding lives +in `general.yaml` only (per-objective overrides can't change it). + +Once more than one camera is configured, every channel entry in the live dropdown, the +napari live widget and the multipoint channel lists gets: + +- a small **colored dot** identifying its camera (fixed palette, keyed by camera id), and +- for non-primary channels, a **`— ` suffix**, e.g. `BF Color — Side Camera`. + +This is decoration only. The canonical channel name is stored in the item's data, so saved +acquisition YAMLs, name-based selection and the MCP/TCP APIs keep using bare names. + +Channels whose camera is declared but **unavailable** (it failed to open, or the id is +unknown) are **greyed out, not hidden**, with the tooltip *"This channel's camera is +declared in cameras.yaml but is not available."* Use the existing per-channel **Enabled** +checkbox to hide a channel. + +If a loaded acquisition YAML names a channel whose camera is unavailable, the multipoint +panel shows a notice listing the dropped channels (`Not acquired (camera unavailable): +…`) — the rest of the run still proceeds. + +## 3. Live view, triggers and camera settings + +- **Selecting a channel switches cameras** before exposure/gain/illumination are applied. + Color frames go through the existing RGB display path. +- **The trigger dropdown adapts to the active camera.** `Hardware Trigger` is offered only + while a camera with `hardware_trigger: true` is active; on the secondary camera the + dropdown offers Software (and Continuous, if recording is enabled) only. Each camera + **remembers its own trigger mode**: switch to the color camera and back, and the primary + returns to Hardware Trigger. +- **Misconfiguration to avoid:** a primary camera with `hardware_trigger: false` combined + with `DEFAULT_TRIGGER_MODE = Hardware Trigger` in the INI. The requested mode cannot be + offered; the software logs a warning and clamps to Software Trigger at startup (which + also issues one MCU trigger-mode command during widget construction). Fix one of the two + settings. +- **Camera settings tabs:** each camera gets its own settings tab, labeled with its + `cameras.yaml` name (`Main Camera`, `Side Camera`) instead of the single `Camera` tab. + Each tab talks to its own camera. +- **Settings cache:** `cache/camera_settings.yaml` is keyed by serial number, so binning + and pixel format are remembered per camera. A legacy flat cache file migrates + automatically as the primary camera's settings. +- Switching cameras also re-clamps the exposure spinbox to the new camera's limits and + redraws the navigation viewer's FOV rectangle (sensor size and pixel size differ per + camera). + +## 4. Acquisition + +The worker switches to `channel.camera` (or the primary, when unbound) before each channel; +channel order is preserved — no reordering to minimize switches. Contrast AF switches to +the AF channel's camera first. Laser AF uses its own separate focus camera and is +unaffected. + +**Per-channel pixel sizes** are recorded for mixed runs: `acquisition parameters.json` +gains a `channel_pixel_sizes_um` map (channel name → µm/px) alongside the existing +single-camera `sensor_pixel_size_um`. + +**Camera warm-up:** on multi-camera systems, one warm-up frame is grabbed per camera used +by the run. This happens on the GUI **Start** path with saving enabled (it rides along with +the disk-space estimate). Runs that skip saving, plus snap, fluidics and headless paths, +skip the warm-up — the same as single-camera behavior today. + +### File format guidance for mixed mono + color runs + +| Saving option | Mixed mono + color | +|---|---| +| **Individual images** (default) | **Use this.** Each frame is its own file, so shapes and dtypes may differ freely. | +| **OME-TIFF** | **Not supported.** The OME-TIFF writer is 2D-grayscale-only and raises `NotImplementedError: OME-TIFF saving currently supports 2D grayscale images only` on an RGB frame. | +| **Zarr v3** | **Blocked** for mixed frame geometry (see below). | +| **Multi-page TIFF** | Not blocked, but not exercised by this feature — prefer individual images. | + +A **Zarr** store allocates one uniform array per region/FOV: shape and dtype are fixed by +the first frame written, and a single `pixel_size_um` is recorded. So when the file saving +option is Zarr **and** the checked channels span cameras that differ in frame size, +color-ness, storage bit depth or binned pixel size, the multipoint panel shows a persistent +red warning and **disables Start** until you switch the format, uncheck one camera's +channels, or make the cameras match via binning/crop. Two cameras with *identical* geometry +may still use Zarr. + +The same check runs at acquisition start as a backstop for headless/MCP runs, which bypass +the widget: the run fails fast with the same message. A selection containing an unavailable +camera's channel is rejected the same way, naming the channels. + +> **Note:** that warning's suggested remedy ("switch the file saving option to OME-TIFF") +> only holds when no color camera is involved — OME-TIFF *can* hold mono frames of +> differing shapes, but not RGB. For a **mono + color** mix, use **individual images**. + +Zarr remains fully valid — and selectable — for single-camera runs. + +## 5. Failures and edge cases + +- **A declared camera fails to open at startup:** the software logs it and continues with + the cameras that did open; channels bound to the missing camera are greyed out, and an + acquisition that includes them is rejected with a message naming them. No startup crash. + If the **primary** (id 1) fails to open, startup fails — there is no imaging camera. +- **A channel references an unknown camera id:** treated the same as unavailable. +- **Hardware trigger on a non-wired camera:** never offered in the GUI; a programmatic + attempt raises `ValueError`. +- **Mid-run disconnect:** handled by the existing driver error paths (the acquisition + aborts). No new machinery. + +## 6. v1 limits + +- **Tracking** and **Simple Recording** are primary-camera-only. Opening either tab + auto-switches to the primary camera (live is stopped first) and logs a notice. +- **Multipoint with Fluidics** has no Start-button guard and no error dialog (unlike the + Flexible and Wellplate panels). Its only net is the acquisition-start backstop, which + aborts the run and writes the reason to the log. +- **No per-camera Zarr stores.** v1 validates the mismatch instead of splitting stores. +- **No serial-number camera opening (except FLIR).** Most drivers open the "first camera + found", so two cameras of the **same vendor type** are not reliably distinguishable yet — + the serial numbers are recorded in `cameras.yaml` but not yet plumbed through the + Toupcam/Hamamatsu/Tucsen drivers. Different-vendor pairs and simulation are fine. +- **No switch-minimizing channel reordering** — your channel order is preserved. +- **`hardware_bindings.yaml` emission-wheel dispatch** is not wired per camera. + +## Trying it in simulation + +Put the two-camera `cameras.yaml` above in `software/machine_configs/` with any distinct +serial numbers, give camera 2 `default_pixel_format: "RGB24"`, and run: + +```bash +python3 main_hcs.py --simulation +``` + +The simulated camera serves RGB frames when configured with a color pixel format, so the +mono + color mix is testable without hardware. diff --git a/software/machine_configs/cameras.yaml.example b/software/machine_configs/cameras.yaml.example index bd1d181c5..8f066c14d 100644 --- a/software/machine_configs/cameras.yaml.example +++ b/software/machine_configs/cameras.yaml.example @@ -1,30 +1,56 @@ # Camera Registry Configuration (v1.0) # -# This file maps user-friendly camera names to hardware identifiers. -# When configuring acquisition channels, users select cameras by name. +# Copy this file to cameras.yaml and edit for your system. # -# This file is optional. If not present, the system assumes a single-camera setup. +# This file is OPTIONAL. Without it — and with a single-camera cameras.yaml, which is +# effectively ignored — the imaging camera comes entirely from the INI [CAMERA_CONFIG] +# section, exactly as before. The registry only changes behavior when it declares more +# than one camera. # -# Copy this file to cameras.yaml and edit for your system. +# MULTI-CAMERA (one active at a time; see docs/dual-camera.md): +# declare each camera with a unique id, name, serial_number and type. Camera id 1 is the +# primary: it is the active camera at startup, and channels with no camera binding image +# on it. Only one camera is live at any moment — selecting a channel bound to the other +# camera switches to it. +# +# UPGRADING an existing multi-camera cameras.yaml: `type` is now REQUIRED whenever more +# than one camera is declared. A file without it fails validation, and the software logs +# a warning and falls back to the single INI camera. Add `type` to every entry. +# +# Per-camera fields (only id/name/serial_number/type are required with >1 camera; every +# other value falls back to the INI [CAMERA_CONFIG] setting when absent): +# type: Toupcam | FLIR | Hamamatsu | iDS | TIS | Tucsen | Photometrics | +# Andor | Default +# hardware_trigger: true if this camera's trigger line is wired to the controller +# (default true). A camera with `hardware_trigger: false` runs in +# software-trigger mode only, and the GUI stops offering Hardware +# Trigger while it is active. +# model: display string only (shown in the UI for reference) +# rotate_image_angle, flip, crop_width, crop_height, default_pixel_format, default_binning +# flip: Vertical | Horizontal | Both +# default_pixel_format: MONO8 | MONO10 | MONO12 | MONO14 | MONO16 | RGB24 | RGB32 | +# RGB48 | BAYER_RG8 | BAYER_RG12 +# default_binning: [x, y] version: 1.0 cameras: - # Primary imaging camera + # Primary imaging camera (id 1). Must be present, and should be the hardware-triggered + # one if any camera is wired for hardware trigger. - name: "Main Camera" - serial_number: "ABC12345" # Camera serial number (from manufacturer) - model: "Hamamatsu C15440" # Optional: displayed in UI for reference - - # Secondary camera for simultaneous imaging (multi-camera systems) - # - name: "Side Camera" - # serial_number: "DEF67890" - # model: "Basler acA2040" + id: 1 + serial_number: "12345ABC" # Camera serial number (from manufacturer) + model: "ITR3CMOS26000KMA" # Optional: displayed in UI for reference + type: "Toupcam" + hardware_trigger: true - # Example: Dual-camera fluorescence system - # - name: "GFP Camera" - # serial_number: "CAM001" - # model: "Photometrics Prime 95B" - # - # - name: "RFP Camera" - # serial_number: "CAM002" - # model: "Photometrics Prime 95B" + # Secondary camera (one active at a time). Typical dual-camera setup: a color camera on + # the second port of a beam splitter, with no trigger wiring. + # Delete this entry on single-camera systems. + - name: "Side Camera" + id: 2 + serial_number: "67890XYZ" + model: "E3ISPM" + type: "Toupcam" + hardware_trigger: false + default_pixel_format: "RGB24" From 7ef2304e6f45e9dd7cbf682026d1c09611613014 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 12:41:18 -0700 Subject: [PATCH 24/52] docs(dual-camera): correct startup-crash and OME-TIFF claims Three corrections from the accuracy audit, all documentation wording: - The primary-camera `hardware_trigger: false` + INI Hardware Trigger default combination does not warn and clamp. Startup applies the default mode to the active camera and a camera built without a hw_trigger_fn raises ValueError, aborting the app before the window appears. The widget-level clamp is real but only reachable on a runtime camera switch. - OME-TIFF does not tolerate heterogeneous mono shapes: one stack file is opened per region+FOV with shape and dtype fixed by the first plane, so differently sized mono frames raise "Image dimensions do not match existing OME memmap stack" and differing bit depths are silently astype-cast. RGB remains unsupported outright. Any camera mismatch should use individual images. - A legacy flat camera-settings cache applies to every camera that asks for it, not only the primary. Co-Authored-By: Claude Fable 5 --- software/docs/dual-camera.md | 59 ++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/software/docs/dual-camera.md b/software/docs/dual-camera.md index 94f1ca282..0404d6e74 100644 --- a/software/docs/dual-camera.md +++ b/software/docs/dual-camera.md @@ -95,17 +95,29 @@ panel shows a notice listing the dropped channels (`Not acquired (camera unavail dropdown offers Software (and Continuous, if recording is enabled) only. Each camera **remembers its own trigger mode**: switch to the color camera and back, and the primary returns to Hardware Trigger. -- **Misconfiguration to avoid:** a primary camera with `hardware_trigger: false` combined - with `DEFAULT_TRIGGER_MODE = Hardware Trigger` in the INI. The requested mode cannot be - offered; the software logs a warning and clamps to Software Trigger at startup (which - also issues one MCU trigger-mode command during widget construction). Fix one of the two - settings. +- **Misconfiguration that crashes startup:** a primary camera with + `hardware_trigger: false` combined with `DEFAULT_TRIGGER_MODE = Hardware Trigger` in the + INI. Startup applies the INI default trigger mode to the active (primary) camera, and a + camera built without a hardware-trigger function cannot enter that mode, so the + application **aborts before the window appears** with: + + ``` + ValueError: Cannot set HARDWARE_TRIGGER camera acquisition mode without a hw_trigger_fn. + You must provide one when constructing the camera. + ``` + + Nothing catches this — there is no warning and no fallback. Fix it in configuration: + either make the hardware-wired camera `id: 1` in `cameras.yaml`, or set the INI default + trigger mode to Software Trigger. (Clamping gracefully at startup, the way the trigger + dropdown already clamps on a runtime camera switch, would be a reasonable future + improvement — it is **not** current behavior.) - **Camera settings tabs:** each camera gets its own settings tab, labeled with its `cameras.yaml` name (`Main Camera`, `Side Camera`) instead of the single `Camera` tab. Each tab talks to its own camera. - **Settings cache:** `cache/camera_settings.yaml` is keyed by serial number, so binning - and pixel format are remembered per camera. A legacy flat cache file migrates - automatically as the primary camera's settings. + and pixel format are remembered per camera. A legacy flat cache file (no `cameras:` key) + is still read, and applies to *every* camera that asks for it until each writes its own + per-serial entry on the next shutdown. - Switching cameras also re-clamps the exposure spinbox to the new camera's limits and redraws the navigation viewer's FOV rectangle (sensor size and pixel size differ per camera). @@ -126,30 +138,45 @@ by the run. This happens on the GUI **Start** path with saving enabled (it rides the disk-space estimate). Runs that skip saving, plus snap, fluidics and headless paths, skip the warm-up — the same as single-camera behavior today. -### File format guidance for mixed mono + color runs +### File format guidance for runs that mix cameras -| Saving option | Mixed mono + color | +| Saving option | Channels spanning cameras with different frame geometry | |---|---| | **Individual images** (default) | **Use this.** Each frame is its own file, so shapes and dtypes may differ freely. | -| **OME-TIFF** | **Not supported.** The OME-TIFF writer is 2D-grayscale-only and raises `NotImplementedError: OME-TIFF saving currently supports 2D grayscale images only` on an RGB frame. | -| **Zarr v3** | **Blocked** for mixed frame geometry (see below). | +| **OME-TIFF** | **Not supported.** Every channel in a run must produce the same frame shape (see below). | +| **Zarr v3** | **Blocked** by the Start guard for mixed frame geometry (see below). | | **Multi-page TIFF** | Not blocked, but not exercised by this feature — prefer individual images. | +**OME-TIFF requires every selected channel to produce the same frame shape.** One stack +file is opened per region + FOV, and its shape and dtype are fixed by the first plane +written, so: + +- **RGB is ruled out entirely** — the writer is 2D-grayscale-only and raises + `NotImplementedError: OME-TIFF saving currently supports 2D grayscale images only`. +- **Two mono cameras of different Y×X also fail** — the second write raises + `ValueError: Image dimensions do not match existing OME memmap stack`. +- **Two mono cameras of matching Y×X but different bit depth are silently re-cast** to the + first plane's dtype (`.astype()`), with no error and no log line. This one corrupts data + quietly rather than failing, so do not rely on OME-TIFF to catch it. + +For **any** camera mismatch — mono + color, or mismatched mono — use **individual images**. + A **Zarr** store allocates one uniform array per region/FOV: shape and dtype are fixed by the first frame written, and a single `pixel_size_um` is recorded. So when the file saving option is Zarr **and** the checked channels span cameras that differ in frame size, color-ness, storage bit depth or binned pixel size, the multipoint panel shows a persistent -red warning and **disables Start** until you switch the format, uncheck one camera's -channels, or make the cameras match via binning/crop. Two cameras with *identical* geometry -may still use Zarr. +red warning and **disables Start** until you switch to individual images, uncheck one +camera's channels, or make the cameras match via binning/crop. Two cameras with *identical* +geometry may still use Zarr. The same check runs at acquisition start as a backstop for headless/MCP runs, which bypass the widget: the run fails fast with the same message. A selection containing an unavailable camera's channel is rejected the same way, naming the channels. > **Note:** that warning's suggested remedy ("switch the file saving option to OME-TIFF") -> only holds when no color camera is involved — OME-TIFF *can* hold mono frames of -> differing shapes, but not RGB. For a **mono + color** mix, use **individual images**. +> is misleading. OME-TIFF cannot hold this selection either — the geometry differences the +> guard rejects (frame size, color-ness, bit depth) are exactly the ones OME-TIFF fails or +> silently mis-casts on. Use **individual images** instead. Zarr remains fully valid — and selectable — for single-camera runs. From c9803ea4e64b727a6e2ef81dc074781749885b5e Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 13:15:50 -0700 Subject: [PATCH 25/52] =?UTF-8?q?fix(dual-camera):=20final=20review=20fixe?= =?UTF-8?q?s=20=E2=80=94=20guard=20message,=20warm-up=20flag=20leak,=20sim?= =?UTF-8?q?=20auto-WB=20signature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the whole-branch final review. 1. The mixed-geometry guard steered users to OME-TIFF, which cannot hold any of the mismatches it rejects: RGB raises NotImplementedError in the OME writer, differing mono Y*X raises mid-run, and differing mono bit depth is silently .astype()'d. Point at individual images instead, and drop the dual-camera doc note that existed only to warn the suggestion was wrong. 2. MultiPointController._live_stopped_for_warm_up was set by the per-camera warm-up and consumed only in run_acquisition, so five abort paths (disk and RAM dialogs on both multipoint widgets, a failed validate, the multi-camera backstop raise) stranded it True. A later run that skips the warm-up (skip-saving, snap, fluidics, TCP) then resumed live it never stopped — illumination on the sample with nobody at the scope. Clear it in start_new_experiment, which runs before the estimate on every Start path, so the warm-up still re-arms it when it legitimately applies. 3. SimulatedCamera.set_auto_white_balance_gains was missing the abstract's `on: bool`, so the per-camera settings tab's Auto WB button raised TypeError inside a Qt slot on the documented RGB simulation path. Co-Authored-By: Claude Fable 5 --- .../control/core/multi_point_controller.py | 7 +++ software/control/core/multi_point_utils.py | 7 ++- software/docs/dual-camera.md | 5 -- software/squid/camera/utils.py | 9 +++- .../test_multi_camera_acquisition_guards.py | 53 +++++++++++++++++++ software/tests/squid/test_camera_facade.py | 12 +++++ .../tests/squid/test_simulated_camera_rgb.py | 24 +++++++++ 7 files changed, 109 insertions(+), 8 deletions(-) diff --git a/software/control/core/multi_point_controller.py b/software/control/core/multi_point_controller.py index 381bcbedd..d84f76a21 100644 --- a/software/control/core/multi_point_controller.py +++ b/software/control/core/multi_point_controller.py @@ -461,6 +461,13 @@ def set_overlap_percent(self, overlap_percent: float): self.overlap_percent = overlap_percent def start_new_experiment(self, experiment_ID): # @@@ to do: change name to prepare_folder_for_new_experiment + # Only run_acquisition consumes the warm-up's live-handoff flag, and several Start + # paths abort before reaching it (disk/RAM dialog on either widget, a failed + # validate, the multi-camera backstop raise). A stranded True would make the next run + # that skips the warm-up — skip-saving, snap, fluidics, TCP — resume live it never + # stopped, illuminating the sample unattended. This runs before the estimate on every + # Start path, so the warm-up re-arms it right after when it legitimately applies. + self._live_stopped_for_warm_up = False # generate unique experiment ID self.experiment_ID = experiment_ID.replace(" ", "_") + "_" + datetime.now().strftime("%Y-%m-%d_%H-%M-%S.%f") self.recording_start_time = time.time() diff --git a/software/control/core/multi_point_utils.py b/software/control/core/multi_point_utils.py index 6d0e07543..035bd464d 100644 --- a/software/control/core/multi_point_utils.py +++ b/software/control/core/multi_point_utils.py @@ -246,8 +246,13 @@ def get_camera_geometry_mismatch(selected_channels, cameras: Dict[int, AbstractC f"camera {camera_id}: {width}x{height} px, {'color' if is_color else 'mono'} " f"uint{depth}, {pixel_um} um/px" for camera_id, (width, height, is_color, depth, pixel_um) in sorted(geometry_by_camera.items()) ) + # The remedy is individual images, not OME-TIFF: OME-TIFF fixes one shape and dtype per + # region/FOV stack too, so it fails or silently mis-casts on every axis checked here + # (RGB raises NotImplementedError, differing mono Y*X raises mid-run, differing mono bit + # depth is quietly .astype()'d). return ( "Selected channels span cameras with different frame geometry " f"({details}). This selection cannot be saved as Zarr — switch the file saving option " - "to OME-TIFF, or make the cameras match via binning/crop, or select channels from one camera." + "to individual images, or make the cameras match via binning/crop, or select channels " + "from one camera." ) diff --git a/software/docs/dual-camera.md b/software/docs/dual-camera.md index 0404d6e74..db3709f8c 100644 --- a/software/docs/dual-camera.md +++ b/software/docs/dual-camera.md @@ -173,11 +173,6 @@ The same check runs at acquisition start as a backstop for headless/MCP runs, wh the widget: the run fails fast with the same message. A selection containing an unavailable camera's channel is rejected the same way, naming the channels. -> **Note:** that warning's suggested remedy ("switch the file saving option to OME-TIFF") -> is misleading. OME-TIFF cannot hold this selection either — the geometry differences the -> guard rejects (frame size, color-ness, bit depth) are exactly the ones OME-TIFF fails or -> silently mis-casts on. Use **individual images** instead. - Zarr remains fully valid — and selectable — for single-camera runs. ## 5. Failures and edge cases diff --git a/software/squid/camera/utils.py b/software/squid/camera/utils.py index b67ad98d0..18537a0d7 100644 --- a/software/squid/camera/utils.py +++ b/software/squid/camera/utils.py @@ -354,8 +354,13 @@ def set_white_balance_gains(self, red_gain: float, green_gain: float, blue_gain: self._white_balance_gains = (red_gain, green_gain, blue_gain) @debug_log - def set_auto_white_balance_gains(self) -> Tuple[float, float, float]: - self.set_white_balance_gains(1.0, 1.0, 1.0) + def set_auto_white_balance_gains(self, on: bool) -> Tuple[float, float, float]: + # The GUI calls this with on=True/False (see CameraSettingsWidget.toggle_auto_wb), so + # the flag is part of the signature even though a simulated sensor has nothing to + # auto-balance. On: hand back the neutral gains. Off: leave the gains as they are — + # the caller reads them back and re-applies them. + if on: + self.set_white_balance_gains(1.0, 1.0, 1.0) return self.get_white_balance_gains() diff --git a/software/tests/control/test_multi_camera_acquisition_guards.py b/software/tests/control/test_multi_camera_acquisition_guards.py index 68e12a5ec..6cc8a2cb1 100644 --- a/software/tests/control/test_multi_camera_acquisition_guards.py +++ b/software/tests/control/test_multi_camera_acquisition_guards.py @@ -103,6 +103,11 @@ def test_color_vs_mono_mismatch_detected(): assert message is not None assert "color" in message and "mono" in message assert "Zarr" in message + # The suggested remedy has to be one that actually works. OME-TIFF holds none of the + # mismatches this guard rejects: RGB raises NotImplementedError, differing mono Y*X + # raises mid-run, and differing mono bit depth is silently re-cast. + assert "individual images" in message + assert "OME-TIFF" not in message def test_crop_mismatch_detected(): @@ -511,6 +516,54 @@ def _stop(*args, **kwargs): assert mpc._live_stopped_for_warm_up is False # consumed, so it cannot leak into a later run +def _warm_up_that_stopped_live(monkeypatch, mpc): + """Run the warm-up on a live microscope so it stops live and arms the handoff flag — + the state a Start attempt is in when the disk/RAM dialog is still on screen.""" + monkeypatch.setattr(mpc.liveController, "stop_live", lambda: setattr(mpc.liveController, "is_live", False)) + _record_warm_up_grabs(monkeypatch, mpc) + mpc.liveController.is_live = True + mpc._warm_up_cameras_and_get_test_image() + assert mpc._live_stopped_for_warm_up is True # precondition, not the assertion under test + + +def test_start_new_experiment_clears_a_stranded_warm_up_flag(monkeypatch, tmp_path): + """Only run_acquisition consumes the flag, and several Start paths abort before it: the + disk-space and RAM dialogs (both widgets), a failed validate, the backstop raise. The + next Start goes through start_new_experiment first, so that is where the leak dies.""" + mpc = _simulated_controller(monkeypatch, TWO_CAMERA_REGISTRY) + mpc.selected_configurations = _channels_on_cameras(mpc, [2, 1]) + mpc.set_base_path(str(tmp_path)) + _warm_up_that_stopped_live(monkeypatch, mpc) # ...then the user cancels at the dialog + + mpc.start_new_experiment("after the abort") + + assert mpc._live_stopped_for_warm_up is False + + +def test_a_run_after_an_aborted_warm_up_does_not_resume_live(monkeypatch, tmp_path): + """The consequence of the leak: the next run to skip the warm-up (skip-saving, snap, + fluidics, TCP) would inherit the flag, decide the user had been live, and switch + illumination back on over the sample when it finishes — with nobody at the scope.""" + mpc = _simulated_controller(monkeypatch, TWO_CAMERA_REGISTRY) + mpc.selected_configurations = _channels_on_cameras(mpc, [2, 1]) + mpc.set_base_path(str(tmp_path)) + monkeypatch.setattr(control._def, "FILE_SAVING_OPTION", control._def.FileSavingOption.OME_TIFF) + monkeypatch.setattr(mpc, "_start_per_acquisition_log", lambda *a, **kw: None) + mpc.start_new_experiment("aborted attempt") + _warm_up_that_stopped_live(monkeypatch, mpc) + + # A later run that never warms up, with the user no longer live. + def _stop(*args, **kwargs): + raise _PastTheGuards() + + monkeypatch.setattr(mpc.camera, "enable_callbacks", _stop) + mpc.start_new_experiment("later run") + with pytest.raises(_PastTheGuards): + mpc.run_acquisition() + + assert mpc.liveController_was_live_before_multipoint is False + + def test_acquisition_parameters_json_records_per_channel_pixel_sizes(monkeypatch, tmp_path): mpc = _simulated_controller(monkeypatch, TWO_CAMERA_REGISTRY) mpc.selected_configurations = _channels_on_cameras(mpc, [1, 2]) diff --git a/software/tests/squid/test_camera_facade.py b/software/tests/squid/test_camera_facade.py index 8d4782b18..7a48c9f42 100644 --- a/software/tests/squid/test_camera_facade.py +++ b/software/tests/squid/test_camera_facade.py @@ -109,6 +109,18 @@ def test_is_color_and_geometry_follow_active(cameras): assert facade.get_pixel_size_binned_um() == cameras[2].get_pixel_size_binned_um() +def test_auto_white_balance_delegates_to_active(cameras): + """The Auto WB button in the per-camera settings tab reaches the camera through the + facade, keyword-style, so both hops must carry the `on` flag.""" + facade = ActiveCameraFacade(cameras, active_id=1) + cameras[1].set_white_balance_gains(2.0, 2.0, 2.0) + cameras[2].set_white_balance_gains(3.0, 3.0, 3.0) + + assert facade.set_auto_white_balance_gains(on=True) == (1.0, 1.0, 1.0) + assert cameras[1].get_white_balance_gains() == (1.0, 1.0, 1.0) + assert cameras[2].get_white_balance_gains() == (3.0, 3.0, 3.0) # inactive camera untouched + + def test_close_closes_all(cameras): closed = [] for cam_id, cam in cameras.items(): diff --git a/software/tests/squid/test_simulated_camera_rgb.py b/software/tests/squid/test_simulated_camera_rgb.py index b62793840..f1ac4b88e 100644 --- a/software/tests/squid/test_simulated_camera_rgb.py +++ b/software/tests/squid/test_simulated_camera_rgb.py @@ -126,6 +126,30 @@ def test_process_raw_frame_keeps_rgb_3_channel_end_to_end(): assert frame.is_color() +# --- white balance ----------------------------------------------------------------------------- + + +def test_auto_white_balance_gains_takes_the_on_flag(): + """The per-camera settings tab's Auto WB button calls set_auto_white_balance_gains(on=...) + from a Qt slot, so a signature that omits `on` fails as a swallowed TypeError with no + white balance applied — on the RGB simulation path the docs point people at.""" + cam = make_sim(CameraPixelFormat.RGB24, **SMALL_FRAME) + cam.set_white_balance_gains(2.0, 3.0, 4.0) + + assert cam.set_auto_white_balance_gains(on=True) == (1.0, 1.0, 1.0) + assert cam.get_white_balance_gains() == (1.0, 1.0, 1.0) + + +def test_auto_white_balance_off_leaves_the_gains_alone(): + """Off is "stop auto-balancing", not "re-balance": the widget reads the gains back and + re-applies them, which only makes sense if turning it off keeps what is there.""" + cam = make_sim(CameraPixelFormat.RGB24, **SMALL_FRAME) + cam.set_white_balance_gains(2.0, 3.0, 4.0) + + assert cam.set_auto_white_balance_gains(on=False) == (2.0, 3.0, 4.0) + assert cam.get_white_balance_gains() == (2.0, 3.0, 4.0) + + # --- switching pixel format must invalidate the cached raw frame ------------------------------ From 9bb18d36906f160e881627b87e4896652d3ed7aa Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 17:07:18 -0700 Subject: [PATCH 26/52] =?UTF-8?q?feat(gui):=20channel-list=20icon=20encode?= =?UTF-8?q?s=20sensor=20type=20=E2=80=94=20grey=20dot=20mono,=20RGB=20disc?= =?UTF-8?q?=20color?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-camera palette dot only distinguished camera identity; with a mono + color pair the user couldn't tell which channel images in color. The icon now derives from the camera's pixel format (live camera state, registry default_pixel_format fallback for unopened cameras); identity remains carried by the '— ' suffix. Co-Authored-By: Claude Fable 5 --- software/control/widgets.py | 52 ++++++++++++---- software/docs/dual-camera.md | 5 +- .../control/test_channel_display_labels.py | 60 +++++++++++++++++-- 3 files changed, 100 insertions(+), 17 deletions(-) diff --git a/software/control/widgets.py b/software/control/widgets.py index a13e9fd81..37d9051ad 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -73,24 +73,53 @@ def camera_id_from_display(registry, display_text) -> Optional[int]: return definition.id if definition is not None else None -# Per-camera dot colors for channel lists, indexed by (camera_id - 1). Chosen to be -# distinguishable on light and dark palettes. -CAMERA_DOT_COLORS = ["#4C9BD4", "#E0A438", "#7BC47F", "#C57BC4"] +# Channel-list camera icons are semantic: a neutral grey dot marks a monochrome +# camera, an RGB tri-wedge disc marks a color camera. Camera *identity* is carried +# by the "— " label suffix, not the icon. +_MONO_DOT_COLOR = "#8A939B" # readable on light and dark palettes +_RGB_WEDGE_COLORS = [QColor(220, 60, 60), QColor(60, 170, 80), QColor(70, 105, 225)] -def camera_dot_icon(camera_id: int) -> QIcon: - """Small filled circle identifying a camera in channel lists.""" +def camera_dot_icon(is_color: bool) -> QIcon: + """Sensor-type icon for channel lists: grey dot = mono, RGB disc = color.""" pixmap = QPixmap(12, 12) pixmap.fill(Qt.transparent) painter = QPainter(pixmap) painter.setRenderHint(QPainter.Antialiasing) - painter.setBrush(QColor(CAMERA_DOT_COLORS[(camera_id - 1) % len(CAMERA_DOT_COLORS)])) painter.setPen(Qt.NoPen) - painter.drawEllipse(1, 1, 10, 10) + if is_color: + # Three 120-degree wedges starting at the top (Qt angles are 1/16 deg, CCW). + for i, wedge_color in enumerate(_RGB_WEDGE_COLORS): + painter.setBrush(wedge_color) + painter.drawPie(1, 1, 10, 10, (90 - i * 120) * 16, -120 * 16) + else: + painter.setBrush(QColor(_MONO_DOT_COLOR)) + painter.drawEllipse(1, 1, 10, 10) painter.end() return QIcon(pixmap) +def _camera_is_color(microscope, registry, camera_id: int) -> bool: + """Whether a camera produces color frames, for icon selection. + + Prefers the live camera's current pixel format; falls back to the registry's + declared default for cameras that failed to open. Unknown -> mono. + """ + camera = microscope.cameras.get(camera_id) + if camera is not None: + try: + return bool(camera.is_color) + except Exception: + return False + definition = registry.get_camera_by_id(camera_id) if registry is not None else None + if definition is not None and definition.default_pixel_format: + try: + return CameraPixelFormat.is_color_format(CameraPixelFormat.from_string(definition.default_pixel_format)) + except KeyError: + return False + return False + + def channel_display_label(channel, registry) -> str: """Display text for a channel in dropdowns/lists. @@ -126,7 +155,8 @@ def decorate(channel_name): return channel_name, None, True camera_id = channel.camera if channel.camera is not None else control._def.PRIMARY_CAMERA_ID available = camera_id in microscope.cameras - return channel_display_label(channel, registry), camera_dot_icon(camera_id), available + icon = camera_dot_icon(_camera_is_color(microscope, registry, camera_id)) + return channel_display_label(channel, registry), icon, available return decorate @@ -4256,7 +4286,8 @@ def _add_mode_item(self, config): label = channel_display_label(config, registry) if self._multi_camera(): camera_id = config.camera if config.camera is not None else control._def.PRIMARY_CAMERA_ID - self.dropdown_modeSelection.addItem(camera_dot_icon(camera_id), label, userData=config.name) + is_color = _camera_is_color(self.liveController.microscope, registry, camera_id) + self.dropdown_modeSelection.addItem(camera_dot_icon(is_color), label, userData=config.name) else: self.dropdown_modeSelection.addItem(label, userData=config.name) camera_available = config.camera is None or config.camera in self.liveController.microscope.cameras @@ -11851,7 +11882,8 @@ def _add_mode_item(self, config): label = channel_display_label(config, registry) if self._multi_camera(): camera_id = config.camera if config.camera is not None else control._def.PRIMARY_CAMERA_ID - self.dropdown_modeSelection.addItem(camera_dot_icon(camera_id), label, userData=config.name) + is_color = _camera_is_color(self.liveController.microscope, registry, camera_id) + self.dropdown_modeSelection.addItem(camera_dot_icon(is_color), label, userData=config.name) else: self.dropdown_modeSelection.addItem(label, userData=config.name) camera_available = config.camera is None or config.camera in self.liveController.microscope.cameras diff --git a/software/docs/dual-camera.md b/software/docs/dual-camera.md index db3709f8c..644da7dd0 100644 --- a/software/docs/dual-camera.md +++ b/software/docs/dual-camera.md @@ -71,7 +71,10 @@ in `general.yaml` only (per-objective overrides can't change it). Once more than one camera is configured, every channel entry in the live dropdown, the napari live widget and the multipoint channel lists gets: -- a small **colored dot** identifying its camera (fixed palette, keyed by camera id), and +- a small **sensor-type icon**: a neutral **grey dot** for a monochrome camera, an + **RGB tri-color disc** for a color camera (derived from the camera's current pixel + format; for a camera that failed to open, from its `default_pixel_format` in + `cameras.yaml`), and - for non-primary channels, a **`— ` suffix**, e.g. `BF Color — Side Camera`. This is decoration only. The canonical channel name is stored in the item's data, so saved diff --git a/software/tests/control/test_channel_display_labels.py b/software/tests/control/test_channel_display_labels.py index b297a2b08..f63f1fe92 100644 --- a/software/tests/control/test_channel_display_labels.py +++ b/software/tests/control/test_channel_display_labels.py @@ -14,7 +14,6 @@ from control.channel_sequence import UNAVAILABLE_CAMERA_TOOLTIP from control.models.camera_registry import CameraDefinition, CameraRegistryConfig from control.widgets import ( - CAMERA_DOT_COLORS, LiveControlWidget, NapariLiveWidget, _make_channel_decorator, @@ -53,12 +52,29 @@ def test_label_for_unknown_camera_id_marks_unavailable(): assert channel_display_label(_Ch("Ghost", camera=9), TWO_CAM) == "Ghost — camera 9 (unavailable)" +def _rgba_list(icon): + """All pixel RGBA tuples of an icon's 12x12 rendering, for content comparison.""" + image = icon.pixmap(12, 12).toImage() + return [image.pixelColor(x, y).getRgb() for y in range(image.height()) for x in range(image.width())] + + def test_dot_icon_deterministic(qtbot): - icon_a = camera_dot_icon(2) - icon_b = camera_dot_icon(2) - assert not icon_a.isNull() and not icon_b.isNull() - assert len(CAMERA_DOT_COLORS) >= 2 - assert camera_dot_icon(1).cacheKey() != 0 + assert _rgba_list(camera_dot_icon(False)) == _rgba_list(camera_dot_icon(False)) + assert _rgba_list(camera_dot_icon(True)) == _rgba_list(camera_dot_icon(True)) + assert _rgba_list(camera_dot_icon(True)) != _rgba_list(camera_dot_icon(False)) + + +def test_mono_icon_is_neutral_grey(qtbot): + opaque = [(r, g, b) for (r, g, b, a) in _rgba_list(camera_dot_icon(False)) if a > 200] + assert opaque, "mono icon should have opaque pixels" + assert all(max(px) - min(px) < 30 for px in opaque), "mono dot must stay neutral (no hue)" + + +def test_color_icon_shows_distinct_rgb_wedges(qtbot): + opaque = [(r, g, b) for (r, g, b, a) in _rgba_list(camera_dot_icon(True)) if a > 200] + assert any(r - g > 60 and r - b > 60 for (r, g, b) in opaque), "expected a red wedge" + assert any(g - r > 40 and g - b > 40 for (r, g, b) in opaque), "expected a green wedge" + assert any(b - r > 60 and b - g > 60 for (r, g, b) in opaque), "expected a blue wedge" # --------------------------------------------------------------------------- @@ -119,6 +135,38 @@ def test_missing_camera_marks_disabled(self, qtbot): assert label == "BF Color — Side Camera" assert enabled is False + def test_icon_encodes_sensor_type_from_live_camera(self, qtbot): + controller = _fake_live_controller( + TWO_CAM, [_Ch("DAPI", camera=1), _Ch("BF Color", camera=2)], available_camera_ids=[1, 2] + ) + controller.microscope.cameras[1] = SimpleNamespace(is_color=False) + controller.microscope.cameras[2] = SimpleNamespace(is_color=True) + decorate = _make_channel_decorator(lambda: controller) + _, mono_icon, _ = decorate("DAPI") + _, color_icon, _ = decorate("BF Color") + assert _rgba_list(mono_icon) == _rgba_list(camera_dot_icon(False)) + assert _rgba_list(color_icon) == _rgba_list(camera_dot_icon(True)) + + def test_missing_camera_icon_falls_back_to_registry_default_pixel_format(self, qtbot): + registry = CameraRegistryConfig( + cameras=[ + CameraDefinition(name="Main Camera", id=1, serial_number="SN1", type="Toupcam"), + CameraDefinition( + name="Side Camera", + id=2, + serial_number="SN2", + type="Toupcam", + hardware_trigger=False, + default_pixel_format="RGB24", + ), + ] + ) + controller = _fake_live_controller(registry, [_Ch("BF Color", camera=2)], available_camera_ids=[1]) + decorate = _make_channel_decorator(lambda: controller) + _, icon, enabled = decorate("BF Color") + assert enabled is False + assert _rgba_list(icon) == _rgba_list(camera_dot_icon(True)) + # --------------------------------------------------------------------------- # LiveControlWidget dropdown wiring: userData carries the bare name From 5c419a0bcf7ef5fa94a8894aac04b4744bc7817a Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 9 Aug 2026 18:52:12 -0700 Subject: [PATCH 27/52] feat(toupcam): open camera by serial number, fix auto white balance signature Two ToupTek cameras on one microscope could not be told apart: the driver always opened the first enumerated device, and the existing _open(sn=...) path never actually worked (it computed sn_matches, then fell through to devices[None] -> TypeError). - ToupcamCamera.__init__ now goes through _open_for_config(), which opens by config.serial_number when one is set and keeps the old _open(index=0) behavior when it is not. - _open(sn=...) resolves an identifier in two passes: first the opaque enumeration id (ToupcamDeviceV2.id, what Toupcam_Open takes), then, since the SDK only reports the true serial number for an open device, by opening each remaining device, reading Toupcam.SerialNumber(), and closing it again unless it matches. A device that cannot be opened (already in use) is skipped rather than fatal. Failing to match now lists every device's id and serial so the right string can be copied into the config. - Probe handles are closed on every path, including when capability building fails after a match. - set_auto_white_balance_gains() gained the abstract `on: bool` parameter; without it the settings tab's Auto WB button raised TypeError inside a Qt slot on a color ToupTek. on=True triggers AwbInit() as before; on=False is a logged no-op because the SDK's AWB is one-push, not continuous. Tests mock the vendored SDK module (no hardware needed) and cover index opening, id matching, serial probing, probe cleanup, error messages, and both auto white balance paths. Co-Authored-By: Claude Fable 5 --- software/control/camera_toupcam.py | 212 +++++++-- software/tests/control/test_camera_toupcam.py | 422 ++++++++++++++++++ 2 files changed, 599 insertions(+), 35 deletions(-) create mode 100644 software/tests/control/test_camera_toupcam.py diff --git a/software/control/camera_toupcam.py b/software/control/camera_toupcam.py index 5102e7a0c..f30ffc4a2 100644 --- a/software/control/camera_toupcam.py +++ b/software/control/camera_toupcam.py @@ -172,38 +172,41 @@ def _calculate_strobe_info( return StrobeInfo(strobe_time_us=strobe_time, trigger_delay_us=trigger_delay_us) @staticmethod - def _open(index=None, sn=None) -> Tuple[toupcam.Toupcam, ToupCamCapabilities]: - log = squid.logging.get_logger("ToupcamCamera._open") - log.info(f"Opening toupcam with {index=}, {sn=}") - devices = toupcam.Toupcam.EnumV2() - if len(devices) <= 0: - raise ValueError("There are no Toupcam V2 devices. Is the camera connected and powered on?") - - if index is not None and sn is not None: - raise ValueError("You specified both a device index and a sn, this is not allowed.") + def _read_serial_number(camera: toupcam.Toupcam) -> Optional[str]: + """ + Best effort read of the true serial number from an already open camera handle. - if sn is not None: - sn_matches = [idx for idx in range(len(devices)) if devices[idx].id == sn] - if not len(sn_matches): - all_sn = [d.id for d in devices] - raise ValueError(f"Could not find camera with SN={sn}, options are: {','.join(all_sn)}") + The Toupcam SDK only exposes the serial number once a device is open, and the + vendored python binding has used both spellings over time, so try each. Returns + None if the serial number could not be read. + """ + log = squid.logging.get_logger("ToupcamCamera._read_serial_number") + getter = getattr(camera, "SerialNumber", None) or getattr(camera, "get_SerialNumber", None) + if getter is None: + log.warning("This toupcam binding has no serial number getter.") + return None + try: + return getter() + except Exception: + log.exception("Failed to read the serial number from an open toupcam device.") + return None - for idx, device in enumerate(devices): - log.info( - "Camera {}: {}: flag = {:#x}, preview = {}, still = {}".format( - idx, - device.displayname, - device.model.flag, - device.model.preview, - device.model.still, - ) + @staticmethod + def _close_quietly(camera: toupcam.Toupcam): + try: + camera.Close() + except Exception: + squid.logging.get_logger("ToupcamCamera._close_quietly").exception( + "Failed to close a probed toupcam device." ) - for r in devices[index].model.res: - log.info("\t = [{} x {}]".format(r.width, r.height)) + @staticmethod + def _capabilities_for_device(device: toupcam.ToupcamDeviceV2) -> ToupCamCapabilities: + log = squid.logging.get_logger("ToupcamCamera._capabilities_for_device") resolution_list = [] - for r in devices[index].model.res: + for r in device.model.res: + log.info("\t = [{} x {}]".format(r.width, r.height)) resolution_list.append((r.width, r.height)) if len(resolution_list) == 0: raise ValueError("No resolutions found for camera") @@ -217,17 +220,145 @@ def _open(index=None, sn=None) -> Tuple[toupcam.Toupcam, ToupCamCapabilities]: y_binning = int(highest_res[1] / res[1]) binning_res[(x_binning, y_binning)] = res - camera = toupcam.Toupcam.Open(devices[index].id) - capabilities = ToupCamCapabilities( + return ToupCamCapabilities( binning_to_resolution=binning_res, - has_fan=(devices[index].model.flag & toupcam.TOUPCAM_FLAG_FAN) > 0, - has_TEC=(devices[index].model.flag & toupcam.TOUPCAM_FLAG_TEC_ONOFF) > 0, - has_low_noise_mode=(devices[index].model.flag & toupcam.TOUPCAM_FLAG_LOW_NOISE) > 0, - has_black_level=(devices[index].model.flag & toupcam.TOUPCAM_FLAG_BLACKLEVEL) > 0, + has_fan=(device.model.flag & toupcam.TOUPCAM_FLAG_FAN) > 0, + has_TEC=(device.model.flag & toupcam.TOUPCAM_FLAG_TEC_ONOFF) > 0, + has_low_noise_mode=(device.model.flag & toupcam.TOUPCAM_FLAG_LOW_NOISE) > 0, + has_black_level=(device.model.flag & toupcam.TOUPCAM_FLAG_BLACKLEVEL) > 0, ) + @staticmethod + def _resolve_sn_to_index( + devices: Sequence[toupcam.ToupcamDeviceV2], sn: str + ) -> Tuple[int, Optional[toupcam.Toupcam]]: + """ + Find the device in `devices` that `sn` refers to. See _open for the accepted strings. + + Returns (index, camera), where camera is an already open handle for the matched + device when we had to open it to read its serial number (the caller owns it and + must not open the device a second time), and None when the match came from the + enumeration data alone. Raises ValueError when nothing matches. + """ + log = squid.logging.get_logger("ToupcamCamera._resolve_sn_to_index") + + # Pass 1: the opaque enumeration id. Free - no device needs to be opened. + for idx, device in enumerate(devices): + if device.id == sn: + log.info(f"Matched {sn=} against the enumeration id of device {idx}.") + return idx, None + + # Pass 2: the true serial number, which the SDK only reports for an open device. + # We open each candidate in turn and close it again unless it is the one we want. + # A device that is already open elsewhere (eg: the other camera of a 2 camera + # system) cannot be probed, so treat a failed open as "not this one" and continue. + log.info(f"No enumeration id matched {sn=}, probing {len(devices)} device(s) for their serial numbers.") + descriptions = [] + for idx, device in enumerate(devices): + try: + camera = toupcam.Toupcam.Open(device.id) + except Exception: + log.exception(f"Failed to open toupcam device {idx} (id={device.id}) while probing serial numbers.") + camera = None + + if camera is None: + log.warning(f"Could not open toupcam device {idx} (id={device.id}) to read its serial number.") + descriptions.append(f"id={device.id} (serial unavailable, could not open)") + continue + + keep_open = False + try: + serial = ToupcamCamera._read_serial_number(camera) + log.info(f"Probed toupcam device {idx}: id={device.id}, serial={serial}") + if serial is not None and serial == sn: + keep_open = True + return idx, camera + descriptions.append( + f"id={device.id} serial={serial}" if serial is not None else f"id={device.id} (serial unavailable)" + ) + finally: + if not keep_open: + ToupcamCamera._close_quietly(camera) + + raise ValueError( + f"Could not find a Toupcam camera matching serial_number={sn}. Available cameras: " + f"{'; '.join(descriptions)}. Use one of those id or serial strings as the camera's serial_number." + ) + + @staticmethod + def _open(index=None, sn=None) -> Tuple[toupcam.Toupcam, ToupCamCapabilities]: + """ + Open a toupcam device and work out its capabilities. + + Args: + index: 0 based index into the EnumV2 device list. When neither index nor sn + is given, the first enumerated device (index 0) is opened. + sn: identifies the camera to open. Two forms are accepted, tried in order: + 1. the opaque enumeration id (toupcam.ToupcamDeviceV2.id, ie: the string + Toupcam_Open takes). Matching this costs nothing. + 2. the camera's true serial number as reported by Toupcam.SerialNumber() + (eg: "TP110826145730ABCD1234FEDC56787"). The SDK only exposes this + for an open device, so devices are opened one at a time until one + matches, and any non matching device is closed again. + Specifying both index and sn is an error. + """ + log = squid.logging.get_logger("ToupcamCamera._open") + log.info(f"Opening toupcam with {index=}, {sn=}") + + if index is not None and sn is not None: + raise ValueError("You specified both a device index and a sn, this is not allowed.") + + devices = toupcam.Toupcam.EnumV2() + if len(devices) <= 0: + raise ValueError("There are no Toupcam V2 devices. Is the camera connected and powered on?") + + for idx, device in enumerate(devices): + log.info( + "Camera {}: {}: flag = {:#x}, preview = {}, still = {}".format( + idx, + device.displayname, + device.model.flag, + device.model.preview, + device.model.still, + ) + ) + + # Non-None only when resolving the sn left us holding an open handle for the match. + camera: Optional[toupcam.Toupcam] = None + if sn is not None: + (index, camera) = ToupcamCamera._resolve_sn_to_index(devices, sn) + elif index is None: + index = 0 + + if not 0 <= index < len(devices): + raise ValueError(f"Toupcam device index={index} is out of range, only {len(devices)} device(s) enumerated.") + + device = devices[index] + try: + capabilities = ToupcamCamera._capabilities_for_device(device) + if camera is None: + camera = toupcam.Toupcam.Open(device.id) + if camera is None: + raise ValueError(f"Failed to open Toupcam device {index} (id={device.id}). Is it in use already?") + except Exception: + # Don't leak a device we opened (or that sn probing left us holding). + if camera is not None: + ToupcamCamera._close_quietly(camera) + raise + return camera, capabilities + @staticmethod + def _open_for_config(config: CameraConfig) -> Tuple[toupcam.Toupcam, ToupCamCapabilities]: + """ + Open the camera this config points at: the one matching config.serial_number when + the config gives one (needed when more than one toupcam is connected), otherwise + the first enumerated camera. + """ + if config.serial_number: + return ToupcamCamera._open(sn=config.serial_number) + return ToupcamCamera._open(index=0) + def __init__(self, config: CameraConfig, hw_trigger_fn, hw_set_strobe_delay_ms_fn): super().__init__(config, hw_trigger_fn, hw_set_strobe_delay_ms_fn) @@ -246,7 +377,7 @@ def __init__(self, config: CameraConfig, hw_trigger_fn, hw_set_strobe_delay_ms_f # is what the camera driver calls when a new frame is available. self._raw_camera_stream_started = False self._raw_frame_callback_lock = threading.Lock() - (self._camera, self._capabilities) = ToupcamCamera._open(index=0) + (self._camera, self._capabilities) = ToupcamCamera._open_for_config(config) self._pixel_format = self._config.default_pixel_format self._binning = self._config.default_binning @@ -847,8 +978,19 @@ def get_white_balance_gains(self) -> Tuple[float, float, float]: def set_white_balance_gains(self, red_gain: float, green_gain: float, blue_gain: float): self._camera.put_WhiteBalanceGain((red_gain, green_gain, blue_gain)) - def set_auto_white_balance_gains(self) -> Tuple[float, float, float]: - self._camera.AwbInit() + def set_auto_white_balance_gains(self, on: bool) -> Tuple[float, float, float]: + """ + Turn auto white balance on or off, and return the resulting (R, G, B) gains. + + The SDK's auto white balance (AwbInit) is a one push operation: it works out the + gains once and leaves them set, so there is no continuous adjustment to turn back + off here. (The SDK does have a continuous mode, TOUPCAM_OPTION_AWB_CONTINUOUS, + but this driver never enables it.) + """ + if on: + self._camera.AwbInit() + else: + self._log.debug("Auto white balance is one push on toupcam cameras, nothing to turn off.") return self.get_white_balance_gains() _BLACK_LEVEL_MAPPING = { diff --git a/software/tests/control/test_camera_toupcam.py b/software/tests/control/test_camera_toupcam.py new file mode 100644 index 000000000..8a7a63d84 --- /dev/null +++ b/software/tests/control/test_camera_toupcam.py @@ -0,0 +1,422 @@ +"""Tests for the ToupTek (toupcam) driver that need no camera hardware. + +The vendored SDK binding (control/toupcam.py) loads its shared library lazily, +so `control.camera_toupcam` imports fine on a machine with no ToupTek attached. +Every test here swaps `control.camera_toupcam.toupcam` for a fake SDK module +(constants still come from the real binding) so we exercise the real driver +logic against a scripted set of enumerated devices. +""" + +import inspect +from typing import Dict, List, Optional + +import pytest + +import control.toupcam as real_toupcam +import squid.logging +from control.camera_toupcam import ToupcamCamera +from squid.abc import CameraPixelFormat +from squid.config import CameraConfig, CameraVariant + + +class FakeToupcamHandle: + """Stand-in for an open toupcam.Toupcam handle.""" + + def __init__(self, device_id: str, serial: Optional[str], serial_raises: bool = False): + self.device_id = device_id + self._serial = serial + self._serial_raises = serial_raises + self.closed = False + self.awb_init_calls = 0 + self.awb_once_calls = 0 + + def SerialNumber(self) -> str: + if self._serial_raises: + raise real_toupcam.HRESULTException(-1) + return self._serial + + def Close(self): + self.closed = True + + # --- used by the auto-white-balance tests ------------------------------- + def AwbInit(self): + self.awb_init_calls += 1 + + def AwbOnce(self): + self.awb_once_calls += 1 + + def get_WhiteBalanceGain(self): + return (11, 22, 33) + + +class FakeDeviceSpec: + def __init__( + self, + device_id: str, + serial: Optional[str] = None, + displayname: str = "FakeCam", + flag: int = 0, + resolutions=((3000, 2000), (1500, 1000)), + openable: bool = True, + serial_raises: bool = False, + ): + self.device_id = device_id + self.serial = serial + self.displayname = displayname + self.flag = flag + self.resolutions = resolutions + self.openable = openable + self.serial_raises = serial_raises + + +class _FakeResolution: + def __init__(self, width, height): + self.width = width + self.height = height + + +class _FakeModel: + def __init__(self, spec: FakeDeviceSpec): + self.flag = spec.flag + self.preview = len(spec.resolutions) + self.still = 0 + self.res = [_FakeResolution(w, h) for (w, h) in spec.resolutions] + + +class _FakeDevice: + def __init__(self, spec: FakeDeviceSpec): + self.displayname = spec.displayname + self.id = spec.device_id + self.model = _FakeModel(spec) + + +class FakeToupcamSdk: + """Fake `control.toupcam` module: fake Toupcam class, real constants. + + Attribute lookups that miss fall through to the real binding, so the driver + still sees the genuine TOUPCAM_* constants and exception types. + """ + + def __init__(self, specs: List[FakeDeviceSpec]): + self._specs = specs + self.open_calls: List[str] = [] + self.handles: Dict[str, FakeToupcamHandle] = {} + sdk = self + + class _FakeToupcam: + @staticmethod + def EnumV2(): + return [_FakeDevice(s) for s in sdk._specs] + + @staticmethod + def Open(cam_id): + sdk.open_calls.append(cam_id) + spec = next((s for s in sdk._specs if s.device_id == cam_id), None) + if spec is None or not spec.openable: + return None + handle = FakeToupcamHandle(spec.device_id, spec.serial, spec.serial_raises) + sdk.handles[cam_id] = handle + return handle + + self.Toupcam = _FakeToupcam + + def __getattr__(self, name): + # Constants (TOUPCAM_FLAG_*, ...) and exception types come from the real binding. + return getattr(real_toupcam, name) + + +@pytest.fixture +def fake_sdk(monkeypatch): + def _install(specs: List[FakeDeviceSpec]) -> FakeToupcamSdk: + sdk = FakeToupcamSdk(specs) + monkeypatch.setattr("control.camera_toupcam.toupcam", sdk) + return sdk + + return _install + + +def _config(serial_number: Optional[str] = None) -> CameraConfig: + return CameraConfig( + camera_type=CameraVariant.TOUPCAM, + default_pixel_format=CameraPixelFormat.MONO8, + serial_number=serial_number, + ) + + +# -------------------------------------------------------------------------- +# _open: index / no-serial behavior (pins today's behavior) +# -------------------------------------------------------------------------- + + +def test_open_index_zero_opens_first_device(fake_sdk): + sdk = fake_sdk([FakeDeviceSpec("port-a", serial="SN-A"), FakeDeviceSpec("port-b", serial="SN-B")]) + + camera, capabilities = ToupcamCamera._open(index=0) + + assert camera is sdk.handles["port-a"] + assert sdk.open_calls == ["port-a"] # no probing when an index is given + assert capabilities.binning_to_resolution == {(1, 1): (3000, 2000), (2, 2): (1500, 1000)} + + +def test_open_index_one_opens_second_device(fake_sdk): + sdk = fake_sdk([FakeDeviceSpec("port-a", serial="SN-A"), FakeDeviceSpec("port-b", serial="SN-B")]) + + camera, _ = ToupcamCamera._open(index=1) + + assert camera is sdk.handles["port-b"] + assert sdk.open_calls == ["port-b"] + + +def test_open_capabilities_from_device_flags(fake_sdk): + flag = real_toupcam.TOUPCAM_FLAG_FAN | real_toupcam.TOUPCAM_FLAG_BLACKLEVEL + fake_sdk([FakeDeviceSpec("port-a", serial="SN-A", flag=flag)]) + + _, capabilities = ToupcamCamera._open(index=0) + + assert capabilities.has_fan + assert capabilities.has_black_level + assert not capabilities.has_TEC + assert not capabilities.has_low_noise_mode + + +def test_open_with_no_devices_raises(fake_sdk): + fake_sdk([]) + + with pytest.raises(ValueError, match="no Toupcam"): + ToupcamCamera._open(index=0) + + +def test_open_with_index_out_of_range_raises_value_error(fake_sdk): + fake_sdk([FakeDeviceSpec("port-a", serial="SN-A")]) + + with pytest.raises(ValueError): + ToupcamCamera._open(index=3) + + +def test_open_rejects_both_index_and_sn(fake_sdk): + fake_sdk([FakeDeviceSpec("port-a", serial="SN-A")]) + + with pytest.raises(ValueError, match="both"): + ToupcamCamera._open(index=0, sn="SN-A") + + +# -------------------------------------------------------------------------- +# _open: serial number matching +# -------------------------------------------------------------------------- + + +def test_open_by_enumeration_id_does_not_probe(fake_sdk): + sdk = fake_sdk( + [ + FakeDeviceSpec("port-a", serial="SN-A"), + FakeDeviceSpec("port-b", serial="SN-B"), + FakeDeviceSpec("port-c", serial="SN-C"), + ] + ) + + camera, _ = ToupcamCamera._open(sn="port-b") + + assert camera is sdk.handles["port-b"] + assert not camera.closed + # Matching an enumeration id must not open (probe) any other camera. + assert sdk.open_calls == ["port-b"] + + +def test_open_by_true_serial_probes_and_closes_non_matches(fake_sdk): + sdk = fake_sdk( + [ + FakeDeviceSpec("port-a", serial="SN-A"), + FakeDeviceSpec("port-b", serial="SN-B"), + FakeDeviceSpec("port-c", serial="SN-C"), + ] + ) + + camera, capabilities = ToupcamCamera._open(sn="SN-B") + + assert camera is sdk.handles["port-b"] + assert not camera.closed, "the matched camera must stay open and be handed to the caller" + assert sdk.handles["port-a"].closed, "a probed, non-matching camera must be closed again" + # Probe port-a, then port-b (the match). port-c is never touched, and the handle the + # probe already opened is handed straight to the caller instead of being reopened. + assert sdk.open_calls == ["port-a", "port-b"] + assert sdk.open_calls.count("port-b") == 1, "the matched device must not be opened a second time" + assert capabilities.binning_to_resolution == {(1, 1): (3000, 2000), (2, 2): (1500, 1000)} + + +def test_open_by_true_serial_uses_matching_devices_capabilities(fake_sdk): + fake_sdk( + [ + FakeDeviceSpec("port-a", serial="SN-A", flag=real_toupcam.TOUPCAM_FLAG_FAN), + FakeDeviceSpec("port-b", serial="SN-B", flag=real_toupcam.TOUPCAM_FLAG_TEC_ONOFF), + ] + ) + + _, capabilities = ToupcamCamera._open(sn="SN-B") + + assert capabilities.has_TEC + assert not capabilities.has_fan + + +def test_open_by_true_serial_skips_devices_that_cannot_be_probed(fake_sdk): + # A camera already opened by someone else cannot be probed; we must keep going. + sdk = fake_sdk( + [ + FakeDeviceSpec("port-a", serial="SN-A", openable=False), + FakeDeviceSpec("port-b", serial="SN-B"), + ] + ) + + camera, _ = ToupcamCamera._open(sn="SN-B") + + assert camera is sdk.handles["port-b"] + + +def test_open_by_true_serial_survives_serial_read_failure(fake_sdk): + sdk = fake_sdk( + [ + FakeDeviceSpec("port-a", serial=None, serial_raises=True), + FakeDeviceSpec("port-b", serial="SN-B"), + ] + ) + + camera, _ = ToupcamCamera._open(sn="SN-B") + + assert camera is sdk.handles["port-b"] + assert sdk.handles["port-a"].closed + + +def test_open_with_unknown_serial_raises_listing_ids_and_serials(fake_sdk): + fake_sdk( + [ + FakeDeviceSpec("port-a", serial="SN-A"), + FakeDeviceSpec("port-b", serial="SN-B"), + ] + ) + + with pytest.raises(ValueError) as exc_info: + ToupcamCamera._open(sn="SN-NOPE") + + message = str(exc_info.value) + assert "SN-NOPE" in message + for identifier in ("port-a", "port-b", "SN-A", "SN-B"): + assert identifier in message, f"{identifier} missing from error message: {message}" + + +def test_open_with_unknown_serial_closes_every_probe(fake_sdk): + sdk = fake_sdk([FakeDeviceSpec("port-a", serial="SN-A"), FakeDeviceSpec("port-b", serial="SN-B")]) + + with pytest.raises(ValueError): + ToupcamCamera._open(sn="SN-NOPE") + + assert set(sdk.handles) == {"port-a", "port-b"}, "every device should have been probed" + assert all(handle.closed for handle in sdk.handles.values()) + + +def test_open_does_not_leak_the_matched_handle_when_capabilities_fail(fake_sdk): + # A device with no resolutions makes capability building fail *after* sn probing + # already opened the matched device - that handle must not be leaked. + sdk = fake_sdk([FakeDeviceSpec("port-a", serial="SN-A", resolutions=())]) + + with pytest.raises(ValueError, match="No resolutions"): + ToupcamCamera._open(sn="SN-A") + + assert sdk.handles["port-a"].closed + + +def test_open_raises_when_sdk_open_returns_none(fake_sdk): + fake_sdk([FakeDeviceSpec("port-a", serial="SN-A", openable=False)]) + + with pytest.raises(ValueError): + ToupcamCamera._open(index=0) + + +# -------------------------------------------------------------------------- +# Config -> open wiring +# -------------------------------------------------------------------------- + + +def test_open_for_config_without_serial_uses_index_zero(monkeypatch): + calls = [] + monkeypatch.setattr( + ToupcamCamera, "_open", staticmethod(lambda index=None, sn=None: calls.append((index, sn)) or ("cam", "caps")) + ) + + assert ToupcamCamera._open_for_config(_config(serial_number=None)) == ("cam", "caps") + assert calls == [(0, None)] + + +def test_open_for_config_with_serial_opens_by_serial(monkeypatch): + calls = [] + monkeypatch.setattr( + ToupcamCamera, "_open", staticmethod(lambda index=None, sn=None: calls.append((index, sn)) or ("cam", "caps")) + ) + + ToupcamCamera._open_for_config(_config(serial_number="SN-B")) + + assert calls == [(None, "SN-B")] + + +def test_open_for_config_treats_empty_serial_as_unset(monkeypatch): + calls = [] + monkeypatch.setattr( + ToupcamCamera, "_open", staticmethod(lambda index=None, sn=None: calls.append((index, sn)) or ("cam", "caps")) + ) + + ToupcamCamera._open_for_config(_config(serial_number="")) + + assert calls == [(0, None)] + + +class _StopInit(Exception): + """Sentinel raised from a patched _open_for_config to end __init__ early.""" + + +def test_init_routes_through_open_for_config(monkeypatch): + seen = [] + + def _fake_open_for_config(config): + seen.append(config.serial_number) + raise _StopInit() + + monkeypatch.setattr(ToupcamCamera, "_open_for_config", staticmethod(_fake_open_for_config)) + + with pytest.raises(_StopInit): + ToupcamCamera(_config(serial_number="SN-B"), None, None) + + assert seen == ["SN-B"] + + +# -------------------------------------------------------------------------- +# set_auto_white_balance_gains +# -------------------------------------------------------------------------- + + +def _camera_with_fake_handle() -> ToupcamCamera: + """A ToupcamCamera with just enough state for the white balance methods.""" + camera = ToupcamCamera.__new__(ToupcamCamera) + camera._camera = FakeToupcamHandle("port-a", "SN-A") + camera._log = squid.logging.get_logger("test_camera_toupcam") + return camera + + +def test_set_auto_white_balance_gains_signature_matches_abstract(): + parameters = list(inspect.signature(ToupcamCamera.set_auto_white_balance_gains).parameters) + assert parameters == ["self", "on"] + + +def test_set_auto_white_balance_gains_on_triggers_sdk_awb(): + camera = _camera_with_fake_handle() + + result = camera.set_auto_white_balance_gains(on=True) + + assert camera._camera.awb_init_calls == 1 + assert result == (11, 22, 33) + + +def test_set_auto_white_balance_gains_off_does_not_trigger_awb(): + camera = _camera_with_fake_handle() + + camera.set_auto_white_balance_gains(on=False) + + assert camera._camera.awb_init_calls == 0 + assert camera._camera.awb_once_calls == 0 From c6130cc42a688ede9aff565ba48f249c9bc5d6d8 Mon Sep 17 00:00:00 2001 From: You Yan Date: Mon, 10 Aug 2026 13:59:01 -0700 Subject: [PATCH 28/52] feat(qt): add binding selector preferring PyQt6 when installed Co-Authored-By: Claude Fable 5 --- software/squid/qt_binding.py | 28 +++++++++++++++++ software/tests/squid/test_qt_binding.py | 42 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 software/squid/qt_binding.py create mode 100644 software/tests/squid/test_qt_binding.py diff --git a/software/squid/qt_binding.py b/software/squid/qt_binding.py new file mode 100644 index 000000000..3c02a2661 --- /dev/null +++ b/software/squid/qt_binding.py @@ -0,0 +1,28 @@ +"""Select the Qt binding for qtpy and pytest-qt. + +Must be imported and called before the first ``qtpy`` import anywhere in the +process. Deliberately does not import any Qt binding itself: qtpy latches +onto an already-imported binding regardless of QT_API, so availability is +probed with ``importlib.util.find_spec`` instead. +""" + +import importlib.util +import os + + +def select_qt_api() -> str: + """Set QT_API/PYTEST_QT_API so qtpy and pytest-qt agree on one binding. + + Preference order: + 1. An explicit QT_API environment variable always wins. + 2. PyQt6, when installed. + 3. PyQt5 otherwise. + + Returns the selected api name (e.g. "pyqt6"). + """ + api = os.environ.get("QT_API") + if not api: + api = "pyqt6" if importlib.util.find_spec("PyQt6") is not None else "pyqt5" + os.environ["QT_API"] = api + os.environ.setdefault("PYTEST_QT_API", api) + return api diff --git a/software/tests/squid/test_qt_binding.py b/software/tests/squid/test_qt_binding.py new file mode 100644 index 000000000..771079efd --- /dev/null +++ b/software/tests/squid/test_qt_binding.py @@ -0,0 +1,42 @@ +import importlib.util +import os + +from squid.qt_binding import select_qt_api + + +def test_explicit_qt_api_wins(monkeypatch): + monkeypatch.setenv("QT_API", "pyqt5") + monkeypatch.delenv("PYTEST_QT_API", raising=False) + + assert select_qt_api() == "pyqt5" + assert os.environ["QT_API"] == "pyqt5" + # pytest-qt must agree with qtpy or the process loads two bindings. + assert os.environ["PYTEST_QT_API"] == "pyqt5" + + +def test_prefers_pyqt6_when_available(monkeypatch): + monkeypatch.delenv("QT_API", raising=False) + monkeypatch.delenv("PYTEST_QT_API", raising=False) + monkeypatch.setattr(importlib.util, "find_spec", lambda name: object()) + + assert select_qt_api() == "pyqt6" + assert os.environ["QT_API"] == "pyqt6" + assert os.environ["PYTEST_QT_API"] == "pyqt6" + + +def test_falls_back_to_pyqt5_without_pyqt6(monkeypatch): + monkeypatch.delenv("QT_API", raising=False) + monkeypatch.delenv("PYTEST_QT_API", raising=False) + monkeypatch.setattr(importlib.util, "find_spec", lambda name: None) + + assert select_qt_api() == "pyqt5" + assert os.environ["QT_API"] == "pyqt5" + assert os.environ["PYTEST_QT_API"] == "pyqt5" + + +def test_existing_pytest_qt_api_not_overwritten(monkeypatch): + monkeypatch.setenv("QT_API", "pyqt5") + monkeypatch.setenv("PYTEST_QT_API", "pyqt6") + + select_qt_api() + assert os.environ["PYTEST_QT_API"] == "pyqt6" From eaca176e7cdf377c79724b557f17826429cbb5a7 Mon Sep 17 00:00:00 2001 From: You Yan Date: Mon, 10 Aug 2026 14:00:04 -0700 Subject: [PATCH 29/52] feat(qt): entry points select Qt binding via squid.qt_binding Co-Authored-By: Claude Fable 5 --- software/main_hcs.py | 8 +++++--- software/tests/conftest.py | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/software/main_hcs.py b/software/main_hcs.py index dddc81816..876b7f9f4 100644 --- a/software/main_hcs.py +++ b/software/main_hcs.py @@ -1,12 +1,14 @@ -# set QT_API environment variable import argparse import logging import os - -os.environ["QT_API"] = "pyqt5" import signal import sys +# Select the Qt binding (PyQt6 preferred when installed) before any qtpy import. +from squid.qt_binding import select_qt_api + +select_qt_api() + # qt libraries from qtpy.QtWidgets import * from qtpy.QtGui import * diff --git a/software/tests/conftest.py b/software/tests/conftest.py index ceb366c98..115945215 100644 --- a/software/tests/conftest.py +++ b/software/tests/conftest.py @@ -18,6 +18,13 @@ import pytest +# Select the Qt binding (PyQt6 preferred when installed) before anything +# imports qtpy. Also keeps pytest-qt (PYTEST_QT_API) on the same binding — +# left alone it prefers PyQt6 on its own and the process would load both. +from squid.qt_binding import select_qt_api + +select_qt_api() + import control.microcontroller import control.microscope from control.core.multi_point_controller import MultiPointController From b9634c8a0c85abe7a4fae5eaea7688e6aeeefd79 Mon Sep 17 00:00:00 2001 From: You Yan Date: Mon, 10 Aug 2026 14:00:34 -0700 Subject: [PATCH 30/52] fix(qt6): use binding-agnostic matplotlib backend_qtagg Co-Authored-By: Claude Fable 5 --- software/control/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/control/widgets.py b/software/control/widgets.py index 37d9051ad..fb64fd1af 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -14988,7 +14988,7 @@ def save_settings(self): json.dump(data, f) -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure from mpl_toolkits.mplot3d import proj3d from scipy.interpolate import griddata From 7cd57011edf9bec1aded0da8a5cc6651d8ba0e17 Mon Sep 17 00:00:00 2001 From: You Yan Date: Mon, 10 Aug 2026 14:01:04 -0700 Subject: [PATCH 31/52] fix(qt6): replace removed QDesktopWidget with QScreen API Co-Authored-By: Claude Fable 5 --- software/control/core_volumetric_imaging.py | 4 ++-- software/control/gui_hcs.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/software/control/core_volumetric_imaging.py b/software/control/core_volumetric_imaging.py index 9c15c81aa..2c97c6927 100644 --- a/software/control/core_volumetric_imaging.py +++ b/software/control/core_volumetric_imaging.py @@ -173,8 +173,8 @@ def __init__(self, window_title=""): self.setCentralWidget(self.widget) # set window size - desktopWidget = QDesktopWidget() - width = min(desktopWidget.height() * 0.9, 1000) # @@@TO MOVE@@@# + screen_height = QApplication.primaryScreen().size().height() + width = int(min(screen_height * 0.9, 1000)) # @@@TO MOVE@@@# height = width self.setFixedSize(width, height) diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 5fc209a61..530d436e8 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -1478,8 +1478,7 @@ def _getMainWindowMinimumSize(self): We want our main window to fit on the primary screen, so grab the users primary screen and return something slightly smaller than that. """ - desktop_info = QDesktopWidget() - primary_screen_size = desktop_info.screen(desktop_info.primaryScreen()).size() + primary_screen_size = QApplication.primaryScreen().size() height_min = int(0.9 * primary_screen_size.height()) width_min = int(0.96 * primary_screen_size.width()) From 1363daebe3a37190b2c781cebeadfbfffe28c5de Mon Sep 17 00:00:00 2001 From: You Yan Date: Mon, 10 Aug 2026 14:01:44 -0700 Subject: [PATCH 32/52] fix(napari): guard layerButtons for napari versions that removed it Co-Authored-By: Claude Fable 5 --- software/control/widgets.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/software/control/widgets.py b/software/control/widgets.py index fb64fd1af..532232590 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -12064,7 +12064,8 @@ def make_row(label_widget, entry_widget, value_label=None): layer_controls_widget = self.viewer.window._qt_viewer.dockLayerControls.widget() layer_list_widget = self.viewer.window._qt_viewer.dockLayerList.widget() - self.viewer.window._qt_viewer.layerButtons.hide() + if hasattr(self.viewer.window._qt_viewer, "layerButtons"): + self.viewer.window._qt_viewer.layerButtons.hide() self.viewer.window.remove_dock_widget(self.viewer.window._qt_viewer.dockLayerControls) self.viewer.window.remove_dock_widget(self.viewer.window._qt_viewer.dockLayerList) @@ -12105,7 +12106,8 @@ def make_row(label_widget, entry_widget, value_label=None): layer_controls_widget = self.viewer.window._qt_viewer.dockLayerControls.widget() layer_list_widget = self.viewer.window._qt_viewer.dockLayerList.widget() - self.viewer.window._qt_viewer.layerButtons.hide() + if hasattr(self.viewer.window._qt_viewer, "layerButtons"): + self.viewer.window._qt_viewer.layerButtons.hide() self.viewer.window.remove_dock_widget(self.viewer.window._qt_viewer.dockLayerControls) self.viewer.window.remove_dock_widget(self.viewer.window._qt_viewer.dockLayerList) self.print_window_menu_items() From be66789af875f23a02d8090faa8bd54a9e7a9d52 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 10 Aug 2026 16:17:36 -0700 Subject: [PATCH 33/52] feat(toupcam): colour capture, and pixel formats that follow the sensor A colour ToupTek could not produce a colour frame. The dual-camera feature documents `default_pixel_format: "RGB24"` for the colour camera, but building one raised `ValueError: Unsupported pixel format` and every other colour route was dead too. The RGB work on this branch went into SimulatedCamera, so simulation passed and hardware did not. Verified on an ITR3CMOS26000KPA. - _configure_camera picks the frame format from the configured pixel format instead of hard coding RAW. The SDK only debayers in RGB frame format, so an RGB pixel format could never be configured. - _calculate_strobe_info keys the line length table off the sensor's readout depth rather than the size of the host side pixel. RGB24/RGB32 are 8 bit readouts and RGB48 is a 16 bit one; deriving the depth from the byte size left line_length at 0 and divided by zero. - The frame callback handles RGB frames. It used to reject anything that was not RAW outright, dropping every colour frame. _rgb_image_from_read_buffer strips the SDK's row padding and returns (h, w, 3) - uint8 for RGB24/RGB32, uint16 for RGB48 - and keeps Grey8/Grey16 in RGB mode 2D, which would otherwise have silently produced an (h, w, 2) array. - The byte order is pinned to RGB. The SDK defaults to BGR on Windows, so without this the red and blue channels swap on one platform only. - Buffer sizing uses _row_pitch_bytes, which matches the SDK's documented row pitch. The old call passed bits to _tdib_width_bytes, which multiplies by 24 itself, over-allocating roughly 24x. That helper is now unused and removed. get_available_pixel_formats() is implemented off the sensor's TOUPCAM_FLAG_MONO bit (new is_mono capability) rather than raising. It raised before, so the camera settings tab fell back to a hard coded mono list for every ToupTek: on a colour camera that hid RGB24 and displayed MONO8 while the camera was actually in RGB24 (setCurrentText on an absent entry is a silent no-op), and it offered BAYER_RG8/RG12, which no ToupTek supports - selecting one raised inside a Qt slot. set_pixel_format validates before touching the camera. Applying the frame format switch and then failing on the pixel format left a pair that _get_pixel_size_in_bytes cannot map, which broke every later frame and every exposure change rather than just that call. Tests mock the vendored SDK (no hardware needed) and cover frame format selection, row pitch padding, buffer unpacking for all four formats plus the grey-in-RGB-mode cases, strobe timing for the colour pixel sizes, the sensor-type format lists, and that a rejected pixel format leaves the camera untouched. Co-Authored-By: Claude Opus 5 (1M context) --- software/control/camera_toupcam.py | 149 +++++++++-- software/tests/control/test_camera_toupcam.py | 251 +++++++++++++++++- 2 files changed, 381 insertions(+), 19 deletions(-) diff --git a/software/control/camera_toupcam.py b/software/control/camera_toupcam.py index f30ffc4a2..440846961 100644 --- a/software/control/camera_toupcam.py +++ b/software/control/camera_toupcam.py @@ -31,6 +31,9 @@ class ToupCamCapabilities(pydantic.BaseModel): has_TEC: bool has_low_noise_mode: bool has_black_level: bool + # Monochrome sensor. Decides which pixel formats the camera can produce, so it is + # what get_available_pixel_formats reports off. + is_mono: bool = True class StrobeInfo(pydantic.BaseModel): @@ -60,9 +63,39 @@ def _event_callback(event_number, camera): if event_number == toupcam.TOUPCAM_EVENT_IMAGE: camera._on_frame_callback() + # Pixel formats the SDK debayers for us. These are the only ones that need the RGB + # frame format; everything else (MONO*) is read straight off the sensor in RAW mode. + _RGB_PIXEL_FORMATS = ( + CameraPixelFormat.RGB24, + CameraPixelFormat.RGB32, + CameraPixelFormat.RGB48, + ) + + @staticmethod + def _frame_format_for_pixel_format(pixel_format: CameraPixelFormat) -> CameraFrameFormat: + """ + The frame format a pixel format has to be read in. + + The RGB formats are produced by the SDK's own debayering, which only runs in RGB + frame format (TOUPCAM_OPTION_RAW=0). MONO formats come off the sensor in RAW mode. + """ + if pixel_format in ToupcamCamera._RGB_PIXEL_FORMATS: + return CameraFrameFormat.RGB + return CameraFrameFormat.RAW + @staticmethod - def _tdib_width_bytes(w): - return (w * 24 + 31) // 32 * 4 + def _row_pitch_bytes(width: int, pixel_size_in_bytes: int) -> int: + """ + Bytes from the start of one row to the start of the next, in RGB frame format. + + This must match the SDK's default row pitch, which is what PullImageV2 writes: + RGB32 rows are packed (Width * 4), every other format is padded out to a 4 byte + boundary (TDIBWIDTHBYTES(bits_per_pixel * Width)). See the PullImageV4 rowPitch + table in toupcam.py. + """ + if pixel_size_in_bytes == 4: + return width * 4 + return (width * pixel_size_in_bytes * 8 + 31) // 32 * 4 @staticmethod def _calculate_strobe_info( @@ -71,7 +104,12 @@ def _calculate_strobe_info( log = squid.logging.get_logger("ToupcamCamera._calculate_strobe_delay") # use camera arguments such as resolutuon, ROI, exposure time, set max FPS, bandwidth to calculate the trigger delay time - pixel_bits = pixel_size * 8 + # The line length table below is indexed by the sensor's readout depth - what + # TOUPCAM_OPTION_BITDEPTH selects - and not by the size of the pixel we are handed. + # They differ for the debayered RGB formats: RGB24 (3 bytes) and RGB32 (4 bytes) + # are 8 bit readouts, RGB48 (6 bytes) is a 16 bit one. Deriving pixel_bits from + # the byte size alone leaves line_length at 0 for those, which then divides by zero. + pixel_bits = 8 if pixel_size in (1, 3, 4) else 16 line_length = 0 low_noise = 0 @@ -226,6 +264,7 @@ def _capabilities_for_device(device: toupcam.ToupcamDeviceV2) -> ToupCamCapabili has_TEC=(device.model.flag & toupcam.TOUPCAM_FLAG_TEC_ONOFF) > 0, has_low_noise_mode=(device.model.flag & toupcam.TOUPCAM_FLAG_LOW_NOISE) > 0, has_black_level=(device.model.flag & toupcam.TOUPCAM_FLAG_BLACKLEVEL) > 0, + is_mono=(device.model.flag & toupcam.TOUPCAM_FLAG_MONO) > 0, ) @staticmethod @@ -451,6 +490,35 @@ def _start_raw_camera_stream(self): self._log.exception("failed to start camera, hr=0x{:x}".format(ex.hr)) raise ex + def _rgb_image_from_read_buffer(self, width: int, height: int, pixel_size: int) -> np.array: + """ + Unpack a frame the SDK produced in RGB frame format out of the internal read buffer. + + Rows are padded out to the pitch _row_pitch_bytes describes, so the padding has to + be sliced off before the buffer can be seen as an image. Not every frame read this + way is colour: RGB frame format also covers the SDK's Grey8/Grey16 output (a mono + pixel format in RGB mode), which stays 2D. Colour frames come back as + (height, width, 3) - uint8 for RGB24/RGB32, uint16 for RGB48 - with RGB32's fourth + byte per pixel dropped, since the rest of squid expects 3 channel colour frames. + """ + row_pitch = ToupcamCamera._row_pitch_bytes(width, pixel_size) + rows = np.frombuffer(self._internal_read_buffer, dtype=np.uint8, count=row_pitch * height).reshape( + height, row_pitch + ) + packed = rows[:, : width * pixel_size] + + if pixel_size in (2, 6): + # uint16 components: one for Grey16, three for RGB48. .view needs a contiguous + # buffer, and slicing the padding off broke that. + wide = np.ascontiguousarray(packed).view(np.uint16) + return wide.reshape(height, width) if pixel_size == 2 else wide.reshape(height, width, 3) + + if pixel_size == 1: # Grey8 + return packed.reshape(height, width) + + image = packed.reshape(height, width, pixel_size) + return image[:, :, :3] if pixel_size == 4 else image + def _on_frame_callback(self): """ This is the callback that we have the toupcam software call when a frame is ready. It should always be running. @@ -480,16 +548,18 @@ def _on_frame_callback(self): this_frame_format = self.get_frame_format() this_pixel_format = self.get_pixel_format() - if this_frame_format != CameraFrameFormat.RAW: - self._log.error("Only RAW CameraFrameFormat are supported, cannot handle frame.") - return - (x_offset, y_offset, width, height) = self.get_region_of_interest() - if self._get_pixel_size_in_bytes() == 1: - raw_image = np.frombuffer(self._internal_read_buffer, dtype="uint8") - elif self._get_pixel_size_in_bytes() == 2: - raw_image = np.frombuffer(self._internal_read_buffer, dtype="uint16") - current_raw_image = raw_image.reshape(height, width) + pixel_size = self._get_pixel_size_in_bytes() + + if this_frame_format == CameraFrameFormat.RGB: + current_raw_image = self._rgb_image_from_read_buffer(width, height, pixel_size) + elif pixel_size == 1: + current_raw_image = np.frombuffer(self._internal_read_buffer, dtype="uint8").reshape(height, width) + elif pixel_size == 2: + current_raw_image = np.frombuffer(self._internal_read_buffer, dtype="uint16").reshape(height, width) + else: + self._log.error(f"Cannot handle a RAW frame with {pixel_size=}, dropping it.") + return process_start_ns = time.perf_counter_ns() current_frame = CameraFrame( @@ -549,8 +619,8 @@ def _update_internal_settings(self, send_exposure=True): # calculate buffer size pixel_size = self._get_pixel_size_in_bytes() - if self.get_frame_format() == CameraFrameFormat.RGB and pixel_size != 4: - buffer_size = ToupcamCamera._tdib_width_bytes(width * pixel_size * 8) * height + if self.get_frame_format() == CameraFrameFormat.RGB: + buffer_size = ToupcamCamera._row_pitch_bytes(width, pixel_size) * height else: buffer_size = width * pixel_size * height # create the buffer @@ -615,8 +685,11 @@ def _configure_camera(self): else: self.set_temperature(self._config.default_temperature) - self._raw_set_frame_format(CameraFrameFormat.RAW) - self._raw_set_pixel_format(self._pixel_format) # 'MONO8' + # The frame format has to follow the pixel format: a colour camera configured with + # an RGB default_pixel_format needs the SDK's debayering, which only runs in RGB + # frame format. Hard coding RAW here made every RGB format fail to configure. + self._raw_set_frame_format(ToupcamCamera._frame_format_for_pixel_format(self._pixel_format)) + self._raw_set_pixel_format(self._pixel_format) try: self.set_black_level(self._config.default_black_level) except NotImplementedError: @@ -756,7 +829,21 @@ def _raw_set_pixel_format(self, pixel_format: CameraPixelFormat): self._pixel_format = pixel_format def set_pixel_format(self, pixel_format: CameraPixelFormat): + # Validate before touching the camera. The frame format switch below and the pixel + # format itself have to land together: applying the first and then failing on the + # second leaves a frame/pixel format pair that _get_pixel_size_in_bytes cannot map, + # which breaks every later frame and exposure change rather than just this call. + available = self.get_available_pixel_formats() + if pixel_format not in available: + raise ValueError( + f"Unsupported pixel format: {pixel_format=}. This camera supports: " + f"{', '.join(pf.name for pf in available)}." + ) + with self._pause_streaming(): + # Switching between a MONO and an RGB format is also a frame format switch, so + # do it here rather than making every caller pair the two calls up themselves. + self._raw_set_frame_format(ToupcamCamera._frame_format_for_pixel_format(pixel_format)) self._raw_set_pixel_format(pixel_format) self.set_black_level(self._config.default_black_level) self._update_internal_settings() @@ -765,7 +852,25 @@ def get_pixel_format(self) -> CameraPixelFormat: return self._pixel_format def get_available_pixel_formats(self) -> Sequence[CameraPixelFormat]: - raise NotImplementedError("get_available_pixel_formats is not implemented for Toupcam") + """ + The pixel formats this camera can actually produce. + + A monochrome sensor reads out grey levels; a colour one is debayered by the SDK + into RGB. Reading a colour sensor with a MONO format is possible but hands back + undebayered Bayer data that looks like a grid artifact, so it is not offered. + + Reporting this properly matters for the GUI: the camera settings tab falls back to + a hard coded mono list when this raises, which on a colour camera offers formats + that cannot be set and hides the ones that can. + """ + if self._capabilities.is_mono: + return ( + CameraPixelFormat.MONO8, + CameraPixelFormat.MONO12, + CameraPixelFormat.MONO14, + CameraPixelFormat.MONO16, + ) + return ToupcamCamera._RGB_PIXEL_FORMATS def set_auto_exposure(self, enabled: bool): try: @@ -779,6 +884,16 @@ def _raw_set_frame_format(self, data_format: CameraFrameFormat): self._camera.put_Option( toupcam.TOUPCAM_OPTION_RAW, ToupcamCamera.TOUPCAM_OPTION_RAW_RGB_VAL ) # 0 is RGB mode, 1 is RAW mode + # The SDK's byte order defaults to BGR on Windows (RGB elsewhere). The rest of + # squid treats colour frames as RGB, so pin it rather than inheriting a channel + # swap that only shows up on one platform. Not every model implements it. + try: + self._camera.put_Option(toupcam.TOUPCAM_OPTION_BYTEORDER, 0) # 0 is RGB, 1 is BGR + except toupcam.HRESULTException as ex: + self._log.warning( + f"Could not pin the byte order to RGB, colour channels may be swapped --> " + f"{control.toupcam_exceptions.explain(ex)}" + ) elif data_format == CameraFrameFormat.RAW: self._camera.put_Option( toupcam.TOUPCAM_OPTION_RAW, ToupcamCamera.TOUPCAM_OPTION_RAW_RAW_VAL diff --git a/software/tests/control/test_camera_toupcam.py b/software/tests/control/test_camera_toupcam.py index 8a7a63d84..e720bc3e4 100644 --- a/software/tests/control/test_camera_toupcam.py +++ b/software/tests/control/test_camera_toupcam.py @@ -10,12 +10,13 @@ import inspect from typing import Dict, List, Optional +import numpy as np import pytest import control.toupcam as real_toupcam import squid.logging -from control.camera_toupcam import ToupcamCamera -from squid.abc import CameraPixelFormat +from control.camera_toupcam import ToupcamCamera, ToupCamCapabilities +from squid.abc import CameraFrameFormat, CameraPixelFormat from squid.config import CameraConfig, CameraVariant @@ -420,3 +421,249 @@ def test_set_auto_white_balance_gains_off_does_not_trigger_awb(): assert camera._camera.awb_init_calls == 0 assert camera._camera.awb_once_calls == 0 + + +# -------------------------------------------------------------------------- +# Colour capture: frame format selection, row pitch, buffer unpacking +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "pixel_format, expected", + [ + (CameraPixelFormat.MONO8, CameraFrameFormat.RAW), + (CameraPixelFormat.MONO12, CameraFrameFormat.RAW), + (CameraPixelFormat.MONO16, CameraFrameFormat.RAW), + (CameraPixelFormat.RGB24, CameraFrameFormat.RGB), + (CameraPixelFormat.RGB32, CameraFrameFormat.RGB), + (CameraPixelFormat.RGB48, CameraFrameFormat.RGB), + ], +) +def test_frame_format_follows_pixel_format(pixel_format, expected): + assert ToupcamCamera._frame_format_for_pixel_format(pixel_format) == expected + + +@pytest.mark.parametrize( + "width, pixel_size, expected", + [ + (3, 3, 12), # RGB24, 9 bytes of pixels padded up to a 4 byte boundary + (4, 3, 12), # RGB24, already aligned + (3, 4, 12), # RGB32 rows are packed, never padded + (3, 6, 20), # RGB48, 18 bytes of pixels padded up to 20 + (2, 6, 12), # RGB48, already aligned + ], +) +def test_row_pitch_matches_sdk_default(width, pixel_size, expected): + assert ToupcamCamera._row_pitch_bytes(width, pixel_size) == expected + + +def _camera_with_buffer(buffer: bytes) -> ToupcamCamera: + camera = ToupcamCamera.__new__(ToupcamCamera) + camera._internal_read_buffer = buffer + camera._log = squid.logging.get_logger("test_camera_toupcam") + return camera + + +def test_rgb24_unpacking_strips_row_padding(): + # 2 rows of 3 RGB24 pixels: 9 bytes of pixel data, then 3 bytes of padding. + pixels = np.arange(1, 19, dtype=np.uint8).reshape(2, 9) + padding = np.zeros((2, 3), dtype=np.uint8) + buffer = np.hstack([pixels, padding]).tobytes() + + image = _camera_with_buffer(buffer)._rgb_image_from_read_buffer(width=3, height=2, pixel_size=3) + + assert image.shape == (2, 3, 3) + assert image.dtype == np.uint8 + np.testing.assert_array_equal(image, pixels.reshape(2, 3, 3)) + + +def test_rgb32_unpacking_drops_the_fourth_component(): + # 1 row of 2 RGBA pixels; the fourth byte is padding the sensor does not fill. + buffer = bytes([1, 2, 3, 255, 4, 5, 6, 255]) + + image = _camera_with_buffer(buffer)._rgb_image_from_read_buffer(width=2, height=1, pixel_size=4) + + assert image.shape == (1, 2, 3) + np.testing.assert_array_equal(image, np.array([[[1, 2, 3], [4, 5, 6]]], dtype=np.uint8)) + + +@pytest.mark.parametrize( + "pixel_size, dtype, values", + [ + (1, np.uint8, [1, 2, 3]), # Grey8 in RGB mode + (2, np.uint16, [1000, 2000, 3000]), # Grey16 in RGB mode + ], +) +def test_grey_in_rgb_mode_stays_two_dimensional(pixel_size, dtype, values): + """A mono pixel format in RGB frame format is the SDK's Grey8/Grey16 output.""" + row = np.array(values, dtype=dtype) + padding = b"\x00" * (ToupcamCamera._row_pitch_bytes(3, pixel_size) - 3 * pixel_size) + + image = _camera_with_buffer(row.tobytes() + padding)._rgb_image_from_read_buffer( + width=3, height=1, pixel_size=pixel_size + ) + + assert image.shape == (1, 3) + assert image.dtype == dtype + np.testing.assert_array_equal(image, row.reshape(1, 3)) + + +def test_rgb48_unpacking_yields_uint16_components(): + # 1 row of 3 RGB48 pixels: 18 bytes of pixel data padded out to a 20 byte pitch. + pixels = np.arange(1000, 1009, dtype=np.uint16) + buffer = pixels.tobytes() + b"\x00\x00" + + image = _camera_with_buffer(buffer)._rgb_image_from_read_buffer(width=3, height=1, pixel_size=6) + + assert image.shape == (1, 3, 3) + assert image.dtype == np.uint16 + np.testing.assert_array_equal(image, pixels.reshape(1, 3, 3)) + + +# -------------------------------------------------------------------------- +# Available pixel formats follow the sensor type +# -------------------------------------------------------------------------- + + +def _capabilities(is_mono: bool) -> ToupCamCapabilities: + return ToupCamCapabilities( + binning_to_resolution={}, + has_fan=False, + has_TEC=False, + has_low_noise_mode=False, + has_black_level=False, + is_mono=is_mono, + ) + + +def _camera_with_capabilities(is_mono: bool) -> ToupcamCamera: + camera = ToupcamCamera.__new__(ToupcamCamera) + camera._capabilities = _capabilities(is_mono) + camera._log = squid.logging.get_logger("test_camera_toupcam") + return camera + + +def test_mono_camera_offers_only_mono_formats(): + formats = _camera_with_capabilities(is_mono=True).get_available_pixel_formats() + + assert list(formats) == [ + CameraPixelFormat.MONO8, + CameraPixelFormat.MONO12, + CameraPixelFormat.MONO14, + CameraPixelFormat.MONO16, + ] + + +def test_colour_camera_offers_only_rgb_formats(): + formats = _camera_with_capabilities(is_mono=False).get_available_pixel_formats() + + assert list(formats) == [CameraPixelFormat.RGB24, CameraPixelFormat.RGB32, CameraPixelFormat.RGB48] + + +@pytest.mark.parametrize("is_mono", [True, False]) +def test_available_pixel_formats_never_offers_bayer(is_mono): + """_raw_set_pixel_format has no BAYER branch, so offering one crashes a Qt slot.""" + formats = _camera_with_capabilities(is_mono).get_available_pixel_formats() + + assert CameraPixelFormat.BAYER_RG8 not in formats + assert CameraPixelFormat.BAYER_RG12 not in formats + + +def test_is_mono_capability_read_from_device_flag(fake_sdk): + fake_sdk([FakeDeviceSpec("port-a", serial="SN-A", flag=real_toupcam.TOUPCAM_FLAG_MONO)]) + + _, capabilities = ToupcamCamera._open(index=0) + + assert capabilities.is_mono + + +def test_is_mono_capability_false_for_colour_device(fake_sdk): + fake_sdk([FakeDeviceSpec("port-a", serial="SN-A", flag=real_toupcam.TOUPCAM_FLAG_FAN)]) + + _, capabilities = ToupcamCamera._open(index=0) + + assert not capabilities.is_mono + + +class _RecordingHandle: + def __init__(self): + self.put_calls = [] + + def put_Option(self, option, value): + self.put_calls.append((option, value)) + + +def test_unsupported_pixel_format_is_rejected_without_touching_the_camera(): + """A rejected switch must not leave a frame/pixel format pair we cannot map.""" + camera = _camera_with_capabilities(is_mono=False) + camera._camera = _RecordingHandle() + + with pytest.raises(ValueError, match="RGB24, RGB32, RGB48"): + camera.set_pixel_format(CameraPixelFormat.BAYER_RG8) + + assert camera._camera.put_calls == [] + + +def test_unsupported_mono_format_on_colour_camera_is_rejected(): + camera = _camera_with_capabilities(is_mono=False) + camera._camera = _RecordingHandle() + + with pytest.raises(ValueError): + camera.set_pixel_format(CameraPixelFormat.MONO16) + + assert camera._camera.put_calls == [] + + +# -------------------------------------------------------------------------- +# Strobe timing for the colour formats +# -------------------------------------------------------------------------- + + +class _FakeStrobeHandle: + """A handle exposing only what _calculate_strobe_info reads.""" + + def __init__(self, width=3104, height=2084): + self._size = (width, height) + + def get_Size(self): + return self._size + + def get_Roi(self): + return (0, 0, self._size[0], self._size[1]) + + def get_Option(self, option): + if option == real_toupcam.TOUPCAM_OPTION_BANDWIDTH: + return 100 + if option == real_toupcam.TOUPCAM_OPTION_MAX_PRECISE_FRAMERATE: + return 100 # tenths of fps + if option == real_toupcam.TOUPCAM_OPTION_LOW_NOISE: + return 0 + raise AssertionError(f"unexpected option read: {option}") + + def put_Option(self, option, value): + pass + + +def _strobe_info_for(pixel_size: int): + return ToupcamCamera._calculate_strobe_info( + camera=_FakeStrobeHandle(), + pixel_size=pixel_size, + exposure_time_ms=20.0, + capabilities=_capabilities(is_mono=True), + ) + + +@pytest.mark.parametrize("pixel_size", [3, 4, 6]) +def test_strobe_info_is_finite_for_colour_pixel_sizes(pixel_size): + """RGB pixel sizes used to leave line_length at 0 and divide by zero.""" + info = _strobe_info_for(pixel_size) + + assert info.strobe_time_us > 0 + + +def test_strobe_info_keys_off_sensor_depth_not_pixel_bytes(): + # RGB24/RGB32 are 8 bit sensor readouts; RGB48 is a 16 bit one. Each must match + # the mono format read at the same depth. + assert _strobe_info_for(3) == _strobe_info_for(1) + assert _strobe_info_for(4) == _strobe_info_for(1) + assert _strobe_info_for(6) == _strobe_info_for(2) From 00409704e9f28a30e3b41d800cb33a54eb61ea1e Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 11 Aug 2026 18:54:11 -0700 Subject: [PATCH 34/52] fix(dual-camera): apply live exposure/gain to the active camera The live control panel's exposure and gain edits were connected straight to the primary camera's CameraSettingsWidget, and each of those widgets drives one concrete camera. Editing exposure while a secondary camera was imaging therefore retuned the primary camera and left the active one untouched: the number changed in the UI and was saved to the channel config, but the sensor kept its old exposure until the channel was re-selected (set_microscope_mode applies it through the facade, which does target the active camera). Dispatch both edits through the active camera's settings widget instead. Single-camera systems declare no extra widgets, so they resolve to the same widget as before. Verified against two Toupcams: with the colour camera active, a 123 ms edit now reaches that camera's driver and its SDK handle, and the mono camera keeps 15 ms. Co-Authored-By: Claude Opus 5 (1M context) --- software/control/gui_hcs.py | 33 +++++++++++--- .../control/test_HighContentScreeningGui.py | 45 +++++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 530d436e8..86f4ffeb4 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -1535,8 +1535,10 @@ def make_connections(self): self.profileWidget.signal_profile_changed.connect(self.liveControlWidget.refresh_mode_list) - self.liveControlWidget.signal_newExposureTime.connect(self.cameraSettingWidget.set_exposure_time) - self.liveControlWidget.signal_newAnalogGain.connect(self.cameraSettingWidget.set_analog_gain) + # Dispatched per active camera, not bound to the primary widget - see + # _active_camera_setting_widget. + self.liveControlWidget.signal_newExposureTime.connect(self._apply_live_exposure_time) + self.liveControlWidget.signal_newAnalogGain.connect(self._apply_live_analog_gain) if not self.live_only_mode: self.liveControlWidget.signal_start_live.connect(self.onStartLive) self.liveControlWidget.update_camera_settings() @@ -1617,8 +1619,8 @@ def make_connections(self): self.liveControlWidget.signal_live_configuration.connect(self.napariLiveWidget.set_live_configuration) if USE_NAPARI_FOR_LIVE_CONTROL: - self.napariLiveWidget.signal_newExposureTime.connect(self.cameraSettingWidget.set_exposure_time) - self.napariLiveWidget.signal_newAnalogGain.connect(self.cameraSettingWidget.set_analog_gain) + self.napariLiveWidget.signal_newExposureTime.connect(self._apply_live_exposure_time) + self.napariLiveWidget.signal_newAnalogGain.connect(self._apply_live_analog_gain) self.napariLiveWidget.signal_autoLevelSetting.connect(self.imageDisplayWindow.set_autolevel) else: self.streamHandler.image_to_display.connect(self.imageDisplay.enqueue) @@ -1789,8 +1791,8 @@ def makeNapariConnections(self): if USE_NAPARI_FOR_LIVE_CONTROL: self.napari_connections["napariLiveWidget"].extend( [ - (self.napariLiveWidget.signal_newExposureTime, self.cameraSettingWidget.set_exposure_time), - (self.napariLiveWidget.signal_newAnalogGain, self.cameraSettingWidget.set_analog_gain), + (self.napariLiveWidget.signal_newExposureTime, self._apply_live_exposure_time), + (self.napariLiveWidget.signal_newAnalogGain, self._apply_live_analog_gain), (self.napariLiveWidget.signal_autoLevelSetting, self.imageDisplayWindow.set_autolevel), ] ) @@ -1964,6 +1966,25 @@ def _on_live_controller_warning(self, message: str) -> None: self._live_warning_box = box box.show() + def _active_camera_setting_widget(self) -> "widgets.CameraSettingsWidget": + """The CameraSettingsWidget bound to the camera that is imaging right now. + + Each widget drives one concrete camera (see the construction site), so a live + exposure/gain edit has to be dispatched to the active camera's widget. Sending it + to the primary widget unconditionally applied every edit to the primary camera no + matter which camera was active: on a dual-camera system, editing exposure for a + channel bound to the secondary camera left that camera untouched and silently + retuned the primary instead. + """ + widget = self.cameraSettingWidgets_extra.get(self.microscope.active_camera_id) + return widget if widget is not None else self.cameraSettingWidget + + def _apply_live_exposure_time(self, exposure_time_ms: float) -> None: + self._active_camera_setting_widget().set_exposure_time(exposure_time_ms) + + def _apply_live_analog_gain(self, analog_gain: float) -> None: + self._active_camera_setting_widget().set_analog_gain(analog_gain) + @Slot(int) def _on_active_camera_changed(self, camera_id: int) -> None: """GUI-thread handler for a Microscope active-camera switch. diff --git a/software/tests/control/test_HighContentScreeningGui.py b/software/tests/control/test_HighContentScreeningGui.py index a02afdc63..c42c7c556 100644 --- a/software/tests/control/test_HighContentScreeningGui.py +++ b/software/tests/control/test_HighContentScreeningGui.py @@ -164,6 +164,51 @@ def get_channels_with_secondary_first(self, objective): assert [combo.itemText(i) for i in range(combo.count())] == [control._def.TriggerMode.SOFTWARE] +def test_live_exposure_edit_reaches_the_active_camera(qtbot, monkeypatch, confirm_exit_yes): + """Editing exposure/gain in the live control panel must reach whichever camera is + active, not always the primary. The edit is dispatched through a CameraSettingsWidget + and each of those is bound to one concrete camera, so sending it to the primary + widget unconditionally left the secondary camera's exposure untouched: the number + changed in the UI and in the channel config, but the sensor kept its old exposure + until the channel was re-selected — and the primary camera was silently retuned.""" + monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: TWO_CAMERA_REGISTRY) + # These spinbox edits persist through the repository. isolate_ambient_user_profiles + # already keeps that off the machine's profile, but the generated one is session + # scoped, so stub the write rather than leave these values behind for later tests. + monkeypatch.setattr(ConfigRepository, "update_channel_setting", lambda *args, **kwargs: True) + + scope = control.microscope.Microscope.build_from_global_config(True) + win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) + qtbot.add_widget(win) + + primary = scope.cameras[control._def.PRIMARY_CAMERA_ID] + secondary = scope.cameras[2] + + # The startup channel decides the active camera, so don't assume which one it is. + scope.set_active_camera(control._def.PRIMARY_CAMERA_ID) + win._on_active_camera_changed(control._def.PRIMARY_CAMERA_ID) + + win.liveControlWidget.entry_exposureTime.setValue(11.0) + assert primary.get_exposure_time() == pytest.approx(11.0) + + scope.set_active_camera(2) + win._on_active_camera_changed(2) + + win.liveControlWidget.entry_exposureTime.setValue(37.0) + assert secondary.get_exposure_time() == pytest.approx(37.0), "exposure edit did not reach the active camera" + assert primary.get_exposure_time() == pytest.approx(11.0), "exposure edit leaked onto the inactive camera" + + win.liveControlWidget.entry_analogGain.setValue(4.0) + assert secondary.get_analog_gain() == pytest.approx(4.0), "gain edit did not reach the active camera" + + # ...and switching back drives the primary again, leaving the secondary where it was. + scope.set_active_camera(control._def.PRIMARY_CAMERA_ID) + win._on_active_camera_changed(control._def.PRIMARY_CAMERA_ID) + win.liveControlWidget.entry_exposureTime.setValue(12.0) + assert primary.get_exposure_time() == pytest.approx(12.0) + assert secondary.get_exposure_time() == pytest.approx(37.0) + + def test_tab_change_to_simple_recording_does_not_raise(qtbot, monkeypatch, confirm_exit_yes): """Regression: onTabChanged used to call emit_selected_channels() on every record tab and toggleAcquisitionStart called display_progress_bar() on the current tab, From 8954e964825c5f284d5febdc32aaab8aae31cca5 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 11 Aug 2026 18:54:22 -0700 Subject: [PATCH 35/52] fix(dual-camera): keep per-channel napari layers across mixed dtypes NapariMultiChannelWidget.updateLayers compared each incoming frame's dtype against a single widget-wide self.dtype. A run that mixes cameras interleaves a mono camera's MONO16 (uint16, HxW) with a colour camera's RGB24 (uint8, HxWx3), so that test was true on every camera switch: initLayers then cleared the whole LayerList and each layer was re-added as its next frame arrived. That churn runs on the GUI thread. At 2084x2084 with five channels it cost 749 ms per FOV (11 ms after this change), which saturates the Qt event loop for long enough that Windows reports the window as "Not Responding"; closing it during a stall kills the process with no traceback. It also meant the display never held more than four of the five channels, because each flip destroyed the others. Compare against the channel's own layer and rebuild only that one, and size each canvas from the frame that feeds it rather than from whichever camera sent the acquisition's first frame. Single-camera runs see one dtype throughout, so the rebuild branch never fires. Co-Authored-By: Claude Opus 5 (1M context) --- software/control/widgets.py | 29 ++- .../test_napari_multi_channel_widget.py | 196 ++++++++++++++++++ 2 files changed, 215 insertions(+), 10 deletions(-) create mode 100644 software/tests/control/test_napari_multi_channel_widget.py diff --git a/software/control/widgets.py b/software/control/widgets.py index 532232590..e73f5fae9 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -12540,21 +12540,30 @@ def initLayers(self, image_height, image_width, image_dtype): def updateLayers(self, image, x, y, k, channel_name): """Updates the appropriate slice of the canvas with the new image data.""" rgb = len(image.shape) == 3 - - # Check if the layer exists and has a different dtype - if self.dtype != np.dtype(image.dtype): # or self.viewer.layers[channel_name].data.dtype != image.dtype: - # Remove the existing layer - self.layers_initialized = False - self.acquisition_initialized = False + incoming_dtype = np.dtype(image.dtype) if not self.layers_initialized: self.initLayers(image.shape[0], image.shape[1], image.dtype) - if channel_name not in self.viewer.layers: + # Dual-camera runs interleave frames from cameras with different geometry - a mono + # camera's MONO16 (uint16, HxW) and a colour camera's RGB24 (uint8, HxWx3). Compare + # against THIS channel's own layer and rebuild only that one. Comparing against a + # single widget-wide self.dtype instead made every camera switch clear the whole + # LayerList and re-add each layer as its next frame arrived, which stalled the GUI + # thread for seconds per FOV (Windows then reports "Not Responding"). + existing = self.viewer.layers[channel_name] if channel_name in self.viewer.layers else None + if existing is not None and (existing.data.dtype != incoming_dtype or existing.data.shape[1:] != image.shape): + self.viewer.layers.remove(existing) + self.channels.discard(channel_name) + existing = None + + if existing is None: self.channels.add(channel_name) + # Per-channel geometry: a layer must match the camera that feeds it, not + # whichever camera happened to send the first frame of the acquisition. if rgb: color = None # RGB images do not need a colormap - canvas = np.zeros((self.Nz, self.image_height, self.image_width, 3), dtype=self.dtype) + canvas = np.zeros((self.Nz, image.shape[0], image.shape[1], 3), dtype=incoming_dtype) else: channel_info = CHANNEL_COLORS_MAP.get( self.extractWavelength(channel_name), {"hex": 0xFFFFFF, "name": "gray"} @@ -12563,9 +12572,9 @@ def updateLayers(self, image, x, y, k, channel_name): color = AVAILABLE_COLORMAPS[channel_info["name"]] else: color = self.generateColormap(channel_info) - canvas = np.zeros((self.Nz, self.image_height, self.image_width), dtype=self.dtype) + canvas = np.zeros((self.Nz, image.shape[0], image.shape[1]), dtype=incoming_dtype) - limits = self.getContrastLimits(self.dtype) + limits = self.getContrastLimits(incoming_dtype) layer = self.viewer.add_image( canvas, name=channel_name, diff --git a/software/tests/control/test_napari_multi_channel_widget.py b/software/tests/control/test_napari_multi_channel_widget.py new file mode 100644 index 000000000..4458ead93 --- /dev/null +++ b/software/tests/control/test_napari_multi_channel_widget.py @@ -0,0 +1,196 @@ +"""Napari multi-channel display under a dual-camera (mixed dtype) acquisition. + +A mono camera delivers MONO16 (uint16, HxW) and a colour camera RGB24 (uint8, HxWx3), so a +multipoint run mixing cameras interleaves the two. The display must keep one layer per +channel across those switches. Rebuilding the whole LayerList on every switch cost ~750ms of +GUI-thread time per FOV (vs ~11ms), which saturates the event loop and makes Windows report +the window as "Not Responding"; closing it during a stall kills the app with no traceback. + +These tests drive updateLayers against a stand-in viewer rather than a real napari one: the +behaviour under test is the widget's own decision about when to rebuild a layer, and +constructing a napari Viewer pulls in vispy, which refuses to initialise in a process where +another Qt binding is already loaded (the reason CI runs the GUI test in its own process). +""" + +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from control.core.contrast_manager import ContrastManager + +MONO_CHANNEL = "Fluorescence 488 nm Ex" +COLOR_CHANNEL = "BF LED matrix full" + + +class FakeLayer: + def __init__(self, data, name, rgb): + self.data = data + self.name = name + self.rgb = rgb + self.contrast_limits = (0, 1) + self.events = MagicMock() + self.refresh_count = 0 + + def refresh(self): + self.refresh_count += 1 + + +class FakeLayerList: + """Enough of napari's LayerList for updateLayers, plus bookkeeping for the assertions.""" + + def __init__(self): + self._layers = {} + self.clear_calls = 0 + self.add_calls = 0 + self.remove_calls = 0 + + def __contains__(self, name): + return name in self._layers + + def __getitem__(self, name): + return self._layers[name] + + def __iter__(self): + return iter(list(self._layers.values())) + + def __len__(self): + return len(self._layers) + + def clear(self): + self.clear_calls += 1 + self._layers.clear() + + def remove(self, layer): + self.remove_calls += 1 + del self._layers[layer.name] + + def names(self): + return sorted(self._layers) + + +class FakeViewer: + def __init__(self): + self.layers = FakeLayerList() + self.dims = MagicMock() + + def add_image(self, data, name, visible, rgb, colormap, contrast_limits, blending, scale): + layer = FakeLayer(data, name, rgb) + self.layers._layers[name] = layer + self.layers.add_calls += 1 + return layer + + +@pytest.fixture +def widget(): + # Built without __init__ so no napari Viewer (and therefore no vispy/Qt backend) is + # needed; every attribute updateLayers touches is set explicitly below. + from control.widgets import NapariMultiChannelWidget + + w = NapariMultiChannelWidget.__new__(NapariMultiChannelWidget) + w.objectiveStore = MagicMock() + w.camera = MagicMock() + w.contrastManager = ContrastManager() + w.viewer = FakeViewer() + w.image_width = 0 + w.image_height = 0 + w.dtype = np.uint8 + w.channels = set() + w.pixel_size_um = 1 + w.dz_um = 1 + w.Nz = 1 + w.layers_initialized = False + w.acquisition_initialized = False + w.viewer_scale_initialized = True # skip resetView, which touches the real viewer + w.update_layer_count = 0 + w.grid_enabled = False + return w + + +def mono_frame(size=8): + return np.full((size, size), 1000, dtype=np.uint16) + + +def color_frame(size=8): + return np.full((size, size, 3), 40, dtype=np.uint8) + + +def start_acquisition(widget, dtype=np.uint16, size=8): + """Init the canvas the way the acquisition-start signal does, then zero the churn + counters: initLayers legitimately clears the LayerList once, and what these tests care + about is churn *after* that point.""" + widget.initLayers(size, size, dtype) + widget.viewer.layers.clear_calls = 0 + widget.viewer.layers.add_calls = 0 + widget.viewer.layers.remove_calls = 0 + + +def run_fovs(widget, n): + """n FOVs in the order the worker emits them: colour BF, then a mono channel.""" + for _ in range(n): + widget.updateLayers(color_frame(), x=0.0, y=0.0, k=0, channel_name=COLOR_CHANNEL) + widget.updateLayers(mono_frame(), x=0.0, y=0.0, k=0, channel_name=MONO_CHANNEL) + + +def test_mixed_dtype_channels_keep_their_own_layers(widget): + start_acquisition(widget) + run_fovs(widget, 3) + + assert widget.viewer.layers.names() == sorted([MONO_CHANNEL, COLOR_CHANNEL]), ( + "both channels must still have a layer; a dtype switch used to clear the whole " + "LayerList, leaving only the channel whose frame arrived most recently" + ) + + mono_layer = widget.viewer.layers[MONO_CHANNEL] + color_layer = widget.viewer.layers[COLOR_CHANNEL] + # Each layer keeps the geometry of the camera feeding it, not that of whichever camera + # sent the acquisition's first frame. + assert (mono_layer.data.dtype, mono_layer.data.shape) == (np.uint16, (1, 8, 8)) + assert (color_layer.data.dtype, color_layer.data.shape) == (np.uint8, (1, 8, 8, 3)) + # ...and the frames landed without being cast to the other camera's dtype. + assert mono_layer.data[0].max() == 1000 + assert color_layer.data[0].max() == 40 + + +def test_steady_state_does_no_layer_churn(widget): + """The GUI-thread cost of the bug was the churn itself: one clear + N re-adds per switch.""" + start_acquisition(widget) + run_fovs(widget, 1) # first FOV legitimately creates both layers + + adds_after_first_fov = widget.viewer.layers.add_calls + assert adds_after_first_fov == 2 + + run_fovs(widget, 8) + + assert widget.viewer.layers.add_calls == adds_after_first_fov, "layers were re-added" + assert widget.viewer.layers.remove_calls == 0, "layers were removed" + assert widget.viewer.layers.clear_calls == 0, "the LayerList was cleared mid-acquisition" + + +def test_single_camera_run_is_unchanged(widget): + """One dtype throughout - the common case must behave exactly as before.""" + start_acquisition(widget) + for _ in range(4): + widget.updateLayers(mono_frame(), x=0.0, y=0.0, k=0, channel_name=MONO_CHANNEL) + widget.updateLayers(mono_frame(), x=0.0, y=0.0, k=0, channel_name="Fluorescence 561 nm Ex") + + assert len(widget.viewer.layers) == 2 + assert widget.viewer.layers.clear_calls == 0 + assert widget.viewer.layers.add_calls == 2 + for layer in widget.viewer.layers: + assert (layer.data.dtype, layer.data.shape) == (np.uint16, (1, 8, 8)) + + +def test_geometry_change_on_one_channel_rebuilds_only_that_layer(widget): + """A channel whose frame geometry changes (e.g. its camera was re-binned) is rebuilt; + the other channel's layer survives.""" + start_acquisition(widget) + run_fovs(widget, 1) + survivor = widget.viewer.layers[COLOR_CHANNEL] + + widget.updateLayers(mono_frame(size=16), x=0.0, y=0.0, k=0, channel_name=MONO_CHANNEL) + + assert widget.viewer.layers[MONO_CHANNEL].data.shape == (1, 16, 16) + assert widget.viewer.layers[COLOR_CHANNEL] is survivor + assert survivor.data.shape == (1, 8, 8, 3) + assert widget.viewer.layers.clear_calls == 0 From d94b203f99a2dd8bdf7ad9a6b008ca096890257e Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 11 Aug 2026 18:54:33 -0700 Subject: [PATCH 36/52] test: isolate tests from the machine's user_profiles user_profiles/ is gitignored, so CI starts without one and the app generates a default profile on first use, while a development machine carries local channel state. That state changes test outcomes: a channel bound to a secondary camera decides which camera is active at startup, which decides what the trigger dropdown offers and whether an acquisition needs a camera switch. Four tests failed on a dual-camera machine for that reason alone. The configs are written during tests too - the live-control spinboxes persist through ConfigRepository on every edit - so a run could rewrite a developer's channel configs. Generate a default profile into a temp dir using the app's own ensure_default_configs and point default-path repositories at it, mirroring isolate_ambient_camera_registry. Repositories constructed with an explicit base_path are left alone. Generation failures raise a UsageError naming the fix rather than leaving every test with an empty profile. Co-Authored-By: Claude Opus 5 (1M context) --- software/tests/conftest.py | 75 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/software/tests/conftest.py b/software/tests/conftest.py index 115945215..42427ea2d 100644 --- a/software/tests/conftest.py +++ b/software/tests/conftest.py @@ -102,6 +102,81 @@ def _get_camera_registry(self): monkeypatch.setattr(ConfigRepository, "get_camera_registry", _get_camera_registry) +@pytest.fixture(scope="session") +def canonical_user_profiles(tmp_path_factory): + """A freshly generated "default" profile, the same one CI runs against. + + user_profiles/ is gitignored, so a CI checkout has none and the app generates it from + machine_configs/illumination_channel_config.yaml on first use. Doing exactly that here + gives every test the same starting point CI has: channels at their default exposure and + gain, and none of them bound to a camera. + """ + from control.core.config.repository import ConfigRepository + from control.default_config_generator import ensure_default_configs + import control._def + + profiles_root = tmp_path_factory.mktemp("user_profiles") + (profiles_root / "default").mkdir() + + # Real base_path, so machine_configs (the illumination config) still resolves; only the + # profile output is redirected. Built before isolate_ambient_user_profiles patches + # __init__, so this repository is a plain one. + repo = ConfigRepository() + repo.user_profiles_path = profiles_root + try: + ensure_default_configs( + repo, + "default", + list(control._def.OBJECTIVES) if hasattr(control._def, "OBJECTIVES") else None, + include_confocal=False, + ) + except FileNotFoundError as e: + raise pytest.UsageError( + f"Could not generate the test profile: {e}. Populate it the way CI does:\n" + " cp machine_configs/illumination_channel_config.yaml.example " + "machine_configs/illumination_channel_config.yaml" + ) from e + + # Generation also declines silently when legacy XML configs are pending migration, + # which would leave every test with an empty profile. + if not (profiles_root / "default" / "channel_configs" / "general.yaml").exists(): + raise pytest.UsageError( + "The test profile was not generated (no general.yaml). If software/" + "acquisition_configurations/ holds legacy XML configs, run " + "tools/migrate_acquisition_configs.py first." + ) + return profiles_root + + +@pytest.fixture(autouse=True) +def isolate_ambient_user_profiles(monkeypatch, canonical_user_profiles): + """Point default-path repositories at the canonical profile, not the machine's. + + user_profiles/ is machine-specific and gitignored, and it is read *and written* by the + running application: the live-control exposure/gain spinboxes persist through + ConfigRepository on every edit. Without this pin, + + * tests inherit whatever the developer's channels happen to hold - notably a + `camera:` binding, which decides the active camera at startup and so changes what + the trigger dropdown offers and whether an acquisition needs a camera switch, and + * a test that drives those spinboxes rewrites the developer's channel configs. + + Repositories constructed with an explicit base_path (tmp_path in the repository tests) + are left alone, as in isolate_ambient_camera_registry. + """ + from control.core.config.repository import ConfigRepository + + default_user_profiles_path = ConfigRepository().user_profiles_path + original_init = ConfigRepository.__init__ + + def _init(self, base_path=None): + original_init(self, base_path) + if self.user_profiles_path == default_user_profiles_path: + self.user_profiles_path = canonical_user_profiles + + monkeypatch.setattr(ConfigRepository, "__init__", _init) + + @pytest.fixture(autouse=True) def cleanup_leaked_hardware(monkeypatch): """ From 2ecb197bcad9343a40a3b29b052f4407a9c93ff2 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Wed, 12 Aug 2026 01:22:27 -0700 Subject: [PATCH 37/52] fix(dual-camera): finish removing widget-global state from the display path Review follow-up to 8954e964 and 00409704, which each dropped one piece of widget-wide state but left its neighbours in place. Contrast limits (a regression introduced by 8954e964): ContrastManager tracks a single run-wide acquisition_dtype, and keeping layers alive across a dtype switch removed the accidental rescale the old teardown performed - initLayers called scale_contrast_limits() before re-adding every layer. A uint8 RGB layer was left with uint16 limits, which napari renders as essentially black, so the colour channel was invisible for a whole mixed acquisition. Default limits now come from each layer's own dtype (get_limits_for_dtype); a limit the user set still wins. Layer scale: pixel_size_um was computed once at acquisition start from the active camera, so cameras differing in pixel pitch or binning did not overlay. Each frame now carries the pixel size of its own channel's camera, resolved by the emitter from the channel's `camera` binding - not read from the facade in the widget, where the queued connection means the worker may already have switched. Also drops state the rewrite orphaned: image_width/image_height had no readers left, and a discard/add pair on self.channels cancelled out. Documents that layers_initialized is now a latch. Live exposure/gain dispatch: keyed on the channel's own `camera` binding, the key set_microscope_mode treats as authoritative, instead of active_camera_id - which after an acquisition still points at whatever ran last, so editing a channel on camera 1 retuned camera 2. The edit also no longer relies on QDoubleSpinBox.setValue emitting: it emits nothing when the value is unchanged, and that spinbox is not resynced when set_microscope_mode writes the camera directly, so re-entering a displayed value silently left the old exposure on the sensor. The spinbox is synced with signals blocked and the camera driven with its clamped value. Per-camera settings widgets now live in one map keyed by camera id, primary included, so no caller re-derives "extras plus the primary". Co-Authored-By: Claude Opus 5 (1M context) --- software/control/core/contrast_manager.py | 27 ++++- software/control/core/multi_point_utils.py | 6 +- software/control/gui_hcs.py | 90 +++++++++++---- software/control/widgets.py | 37 +++--- .../control/test_HighContentScreeningGui.py | 107 +++++++++++++----- .../test_napari_multi_channel_widget.py | 60 +++++++++- 6 files changed, 255 insertions(+), 72 deletions(-) diff --git a/software/control/core/contrast_manager.py b/software/control/core/contrast_manager.py index 60940461e..fd54e2e6b 100644 --- a/software/control/core/contrast_manager.py +++ b/software/control/core/contrast_manager.py @@ -18,16 +18,35 @@ def get_limits(self, channel, dtype=None): return self.contrast_limits.get(channel, self.get_default_limits()) def get_default_limits(self): - if self.acquisition_dtype is None: + return self.default_limits_for_dtype(self.acquisition_dtype) + + @staticmethod + def default_limits_for_dtype(dtype): + """Full display range of one dtype, independent of the run-wide acquisition_dtype.""" + if dtype is None: return (0, 1) - elif np.issubdtype(self.acquisition_dtype, np.integer): - info = np.iinfo(self.acquisition_dtype) + elif np.issubdtype(dtype, np.integer): + info = np.iinfo(dtype) return (info.min, info.max) - elif np.issubdtype(self.acquisition_dtype, np.floating): + elif np.issubdtype(dtype, np.floating): return (0.0, 1.0) else: return (0, 1) + def get_limits_for_dtype(self, channel, dtype): + """Limits for one channel, defaulting to the full range of ITS OWN dtype. + + get_limits() falls back to get_default_limits(), which is derived from the single + run-wide acquisition_dtype - i.e. from whichever camera delivered the first frame. + On a dual-camera run that is the wrong range for the other camera's channels: a + uint8 RGB layer handed uint16 limits renders essentially black, and a uint16 layer + handed uint8 limits renders saturated white. A limit the user has actually set for + the channel still wins. + """ + if channel in self.contrast_limits: + return self.contrast_limits[channel] + return self.default_limits_for_dtype(dtype) + def get_scaled_limits(self, channel, target_dtype): min_val, max_val = self.get_limits(channel) if self.acquisition_dtype == target_dtype: diff --git a/software/control/core/multi_point_utils.py b/software/control/core/multi_point_utils.py index 035bd464d..3a9dff13c 100644 --- a/software/control/core/multi_point_utils.py +++ b/software/control/core/multi_point_utils.py @@ -159,11 +159,15 @@ class MultiPointControllerFunctions: # --------------------------------------------------------------------------------------- -def _channel_camera_id(channel) -> int: +def channel_camera_id(channel) -> int: """The camera a channel images on. A null `camera` means the primary camera.""" return channel.camera if getattr(channel, "camera", None) is not None else control._def.PRIMARY_CAMERA_ID +# Historical private alias; callers in this module still use it. +_channel_camera_id = channel_camera_id + + def get_unavailable_camera_channels(selected_channels, cameras: Dict[int, AbstractCamera]) -> List[str]: """Names of selected channels whose camera id is not an available (opened) camera. diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 86f4ffeb4..58d774f93 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -55,6 +55,7 @@ class NDViewerMode(Enum): OverallProgressUpdate, RegionProgressUpdate, PlateViewInit, + channel_camera_id, ) from control.core.objective_store import ObjectiveStore from control.core.stream_handler import StreamHandler @@ -202,7 +203,8 @@ class QtMultiPointController(MultiPointController, QObject): signal_current_configuration = Signal(AcquisitionChannel) signal_register_current_fov = Signal(float, float) napari_layers_init = Signal(int, int, object) - napari_layers_update = Signal(np.ndarray, float, float, int, str) # image, x_mm, y_mm, k, channel + # image, x_mm, y_mm, k, channel, pixel_size_um (of that channel's own camera) + napari_layers_update = Signal(np.ndarray, float, float, int, str, float) signal_set_display_tabs = Signal(list, int, str) # configs: list, Nz: int, xy_mode: str signal_acquisition_save_target = Signal(object) # Optional[str] — output dir for save view signal_acquisition_progress = Signal(int, int, int) @@ -389,6 +391,21 @@ def _signal_acquisition_finished_fn(self): finish_pos = self.stage.get_pos() self.signal_register_current_fov.emit(finish_pos.x_mm, finish_pos.y_mm) + def _layer_pixel_size_um(self, configuration) -> float: + """Image pixel size for this channel's own camera, for the napari layer's scale. + + Resolved from the channel's `camera` binding rather than from the active camera: + this runs per frame on the acquisition thread, the frame reaches the widget through + a queued connection, and the worker may already have switched cameras by then, so + reading the facade in the widget would race. Cameras differing in pixel pitch or + binning must still overlay in the viewer. + """ + factor = self.objectiveStore.get_pixel_size_factor() + camera = self.microscope.cameras.get(channel_camera_id(configuration)) + if camera is None or factor is None: + return self.camera.get_pixel_size_binned_um() + return float(factor) * float(camera.get_pixel_size_binned_um()) + def _signal_new_image_fn(self, frame: squid.abc.CameraFrame, info: CaptureInfo): self.image_to_display.emit(frame.frame) # Z for plot in μm: piezo-only uses piezo position, mixed mode combines stepper + piezo @@ -408,7 +425,12 @@ def _signal_new_image_fn(self, frame: squid.abc.CameraFrame, info: CaptureInfo): objective_magnification = str(int(self.objectiveStore.get_current_objective_info()["magnification"])) napri_layer_name = objective_magnification + "x " + info.configuration.name self.napari_layers_update.emit( - frame.frame, info.position.x_mm, info.position.y_mm, info.z_index, napri_layer_name + frame.frame, + info.position.x_mm, + info.position.y_mm, + info.z_index, + napri_layer_name, + self._layer_pixel_size_um(info.configuration), ) # Cache parsed well indices per region_id — usually a small dict (<=96 @@ -705,9 +727,11 @@ def __init__( # add to this as you add widgets. self.spinningDiskConfocalWidget: Optional[widgets.SpinningDiskConfocalWidget] = None self.nl5Wdiget: Optional[NL5Widget] = None + # Kept as the primary camera's widget for callers that only ever mean the primary + # (it is also cameraSettingWidgets[PRIMARY_CAMERA_ID]). self.cameraSettingWidget: Optional[widgets.CameraSettingsWidget] = None - # Settings widgets for the non-primary cameras of a multi-camera build, keyed by camera id. - self.cameraSettingWidgets_extra: Dict[int, widgets.CameraSettingsWidget] = {} + # Every camera's settings widget, primary included, keyed by camera id. + self.cameraSettingWidgets: Dict[int, widgets.CameraSettingsWidget] = {} self.profileWidget: Optional[widgets.ProfileWidget] = None self.liveControlWidget: Optional[widgets.LiveControlWidget] = None self.navigationWidget: Optional[widgets.NavigationWidget] = None @@ -955,12 +979,15 @@ def load_widgets(self): for camera_id, concrete_camera in sorted(self.microscope.cameras.items()): if camera_id == PRIMARY_CAMERA_ID: continue - self.cameraSettingWidgets_extra[camera_id] = widgets.CameraSettingsWidget( + self.cameraSettingWidgets[camera_id] = widgets.CameraSettingsWidget( concrete_camera, include_gain_exposure_time=False, include_camera_temperature_setting=False, include_camera_auto_wb_setting=True, ) + # Every per-camera lookup goes through this one map, primary included, so no caller + # has to re-derive "extras plus the primary" and risk missing or double-counting it. + self.cameraSettingWidgets[PRIMARY_CAMERA_ID] = self.cameraSettingWidget self.profileWidget = widgets.ProfileWidget(self.microscope.config_repo) self.liveControlWidget = widgets.LiveControlWidget( @@ -1371,11 +1398,9 @@ def setupCameraTabWidget(self): # user can tell them apart; single-camera builds keep the plain "Camera" tab. multi_camera = self.microscope.has_multiple_cameras() registry = self.microscope.config_repo.get_camera_registry() if multi_camera else None - self.cameraTabWidget.addTab( - self.cameraSettingWidget, self._camera_tab_name(PRIMARY_CAMERA_ID, registry) if multi_camera else "Camera" - ) - for camera_id, extra_widget in self.cameraSettingWidgets_extra.items(): - self.cameraTabWidget.addTab(extra_widget, self._camera_tab_name(camera_id, registry)) + for camera_id, camera_widget in sorted(self.cameraSettingWidgets.items()): + label = self._camera_tab_name(camera_id, registry) if multi_camera else "Camera" + self.cameraTabWidget.addTab(camera_widget, label) self.cameraTabWidget.addTab(self.autofocusWidget, "Contrast AF") if SUPPORT_LASER_AUTOFOCUS: self.cameraTabWidget.addTab(self.laserAutofocusControlWidget, "Laser AF") @@ -1966,24 +1991,47 @@ def _on_live_controller_warning(self, message: str) -> None: self._live_warning_box = box box.show() - def _active_camera_setting_widget(self) -> "widgets.CameraSettingsWidget": - """The CameraSettingsWidget bound to the camera that is imaging right now. + def _camera_setting_widget_for_live_edit(self) -> "widgets.CameraSettingsWidget": + """The CameraSettingsWidget for the camera the edited channel images on. - Each widget drives one concrete camera (see the construction site), so a live - exposure/gain edit has to be dispatched to the active camera's widget. Sending it - to the primary widget unconditionally applied every edit to the primary camera no - matter which camera was active: on a dual-camera system, editing exposure for a - channel bound to the secondary camera left that camera untouched and silently - retuned the primary instead. + Each widget drives one concrete camera, so a live exposure/gain edit has to be + dispatched to the right one; sending every edit to the primary widget retuned the + primary camera no matter which camera the channel used. + + Keyed on the channel's own `camera` binding - the same key + LiveController.set_microscope_mode treats as authoritative - and not on + microscope.active_camera_id, which merely reflects whatever ran last. After an + acquisition ending on camera 2, the active id is still 2, so keying on it would + retune camera 2 while the user edited a channel bound to camera 1. """ - widget = self.cameraSettingWidgets_extra.get(self.microscope.active_camera_id) + configuration = getattr(self.liveControlWidget, "currentConfiguration", None) + camera_id = channel_camera_id(configuration) if configuration is not None else self.microscope.active_camera_id + widget = self.cameraSettingWidgets.get(camera_id) return widget if widget is not None else self.cameraSettingWidget def _apply_live_exposure_time(self, exposure_time_ms: float) -> None: - self._active_camera_setting_widget().set_exposure_time(exposure_time_ms) + widget = self._camera_setting_widget_for_live_edit() + # setValue alone is not enough to reach the sensor: QDoubleSpinBox emits nothing when + # it already holds that number, and this spinbox is not resynced when + # set_microscope_mode writes exposure straight to the camera - so re-entering a value + # the tab happens to show would silently leave the old exposure on the sensor. Sync + # the display with signals blocked, then drive the camera with the spinbox's clamped + # value so display and sensor cannot disagree. + widget.entry_exposureTime.blockSignals(True) + try: + widget.entry_exposureTime.setValue(exposure_time_ms) + finally: + widget.entry_exposureTime.blockSignals(False) + widget.camera.set_exposure_time(widget.entry_exposureTime.value()) def _apply_live_analog_gain(self, analog_gain: float) -> None: - self._active_camera_setting_widget().set_analog_gain(analog_gain) + widget = self._camera_setting_widget_for_live_edit() + widget.entry_analogGain.blockSignals(True) + try: + widget.entry_analogGain.setValue(analog_gain) + finally: + widget.entry_analogGain.blockSignals(False) + widget.set_analog_gain_if_supported(widget.entry_analogGain.value()) @Slot(int) def _on_active_camera_changed(self, camera_id: int) -> None: diff --git a/software/control/widgets.py b/software/control/widgets.py index e73f5fae9..b28394c92 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -12444,10 +12444,9 @@ def __init__(self, objectiveStore, camera, contrastManager, grid_enabled=False, self.objectiveStore = objectiveStore self.camera = camera self.contrastManager = contrastManager - self.image_width = 0 - self.image_height = 0 self.dtype = np.uint8 self.channels = set() + # Fallback only; each layer is scaled by the pixel size passed with its own frame. self.pixel_size_um = 1 self.dz_um = 1 self.Nz = 1 @@ -12520,7 +12519,16 @@ def generateColormap(self, channel_info): return Colormap(colors=[c0, c1], controls=[0, 1], name=channel_info["name"]) def initLayers(self, image_height, image_width, image_dtype): - """Initializes the full canvas for each channel based on the acquisition parameters.""" + """Prepare the viewer for an acquisition. Individual layers are built on demand by + updateLayers, which sizes each one from the frame that feeds it, so image_height and + image_width are only the announced geometry of the first frame - nothing here stores + them. + + layers_initialized latches True: updateLayers rebuilds a single layer in place when + its geometry changes, so there is no longer any path that resets it. Do not size + anything off widget-wide geometry here - that is exactly what stopped a mono and a + colour camera from coexisting. + """ if self.acquisition_initialized: for layer in list(self.viewer.layers): if layer.name not in self.channels: @@ -12531,16 +12539,22 @@ def initLayers(self, image_height, image_width, image_dtype): if self.dtype != np.dtype(image_dtype) and not USE_NAPARI_FOR_LIVE_VIEW: self.contrastManager.scale_contrast_limits(image_dtype) - self.image_width = image_width - self.image_height = image_height self.dtype = np.dtype(image_dtype) self.layers_initialized = True self.update_layer_count = 0 - def updateLayers(self, image, x, y, k, channel_name): - """Updates the appropriate slice of the canvas with the new image data.""" + def updateLayers(self, image, x, y, k, channel_name, pixel_size_um=None): + """Updates the appropriate slice of the canvas with the new image data. + + pixel_size_um is this frame's own image pixel size, resolved by the emitter from the + channel's bound camera. self.pixel_size_um is only a fallback: it is computed once at + acquisition start from the *active* camera, so on a dual-camera run with different + pixel pitch or binning it describes the wrong sensor for half the channels, and + layers scaled with it do not overlay. + """ rgb = len(image.shape) == 3 incoming_dtype = np.dtype(image.dtype) + layer_pixel_size_um = self.pixel_size_um if pixel_size_um is None else pixel_size_um if not self.layers_initialized: self.initLayers(image.shape[0], image.shape[1], image.dtype) @@ -12554,7 +12568,6 @@ def updateLayers(self, image, x, y, k, channel_name): existing = self.viewer.layers[channel_name] if channel_name in self.viewer.layers else None if existing is not None and (existing.data.dtype != incoming_dtype or existing.data.shape[1:] != image.shape): self.viewer.layers.remove(existing) - self.channels.discard(channel_name) existing = None if existing is None: @@ -12574,7 +12587,7 @@ def updateLayers(self, image, x, y, k, channel_name): color = self.generateColormap(channel_info) canvas = np.zeros((self.Nz, image.shape[0], image.shape[1]), dtype=incoming_dtype) - limits = self.getContrastLimits(incoming_dtype) + limits = self.contrastManager.get_limits_for_dtype(channel_name, incoming_dtype) layer = self.viewer.add_image( canvas, name=channel_name, @@ -12583,11 +12596,9 @@ def updateLayers(self, image, x, y, k, channel_name): colormap=color, contrast_limits=limits, blending="additive", - scale=(self.dz_um, self.pixel_size_um, self.pixel_size_um), + scale=(self.dz_um, layer_pixel_size_um, layer_pixel_size_um), ) - # print(f"multi channel - dz_um:{self.dz_um}, pixel_y_um:{self.pixel_size_um}, pixel_x_um:{self.pixel_size_um}") - layer.contrast_limits = self.contrastManager.get_limits(channel_name) layer.events.contrast_limits.connect(self.signalContrastLimits) if not self.viewer_scale_initialized: @@ -12598,7 +12609,7 @@ def updateLayers(self, image, x, y, k, channel_name): layer = self.viewer.layers[channel_name] layer.data[k] = image - layer.contrast_limits = self.contrastManager.get_limits(channel_name) + layer.contrast_limits = self.contrastManager.get_limits_for_dtype(channel_name, incoming_dtype) self.update_layer_count += 1 if self.update_layer_count % len(self.channels) == 0: if self.Nz > 1: diff --git a/software/tests/control/test_HighContentScreeningGui.py b/software/tests/control/test_HighContentScreeningGui.py index c42c7c556..b66dc5de0 100644 --- a/software/tests/control/test_HighContentScreeningGui.py +++ b/software/tests/control/test_HighContentScreeningGui.py @@ -99,7 +99,8 @@ def test_single_camera_gui_has_one_plain_camera_tab(qtbot, confirm_exit_yes): labels = [win.cameraTabWidget.tabText(i) for i in range(win.cameraTabWidget.count())] assert labels.count("Camera") == 1 - assert win.cameraSettingWidgets_extra == {} + assert list(win.cameraSettingWidgets) == [control._def.PRIMARY_CAMERA_ID] + assert win.cameraSettingWidgets[control._def.PRIMARY_CAMERA_ID] is win.cameraSettingWidget combo = win.liveControlWidget.dropdown_triggerManu options = [combo.itemText(i) for i in range(combo.count())] @@ -122,8 +123,8 @@ def test_multi_camera_gui_names_tabs_from_registry(qtbot, monkeypatch, confirm_e assert "Main Camera" in labels assert "Side Camera" in labels assert "Camera" not in labels - assert list(win.cameraSettingWidgets_extra) == [2] - assert win.cameraSettingWidgets_extra[2].camera is scope.cameras[2] + assert sorted(win.cameraSettingWidgets) == [control._def.PRIMARY_CAMERA_ID, 2] + assert win.cameraSettingWidgets[2].camera is scope.cameras[2] assert win.cameraSettingWidget.camera is scope.cameras[control._def.PRIMARY_CAMERA_ID] # The trigger dropdown follows the active camera's capability. Camera 2 declares @@ -164,51 +165,101 @@ def get_channels_with_secondary_first(self, objective): assert [combo.itemText(i) for i in range(combo.count())] == [control._def.TriggerMode.SOFTWARE] -def test_live_exposure_edit_reaches_the_active_camera(qtbot, monkeypatch, confirm_exit_yes): - """Editing exposure/gain in the live control panel must reach whichever camera is - active, not always the primary. The edit is dispatched through a CameraSettingsWidget - and each of those is bound to one concrete camera, so sending it to the primary - widget unconditionally left the secondary camera's exposure untouched: the number - changed in the UI and in the channel config, but the sensor kept its old exposure - until the channel was re-selected — and the primary camera was silently retuned.""" +def _build_two_camera_gui(qtbot, monkeypatch): monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: TWO_CAMERA_REGISTRY) - # These spinbox edits persist through the repository. isolate_ambient_user_profiles - # already keeps that off the machine's profile, but the generated one is session - # scoped, so stub the write rather than leave these values behind for later tests. - monkeypatch.setattr(ConfigRepository, "update_channel_setting", lambda *args, **kwargs: True) - scope = control.microscope.Microscope.build_from_global_config(True) win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) qtbot.add_widget(win) + return scope, win + + +def _bind_live_channel_to(win, camera_id): + """Point the live panel at a channel bound to camera_id, as selecting one would. + A deep copy, because the repository hands out shared channel objects and a test must + not mutate them for everything else in the session. + """ + configuration = win.liveControlWidget.currentConfiguration.model_copy(deep=True) + configuration.camera = camera_id + win.liveControlWidget.currentConfiguration = configuration + + +def test_live_exposure_edit_reaches_the_channels_own_camera(qtbot, monkeypatch, confirm_exit_yes): + """Editing exposure/gain in the live control panel must reach the camera the edited + channel images on. The edit is dispatched through a CameraSettingsWidget and each of + those drives one concrete camera, so sending it to the primary widget unconditionally + left the secondary camera's exposure untouched: the number changed in the UI and in the + channel config, but the sensor kept its old exposure until the channel was re-selected + — and the primary camera was silently retuned.""" + scope, win = _build_two_camera_gui(qtbot, monkeypatch) primary = scope.cameras[control._def.PRIMARY_CAMERA_ID] secondary = scope.cameras[2] - # The startup channel decides the active camera, so don't assume which one it is. - scope.set_active_camera(control._def.PRIMARY_CAMERA_ID) - win._on_active_camera_changed(control._def.PRIMARY_CAMERA_ID) - + _bind_live_channel_to(win, control._def.PRIMARY_CAMERA_ID) win.liveControlWidget.entry_exposureTime.setValue(11.0) assert primary.get_exposure_time() == pytest.approx(11.0) + secondary_before = secondary.get_exposure_time() - scope.set_active_camera(2) - win._on_active_camera_changed(2) - + _bind_live_channel_to(win, 2) win.liveControlWidget.entry_exposureTime.setValue(37.0) - assert secondary.get_exposure_time() == pytest.approx(37.0), "exposure edit did not reach the active camera" - assert primary.get_exposure_time() == pytest.approx(11.0), "exposure edit leaked onto the inactive camera" + assert secondary.get_exposure_time() == pytest.approx(37.0), "exposure edit did not reach the channel's camera" + assert primary.get_exposure_time() == pytest.approx(11.0), "exposure edit leaked onto the other camera" + assert secondary_before != pytest.approx(37.0) win.liveControlWidget.entry_analogGain.setValue(4.0) - assert secondary.get_analog_gain() == pytest.approx(4.0), "gain edit did not reach the active camera" + assert secondary.get_analog_gain() == pytest.approx(4.0), "gain edit did not reach the channel's camera" - # ...and switching back drives the primary again, leaving the secondary where it was. - scope.set_active_camera(control._def.PRIMARY_CAMERA_ID) - win._on_active_camera_changed(control._def.PRIMARY_CAMERA_ID) + # ...and back to a primary-bound channel, leaving the secondary where it was. + _bind_live_channel_to(win, control._def.PRIMARY_CAMERA_ID) win.liveControlWidget.entry_exposureTime.setValue(12.0) assert primary.get_exposure_time() == pytest.approx(12.0) assert secondary.get_exposure_time() == pytest.approx(37.0) +def test_live_exposure_edit_ignores_a_stale_active_camera(qtbot, monkeypatch, confirm_exit_yes): + """The dispatch must key on the channel's own camera binding, the same key + set_microscope_mode treats as authoritative — not on microscope.active_camera_id, which + still points at whatever ran last. An acquisition ending on camera 2 left the active id + at 2, so editing a channel bound to camera 1 retuned camera 2 instead.""" + scope, win = _build_two_camera_gui(qtbot, monkeypatch) + primary = scope.cameras[control._def.PRIMARY_CAMERA_ID] + secondary = scope.cameras[2] + + # Leave the active camera on 2 (as an acquisition would) while editing a channel on 1. + # No manual _on_active_camera_changed call: the GUI is wired to the microscope's change + # listener, and a same-thread switch dispatches it synchronously — calling it by hand + # would let the test pass even if that wiring were removed. + scope.set_active_camera(2) + assert scope.active_camera_id == 2 + secondary_exposure = secondary.get_exposure_time() + + _bind_live_channel_to(win, control._def.PRIMARY_CAMERA_ID) + win.liveControlWidget.entry_exposureTime.setValue(23.0) + + assert primary.get_exposure_time() == pytest.approx(23.0), "edit did not follow the channel binding" + assert secondary.get_exposure_time() == pytest.approx(secondary_exposure), "stale active camera was retuned" + + +def test_live_exposure_edit_applies_when_the_target_spinbox_already_matches(qtbot, monkeypatch, confirm_exit_yes): + """The edit must reach the sensor even when the target camera's settings tab already + displays that number. Routing through QDoubleSpinBox.setValue alone dropped it: + setValue emits nothing when the value is unchanged, and that spinbox is not resynced + when set_microscope_mode writes exposure straight to the camera.""" + scope, win = _build_two_camera_gui(qtbot, monkeypatch) + secondary = scope.cameras[2] + + _bind_live_channel_to(win, 2) + # The tab shows 50 while the sensor is on 20 — exactly what set_microscope_mode leaves + # behind when it applies a channel's exposure directly to the camera. + win.cameraSettingWidgets[2].entry_exposureTime.setValue(50.0) + secondary.set_exposure_time(20.0) + assert secondary.get_exposure_time() == pytest.approx(20.0) + + win.liveControlWidget.entry_exposureTime.setValue(50.0) + + assert secondary.get_exposure_time() == pytest.approx(50.0), "edit was swallowed by an unchanged spinbox" + + def test_tab_change_to_simple_recording_does_not_raise(qtbot, monkeypatch, confirm_exit_yes): """Regression: onTabChanged used to call emit_selected_channels() on every record tab and toggleAcquisitionStart called display_progress_bar() on the current tab, diff --git a/software/tests/control/test_napari_multi_channel_widget.py b/software/tests/control/test_napari_multi_channel_widget.py index 4458ead93..8e0d756db 100644 --- a/software/tests/control/test_napari_multi_channel_widget.py +++ b/software/tests/control/test_napari_multi_channel_widget.py @@ -24,11 +24,12 @@ class FakeLayer: - def __init__(self, data, name, rgb): + def __init__(self, data, name, rgb, contrast_limits=(0, 1), scale=(1, 1, 1)): self.data = data self.name = name self.rgb = rgb - self.contrast_limits = (0, 1) + self.contrast_limits = contrast_limits + self.scale = scale self.events = MagicMock() self.refresh_count = 0 @@ -75,7 +76,7 @@ def __init__(self): self.dims = MagicMock() def add_image(self, data, name, visible, rgb, colormap, contrast_limits, blending, scale): - layer = FakeLayer(data, name, rgb) + layer = FakeLayer(data, name, rgb, contrast_limits=contrast_limits, scale=scale) self.layers._layers[name] = layer self.layers.add_calls += 1 return layer @@ -92,8 +93,6 @@ def widget(): w.camera = MagicMock() w.contrastManager = ContrastManager() w.viewer = FakeViewer() - w.image_width = 0 - w.image_height = 0 w.dtype = np.uint8 w.channels = set() w.pixel_size_um = 1 @@ -181,6 +180,57 @@ def test_single_camera_run_is_unchanged(widget): assert (layer.data.dtype, layer.data.shape) == (np.uint16, (1, 8, 8)) +def test_each_layer_gets_contrast_limits_for_its_own_dtype(widget): + """ContrastManager tracks one run-wide acquisition_dtype, so its default limits describe + whichever camera arrived first. A uint8 RGB layer handed uint16 limits renders black (and + a uint16 layer handed uint8 limits renders saturated white), so each layer's defaults + must come from its own dtype. The old teardown hid this by re-running initLayers, which + rescaled the limits before re-adding every layer.""" + # start_acquisition announces uint16, so ContrastManager.acquisition_dtype latches to + # uint16 exactly as it does on a real run whose first frame is the mono camera's. + start_acquisition(widget) + run_fovs(widget, 2) + + assert widget.viewer.layers[MONO_CHANNEL].contrast_limits == (0, 65535) + assert widget.viewer.layers[COLOR_CHANNEL].contrast_limits == (0, 255) + + +def test_user_set_contrast_limits_still_win(widget): + """A limit the user dragged in napari must survive; only the default comes from dtype.""" + start_acquisition(widget) + run_fovs(widget, 1) + widget.contrastManager.update_limits(COLOR_CHANNEL, 10, 200) + + run_fovs(widget, 1) + + assert widget.viewer.layers[COLOR_CHANNEL].contrast_limits == (10, 200) + + +def test_each_layer_is_scaled_by_its_own_cameras_pixel_size(widget): + """Layers fed by cameras with different pixel pitch must still overlay, so the scale + comes from the pixel size passed with each frame - not from the widget-wide value, which + is computed once from whichever camera was active at acquisition start.""" + start_acquisition(widget) + widget.pixel_size_um = 999.0 # the wrong-sensor fallback; must not be used + + widget.updateLayers(color_frame(), x=0.0, y=0.0, k=0, channel_name=COLOR_CHANNEL, pixel_size_um=1.85) + widget.updateLayers(mono_frame(), x=0.0, y=0.0, k=0, channel_name=MONO_CHANNEL, pixel_size_um=3.45) + + assert tuple(widget.viewer.layers[COLOR_CHANNEL].scale)[1:] == (1.85, 1.85) + assert tuple(widget.viewer.layers[MONO_CHANNEL].scale)[1:] == (3.45, 3.45) + + +def test_pixel_size_falls_back_to_the_widget_value(widget): + """Callers that pass no pixel size (older signal payloads, single-camera paths) keep the + previous behaviour.""" + start_acquisition(widget) + widget.pixel_size_um = 2.5 + + widget.updateLayers(mono_frame(), x=0.0, y=0.0, k=0, channel_name=MONO_CHANNEL) + + assert tuple(widget.viewer.layers[MONO_CHANNEL].scale)[1:] == (2.5, 2.5) + + def test_geometry_change_on_one_channel_rebuilds_only_that_layer(widget): """A channel whose frame geometry changes (e.g. its camera was re-binned) is rebuilt; the other channel's layer survives.""" From 505c7e7cb02e0a9a5ee9dfa9e0fcac9792cf0180 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Wed, 12 Aug 2026 01:22:41 -0700 Subject: [PATCH 38/52] test: make the profile fixture non-fatal and per-test Review follow-up to d94b203f. The generation guards raised pytest.UsageError, and isolate_ambient_user_profiles is autouse, so on a checkout without the gitignored machine_configs/illumination_channel_config.yaml *every* test in the repo errored instead of only the config-dependent ones - 49 errors where the same run now reports 48 passed and one warning. The application merely logs a warning for that condition (ConfigRepository.load_profile swallows the FileNotFoundError), so the harness was strictly more brittle than the app. Generation failure now warns and falls back to the ambient profile, i.e. to how those runs behaved before the fixture existed. The profile is also copied per test rather than shared for the session. The app persists channel settings through ConfigRepository as spinboxes are edited, so a single directory let one widget-driven test decide what every later test read, making outcomes order-dependent; the previous GUI test had to stub update_channel_setting to work around exactly that. The copy goes in a directory of the fixture's own rather than tmp_path, because the repository tests use tmp_path as a ConfigRepository base_path and a profile planted there collides with the ones they build themselves. tests/test_profile_isolation.py covers the leak: one test writes a channel setting, the next asserts it did not carry over. Co-Authored-By: Claude Opus 5 (1M context) --- software/tests/conftest.py | 67 ++++++++++++++++-------- software/tests/test_profile_isolation.py | 42 +++++++++++++++ 2 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 software/tests/test_profile_isolation.py diff --git a/software/tests/conftest.py b/software/tests/conftest.py index 42427ea2d..37add4fb5 100644 --- a/software/tests/conftest.py +++ b/software/tests/conftest.py @@ -12,8 +12,10 @@ import logging import os +import shutil import sys import tempfile +import warnings from unittest.mock import patch import pytest @@ -103,19 +105,25 @@ def _get_camera_registry(self): @pytest.fixture(scope="session") -def canonical_user_profiles(tmp_path_factory): - """A freshly generated "default" profile, the same one CI runs against. +def canonical_user_profiles_template(tmp_path_factory): + """A freshly generated "default" profile to copy per test, or None if it can't be made. user_profiles/ is gitignored, so a CI checkout has none and the app generates it from machine_configs/illumination_channel_config.yaml on first use. Doing exactly that here gives every test the same starting point CI has: channels at their default exposure and gain, and none of them bound to a camera. + + Returns None instead of raising when generation is impossible. isolate_ambient_user_profiles + is autouse, so a raise here fails EVERY test in the session — over a gitignored file the + application itself merely warns about (ConfigRepository.load_profile swallows the same + FileNotFoundError). Falling back to the ambient profile keeps such a checkout working + exactly as it did before this fixture existed. """ from control.core.config.repository import ConfigRepository from control.default_config_generator import ensure_default_configs import control._def - profiles_root = tmp_path_factory.mktemp("user_profiles") + profiles_root = tmp_path_factory.mktemp("user_profiles_template") (profiles_root / "default").mkdir() # Real base_path, so machine_configs (the illumination config) still resolves; only the @@ -123,6 +131,12 @@ def canonical_user_profiles(tmp_path_factory): # __init__, so this repository is a plain one. repo = ConfigRepository() repo.user_profiles_path = profiles_root + hint = ( + "Tests will use the machine's own user_profiles/ instead, so results may depend on " + "local channel state. To get the isolated profile, populate the illumination config " + "the way CI does: cp machine_configs/illumination_channel_config.yaml.example " + "machine_configs/illumination_channel_config.yaml" + ) try: ensure_default_configs( repo, @@ -130,49 +144,60 @@ def canonical_user_profiles(tmp_path_factory): list(control._def.OBJECTIVES) if hasattr(control._def, "OBJECTIVES") else None, include_confocal=False, ) - except FileNotFoundError as e: - raise pytest.UsageError( - f"Could not generate the test profile: {e}. Populate it the way CI does:\n" - " cp machine_configs/illumination_channel_config.yaml.example " - "machine_configs/illumination_channel_config.yaml" - ) from e - - # Generation also declines silently when legacy XML configs are pending migration, - # which would leave every test with an empty profile. + except (FileNotFoundError, OSError) as e: + warnings.warn(f"Could not generate the test profile ({e}). {hint}") + return None + + # Generation also declines silently when legacy XML configs are pending migration + # (see has_legacy_configs_to_migrate), which would leave an empty profile behind. if not (profiles_root / "default" / "channel_configs" / "general.yaml").exists(): - raise pytest.UsageError( - "The test profile was not generated (no general.yaml). If software/" - "acquisition_configurations/ holds legacy XML configs, run " - "tools/migrate_acquisition_configs.py first." + warnings.warn( + f"The test profile was not generated (no general.yaml); if " + f"software/acquisition_configurations/ holds legacy XML configs, run " + f"tools/migrate_acquisition_configs.py first. {hint}" ) + return None return profiles_root @pytest.fixture(autouse=True) -def isolate_ambient_user_profiles(monkeypatch, canonical_user_profiles): - """Point default-path repositories at the canonical profile, not the machine's. +def isolate_ambient_user_profiles(monkeypatch, tmp_path_factory, canonical_user_profiles_template): + """Give each test its own copy of the canonical profile, not the machine's. user_profiles/ is machine-specific and gitignored, and it is read *and written* by the - running application: the live-control exposure/gain spinboxes persist through - ConfigRepository on every edit. Without this pin, + running application: the live-control exposure/gain/intensity spinboxes persist through + ConfigRepository on every edit. Without this, * tests inherit whatever the developer's channels happen to hold - notably a `camera:` binding, which decides the active camera at startup and so changes what the trigger dropdown offers and whether an acquisition needs a camera switch, and * a test that drives those spinboxes rewrites the developer's channel configs. + The copy is per test rather than one shared directory: a widget-driven edit persists, so + a shared profile would carry one test's channel values (or a ProfileWidget-created + profile) into every later test in the session, making outcomes order-dependent. + Repositories constructed with an explicit base_path (tmp_path in the repository tests) are left alone, as in isolate_ambient_camera_registry. """ + if canonical_user_profiles_template is None: + return # nothing generated; leave the ambient profile in place (see the fixture above) + from control.core.config.repository import ConfigRepository + # A directory of our own, NOT the test's tmp_path: the repository tests use tmp_path as + # a ConfigRepository base_path, so planting a profile under it would collide with the + # profiles they build there themselves. + profiles_root = tmp_path_factory.mktemp("isolated_profiles") / "user_profiles" + shutil.copytree(canonical_user_profiles_template, profiles_root) + default_user_profiles_path = ConfigRepository().user_profiles_path original_init = ConfigRepository.__init__ def _init(self, base_path=None): original_init(self, base_path) if self.user_profiles_path == default_user_profiles_path: - self.user_profiles_path = canonical_user_profiles + self.user_profiles_path = profiles_root monkeypatch.setattr(ConfigRepository, "__init__", _init) diff --git a/software/tests/test_profile_isolation.py b/software/tests/test_profile_isolation.py new file mode 100644 index 000000000..96e6535c0 --- /dev/null +++ b/software/tests/test_profile_isolation.py @@ -0,0 +1,42 @@ +"""The autouse profile fixture must isolate tests from each other, not just from the machine. + +The application persists channel settings through ConfigRepository as the user edits live +control spinboxes, so a widget-driven test writes to whatever profile directory the fixture +points at. If that directory were shared for the whole session, one test's edit would decide +what every later test reads, and outcomes would depend on execution order. + +These two tests are order-dependent by construction: the first writes, the second asserts it +did not leak. Keep them in this order. +""" + +from control.core.config.repository import ConfigRepository + +CHANNEL = "BF LED matrix full" +POISON_EXPOSURE = 1234.0 + + +def _exposure_of(objective): + repo = ConfigRepository() + repo.set_profile("default") + channels = repo.get_merged_channels(objective) + return next(c.camera_settings.exposure_time_ms for c in channels if c.name == CHANNEL) + + +def test_a_writes_a_channel_setting_through_the_repository(): + repo = ConfigRepository() + repo.set_profile("default") + objective = next(iter(repo.get_available_objectives())) + + assert repo.update_channel_setting(objective, CHANNEL, "ExposureTime", POISON_EXPOSURE) + assert _exposure_of(objective) == POISON_EXPOSURE + + +def test_b_does_not_see_the_previous_tests_write(): + repo = ConfigRepository() + repo.set_profile("default") + objective = next(iter(repo.get_available_objectives())) + + assert _exposure_of(objective) != POISON_EXPOSURE, ( + "the previous test's channel edit leaked into this one; the profile directory is " + "being shared across tests instead of copied per test" + ) From e92b31408b861aad6d641ca7a1e19578bc0388ce Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Wed, 12 Aug 2026 02:05:37 -0700 Subject: [PATCH 39/52] fix(dual-camera): remember contrast limits per channel in their own dtype Found while verifying the mixed-camera display on hardware: a contrast setting made on one camera's channel was silently rewritten into the other camera's range. Two causes, both from treating the acquisition dtype as run-wide when it is really a property of each camera - every channel on one camera shares its dtype, and it changes only when that camera's pixel format does. ContrastManager kept one acquisition_dtype and rescaled EVERY stored channel on each dtype change, which on a mixed run fires at every camera switch. Limits are now stored with the dtype they were chosen in and converted only when that channel's own dtype changes, lazily on read; scale_contrast_limits just records the latest dtype, so its four existing callers stay correct without sweeping other cameras' channels. get_scaled_limits converts without re-anchoring the record, for views that render at a different depth. The mosaic then fed the same corruption back in. mosaic_dtype is latched from whichever tile arrives first (the colour camera's uint8 here), and the limits it derives for that view were assigned to the layer, whose contrast event wrote them back into the shared per-channel store unlabelled - so a mono channel's (800, 1600) came back as (3.1, 6.2). That assignment is now made with the event blocked, since the value came from the manager in the first place, and a genuine user drag in the mosaic is recorded with mosaic_dtype. Verified on two Toupcams over two consecutive mixed acquisitions: colour keeps (10, 200) and mono keeps (800, 1600) exactly, where mono previously came back rescaled. tests/control/core/test_contrast_manager.py covers the rule directly, including that a channel's own dtype change still converts its limits. Co-Authored-By: Claude Opus 5 (1M context) --- software/control/core/contrast_manager.py | 123 +++++++++++++----- software/control/widgets.py | 6 +- software/control/widgets_mosaic.py | 13 +- .../control/core/test_contrast_manager.py | 88 +++++++++++++ 4 files changed, 192 insertions(+), 38 deletions(-) create mode 100644 software/tests/control/core/test_contrast_manager.py diff --git a/software/control/core/contrast_manager.py b/software/control/core/contrast_manager.py index fd54e2e6b..53b962882 100644 --- a/software/control/core/contrast_manager.py +++ b/software/control/core/contrast_manager.py @@ -1,28 +1,49 @@ +import logging + import numpy as np +logger = logging.getLogger(__name__) + class ContrastManager: + """User contrast limits per channel, each remembered in its own dtype. + + A channel's dtype is its camera's dtype: every channel on one camera shares it, and it + only changes when that camera's pixel format changes. So limits are stored per channel + together with the dtype they were chosen in, and a channel is converted only when its own + dtype changes - lazily, the next time it is displayed. + + The previous model kept a single run-wide acquisition_dtype and rescaled EVERY channel's + stored limits whenever a frame of a different dtype arrived. On a dual-camera run that + fires on every camera switch, so a contrast setting made on the mono camera's channel was + rewritten into the colour camera's 0-255 range (and vice versa) purely because the other + camera delivered a frame. + """ + def __init__(self): self.contrast_limits = {} + # dtype each channel's stored limits are expressed in. + self.limit_dtypes = {} + # Most recently seen dtype. Kept because callers use it as "has any frame arrived + # yet"; it is deliberately NOT used to reinterpret another channel's limits. self.acquisition_dtype = None - def update_limits(self, channel, min_val, max_val): + def update_limits(self, channel, min_val, max_val, dtype=None): self.contrast_limits[channel] = (min_val, max_val) + if dtype is not None: + self.limit_dtypes[channel] = np.dtype(dtype) def get_limits(self, channel, dtype=None): - if dtype is not None: - if self.acquisition_dtype is None: - self.acquisition_dtype = dtype - elif self.acquisition_dtype != dtype: - self.scale_contrast_limits(dtype) - return self.contrast_limits.get(channel, self.get_default_limits()) + if dtype is None: + return self.contrast_limits.get(channel, self.get_default_limits()) + return self.get_limits_for_dtype(channel, dtype) def get_default_limits(self): return self.default_limits_for_dtype(self.acquisition_dtype) @staticmethod def default_limits_for_dtype(dtype): - """Full display range of one dtype, independent of the run-wide acquisition_dtype.""" + """Full display range of one dtype, independent of any other channel's dtype.""" if dtype is None: return (0, 1) elif np.issubdtype(dtype, np.integer): @@ -34,39 +55,73 @@ def default_limits_for_dtype(dtype): return (0, 1) def get_limits_for_dtype(self, channel, dtype): - """Limits for one channel, defaulting to the full range of ITS OWN dtype. - - get_limits() falls back to get_default_limits(), which is derived from the single - run-wide acquisition_dtype - i.e. from whichever camera delivered the first frame. - On a dual-camera run that is the wrong range for the other camera's channels: a - uint8 RGB layer handed uint16 limits renders essentially black, and a uint16 layer - handed uint8 limits renders saturated white. A limit the user has actually set for - the channel still wins. + """This channel's limits, expressed in `dtype`. + + Defaults to the full range of `dtype` for a channel the user has never adjusted. A + channel that HAS been adjusted keeps that choice, converted if (and only if) its own + dtype changed - e.g. its camera was switched from MONO8 to MONO16. """ - if channel in self.contrast_limits: + dtype = np.dtype(dtype) + if self.acquisition_dtype is None: + self.acquisition_dtype = dtype + + if channel not in self.contrast_limits: + return self.default_limits_for_dtype(dtype) + + stored_dtype = self.limit_dtypes.get(channel) + if stored_dtype is None: + # Limits recorded before this channel's dtype was known (legacy callers of + # update_limits). Adopt the dtype rather than rescaling from a guess. + self.limit_dtypes[channel] = dtype return self.contrast_limits[channel] - return self.default_limits_for_dtype(dtype) - def get_scaled_limits(self, channel, target_dtype): - min_val, max_val = self.get_limits(channel) - if self.acquisition_dtype == target_dtype: - return min_val, max_val + if stored_dtype == dtype: + return self.contrast_limits[channel] - source_info = np.iinfo(self.acquisition_dtype) + converted = self._rescale(self.contrast_limits[channel], stored_dtype, dtype) + logger.debug(f"Converting {channel!r} contrast limits {stored_dtype} -> {dtype}: {converted}") + self.contrast_limits[channel] = converted + self.limit_dtypes[channel] = dtype + return converted + + @staticmethod + def _rescale(limits, source_dtype, target_dtype): + """Map limits from one integer dtype's range onto another's.""" + if not (np.issubdtype(source_dtype, np.integer) and np.issubdtype(target_dtype, np.integer)): + # Nothing meaningful to scale between (e.g. a float dtype); keep the values. + return limits + min_val, max_val = limits + source_info = np.iinfo(source_dtype) target_info = np.iinfo(target_dtype) + span = source_info.max - source_info.min + target_span = target_info.max - target_info.min - scaled_min = (min_val - source_info.min) / (source_info.max - source_info.min) * ( - target_info.max - target_info.min - ) + target_info.min - scaled_max = (max_val - source_info.min) / (source_info.max - source_info.min) * ( - target_info.max - target_info.min - ) + target_info.min + def convert(value): + return (value - source_info.min) / span * target_span + target_info.min - return scaled_min, scaled_max + return (convert(min_val), convert(max_val)) + + def get_scaled_limits(self, channel, target_dtype): + """This channel's limits in target_dtype, without recording the conversion. + + Used by views that render a channel at a different depth than it was acquired in + (the mosaic downsamples to its own dtype), so the channel's own record must not be + rewritten to the view's dtype. + """ + target_dtype = np.dtype(target_dtype) + limits = self.contrast_limits.get(channel) + if limits is None: + return self.default_limits_for_dtype(target_dtype) + source_dtype = self.limit_dtypes.get(channel, self.acquisition_dtype) + if source_dtype is None or np.dtype(source_dtype) == target_dtype: + return limits + return self._rescale(limits, np.dtype(source_dtype), target_dtype) def scale_contrast_limits(self, target_dtype): - print(f"{self.acquisition_dtype} -> {target_dtype}") - for channel in self.contrast_limits.keys(): - self.contrast_limits[channel] = self.get_scaled_limits(channel, target_dtype) + """Record that frames of target_dtype are now arriving. - self.acquisition_dtype = target_dtype + No longer rewrites every channel's stored limits: each channel is converted from its + own dtype on read (see get_limits_for_dtype), so a camera switch cannot reinterpret + another camera's channels. Callers that announce a dtype change can keep calling this. + """ + self.acquisition_dtype = np.dtype(target_dtype) diff --git a/software/control/widgets.py b/software/control/widgets.py index b28394c92..bf39c3eaf 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -12620,10 +12620,12 @@ def updateLayers(self, image, x, y, k, channel_name, pixel_size_um=None): def signalContrastLimits(self, event): layer = event.source min_val, max_val = map(float, layer.contrast_limits) - self.contrastManager.update_limits(layer.name, min_val, max_val) + # Record the dtype the user chose these limits in - it is the layer's own camera's + # dtype, so another camera's frames can never reinterpret them. + self.contrastManager.update_limits(layer.name, min_val, max_val, dtype=layer.data.dtype) def getContrastLimits(self, dtype): - return self.contrastManager.get_default_limits() + return self.contrastManager.default_limits_for_dtype(dtype) def resetView(self): self.viewer.reset_view() diff --git a/software/control/widgets_mosaic.py b/software/control/widgets_mosaic.py index 17a6ae662..5c91d5256 100644 --- a/software/control/widgets_mosaic.py +++ b/software/control/widgets_mosaic.py @@ -549,7 +549,14 @@ def updateTile(self, update): new_limits = self.contrastManager.get_scaled_limits(channel_name, self.mosaic_dtype) layer = self.viewer.layers[channel_name] if tuple(layer.contrast_limits) != tuple(new_limits): - layer.contrast_limits = new_limits + # Block the contrast event: these limits were just derived FROM the manager, + # rescaled into this view's dtype (mosaic_dtype comes from whichever tile + # arrived first). Letting the assignment echo back through + # _on_contrast_change overwrote the channel's stored limits with this view's + # dtype, so a contrast set on a uint16 channel came back as its uint8 + # equivalent - e.g. (800, 1600) stored as (3.1, 6.2). + with layer.events.contrast_limits.blocker(): + layer.contrast_limits = new_limits def _update_mosaic_layer(self, layer, image, tl_x_mm, tl_y_mm, prev_top_left): """Place tile on the mosaic canvas, expanding and shifting if extents grew.""" @@ -686,7 +693,9 @@ def _on_double_click(self, layer, event): def _on_contrast_change(self, event): layer = event.source min_val, max_val = layer.contrast_limits - self.contrastManager.update_limits(layer.name, min_val, max_val) + # A real user drag in this view. Label it with the dtype it is expressed in - this + # view renders at mosaic_dtype, which need not be the channel's acquisition dtype. + self.contrastManager.update_limits(layer.name, min_val, max_val, dtype=self.mosaic_dtype) # --- Zoom limits (active in plate mode) --- diff --git a/software/tests/control/core/test_contrast_manager.py b/software/tests/control/core/test_contrast_manager.py new file mode 100644 index 000000000..9a275cf3d --- /dev/null +++ b/software/tests/control/core/test_contrast_manager.py @@ -0,0 +1,88 @@ +"""Contrast limits must be remembered per channel, in that channel's own dtype. + +A channel's dtype is its camera's dtype: all of one camera's channels share it, and it changes +only when that camera's pixel format changes. So a camera switch must never reinterpret the +other camera's channels — which is what a single run-wide acquisition_dtype plus a global +rescale did, rewriting a mono channel's limits into the colour camera's 0-255 range. +""" + +import numpy as np + +from control.core.contrast_manager import ContrastManager + +MONO = "Fluorescence 488 nm Ex" # uint16 camera +COLOR = "BF LED matrix full" # uint8 camera + + +def test_defaults_follow_each_channels_own_dtype(): + cm = ContrastManager() + # The mono camera's frame arrives first, latching acquisition_dtype. + assert cm.get_limits_for_dtype(MONO, np.uint16) == (0, 65535) + assert cm.get_limits_for_dtype(COLOR, np.uint8) == (0, 255) + # ...and the order does not matter. + other = ContrastManager() + assert other.get_limits_for_dtype(COLOR, np.uint8) == (0, 255) + assert other.get_limits_for_dtype(MONO, np.uint16) == (0, 65535) + + +def test_a_camera_switch_does_not_touch_the_other_cameras_limits(): + cm = ContrastManager() + cm.update_limits(MONO, 800, 1600, dtype=np.uint16) + cm.update_limits(COLOR, 10, 200, dtype=np.uint8) + + # Frames now alternate between the two cameras, as a mixed acquisition does. + for _ in range(3): + assert cm.get_limits_for_dtype(COLOR, np.uint8) == (10, 200) + assert cm.get_limits_for_dtype(MONO, np.uint16) == (800, 1600) + + +def test_the_global_dtype_announcement_no_longer_rewrites_stored_limits(): + """scale_contrast_limits is still called by the live/multichannel init paths on a dtype + change; it must only record the dtype now.""" + cm = ContrastManager() + cm.update_limits(MONO, 800, 1600, dtype=np.uint16) + + cm.scale_contrast_limits(np.uint8) # a colour frame arrived + + assert cm.acquisition_dtype == np.dtype(np.uint8) + assert cm.get_limits_for_dtype(MONO, np.uint16) == (800, 1600), "the mono channel was rescaled by a colour frame" + + +def test_a_channels_own_dtype_change_still_converts_its_limits(): + """The legitimate case the rescaling existed for: one camera's pixel format changes, so + that camera's channels must carry their contrast selection into the new range.""" + cm = ContrastManager() + cm.update_limits(MONO, 0, 32768, dtype=np.uint16) # half scale in uint16 + + converted = cm.get_limits_for_dtype(MONO, np.uint8) + + assert converted[0] == 0 + assert round(converted[1]) == 128, converted # half scale in uint8 + # The conversion is recorded, so re-reading is stable rather than compounding. + assert cm.get_limits_for_dtype(MONO, np.uint8) == converted + + +def test_limits_recorded_without_a_dtype_adopt_the_first_dtype_they_are_read_at(): + """Callers that predate the dtype argument must not have their values rescaled from a + guessed source dtype.""" + cm = ContrastManager() + cm.update_limits(MONO, 800, 1600) # no dtype + + assert cm.get_limits_for_dtype(MONO, np.uint16) == (800, 1600) + assert cm.limit_dtypes[MONO] == np.dtype(np.uint16) + + +def test_get_scaled_limits_converts_without_rewriting_the_record(): + """The mosaic renders a channel at its own depth; that must not re-anchor the channel.""" + cm = ContrastManager() + cm.update_limits(MONO, 0, 65535, dtype=np.uint16) + + assert cm.get_scaled_limits(MONO, np.uint8) == (0, 255) + assert cm.contrast_limits[MONO] == (0, 65535), "the stored record was rewritten" + assert cm.limit_dtypes[MONO] == np.dtype(np.uint16) + + +def test_unset_channel_falls_back_to_the_requested_dtype(): + cm = ContrastManager() + assert cm.get_scaled_limits("never seen", np.uint8) == (0, 255) + assert cm.get_limits_for_dtype("never seen", np.uint16) == (0, 65535) From 3790abd035a99c3e89da81bb755292e2abca3c25 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Wed, 12 Aug 2026 02:30:30 -0700 Subject: [PATCH 40/52] docs+test: correct the serial-number limits and cover the mosaic write-back The v1-limits section still said serial-number camera opening was unimplemented ("not yet plumbed through the Toupcam/Hamamatsu/Tucsen drivers"). The Toupcam driver has resolved serials since 5c419a0b/be66789a, and that is exactly what makes a same-model Toupcam pair - the configuration this document describes - work at all. Corrected to name the two drivers that do support it (Toupcam, FLIR) and the ones that still open the first device found, and fixed the same stale claim in the single-camera section, where the file is ignored *including* its serial_number. Also documents what a Toupcam serial_number may contain, since neither form is obvious: the SDK serial or the opaque enumeration id, why the serial is the one to prefer (the enumeration id encodes the USB port), why probing logs a benign "SerialNumber ... Not implemented" for a device the other camera holds open, and that a failed match lists every camera found with its id and serial - the easiest way to discover the value for a machine. Tests for the mosaic half of e92b3140, which had none: a channel's stored limits must survive being displayed in a mosaic latched to another camera's dtype, and a genuine contrast drag in that view must be recorded with mosaic_dtype so it is converted rather than read literally later. Each fails with its half of the fix removed. Co-Authored-By: Claude Opus 5 (1M context) --- software/docs/dual-camera.md | 24 ++++++--- .../control/test_unified_mosaic_widget.py | 49 +++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/software/docs/dual-camera.md b/software/docs/dual-camera.md index 644da7dd0..adf282825 100644 --- a/software/docs/dual-camera.md +++ b/software/docs/dual-camera.md @@ -42,6 +42,15 @@ See `machine_configs/cameras.yaml.example` for the full field list. Key points: `camera_type` can't describe two different cameras. - **`hardware_trigger: false`** means this camera's trigger line is not wired; it runs in software-trigger mode only. +- **What goes in `serial_number`** (Toupcam): either the SDK's own serial, as + `Toupcam.SerialNumber()` reports it (e.g. `TP24082314375443175B435F464E9A3`), or the opaque + enumeration id (the `\\?\usb#vid_0547&...` string). Prefer the serial — the enumeration id + encodes the USB port and changes if the camera is replugged elsewhere. Matching the + enumeration id costs nothing; matching a serial means opening each device in turn to read + it, which is why a device already held open by the other camera logs a benign + `SerialNumber ... Not implemented` error while probing. If nothing matches, the driver + raises and **lists every camera it found with its id and serial** — the easiest way to + discover the right value for a machine. - Optional per-camera overrides — `rotate_image_angle`, `flip`, `crop_width`, `crop_height`, `default_pixel_format`, `default_binning` — fall back to the INI `[CAMERA_CONFIG]` values when absent. Give a color camera an RGB `default_pixel_format`. @@ -58,8 +67,9 @@ falls back to the single INI camera. It does not crash, but you silently get one Nothing changes. With no `cameras.yaml`, or with one declared camera, the imaging camera comes entirely from the INI `[CAMERA_CONFIG]` section — a **1-camera `cameras.yaml` is -effectively ignored** (there is no serial-number-based camera selection in v1). The -registry only takes over when it declares more than one camera. +effectively ignored**, including its `serial_number`, so that camera is opened as the first +device the driver enumerates. The registry only takes over when it declares more than one +camera; serial-number selection comes with it. ## 2. Bind channels to cameras — Settings ▸ Channel Configuration… @@ -198,10 +208,12 @@ Zarr remains fully valid — and selectable — for single-camera runs. Flexible and Wellplate panels). Its only net is the acquisition-start backstop, which aborts the run and writes the reason to the log. - **No per-camera Zarr stores.** v1 validates the mismatch instead of splitting stores. -- **No serial-number camera opening (except FLIR).** Most drivers open the "first camera - found", so two cameras of the **same vendor type** are not reliably distinguishable yet — - the serial numbers are recorded in `cameras.yaml` but not yet plumbed through the - Toupcam/Hamamatsu/Tucsen drivers. Different-vendor pairs and simulation are fine. +- **Serial-number camera opening works for Toupcam and FLIR only.** Those two drivers open + the specific device a `serial_number` names, so two cameras of the same vendor — even the + same model — are distinguishable. Every other driver (Hamamatsu, Tucsen, iDS, Photometrics, + Andor, Default/Daheng) still opens the "first camera found": the serial number is recorded + in `cameras.yaml` but not used to choose the device, so a same-vendor pair on those drivers + is not reliably distinguishable. Different-vendor pairs and simulation are fine. - **No switch-minimizing channel reordering** — your channel order is preserved. - **`hardware_bindings.yaml` emission-wheel dispatch** is not wired per camera. diff --git a/software/tests/control/test_unified_mosaic_widget.py b/software/tests/control/test_unified_mosaic_widget.py index 62a53aa17..38d2c2cab 100644 --- a/software/tests/control/test_unified_mosaic_widget.py +++ b/software/tests/control/test_unified_mosaic_widget.py @@ -155,3 +155,52 @@ def test_plate_view_still_uses_integer_downsample(self, qtbot, monkeypatch): ) # Integer factor 3 -> 2.22 um, NOT the exact target 2.0 um. assert widget.viewer_pixel_size_mm == pytest.approx(0.00222, abs=1e-5) + + +class TestContrastManagerWriteBack: + """The mosaic renders at mosaic_dtype - latched from whichever tile arrives first, which + on a dual-camera run may be the other camera's depth. It must not write that view's + numbers back over a channel's stored limits. + """ + + @pytest.fixture + def widget_with_real_contrast(self, qtbot, monkeypatch): + from control.core.contrast_manager import ContrastManager + + monkeypatch.setattr(control._def, "MOSAIC_VIEW_TARGET_PIXEL_SIZE_UM", 2.0) + contrast = ContrastManager() + widget = UnifiedMosaicWidget(_FakeObjectiveStore(factor=1.85), _FakeCamera(), contrast) + qtbot.addWidget(widget) + widget.mode = DisplayMode.MOSAIC + return widget, contrast + + def test_derived_limits_are_not_written_back_to_the_manager(self, widget_with_real_contrast): + """A uint16 channel's contrast selection must survive being displayed in a uint8 + mosaic. Without the event blocker the assignment echoed through _on_contrast_change + and stored the uint8 equivalent, so (800, 1600) came back as (3.1, 6.2).""" + widget, contrast = widget_with_real_contrast + contrast.update_limits("BF", 800.0, 1600.0, dtype=np.uint16) + + # A uint8 tile latches mosaic_dtype to uint8, so the displayed limits are rescaled. + widget.updateTile(_tile_update(np.full((100, 100), 200, dtype=np.uint8), 10.0, 10.0)) + assert widget.mosaic_dtype == np.uint8 + + assert contrast.contrast_limits["BF"] == (800.0, 1600.0), "the mosaic overwrote the channel's limits" + assert contrast.limit_dtypes["BF"] == np.dtype(np.uint16), "the channel was re-anchored to the view's dtype" + # The layer itself still shows the rescaled values, so the view looks right. + assert tuple(widget.viewer.layers["BF"].contrast_limits) == pytest.approx((3.11, 6.22), abs=0.01) + + def test_a_user_drag_in_the_mosaic_is_recorded_with_the_view_dtype(self, widget_with_real_contrast): + """A real contrast change here is expressed in mosaic_dtype, so it must be labelled + that way - otherwise it would later be read as if it were the channel's own depth.""" + widget, contrast = widget_with_real_contrast + widget.updateTile(_tile_update(np.full((100, 100), 200, dtype=np.uint8), 10.0, 10.0)) + + widget.viewer.layers["BF"].contrast_limits = (20.0, 240.0) # unblocked: a user drag + + assert contrast.contrast_limits["BF"] == (20.0, 240.0) + assert contrast.limit_dtypes["BF"] == np.dtype(np.uint8) + # ...and read back at the channel's acquisition depth it converts, rather than being + # taken literally as uint16 values. + converted = contrast.get_limits_for_dtype("BF", np.uint16) + assert converted == pytest.approx((5140.0, 61680.0), rel=0.01) From 94ba41f2677f2502b2d7123b21b08bb2066e2b47 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 13 Aug 2026 00:54:45 -0400 Subject: [PATCH 41/52] fix(toupcam): make white balance work on colour cameras The SDK picks the white balance mode when the camera is opened, and the two modes are mutually exclusive. Opened plain, ITR3CMOS26000KPA serves Temp/Tint and answers "not implemented" to the entire RGB gain API this driver is written against, so every Auto White Balance press raised HRESULTException. Appending ";wb=rgb" to the camId selects RGB gain mode, after which AwbInit and get/put_WhiteBalanceGain work. Mono cameras have no white balance in either mode and are still opened plain. Both open sites go through the new helper. The serial-number probe opens each device to read its serial and hands the matching handle back to _open for reuse, so changing only the obvious site would have left a camera matched by serial - which is how this machine's colour camera is matched - holding a plain handle and no better off. Two related driver bugs, both of which only became reachable once white balance worked at all: * put_InitWBGain built a c_short array while Toupcam_put_InitWBGain is declared as taking c_ushort * 3, so ctypes rejected every call with a TypeError before it reached the DLL. * set_white_balance_gains forwarded its arguments to an SDK that declares c_int * 3. AbstractCamera types them as float, so any caller holding non-integer gains failed. The fake SDK handle now rejects floats the way ctypes does; without that, a driver that forwards them keeps passing here and fails on hardware. Co-Authored-By: Claude Opus 5 (1M context) --- software/control/camera_toupcam.py | 30 ++++++- software/control/toupcam.py | 5 +- software/tests/control/test_camera_toupcam.py | 82 ++++++++++++++++++- 3 files changed, 110 insertions(+), 7 deletions(-) diff --git a/software/control/camera_toupcam.py b/software/control/camera_toupcam.py index 440846961..0658f1a5d 100644 --- a/software/control/camera_toupcam.py +++ b/software/control/camera_toupcam.py @@ -267,6 +267,25 @@ def _capabilities_for_device(device: toupcam.ToupcamDeviceV2) -> ToupCamCapabili is_mono=(device.model.flag & toupcam.TOUPCAM_FLAG_MONO) > 0, ) + # Opens the camera in RGB gain white balance mode. The SDK fixes the white balance + # mode at open time and the two modes are mutually exclusive, so this has to be part + # of the camId string handed to Toupcam_Open - it cannot be switched afterwards. + WB_RGB_OPEN_SUFFIX = ";wb=rgb" + + @staticmethod + def _open_id_for_device(device: toupcam.ToupcamDeviceV2) -> str: + """ + The camId string to open `device` with. + + Color cameras get the RGB gain white balance mode appended. Without it the SDK + serves Temp/Tint white balance instead, and the whole RGB gain API this driver + uses (AwbInit, get/put_WhiteBalanceGain) reports "not implemented". Mono cameras + have no white balance at all, so they are opened plain. + """ + if device.model.flag & toupcam.TOUPCAM_FLAG_MONO: + return device.id + return device.id + ToupcamCamera.WB_RGB_OPEN_SUFFIX + @staticmethod def _resolve_sn_to_index( devices: Sequence[toupcam.ToupcamDeviceV2], sn: str @@ -295,7 +314,9 @@ def _resolve_sn_to_index( descriptions = [] for idx, device in enumerate(devices): try: - camera = toupcam.Toupcam.Open(device.id) + # Opened the same way _open would, so the handle we keep for the match is + # already in the right white balance mode and never needs reopening. + camera = toupcam.Toupcam.Open(ToupcamCamera._open_id_for_device(device)) except Exception: log.exception(f"Failed to open toupcam device {idx} (id={device.id}) while probing serial numbers.") camera = None @@ -376,7 +397,7 @@ def _open(index=None, sn=None) -> Tuple[toupcam.Toupcam, ToupCamCapabilities]: try: capabilities = ToupcamCamera._capabilities_for_device(device) if camera is None: - camera = toupcam.Toupcam.Open(device.id) + camera = toupcam.Toupcam.Open(ToupcamCamera._open_id_for_device(device)) if camera is None: raise ValueError(f"Failed to open Toupcam device {index} (id={device.id}). Is it in use already?") except Exception: @@ -1091,7 +1112,10 @@ def get_white_balance_gains(self) -> Tuple[float, float, float]: return self._camera.get_WhiteBalanceGain() def set_white_balance_gains(self, red_gain: float, green_gain: float, blue_gain: float): - self._camera.put_WhiteBalanceGain((red_gain, green_gain, blue_gain)) + # The SDK takes integer gains (c_int * 3) and ctypes rejects floats outright, but + # AbstractCamera types these as float and cached gains come back off disk as + # floats, so round rather than hand them straight through. + self._camera.put_WhiteBalanceGain((round(red_gain), round(green_gain), round(blue_gain))) def set_auto_white_balance_gains(self, on: bool) -> Tuple[float, float, float]: """ diff --git a/software/control/toupcam.py b/software/control/toupcam.py index 69c125c27..958929909 100644 --- a/software/control/toupcam.py +++ b/software/control/toupcam.py @@ -2014,8 +2014,11 @@ def put_ColorMatrix(self, v): raise HRESULTException(0x80070057) def put_InitWBGain(self, v): + # Local fix to the vendor file: this built a c_short array while + # Toupcam_put_InitWBGain is declared as taking c_ushort * 3 (see __initlib), + # so ctypes rejected every call with a TypeError before it reached the DLL. if len(v) == 3: - a = (ctypes.c_short * 3)(v[0], v[1], v[2]) + a = (ctypes.c_ushort * 3)(v[0], v[1], v[2]) self.__lib.Toupcam_put_InitWBGain(self.__h, a) else: raise HRESULTException(0x80070057) diff --git a/software/tests/control/test_camera_toupcam.py b/software/tests/control/test_camera_toupcam.py index e720bc3e4..97f2a7068 100644 --- a/software/tests/control/test_camera_toupcam.py +++ b/software/tests/control/test_camera_toupcam.py @@ -49,6 +49,14 @@ def AwbOnce(self): def get_WhiteBalanceGain(self): return (11, 22, 33) + def put_WhiteBalanceGain(self, gains): + # The real SDK declares this as c_int * 3, so ctypes raises on a float. Model that, + # otherwise a driver that forwards floats looks fine here and fails on hardware. + for g in gains: + if not isinstance(g, int): + raise TypeError("'float' object cannot be interpreted as an integer") + self.white_balance_gains = tuple(gains) + class FakeDeviceSpec: def __init__( @@ -101,6 +109,9 @@ class FakeToupcamSdk: def __init__(self, specs: List[FakeDeviceSpec]): self._specs = specs self.open_calls: List[str] = [] + # Full camId strings, suffixes and all. open_calls keeps just the device id, so + # tests about *which* device was opened stay readable. + self.open_camids: List[str] = [] self.handles: Dict[str, FakeToupcamHandle] = {} sdk = self @@ -111,12 +122,17 @@ def EnumV2(): @staticmethod def Open(cam_id): - sdk.open_calls.append(cam_id) - spec = next((s for s in sdk._specs if s.device_id == cam_id), None) + # Toupcam_Open takes ";key=value" options appended to the camId (eg the + # ";wb=rgb" that selects RGB gain white balance), so the device id is only + # the part before the first ";". + sdk.open_camids.append(cam_id) + device_id = cam_id.split(";", 1)[0] if cam_id else cam_id + sdk.open_calls.append(device_id) + spec = next((s for s in sdk._specs if s.device_id == device_id), None) if spec is None or not spec.openable: return None handle = FakeToupcamHandle(spec.device_id, spec.serial, spec.serial_raises) - sdk.handles[cam_id] = handle + sdk.handles[device_id] = handle return handle self.Toupcam = _FakeToupcam @@ -159,6 +175,45 @@ def test_open_index_zero_opens_first_device(fake_sdk): assert capabilities.binning_to_resolution == {(1, 1): (3000, 2000), (2, 2): (1500, 1000)} +def test_open_color_camera_selects_rgb_gain_white_balance(fake_sdk): + """A color camera must be opened in RGB gain mode. + + The SDK fixes the white balance mode at open time and the two modes are mutually + exclusive, so without the suffix the whole RGB gain API this driver uses (AwbInit, + get/put_WhiteBalanceGain) answers "not implemented". + """ + sdk = fake_sdk([FakeDeviceSpec("port-a", serial="SN-A", flag=0)]) + + ToupcamCamera._open(index=0) + + assert sdk.open_camids == ["port-a;wb=rgb"] + + +def test_open_mono_camera_has_no_white_balance_suffix(fake_sdk): + """Mono cameras have no white balance at all, so they are opened plain.""" + sdk = fake_sdk([FakeDeviceSpec("port-a", serial="SN-A", flag=real_toupcam.TOUPCAM_FLAG_MONO)]) + + _, capabilities = ToupcamCamera._open(index=0) + + assert capabilities.is_mono + assert sdk.open_camids == ["port-a"] + + +def test_open_by_serial_probe_keeps_rgb_gain_handle(fake_sdk): + """The handle kept from serial probing must already be in RGB gain mode. + + Probing opens each device to read its serial and hands the matching handle back to + the caller, so it has to be opened the same way _open would open it. + """ + sdk = fake_sdk([FakeDeviceSpec("port-a", serial="SN-A"), FakeDeviceSpec("port-b", serial="SN-B")]) + + camera, _ = ToupcamCamera._open(sn="SN-B") + + assert camera is sdk.handles["port-b"] + assert sdk.open_camids == ["port-a;wb=rgb", "port-b;wb=rgb"] + assert sdk.open_camids.count("port-b;wb=rgb") == 1, "the matched device must not be reopened" + + def test_open_index_one_opens_second_device(fake_sdk): sdk = fake_sdk([FakeDeviceSpec("port-a", serial="SN-A"), FakeDeviceSpec("port-b", serial="SN-B")]) @@ -414,6 +469,27 @@ def test_set_auto_white_balance_gains_on_triggers_sdk_awb(): assert result == (11, 22, 33) +def test_set_white_balance_gains_accepts_floats(): + """Cached gains come back off disk as floats; the SDK only takes ints. + + AbstractCamera types these parameters as float, and the settings cache round-trips + them through YAML, so the driver must convert rather than forward them. + """ + camera = _camera_with_fake_handle() + + camera.set_white_balance_gains(0.0, -63.0, -42.0) + + assert camera._camera.white_balance_gains == (0, -63, -42) + + +def test_set_white_balance_gains_rounds_rather_than_truncates(): + camera = _camera_with_fake_handle() + + camera.set_white_balance_gains(-62.6, 10.4, 2.5) + + assert camera._camera.white_balance_gains == (-63, 10, 2) + + def test_set_auto_white_balance_gains_off_does_not_trigger_awb(): camera = _camera_with_fake_handle() From 12c850d96ea21f2ac0b240afff428bfb21532d8b Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 13 Aug 2026 00:54:53 -0400 Subject: [PATCH 42/52] fix(widgets): stop the auto white balance button raising The handler called straight into the driver, so any failure escaped to the global excepthook and looked like a crash to the user. Two failures are routine rather than exceptional: the SDK computes the gains from live frames and errors when the camera is stopped, and not every camera model implements white balance at all. Refuse up front when the camera is not streaming, with a message that says so, and report driver errors rather than letting them propagate. The off path needs the same treatment as the on path - it reads the gains back, which fails just as readily on a camera that does not support them. Co-Authored-By: Claude Opus 5 (1M context) --- software/control/widgets.py | 28 ++++++++++---- software/tests/control/test_widgets.py | 52 ++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/software/control/widgets.py b/software/control/widgets.py index bf39c3eaf..7f2fa0286 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -4016,13 +4016,27 @@ def set_analog_gain_if_supported(self, gain): def toggle_auto_wb(self, pressed): # 0: OFF 1:CONTINUOUS 2:ONCE - if pressed: - # Run auto white balance once, then uncheck - self.camera.set_auto_white_balance_gains(on=True) - else: - self.camera.set_auto_white_balance_gains(on=False) - r, g, b = self.camera.get_white_balance_gains() - self.camera.set_white_balance_gains(r, g, b) + # The SDK works the gains out from live frames, so a stopped camera fails this + # with an unhelpful driver error. Say so plainly and leave the button unchecked. + if pressed and not self.camera.get_is_streaming(): + self._log.warning("Cannot run auto white balance while the camera is not streaming; start live first.") + self.btn_auto_wb.setChecked(False) + return + + # Any driver-level failure here (unsupported by this camera model, camera + # unplugged, ...) is reported and swallowed: an uncaught exception from a button + # press escapes to the global excepthook and looks like a crash to the user. + try: + if pressed: + # Run auto white balance once, then uncheck + self.camera.set_auto_white_balance_gains(on=True) + else: + self.camera.set_auto_white_balance_gains(on=False) + r, g, b = self.camera.get_white_balance_gains() + self.camera.set_white_balance_gains(r, g, b) + except Exception as e: + self._log.warning(f"Auto white balance failed on this camera: {e}") + self.btn_auto_wb.setChecked(False) def set_exposure_time(self, exposure_time): self.entry_exposureTime.setValue(exposure_time) diff --git a/software/tests/control/test_widgets.py b/software/tests/control/test_widgets.py index 51f2a5f38..bf561ea03 100644 --- a/software/tests/control/test_widgets.py +++ b/software/tests/control/test_widgets.py @@ -2609,3 +2609,55 @@ def test_error_messages_dont_increment_dropped_count(self, warning_widget): # But the error should be in the messages assert any(m["level"] == logging.ERROR for m in widget._messages) + + +# --------------------------------------------------------------------------- +# CameraSettingsWidget.toggle_auto_wb +# +# Called straight off the class with a mock self: the button press behaviour is what +# matters here, not the Qt widget tree, and constructing the real widget needs a camera. +# --------------------------------------------------------------------------- + + +def _auto_wb_widget(streaming=True): + widget = MagicMock() + widget.camera.get_is_streaming.return_value = streaming + return widget + + +def test_toggle_auto_wb_refuses_when_camera_not_streaming(): + """The SDK computes gains from live frames, so a stopped camera must not be asked.""" + widget = _auto_wb_widget(streaming=False) + + control.widgets.CameraSettingsWidget.toggle_auto_wb(widget, True) + + widget.camera.set_auto_white_balance_gains.assert_not_called() + widget.btn_auto_wb.setChecked.assert_called_once_with(False) + + +def test_toggle_auto_wb_runs_when_streaming(): + widget = _auto_wb_widget(streaming=True) + + control.widgets.CameraSettingsWidget.toggle_auto_wb(widget, True) + + widget.camera.set_auto_white_balance_gains.assert_called_once_with(on=True) + + +def test_toggle_auto_wb_swallows_driver_errors(): + """A driver failure must not escape to the global excepthook and look like a crash.""" + widget = _auto_wb_widget(streaming=True) + widget.camera.set_auto_white_balance_gains.side_effect = RuntimeError("Not implemented") + + control.widgets.CameraSettingsWidget.toggle_auto_wb(widget, True) + + widget.btn_auto_wb.setChecked.assert_called_once_with(False) + + +def test_toggle_auto_wb_off_path_swallows_driver_errors(): + """The off path reads gains back, which fails just as easily on an unsupported camera.""" + widget = _auto_wb_widget(streaming=True) + widget.camera.get_white_balance_gains.side_effect = RuntimeError("Not implemented") + + control.widgets.CameraSettingsWidget.toggle_auto_wb(widget, False) + + widget.camera.set_white_balance_gains.assert_not_called() From 1f029b251276e04f7a6a47f7d75e1756246a2984 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 13 Aug 2026 00:55:06 -0400 Subject: [PATCH 43/52] feat(camera): remember white balance gains across restarts Gains are per-camera identity state, so they join binning and pixel format in the existing per-serial cache instead of getting a mechanism of their own. They are read inside their own try: a mono camera raises on white balance, and that must not cost us the binning and pixel format already read successfully. Cache entries written before this simply load as None. The restore reports each value only when it actually applied. Printing the cached value regardless read as success even when the camera rejected it, which is exactly how a failing restore went unnoticed - and a failed restore of gains we did cache is a warning rather than a debug line, because the save on close then overwrites them with whatever the camera happens to hold. Tests also stop writing to the machine's cache/camera_settings.yaml. The GUI close path saves through it, so any test that built and closed a GUI replaced the developer's real per-camera settings with the test's simulated cameras - the same class of leak as isolate_ambient_user_profiles, for the one piece of ambient state that had not been covered. The fixture wraps the module functions rather than patching _DEFAULT_CACHE_PATH: that constant is bound as a default argument value at import time, so rebinding it would not change where any existing call goes. Calls passing an explicit cache_path are untouched. Co-Authored-By: Claude Opus 5 (1M context) --- software/control/gui_hcs.py | 38 +++++++-- software/squid/camera/settings_cache.py | 37 +++++++-- software/tests/conftest.py | 43 ++++++++++ software/tests/squid/test_settings_cache.py | 92 +++++++++++++++++++++ 4 files changed, 200 insertions(+), 10 deletions(-) diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 58d774f93..56d9585f0 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -1161,12 +1161,40 @@ def _restore_cached_camera_settings(self) -> None: binning_restored = self._restore_binning(camera, cached_settings.binning, sync_widget) pixel_format_restored = self._restore_pixel_format(camera, cached_settings.pixel_format, sync_widget) + # After the pixel format, since that is what decides whether this camera is + # delivering colour at all. + wb_restored = self._restore_white_balance_gains(camera, cached_settings.white_balance_gains) + + # Each value is reported only when it actually applied: printing the cached + # value regardless reads as success even when the camera rejected it. + restored = [] + if binning_restored: + restored.append(f"binning={cached_settings.binning}") + if pixel_format_restored: + restored.append(f"pixel_format={cached_settings.pixel_format}") + if wb_restored: + restored.append(f"white_balance_gains={cached_settings.white_balance_gains}") + + if restored: + self.log.info(f"Restored camera {camera_id} settings: {', '.join(restored)}") + + def _restore_white_balance_gains(self, camera: AbstractCamera, gains: Optional[Tuple[float, float, float]]) -> bool: + """Apply cached white balance gains to the given camera. + + Returns True if successfully applied, False otherwise. A camera with no white + balance (a mono one) has no cached gains and returns early, so reaching the + failure path means gains we *did* cache would not go back on - worth a warning, + because the save on close then overwrites them with whatever the camera holds. + """ + if gains is None: + return False - if binning_restored or pixel_format_restored: - self.log.info( - f"Restored camera {camera_id} settings: binning={cached_settings.binning}, " - f"pixel_format={cached_settings.pixel_format}" - ) + try: + camera.set_white_balance_gains(*gains) + except Exception as e: + self.log.warning(f"Could not restore white balance gains {gains} on this camera: {e}") + return False + return True def _restore_binning(self, camera: AbstractCamera, binning: Tuple[int, int], sync_widget: bool) -> bool: """Apply binning setting to the given camera, optionally syncing the UI dropdown. diff --git a/software/squid/camera/settings_cache.py b/software/squid/camera/settings_cache.py index 1d4ab85be..3745632b5 100644 --- a/software/squid/camera/settings_cache.py +++ b/software/squid/camera/settings_cache.py @@ -1,8 +1,8 @@ """Camera settings persistence for session continuity. -This module provides save/load functionality for camera settings (binning, pixel format) -to maintain user preferences across application restarts. Settings are stored as YAML -in the cache directory. +This module provides save/load functionality for camera settings (binning, pixel format, +white balance gains) to maintain user preferences across application restarts. Settings +are stored as YAML in the cache directory. The on-disk format is keyed by camera serial number so that a multi-camera system keeps one entry per camera: @@ -10,7 +10,10 @@ version: 2 cameras: SN1: {binning: [2, 2], pixel_format: MONO8} - SN2: {binning: [1, 1], pixel_format: MONO12} + SN2: {binning: [1, 1], pixel_format: RGB24, white_balance_gains: [25, -10, 40]} + +white_balance_gains is absent for cameras that have no white balance (mono cameras), +and entries written before it existed simply load as None. Cameras without a serial number (INI-only configurations) are stored under the "default" key. Files written by older versions are a flat mapping without the @@ -52,16 +55,21 @@ class CachedCameraSettings: binning: Tuple of (x, y) binning factors. Must be positive integers. pixel_format: String representation of CameraPixelFormat enum value, or None if not cached. + white_balance_gains: (R, G, B) white balance gains, or None when the camera has + no white balance (mono cameras) or nothing was cached. """ binning: Tuple[int, int] pixel_format: Optional[str] + white_balance_gains: Optional[Tuple[float, float, float]] = None def __post_init__(self): if len(self.binning) != 2: raise ValueError(f"Binning must be a 2-tuple, got {self.binning}") if self.binning[0] < 1 or self.binning[1] < 1: raise ValueError(f"Binning values must be positive, got {self.binning}") + if self.white_balance_gains is not None and len(self.white_balance_gains) != 3: + raise ValueError(f"White balance gains must be a 3-tuple, got {self.white_balance_gains}") def _serial_key(camera: AbstractCamera) -> str: @@ -84,7 +92,17 @@ def _settings_dict_for(camera: AbstractCamera) -> Optional[dict]: except Exception as e: _log.error(f"Cannot read camera settings - camera may be disconnected: {e}") return None - return {"binning": list(binning), "pixel_format": pixel_format.value if pixel_format else None} + + settings = {"binning": list(binning), "pixel_format": pixel_format.value if pixel_format else None} + + # Read in its own try: a mono camera has no white balance and raises here, which must + # not cost us the binning and pixel format we already read successfully. + try: + settings["white_balance_gains"] = list(camera.get_white_balance_gains()) + except Exception as e: + _log.debug(f"Not caching white balance gains - camera does not provide them: {e}") + + return settings def save_all_camera_settings(cameras: Dict[int, AbstractCamera], cache_path: Path = _DEFAULT_CACHE_PATH) -> None: @@ -230,9 +248,18 @@ def load_camera_settings( _log.warning("Camera settings cache missing 'binning' key - using default") binning_raw = list(DEFAULT_BINNING) + gains_raw = settings.get("white_balance_gains") + gains = None + if gains_raw is not None: + if isinstance(gains_raw, list) and len(gains_raw) == 3: + gains = (float(gains_raw[0]), float(gains_raw[1]), float(gains_raw[2])) + else: + _log.warning(f"Invalid white balance gains in cache: {gains_raw} - ignoring") + return CachedCameraSettings( binning=(int(binning_raw[0]), int(binning_raw[1])), pixel_format=settings.get("pixel_format"), + white_balance_gains=gains, ) except (TypeError, ValueError) as e: _log.error(f"Camera settings cache contains invalid data: {e}") diff --git a/software/tests/conftest.py b/software/tests/conftest.py index 37add4fb5..dbe4c354b 100644 --- a/software/tests/conftest.py +++ b/software/tests/conftest.py @@ -104,6 +104,49 @@ def _get_camera_registry(self): monkeypatch.setattr(ConfigRepository, "get_camera_registry", _get_camera_registry) +@pytest.fixture(autouse=True) +def isolate_ambient_camera_settings_cache(monkeypatch, tmp_path_factory): + """Keep tests out of the machine's cache/camera_settings.yaml. + + That file is gitignored, machine-specific, and written by the running application: + HighContentScreeningGui's close path calls save_all_camera_settings(). Any test that + builds and closes a GUI therefore overwrote the developer's real per-camera binning, + pixel format and white balance with the test's simulated cameras - the GUI tests use + serials "SIM-1"/"SIM-2", which is exactly what such a clobbered file ends up holding. + + The redirect wraps the three module functions rather than patching + _DEFAULT_CACHE_PATH: that constant is bound as a default argument value at import + time, so rebinding the module attribute would not change where an existing call goes. + + Calls that pass an explicit cache_path (the settings-cache tests use tmp_path) are + handed straight through, as in isolate_ambient_camera_registry. + """ + import squid.camera.settings_cache as settings_cache + + default_path = settings_cache._DEFAULT_CACHE_PATH + isolated_path = tmp_path_factory.mktemp("camera_settings_cache") / "camera_settings.yaml" + + original_save_all = settings_cache.save_all_camera_settings + original_save = settings_cache.save_camera_settings + original_load = settings_cache.load_camera_settings + + def _redirected(cache_path): + return isolated_path if cache_path == default_path else cache_path + + def _save_all(cameras, cache_path=default_path): + return original_save_all(cameras, cache_path=_redirected(cache_path)) + + def _save(camera, cache_path=default_path): + return original_save(camera, cache_path=_redirected(cache_path)) + + def _load(cache_path=default_path, *, serial=None): + return original_load(_redirected(cache_path), serial=serial) + + monkeypatch.setattr(settings_cache, "save_all_camera_settings", _save_all) + monkeypatch.setattr(settings_cache, "save_camera_settings", _save) + monkeypatch.setattr(settings_cache, "load_camera_settings", _load) + + @pytest.fixture(scope="session") def canonical_user_profiles_template(tmp_path_factory): """A freshly generated "default" profile to copy per test, or None if it can't be made. diff --git a/software/tests/squid/test_settings_cache.py b/software/tests/squid/test_settings_cache.py index 00c3f191b..ff7688f4c 100644 --- a/software/tests/squid/test_settings_cache.py +++ b/software/tests/squid/test_settings_cache.py @@ -345,3 +345,95 @@ def _raise(): save_all_camera_settings({1: broken}, cache_path=cache) assert cache.read_text() == original + + +# --------------------------------------------------------------------------- +# White balance gains +# --------------------------------------------------------------------------- + + +def test_white_balance_gains_round_trip(tmp_path, monkeypatch): + """A colour camera's gains survive a save/load cycle.""" + from squid.camera.settings_cache import load_camera_settings, save_all_camera_settings + + cache = tmp_path / "camera_settings.yaml" + camera = _sim_with_serial("SN-COLOR") + monkeypatch.setattr(camera, "get_white_balance_gains", lambda: (25, -10, 40)) + + save_all_camera_settings({1: camera}, cache_path=cache) + + assert load_camera_settings(serial="SN-COLOR", cache_path=cache).white_balance_gains == (25.0, -10.0, 40.0) + + +def test_camera_without_white_balance_still_caches_other_settings(tmp_path, monkeypatch): + """A mono camera raises on white balance; binning and pixel format must survive.""" + from squid.camera.settings_cache import load_camera_settings, save_all_camera_settings + + def _raise(): + raise RuntimeError("Not implemented") + + cache = tmp_path / "camera_settings.yaml" + camera = _sim_with_serial("SN-MONO") + camera.set_binning(2, 2) + monkeypatch.setattr(camera, "get_white_balance_gains", _raise) + + save_all_camera_settings({1: camera}, cache_path=cache) + + settings = load_camera_settings(serial="SN-MONO", cache_path=cache) + assert settings.binning == (2, 2) + assert settings.white_balance_gains is None + assert "white_balance_gains" not in yaml.safe_load(cache.read_text())["cameras"]["SN-MONO"] + + +def test_cache_without_white_balance_key_loads_as_none(tmp_path): + """Entries written before white balance was cached must still load.""" + from squid.camera.settings_cache import load_camera_settings + + cache = tmp_path / "camera_settings.yaml" + cache.write_text("version: 2\ncameras:\n SN1: {binning: [2, 2], pixel_format: MONO16}\n") + + settings = load_camera_settings(serial="SN1", cache_path=cache) + assert settings.binning == (2, 2) + assert settings.white_balance_gains is None + + +def test_malformed_white_balance_gains_are_ignored(tmp_path): + """Bad gains must not throw away the rest of the entry.""" + from squid.camera.settings_cache import load_camera_settings + + cache = tmp_path / "camera_settings.yaml" + cache.write_text("version: 2\ncameras:\n SN1: {binning: [2, 2], white_balance_gains: [1, 2]}\n") + + settings = load_camera_settings(serial="SN1", cache_path=cache) + assert settings is not None + assert settings.binning == (2, 2) + assert settings.white_balance_gains is None + + +def test_cached_settings_rejects_wrong_length_gains(): + from squid.camera.settings_cache import CachedCameraSettings + + with pytest.raises(ValueError): + CachedCameraSettings(binning=(1, 1), pixel_format=None, white_balance_gains=(1.0, 2.0)) + + +def test_default_path_saves_never_touch_the_real_cache(): + """The autouse isolation fixture must keep default-path writes off the machine's file. + + Goes through the module attribute rather than an imported name, because that is how + the application calls it (and how the fixture's redirect is reached). Without the + fixture this test rewrites the developer's own cache/camera_settings.yaml. + """ + import squid.camera.settings_cache as settings_cache + + real_path = settings_cache._DEFAULT_CACHE_PATH + before = real_path.read_text() if real_path.exists() else None + + camera = _sim_with_serial("SN-LEAK-CHECK") + settings_cache.save_all_camera_settings({1: camera}) # no cache_path -> the default + + after = real_path.read_text() if real_path.exists() else None + assert after == before, "a default-path save reached the machine's real camera settings cache" + + # ...and the save still worked, just somewhere harmless. + assert settings_cache.load_camera_settings(serial="SN-LEAK-CHECK") is not None From 77b0111ef27f5daa580c209da73cfe4b8d550c22 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 13 Aug 2026 01:43:11 -0400 Subject: [PATCH 44/52] feat(objectives): let a machine override objectives.csv from cache tube_lens_f_mm describes the objectives actually fitted, not a property of the software: Nikon objectives are designed for a 200mm tube lens and Olympus for 180mm, and the effective magnification is the nominal one scaled by installed/design. objective_and_sample_formats/objectives.csv is checked in and shared by every machine, so a system running Nikon objectives on an Olympus tube lens had nowhere to say so - editing the shared file would push one machine's optics onto all of them. Sample formats already resolve cache first and fall back to the checked-in default; objectives now do the same, in the same function. cache/ is gitignored, so machine-specific optics stay local. Getting this wrong is quiet rather than loud: everything derived from pixel size - scan grid spacing, stitching, scale bars, click-to-move distances, recorded metadata - is off by the ratio, 11% for Nikon on an Olympus tube lens, with nothing to indicate it. Co-Authored-By: Claude Opus 5 (1M context) --- software/control/_def.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index 5890ef86a..294361df1 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -1168,12 +1168,23 @@ def read_sample_formats_csv(file_path): def load_formats(): - """Load formats, prioritizing cache for sample formats.""" + """Load formats, prioritizing cache for objectives and sample formats.""" cache_path = "cache" default_path = "objective_and_sample_formats" - # Load objectives (from default location) - objectives = read_objectives_csv(os.path.join(default_path, "objectives.csv")) + # Try cache first for objectives, fall back to default if not found. tube_lens_f_mm + # is a property of the objectives actually fitted - Nikon are designed for a 200mm + # tube lens, Olympus for 180mm - so a machine whose optics differ from the checked-in + # default needs its own copy rather than a change every other machine inherits. + cached_objectives_path = os.path.join(cache_path, "objectives.csv") + default_objectives_path = os.path.join(default_path, "objectives.csv") + + if os.path.exists(cached_objectives_path): + print("Using cached objectives") + objectives = read_objectives_csv(cached_objectives_path) + else: + print("Using default objectives") + objectives = read_objectives_csv(default_objectives_path) # Try cache first for sample formats, fall back to default if not found cached_formats_path = os.path.join(cache_path, "sample_formats.csv") From 14715765d3d9d194553304f34a78c5481bbb9e27 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 13 Aug 2026 04:30:48 -0400 Subject: [PATCH 45/52] fix(click-to-move): undo the display rotation before moving the stage A click arrives as a displacement from the centre of the *displayed* image, which rotate_and_flip_image has already rotated and flipped, while the stage moves along sensor axes. The handler passed those display coordinates straight to move_x/move_y, so on a rotated view the stage set off along the wrong axis entirely - a 90 degree view turns a horizontal click into vertical travel. display_to_sensor_displacement() inverts that transform and lives beside the forward one, so the two stay together. It takes a displacement rather than a point: both operations are about the image centre, so a centre-relative vector needs only the linear part and no translation, which is why it needs no image size. Exposed as a camera method and delegated by the facade rather than read off a config in the GUI. The facade deliberately holds no _config, so reaching through it would raise AttributeError on a multi-camera system - and the rotation and flip are per-camera anyway, since two cameras can be mounted differently, so the correction has to come from whichever one produced the image. Verified by round-tripping against the real transform - mark a pixel, run the frame through the display path, measure where it landed, invert - for all four rotations against all four flips, rather than re-deriving the algebra in the test and hoping both derivations agree. Co-Authored-By: Claude Opus 5 (1M context) --- software/control/gui_hcs.py | 5 +++ software/control/utils.py | 40 ++++++++++++++++++ software/squid/abc.py | 13 ++++++ software/squid/camera/facade.py | 5 +++ software/tests/control/test_utils.py | 49 ++++++++++++++++++++++ software/tests/squid/test_camera_facade.py | 29 ++++++++++++- 6 files changed, 140 insertions(+), 1 deletion(-) diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 56d9585f0..aaf2f34f6 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -2879,6 +2879,11 @@ def move_from_click_image(self, click_x, click_y, image_width, image_height): self.log.warning("Click to move: pixel size unavailable, ignoring click") return + # The click is measured on the displayed image, which is rotated and flipped + # relative to the sensor, while the stage moves along sensor axes. Without this + # a rotated view sends the stage off along the wrong axis. + click_x, click_y = self.microscope.camera.display_to_sensor_displacement(click_x, click_y) + pixel_sign_y = 1 if INVERTED_OBJECTIVE else -1 delta_x_mm = pixel_size_um * click_x / 1000.0 delta_y_mm = pixel_sign_y * pixel_size_um * click_y / 1000.0 diff --git a/software/control/utils.py b/software/control/utils.py index 3bbb52467..4dfdb13c9 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -110,6 +110,46 @@ def rotate_and_flip_image(image, rotate_image_angle: float, flip_image: Optional return ret_image +def display_to_sensor_displacement( + dx: float, dy: float, rotate_image_angle: Optional[float], flip_image: Optional[FlipVariant] +) -> Tuple[float, float]: + """Map a displacement read off the displayed image back onto raw sensor axes. + + rotate_and_flip_image() is what turns a raw frame into what the user sees, so + anything that measures a position on screen - click to move, for one - is working in + rotated and flipped coordinates while the stage moves along sensor axes. This + inverts that transform. + + Only a displacement is handled, not a point: the rotation and flip are both about + the image centre, so a centre-relative vector needs the linear part alone and no + translation. Sizes never enter into it, which is why nothing here takes an image + width or height. + """ + # The flip is applied last on the way out, so it comes off first here. Each flip is + # its own inverse. + if flip_image is not None: + if flip_image == FlipVariant.VERTICAL: + dy = -dy + elif flip_image == FlipVariant.HORIZONTAL: + dx = -dx + elif flip_image == FlipVariant.BOTH: + dx, dy = -dx, -dy + + # cv2 rotates clockwise about the centre with y running down the image, so the + # forward map for 90 is (x, y) -> (-y, x); these are its inverses. + if rotate_image_angle and rotate_image_angle != 0: + if rotate_image_angle == 90: + dx, dy = dy, -dx + elif rotate_image_angle == -90: + dx, dy = -dy, dx + elif rotate_image_angle == 180: + dx, dy = -dx, -dy + else: + raise ValueError(f"Unhandled rotation: {rotate_image_angle}") + + return dx, dy + + def generate_dpc(im_left, im_right): # Normalize the images im_left = im_left.astype(float) / 255 diff --git a/software/squid/abc.py b/software/squid/abc.py index 729111d2b..d615840d8 100644 --- a/software/squid/abc.py +++ b/software/squid/abc.py @@ -685,6 +685,19 @@ def _process_raw_frame(self, raw_frame: np.array) -> np.array: return image + def display_to_sensor_displacement(self, dx: float, dy: float) -> Tuple[float, float]: + """Map a displacement measured on this camera's displayed image onto sensor axes. + + The inverse of the rotate/flip _process_raw_image applies. Callers that read a + position off screen and then drive the stage need this, because the stage moves + along sensor axes. It lives on the camera because the rotation and flip are + per-camera: on a multi-camera system each one can be mounted differently, so the + correction has to come from whichever camera produced the image. + """ + return control.utils.display_to_sensor_displacement( + dx, dy, rotate_image_angle=self._config.rotate_image_angle, flip_image=self._config.flip + ) + def get_crop_size(self) -> Tuple[int, int]: """ Returns the final crop size of the image (after software crop). diff --git a/software/squid/camera/facade.py b/software/squid/camera/facade.py index 432914516..103cd7fc1 100644 --- a/software/squid/camera/facade.py +++ b/software/squid/camera/facade.py @@ -169,6 +169,11 @@ def get_is_streaming(self): def get_crop_size(self) -> Tuple[int, int]: return self._active().get_crop_size() + def display_to_sensor_displacement(self, dx: float, dy: float) -> Tuple[float, float]: + # Must delegate: the base implementation reads self._config, which the facade + # does not have, and the rotation/flip differ per camera anyway. + return self._active().display_to_sensor_displacement(dx, dy) + def get_fov_size_mm(self) -> float: return self._active().get_fov_size_mm() diff --git a/software/tests/control/test_utils.py b/software/tests/control/test_utils.py index 41d90236a..048c3094d 100644 --- a/software/tests/control/test_utils.py +++ b/software/tests/control/test_utils.py @@ -5,6 +5,12 @@ import threading import time +import numpy as np +import pytest + +from control.utils import display_to_sensor_displacement, rotate_and_flip_image +from squid.config import FlipVariant + def test_squid_repo_info(): # At least make sure we get something and that it calls without issue. @@ -212,3 +218,46 @@ def test_callback(success, error_msg): # Verify results assert operation_result == [("value1", "value2")] assert callback_result == [(True, None)] + + +# --------------------------------------------------------------------------- +# display_to_sensor_displacement +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("rotate", [None, 90, -90, 180]) +@pytest.mark.parametrize("flip", [None, FlipVariant.VERTICAL, FlipVariant.HORIZONTAL, FlipVariant.BOTH]) +@pytest.mark.parametrize("marker", [(0, 0), (1, 5), (4, 2), (2, 3)]) +def test_display_to_sensor_displacement_inverts_the_display_transform(rotate, flip, marker): + """Round-trip against the real transform rather than re-deriving the maths. + + A pixel is marked in a raw frame, the frame is put through the display transform, + and the inverse must recover the mark's original centre-relative offset. Odd + dimensions keep the centre on an exact pixel. + """ + height, width = 5, 7 + row, col = marker + raw = np.zeros((height, width), dtype=np.uint8) + raw[row, col] = 255 + expected = (col - (width - 1) / 2, row - (height - 1) / 2) + + displayed = rotate_and_flip_image(raw, rotate, flip) + disp_h, disp_w = displayed.shape + disp_row, disp_col = (int(v) for v in np.argwhere(displayed == 255)[0]) + seen = (disp_col - (disp_w - 1) / 2, disp_row - (disp_h - 1) / 2) + + assert display_to_sensor_displacement(*seen, rotate, flip) == pytest.approx(expected) + + +def test_display_to_sensor_displacement_is_identity_without_rotation_or_flip(): + assert display_to_sensor_displacement(12.0, -7.0, None, None) == (12.0, -7.0) + + +def test_display_to_sensor_displacement_swaps_axes_for_90(): + """A 90 degree view turns a horizontal click into vertical stage travel.""" + assert display_to_sensor_displacement(10.0, 0.0, 90, None) == (0.0, -10.0) + + +def test_display_to_sensor_displacement_rejects_unhandled_rotation(): + with pytest.raises(ValueError): + display_to_sensor_displacement(1.0, 1.0, 45, None) diff --git a/software/tests/squid/test_camera_facade.py b/software/tests/squid/test_camera_facade.py index 7a48c9f42..5f100cc24 100644 --- a/software/tests/squid/test_camera_facade.py +++ b/software/tests/squid/test_camera_facade.py @@ -4,7 +4,7 @@ from squid.abc import CameraAcquisitionMode, CameraFrame from squid.camera.facade import ActiveCameraFacade from squid.camera.utils import SimulatedCamera -from squid.config import CameraPixelFormat +from squid.config import CameraPixelFormat, FlipVariant def make_sim(serial, pixel_format=CameraPixelFormat.MONO16, hw=False): @@ -128,3 +128,30 @@ def test_close_closes_all(cameras): facade = ActiveCameraFacade(cameras, active_id=1) facade.close() assert sorted(closed) == [1, 2] + + +def test_display_to_sensor_displacement_follows_the_active_camera(): + """Each camera can be mounted differently, so the correction must track the switch. + + The facade owns no config of its own, so this also pins that it delegates rather + than falling through to the base implementation and its missing self._config. + """ + cam1 = make_sim("SN1") + cam2 = make_sim("SN2") + # Same rotation, different flips - as on a rig where one sensor is mounted mirrored. + # The probe vector needs both components: these two flips differ only vertically. + cam1._config = cam1._config.model_copy(update={"rotate_image_angle": 90, "flip": FlipVariant.BOTH}) + cam2._config = cam2._config.model_copy(update={"rotate_image_angle": 90, "flip": FlipVariant.HORIZONTAL}) + try: + facade = ActiveCameraFacade({1: cam1, 2: cam2}, active_id=1) + + assert facade.display_to_sensor_displacement(10.0, 4.0) == cam1.display_to_sensor_displacement(10.0, 4.0) + + facade.set_active(2) + assert facade.display_to_sensor_displacement(10.0, 4.0) == cam2.display_to_sensor_displacement(10.0, 4.0) + + # The two must genuinely differ, otherwise the assertions above prove nothing. + assert cam1.display_to_sensor_displacement(10.0, 4.0) != cam2.display_to_sensor_displacement(10.0, 4.0) + finally: + cam1.close() + cam2.close() From b14ee547c65ce90e90ec5a4bd6484deabd0f897f Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 13 Aug 2026 04:31:03 -0400 Subject: [PATCH 46/52] feat(mosaic): save colour layers as PNG instead of dropping them The mosaic overview is one OME-TIFF with a plane per channel, (C, Y, X). A colour layer is (H, W, 3) and cannot be a member of that stack, so colour was skipped from the save with a warning - once per timepoint during an acquisition, since save_for_timepoint goes through the same writer. On a machine whose second camera runs RGB24 that meant the colour channel was simply absent from every overview. Colour layers are now written one PNG each, alongside the mono TIFF, for both the whole view and the per-well crops. PNG cannot carry a pixel size, so theirs is recorded only in the YAML sidecar next to the TIFF's embedded OME metadata; a consumer needs the sidecar to scale them. Also fixes a colour-only acquisition saving nothing whatsoever: with no mono layers to stack, the save bailed out early and no overview was produced at all. squid holds colour frames as RGB and cv2 writes BGR, so the write converts, as the tracking and streaming save paths already do. A test pins an exact pixel round-trip - getting this wrong swaps red and blue and reports no error. Co-Authored-By: Claude Opus 5 (1M context) --- software/control/widgets_mosaic.py | 68 +++++++++++-- .../control/test_unified_mosaic_widget.py | 97 +++++++++++++++++++ 2 files changed, 158 insertions(+), 7 deletions(-) diff --git a/software/control/widgets_mosaic.py b/software/control/widgets_mosaic.py index 5c91d5256..370ed85ac 100644 --- a/software/control/widgets_mosaic.py +++ b/software/control/widgets_mosaic.py @@ -12,6 +12,7 @@ from concurrent.futures import ThreadPoolExecutor from typing import List, Optional, Tuple +import cv2 import numpy as np import tifffile import yaml @@ -870,20 +871,24 @@ def _snapshot_for_save(self) -> Optional[dict]: if resolution_um is None: self._log.warning("Save skipped: viewer_pixel_size_mm is unset.") return None + # Mono and colour are separated here because they leave by different routes: the + # mono channels are stacked into one (C, Y, X) OME-TIFF, which an (H, W, 3) colour + # layer cannot join, so colour layers are written as PNGs instead. channels = [] + rgb_channels = [] for layer in self._image_layers(): if layer.data.ndim == 3 and layer.data.shape[2] == 3: - # Defer RGB save support — see plan R7. - self._log.warning(f"Skipping RGB layer '{layer.name}' from save (not supported yet).") + rgb_channels.append((layer.name, np.array(layer.data, copy=True))) continue channels.append((layer.name, np.array(layer.data, copy=True))) - if not channels: - self._log.warning("Save skipped: no monochrome image layers present.") + if not channels and not rgb_channels: + self._log.warning("Save skipped: no image layers present.") return None snapshot = { "mode": self.mode.value, "resolution_um": resolution_um, "channels": channels, + "rgb_channels": rgb_channels, "saved_at": time.strftime("%Y-%m-%dT%H:%M:%S"), # Capture the flag values now so toggling them between snapshot and # write doesn't leave the sidecar describing one thing and the @@ -917,14 +922,24 @@ def _write_save_snapshot(self, target_dir: str, snapshot: dict) -> None: resolution_um = snapshot["resolution_um"] res_tag = f"{int(round(resolution_um))}um" channels = snapshot["channels"] + rgb_channels = snapshot.get("rgb_channels") or [] channel_names = [name for name, _ in channels] - sidecar = {k: v for k, v in snapshot.items() if k != "channels"} + sidecar = {k: v for k, v in snapshot.items() if k not in ("channels", "rgb_channels")} sidecar["channel_names"] = channel_names + if rgb_channels: + sidecar["rgb_channel_names"] = [name for name, _ in rgb_channels] save_overview = snapshot["save_overview"] save_per_well = snapshot["save_per_well"] and mode == DisplayMode.PLATE.value - if save_overview: + if save_overview and rgb_channels: + sidecar["rgb_view_files"] = self._write_rgb_pngs( + target_dir, rgb_channels, f"mosaic_{mode}_{res_tag}", resolution_um + ) + + # Guarded on `channels`: a colour-only acquisition has nothing to stack, and + # np.stack would raise on the empty list. + if save_overview and channels: stack = np.stack([data for _, data in channels], axis=0) # (C, H, W) whole_path = os.path.join(target_dir, f"mosaic_{mode}_{res_tag}.ome.tiff") tifffile.imwrite( @@ -953,21 +968,60 @@ def _write_save_snapshot(self, target_dir: str, snapshot: dict) -> None: except Exception: self._log.exception(f"Mosaic-view save failed for {target_dir}") + def _write_rgb_pngs(self, target_dir: str, rgb_channels: list, name_prefix: str, resolution_um: float) -> List[str]: + """Write each colour layer as its own PNG and return the filenames written. + + PNG rather than a channel of the OME-TIFF: that stack is (C, Y, X) with one plane + per channel, which an (H, W, 3) colour image cannot be a member of. One file per + colour layer keeps the colour together and stays readable anywhere. + + The pixel size cannot be carried inside a PNG, so it is only recorded in the YAML + sidecar alongside the mono TIFF's - a consumer needs the sidecar to scale these. + """ + written = [] + for name, data in rgb_channels: + safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in name) + path = os.path.join(target_dir, f"{name_prefix}_{safe_name}.png") + # squid holds colour frames as RGB; cv2 writes the array as BGR. + bgr = cv2.cvtColor(data, cv2.COLOR_RGB2BGR) + if not cv2.imwrite(path, bgr): + self._log.error(f"Failed to write RGB layer '{name}' to {path}") + continue + written.append(os.path.basename(path)) + self._log.info(f"Saved RGB view: {path} ({data.shape}, {resolution_um:.3f} um/px)") + return written + def _write_per_well_tiffs(self, target_dir: str, snapshot: dict, res_tag: str) -> None: """Plate-mode helper: crop each well's slot from the channel stack and - write one multi-channel TIFF per well.""" + write one multi-channel TIFF per well. Colour layers are cropped the same way but + written as one PNG per well per layer, for the same reason as the whole view.""" plate = snapshot.get("plate") or {} slot_h, slot_w = plate.get("well_slot_shape_px", (0, 0)) if slot_h == 0 or slot_w == 0: return wells_dir = os.path.join(target_dir, "wells") ensure_directory_exists(wells_dir) + rgb_channels = snapshot.get("rgb_channels") or [] for well_id in plate.get("well_ids", []): try: row, col = parse_well_id(well_id) except (ValueError, TypeError): self._log.warning(f"Skipping per-well save for unparseable well_id '{well_id}'") continue + + if rgb_channels: + y_start = row * slot_h + x_start = col * slot_w + well_rgb = [ + (name, data[y_start : y_start + slot_h, x_start : x_start + slot_w]) for name, data in rgb_channels + ] + self._write_rgb_pngs( + wells_dir, + [(name, crop) for name, crop in well_rgb if crop.size], + f"{well_id}_{res_tag}", + snapshot["resolution_um"], + ) + crops = [] for _, data in snapshot["channels"]: y_start = row * slot_h diff --git a/software/tests/control/test_unified_mosaic_widget.py b/software/tests/control/test_unified_mosaic_widget.py index 38d2c2cab..63809939a 100644 --- a/software/tests/control/test_unified_mosaic_widget.py +++ b/software/tests/control/test_unified_mosaic_widget.py @@ -204,3 +204,100 @@ def test_a_user_drag_in_the_mosaic_is_recorded_with_the_view_dtype(self, widget_ # taken literally as uint16 values. converted = contrast.get_limits_for_dtype("BF", np.uint16) assert converted == pytest.approx((5140.0, 61680.0), rel=0.01) + + +class TestRgbSaveAsPng: + """Colour layers are saved as PNG. + + They cannot join the overview's (C, Y, X) OME-TIFF stack - a colour plane is + (H, W, 3) - so they are written alongside it instead of being dropped. + """ + + @staticmethod + def _snapshot(mono, rgb, per_well=False, plate=None): + snapshot = { + "mode": "plate", + "resolution_um": 5.0, + "channels": mono, + "rgb_channels": rgb, + "saved_at": "now", + "save_overview": True, + "save_per_well": per_well, + } + if plate: + snapshot["plate"] = plate + return snapshot + + @staticmethod + def _sidecar(target): + import yaml + + name = next(p for p in target.iterdir() if p.suffix == ".yaml") + return yaml.safe_load(name.read_text()) + + def test_rgb_layer_is_written_as_png_beside_the_mono_tiff(self, mosaic_widget, tmp_path): + widget, _ = mosaic_widget + mono = [("Fluorescence 405 nm Ex", np.zeros((8, 12), np.uint16))] + rgb = [("BF LED matrix full", np.zeros((8, 12, 3), np.uint8))] + + widget._write_save_snapshot(str(tmp_path), self._snapshot(mono, rgb)) + + pngs = sorted(p.name for p in tmp_path.glob("*.png")) + assert pngs == ["mosaic_plate_5um_BF_LED_matrix_full.png"] + assert list(tmp_path.glob("*.ome.tiff")), "the mono stack must still be written" + sidecar = self._sidecar(tmp_path) + assert sidecar["rgb_channel_names"] == ["BF LED matrix full"] + assert sidecar["rgb_view_files"] == pngs + + def test_colour_only_acquisition_still_saves(self, mosaic_widget, tmp_path): + """Previously this produced nothing: no mono layers meant the save was skipped.""" + widget, _ = mosaic_widget + rgb = [("BF LED matrix full", np.zeros((8, 12, 3), np.uint8))] + + widget._write_save_snapshot(str(tmp_path), self._snapshot([], rgb)) + + assert list(tmp_path.glob("*.png")) + assert not list(tmp_path.glob("*.ome.tiff")), "nothing to stack, so no TIFF" + assert self._sidecar(tmp_path)["channel_names"] == [] + + def test_mono_only_save_is_unchanged(self, mosaic_widget, tmp_path): + widget, _ = mosaic_widget + mono = [("Fluorescence 405 nm Ex", np.zeros((8, 12), np.uint16))] + + widget._write_save_snapshot(str(tmp_path), self._snapshot(mono, [])) + + assert list(tmp_path.glob("*.ome.tiff")) + assert not list(tmp_path.glob("*.png")) + sidecar = self._sidecar(tmp_path) + assert "rgb_channel_names" not in sidecar + assert "rgb_view_files" not in sidecar + + def test_png_keeps_channel_order(self, mosaic_widget, tmp_path): + """squid holds colour as RGB and cv2 writes BGR, so the conversion must happen.""" + import cv2 + + widget, _ = mosaic_widget + image = np.zeros((3, 4, 3), dtype=np.uint8) + image[0, :] = (255, 0, 0) # red + image[2, :] = (0, 0, 255) # blue + + widget._write_save_snapshot(str(tmp_path), self._snapshot([], [("BF", image)])) + + written = next(tmp_path.glob("*.png")) + read_back = cv2.cvtColor(cv2.imread(str(written), cv2.IMREAD_COLOR), cv2.COLOR_BGR2RGB) + assert np.array_equal(read_back, image), "red and blue must not be swapped" + + def test_per_well_writes_a_png_per_well(self, mosaic_widget, tmp_path): + widget, _ = mosaic_widget + mono = [("Fluorescence 405 nm Ex", np.zeros((8, 12), np.uint16))] + rgb = [("BF LED matrix full", np.zeros((8, 12, 3), np.uint8))] + plate = {"well_slot_shape_px": [4, 6], "well_ids": ["A1", "A2"]} + + widget._write_save_snapshot(str(tmp_path), self._snapshot(mono, rgb, per_well=True, plate=plate)) + + wells = tmp_path / "wells" + assert sorted(p.name for p in wells.glob("*.png")) == [ + "A1_5um_BF_LED_matrix_full.png", + "A2_5um_BF_LED_matrix_full.png", + ] + assert sorted(p.name for p in wells.glob("*.tiff")) == ["A1_5um.tiff", "A2_5um.tiff"] From 5be0774795aef4807619d1d39bff6a7fd2d11a65 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 13 Aug 2026 06:09:17 -0400 Subject: [PATCH 47/52] Revert "fix(click-to-move): undo the display rotation before moving the stage" This reverts commit 14715765. The premise was wrong: it assumed the stage axes line up with the raw sensor, so a click read off a rotated display had to be un-rotated before driving the stage. On a machine that needs rotate_image_angle at all, the opposite is true. The rotation is there precisely because the sensor is mounted rotated, so the setting is what brings sensor and stage into agreement - the displayed image is already in the stage frame and a click on it needs no correction. Applying the inverse rotated it back out of alignment, and the stage travelled 90 degrees off. Removing it again centres the clicked feature, confirmed on the dual-camera rig with rotate=90 and flip=Both. The transform itself was correct - a genuine inverse of rotate_and_flip_image, pinned by a round-trip test over all four rotations and four flips. That is the point worth remembering: the maths was verified and the tests passed, and it was still the wrong thing to do, because no test of the transform can check which frame the stage actually moves in. Before reinstating anything like this, measure it: move the stage a known distance and see which way image content shifts. Co-Authored-By: Claude Opus 5 (1M context) --- software/control/gui_hcs.py | 5 --- software/control/utils.py | 40 ------------------ software/squid/abc.py | 13 ------ software/squid/camera/facade.py | 5 --- software/tests/control/test_utils.py | 49 ---------------------- software/tests/squid/test_camera_facade.py | 29 +------------ 6 files changed, 1 insertion(+), 140 deletions(-) diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index aaf2f34f6..56d9585f0 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -2879,11 +2879,6 @@ def move_from_click_image(self, click_x, click_y, image_width, image_height): self.log.warning("Click to move: pixel size unavailable, ignoring click") return - # The click is measured on the displayed image, which is rotated and flipped - # relative to the sensor, while the stage moves along sensor axes. Without this - # a rotated view sends the stage off along the wrong axis. - click_x, click_y = self.microscope.camera.display_to_sensor_displacement(click_x, click_y) - pixel_sign_y = 1 if INVERTED_OBJECTIVE else -1 delta_x_mm = pixel_size_um * click_x / 1000.0 delta_y_mm = pixel_sign_y * pixel_size_um * click_y / 1000.0 diff --git a/software/control/utils.py b/software/control/utils.py index 4dfdb13c9..3bbb52467 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -110,46 +110,6 @@ def rotate_and_flip_image(image, rotate_image_angle: float, flip_image: Optional return ret_image -def display_to_sensor_displacement( - dx: float, dy: float, rotate_image_angle: Optional[float], flip_image: Optional[FlipVariant] -) -> Tuple[float, float]: - """Map a displacement read off the displayed image back onto raw sensor axes. - - rotate_and_flip_image() is what turns a raw frame into what the user sees, so - anything that measures a position on screen - click to move, for one - is working in - rotated and flipped coordinates while the stage moves along sensor axes. This - inverts that transform. - - Only a displacement is handled, not a point: the rotation and flip are both about - the image centre, so a centre-relative vector needs the linear part alone and no - translation. Sizes never enter into it, which is why nothing here takes an image - width or height. - """ - # The flip is applied last on the way out, so it comes off first here. Each flip is - # its own inverse. - if flip_image is not None: - if flip_image == FlipVariant.VERTICAL: - dy = -dy - elif flip_image == FlipVariant.HORIZONTAL: - dx = -dx - elif flip_image == FlipVariant.BOTH: - dx, dy = -dx, -dy - - # cv2 rotates clockwise about the centre with y running down the image, so the - # forward map for 90 is (x, y) -> (-y, x); these are its inverses. - if rotate_image_angle and rotate_image_angle != 0: - if rotate_image_angle == 90: - dx, dy = dy, -dx - elif rotate_image_angle == -90: - dx, dy = -dy, dx - elif rotate_image_angle == 180: - dx, dy = -dx, -dy - else: - raise ValueError(f"Unhandled rotation: {rotate_image_angle}") - - return dx, dy - - def generate_dpc(im_left, im_right): # Normalize the images im_left = im_left.astype(float) / 255 diff --git a/software/squid/abc.py b/software/squid/abc.py index d615840d8..729111d2b 100644 --- a/software/squid/abc.py +++ b/software/squid/abc.py @@ -685,19 +685,6 @@ def _process_raw_frame(self, raw_frame: np.array) -> np.array: return image - def display_to_sensor_displacement(self, dx: float, dy: float) -> Tuple[float, float]: - """Map a displacement measured on this camera's displayed image onto sensor axes. - - The inverse of the rotate/flip _process_raw_image applies. Callers that read a - position off screen and then drive the stage need this, because the stage moves - along sensor axes. It lives on the camera because the rotation and flip are - per-camera: on a multi-camera system each one can be mounted differently, so the - correction has to come from whichever camera produced the image. - """ - return control.utils.display_to_sensor_displacement( - dx, dy, rotate_image_angle=self._config.rotate_image_angle, flip_image=self._config.flip - ) - def get_crop_size(self) -> Tuple[int, int]: """ Returns the final crop size of the image (after software crop). diff --git a/software/squid/camera/facade.py b/software/squid/camera/facade.py index 103cd7fc1..432914516 100644 --- a/software/squid/camera/facade.py +++ b/software/squid/camera/facade.py @@ -169,11 +169,6 @@ def get_is_streaming(self): def get_crop_size(self) -> Tuple[int, int]: return self._active().get_crop_size() - def display_to_sensor_displacement(self, dx: float, dy: float) -> Tuple[float, float]: - # Must delegate: the base implementation reads self._config, which the facade - # does not have, and the rotation/flip differ per camera anyway. - return self._active().display_to_sensor_displacement(dx, dy) - def get_fov_size_mm(self) -> float: return self._active().get_fov_size_mm() diff --git a/software/tests/control/test_utils.py b/software/tests/control/test_utils.py index 048c3094d..41d90236a 100644 --- a/software/tests/control/test_utils.py +++ b/software/tests/control/test_utils.py @@ -5,12 +5,6 @@ import threading import time -import numpy as np -import pytest - -from control.utils import display_to_sensor_displacement, rotate_and_flip_image -from squid.config import FlipVariant - def test_squid_repo_info(): # At least make sure we get something and that it calls without issue. @@ -218,46 +212,3 @@ def test_callback(success, error_msg): # Verify results assert operation_result == [("value1", "value2")] assert callback_result == [(True, None)] - - -# --------------------------------------------------------------------------- -# display_to_sensor_displacement -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("rotate", [None, 90, -90, 180]) -@pytest.mark.parametrize("flip", [None, FlipVariant.VERTICAL, FlipVariant.HORIZONTAL, FlipVariant.BOTH]) -@pytest.mark.parametrize("marker", [(0, 0), (1, 5), (4, 2), (2, 3)]) -def test_display_to_sensor_displacement_inverts_the_display_transform(rotate, flip, marker): - """Round-trip against the real transform rather than re-deriving the maths. - - A pixel is marked in a raw frame, the frame is put through the display transform, - and the inverse must recover the mark's original centre-relative offset. Odd - dimensions keep the centre on an exact pixel. - """ - height, width = 5, 7 - row, col = marker - raw = np.zeros((height, width), dtype=np.uint8) - raw[row, col] = 255 - expected = (col - (width - 1) / 2, row - (height - 1) / 2) - - displayed = rotate_and_flip_image(raw, rotate, flip) - disp_h, disp_w = displayed.shape - disp_row, disp_col = (int(v) for v in np.argwhere(displayed == 255)[0]) - seen = (disp_col - (disp_w - 1) / 2, disp_row - (disp_h - 1) / 2) - - assert display_to_sensor_displacement(*seen, rotate, flip) == pytest.approx(expected) - - -def test_display_to_sensor_displacement_is_identity_without_rotation_or_flip(): - assert display_to_sensor_displacement(12.0, -7.0, None, None) == (12.0, -7.0) - - -def test_display_to_sensor_displacement_swaps_axes_for_90(): - """A 90 degree view turns a horizontal click into vertical stage travel.""" - assert display_to_sensor_displacement(10.0, 0.0, 90, None) == (0.0, -10.0) - - -def test_display_to_sensor_displacement_rejects_unhandled_rotation(): - with pytest.raises(ValueError): - display_to_sensor_displacement(1.0, 1.0, 45, None) diff --git a/software/tests/squid/test_camera_facade.py b/software/tests/squid/test_camera_facade.py index 5f100cc24..7a48c9f42 100644 --- a/software/tests/squid/test_camera_facade.py +++ b/software/tests/squid/test_camera_facade.py @@ -4,7 +4,7 @@ from squid.abc import CameraAcquisitionMode, CameraFrame from squid.camera.facade import ActiveCameraFacade from squid.camera.utils import SimulatedCamera -from squid.config import CameraPixelFormat, FlipVariant +from squid.config import CameraPixelFormat def make_sim(serial, pixel_format=CameraPixelFormat.MONO16, hw=False): @@ -128,30 +128,3 @@ def test_close_closes_all(cameras): facade = ActiveCameraFacade(cameras, active_id=1) facade.close() assert sorted(closed) == [1, 2] - - -def test_display_to_sensor_displacement_follows_the_active_camera(): - """Each camera can be mounted differently, so the correction must track the switch. - - The facade owns no config of its own, so this also pins that it delegates rather - than falling through to the base implementation and its missing self._config. - """ - cam1 = make_sim("SN1") - cam2 = make_sim("SN2") - # Same rotation, different flips - as on a rig where one sensor is mounted mirrored. - # The probe vector needs both components: these two flips differ only vertically. - cam1._config = cam1._config.model_copy(update={"rotate_image_angle": 90, "flip": FlipVariant.BOTH}) - cam2._config = cam2._config.model_copy(update={"rotate_image_angle": 90, "flip": FlipVariant.HORIZONTAL}) - try: - facade = ActiveCameraFacade({1: cam1, 2: cam2}, active_id=1) - - assert facade.display_to_sensor_displacement(10.0, 4.0) == cam1.display_to_sensor_displacement(10.0, 4.0) - - facade.set_active(2) - assert facade.display_to_sensor_displacement(10.0, 4.0) == cam2.display_to_sensor_displacement(10.0, 4.0) - - # The two must genuinely differ, otherwise the assertions above prove nothing. - assert cam1.display_to_sensor_displacement(10.0, 4.0) != cam2.display_to_sensor_displacement(10.0, 4.0) - finally: - cam1.close() - cam2.close() From 98a791051573dcaf1a756fedcee9b6f81f6b45f1 Mon Sep 17 00:00:00 2001 From: You Yan Date: Fri, 14 Aug 2026 19:04:03 -0700 Subject: [PATCH 48/52] revert(qt): drop the PyQt6 compatibility changes Reverts the four PyQt6 compatibility commits in a single commit, restoring the PyQt5 pin at the entry points: - 7cd57011 fix(qt6): replace removed QDesktopWidget with QScreen API - b9634c8a fix(qt6): use binding-agnostic matplotlib backend_qtagg - eaca176e feat(qt): entry points select Qt binding via squid.qt_binding - c6130cc4 feat(qt): add binding selector preferring PyQt6 when installed Co-Authored-By: Claude Fable 5 --- software/control/core_volumetric_imaging.py | 4 +- software/control/gui_hcs.py | 3 +- software/control/widgets.py | 2 +- software/main_hcs.py | 8 ++-- software/squid/qt_binding.py | 28 -------------- software/tests/conftest.py | 7 ---- software/tests/squid/test_qt_binding.py | 42 --------------------- 7 files changed, 8 insertions(+), 86 deletions(-) delete mode 100644 software/squid/qt_binding.py delete mode 100644 software/tests/squid/test_qt_binding.py diff --git a/software/control/core_volumetric_imaging.py b/software/control/core_volumetric_imaging.py index 2c97c6927..9c15c81aa 100644 --- a/software/control/core_volumetric_imaging.py +++ b/software/control/core_volumetric_imaging.py @@ -173,8 +173,8 @@ def __init__(self, window_title=""): self.setCentralWidget(self.widget) # set window size - screen_height = QApplication.primaryScreen().size().height() - width = int(min(screen_height * 0.9, 1000)) # @@@TO MOVE@@@# + desktopWidget = QDesktopWidget() + width = min(desktopWidget.height() * 0.9, 1000) # @@@TO MOVE@@@# height = width self.setFixedSize(width, height) diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 56d9585f0..f808935df 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -1531,7 +1531,8 @@ def _getMainWindowMinimumSize(self): We want our main window to fit on the primary screen, so grab the users primary screen and return something slightly smaller than that. """ - primary_screen_size = QApplication.primaryScreen().size() + desktop_info = QDesktopWidget() + primary_screen_size = desktop_info.screen(desktop_info.primaryScreen()).size() height_min = int(0.9 * primary_screen_size.height()) width_min = int(0.96 * primary_screen_size.width()) diff --git a/software/control/widgets.py b/software/control/widgets.py index 7f2fa0286..75876aae3 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -15026,7 +15026,7 @@ def save_settings(self): json.dump(data, f) -from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure from mpl_toolkits.mplot3d import proj3d from scipy.interpolate import griddata diff --git a/software/main_hcs.py b/software/main_hcs.py index 876b7f9f4..dddc81816 100644 --- a/software/main_hcs.py +++ b/software/main_hcs.py @@ -1,14 +1,12 @@ +# set QT_API environment variable import argparse import logging import os + +os.environ["QT_API"] = "pyqt5" import signal import sys -# Select the Qt binding (PyQt6 preferred when installed) before any qtpy import. -from squid.qt_binding import select_qt_api - -select_qt_api() - # qt libraries from qtpy.QtWidgets import * from qtpy.QtGui import * diff --git a/software/squid/qt_binding.py b/software/squid/qt_binding.py deleted file mode 100644 index 3c02a2661..000000000 --- a/software/squid/qt_binding.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Select the Qt binding for qtpy and pytest-qt. - -Must be imported and called before the first ``qtpy`` import anywhere in the -process. Deliberately does not import any Qt binding itself: qtpy latches -onto an already-imported binding regardless of QT_API, so availability is -probed with ``importlib.util.find_spec`` instead. -""" - -import importlib.util -import os - - -def select_qt_api() -> str: - """Set QT_API/PYTEST_QT_API so qtpy and pytest-qt agree on one binding. - - Preference order: - 1. An explicit QT_API environment variable always wins. - 2. PyQt6, when installed. - 3. PyQt5 otherwise. - - Returns the selected api name (e.g. "pyqt6"). - """ - api = os.environ.get("QT_API") - if not api: - api = "pyqt6" if importlib.util.find_spec("PyQt6") is not None else "pyqt5" - os.environ["QT_API"] = api - os.environ.setdefault("PYTEST_QT_API", api) - return api diff --git a/software/tests/conftest.py b/software/tests/conftest.py index dbe4c354b..4d055746a 100644 --- a/software/tests/conftest.py +++ b/software/tests/conftest.py @@ -20,13 +20,6 @@ import pytest -# Select the Qt binding (PyQt6 preferred when installed) before anything -# imports qtpy. Also keeps pytest-qt (PYTEST_QT_API) on the same binding — -# left alone it prefers PyQt6 on its own and the process would load both. -from squid.qt_binding import select_qt_api - -select_qt_api() - import control.microcontroller import control.microscope from control.core.multi_point_controller import MultiPointController diff --git a/software/tests/squid/test_qt_binding.py b/software/tests/squid/test_qt_binding.py deleted file mode 100644 index 771079efd..000000000 --- a/software/tests/squid/test_qt_binding.py +++ /dev/null @@ -1,42 +0,0 @@ -import importlib.util -import os - -from squid.qt_binding import select_qt_api - - -def test_explicit_qt_api_wins(monkeypatch): - monkeypatch.setenv("QT_API", "pyqt5") - monkeypatch.delenv("PYTEST_QT_API", raising=False) - - assert select_qt_api() == "pyqt5" - assert os.environ["QT_API"] == "pyqt5" - # pytest-qt must agree with qtpy or the process loads two bindings. - assert os.environ["PYTEST_QT_API"] == "pyqt5" - - -def test_prefers_pyqt6_when_available(monkeypatch): - monkeypatch.delenv("QT_API", raising=False) - monkeypatch.delenv("PYTEST_QT_API", raising=False) - monkeypatch.setattr(importlib.util, "find_spec", lambda name: object()) - - assert select_qt_api() == "pyqt6" - assert os.environ["QT_API"] == "pyqt6" - assert os.environ["PYTEST_QT_API"] == "pyqt6" - - -def test_falls_back_to_pyqt5_without_pyqt6(monkeypatch): - monkeypatch.delenv("QT_API", raising=False) - monkeypatch.delenv("PYTEST_QT_API", raising=False) - monkeypatch.setattr(importlib.util, "find_spec", lambda name: None) - - assert select_qt_api() == "pyqt5" - assert os.environ["QT_API"] == "pyqt5" - assert os.environ["PYTEST_QT_API"] == "pyqt5" - - -def test_existing_pytest_qt_api_not_overwritten(monkeypatch): - monkeypatch.setenv("QT_API", "pyqt5") - monkeypatch.setenv("PYTEST_QT_API", "pyqt6") - - select_qt_api() - assert os.environ["PYTEST_QT_API"] == "pyqt6" From 2e6a2eee905cbc1e9c06cf8ca4ca8d8e6be175b2 Mon Sep 17 00:00:00 2001 From: You Yan Date: Sat, 15 Aug 2026 12:11:21 -0700 Subject: [PATCH 49/52] feat(photometrics): open by device_index; refcount PVCAM init for dual cameras PVCAM has no open-by-serial, so cameras.yaml gains an optional per-camera device_index (0-based PVCAM enumeration index) that PhotometricsCamera now honors, making a dual-Photometrics pair distinguishable. init_pvcam/ uninit_pvcam are process-global, so they are now refcounted: the library stays initialized until the last open camera closes, and a failed open releases its hold. Co-Authored-By: Claude Fable 5 --- software/control/camera_photometrics.py | 59 +++++-- software/control/models/camera_registry.py | 6 + software/docs/dual-camera.md | 9 +- software/machine_configs/cameras.yaml.example | 5 + software/squid/config.py | 6 +- .../tests/control/test_camera_photometrics.py | 166 ++++++++++++++++++ .../tests/control/test_multi_camera_models.py | 12 ++ .../test_camera_config_from_definition.py | 12 ++ 8 files changed, 255 insertions(+), 20 deletions(-) create mode 100644 software/tests/control/test_camera_photometrics.py diff --git a/software/control/camera_photometrics.py b/software/control/camera_photometrics.py index a4303fe26..3cb18e5dc 100644 --- a/software/control/camera_photometrics.py +++ b/software/control/camera_photometrics.py @@ -17,34 +17,60 @@ ) from control._def import * +# PVCAM is a process-global library: init/uninit must bracket ALL open cameras, +# so a refcount (not per-camera init/uninit) lets two PhotometricsCamera +# instances coexist and close in any order. +_pvcam_lock = threading.Lock() +_pvcam_user_count = 0 + + +def _acquire_pvcam(): + global _pvcam_user_count + with _pvcam_lock: + if _pvcam_user_count == 0: + pvc.init_pvcam() + _pvcam_user_count += 1 + + +def _release_pvcam(): + global _pvcam_user_count + with _pvcam_lock: + if _pvcam_user_count == 0: + return + _pvcam_user_count -= 1 + if _pvcam_user_count == 0: + pvc.uninit_pvcam() + class PhotometricsCamera(AbstractCamera): PIXEL_SIZE_UM = 6.5 # Kinetix camera @staticmethod def _open(index: Optional[int] = None) -> PVCam: - """Open a Photometrics camera and return the camera object.""" + """Open the Photometrics camera at the given PVCAM enumeration index. + + None opens the first detected camera. PVCAM has no open-by-serial, so + cameras.yaml `device_index` is how a multi-Photometrics system picks a + specific device (enumeration order can change if USB topology changes). + """ log = squid.logging.get_logger("PhotometricsCamera._open") - pvc.init_pvcam() + _acquire_pvcam() try: - if index is not None: - # Open by index (not commonly used for Photometrics) - cameras = list(PVCam.detect_camera()) - if index >= len(cameras): - raise CameraError(f"Camera index {index} out of range. Found {len(cameras)} cameras.") - cam = cameras[index] - else: - # Open first available camera - cam = next(PVCam.detect_camera()) - + cameras = list(PVCam.detect_camera()) + effective_index = 0 if index is None else index + if effective_index >= len(cameras): + raise CameraError( + f"Camera index {effective_index} out of range. Found {len(cameras)} Photometrics cameras." + ) + cam = cameras[effective_index] cam.open() - log.info("Photometrics camera opened successfully") + log.info(f"Photometrics camera opened successfully (index {effective_index})") return cam except Exception as e: - pvc.uninit_pvcam() + _release_pvcam() raise CameraError(f"Failed to open Photometrics camera: {e}") def __init__( @@ -72,7 +98,7 @@ def __init__( self._is_streaming = threading.Event() # Open camera - self._camera = PhotometricsCamera._open() + self._camera = PhotometricsCamera._open(camera_config.device_index) # Camera configuration self._exposure_time_ms = 20 # set it to some default value @@ -141,7 +167,8 @@ def close(self): self._camera.close() except Exception as e: raise CameraError(f"Failed to close camera: {e}") - pvc.uninit_pvcam() + finally: + _release_pvcam() def _ensure_read_thread_running(self): with self._read_thread_lock: diff --git a/software/control/models/camera_registry.py b/software/control/models/camera_registry.py index 4d6708d02..0ade56008 100644 --- a/software/control/models/camera_registry.py +++ b/software/control/models/camera_registry.py @@ -53,6 +53,12 @@ class CameraDefinition(BaseModel): crop_height: Optional[int] = Field(None, ge=1, description="Per-camera unbinned crop height override") default_pixel_format: Optional[str] = Field(None, description="Per-camera default pixel format override") default_binning: Optional[List[int]] = Field(None, description="Per-camera default binning override, [x, y]") + device_index: Optional[int] = Field( + None, + ge=0, + description="Driver enumeration index selecting the physical device, for drivers that " + "cannot open by serial number (currently Photometrics/PVCAM)", + ) model_config = {"extra": "forbid"} diff --git a/software/docs/dual-camera.md b/software/docs/dual-camera.md index adf282825..a7fcb6b5a 100644 --- a/software/docs/dual-camera.md +++ b/software/docs/dual-camera.md @@ -210,9 +210,12 @@ Zarr remains fully valid — and selectable — for single-camera runs. - **No per-camera Zarr stores.** v1 validates the mismatch instead of splitting stores. - **Serial-number camera opening works for Toupcam and FLIR only.** Those two drivers open the specific device a `serial_number` names, so two cameras of the same vendor — even the - same model — are distinguishable. Every other driver (Hamamatsu, Tucsen, iDS, Photometrics, - Andor, Default/Daheng) still opens the "first camera found": the serial number is recorded - in `cameras.yaml` but not used to choose the device, so a same-vendor pair on those drivers + same model — are distinguishable. Photometrics has no open-by-serial in PVCAM, but accepts + a per-camera `device_index` (0-based enumeration index) instead, so a dual-Photometrics + pair is distinguishable as long as the USB topology stays put — enumeration order can + change when devices are replugged. Every other driver (Hamamatsu, Tucsen, iDS, Andor, + Default/Daheng) still opens the "first camera found": the serial number is recorded in + `cameras.yaml` but not used to choose the device, so a same-vendor pair on those drivers is not reliably distinguishable. Different-vendor pairs and simulation are fine. - **No switch-minimizing channel reordering** — your channel order is preserved. - **`hardware_bindings.yaml` emission-wheel dispatch** is not wired per camera. diff --git a/software/machine_configs/cameras.yaml.example b/software/machine_configs/cameras.yaml.example index 8f066c14d..845c9160c 100644 --- a/software/machine_configs/cameras.yaml.example +++ b/software/machine_configs/cameras.yaml.example @@ -26,6 +26,11 @@ # software-trigger mode only, and the GUI stops offering Hardware # Trigger while it is active. # model: display string only (shown in the UI for reference) +# device_index: driver enumeration index (0-based) selecting the physical device, +# for drivers whose SDK cannot open by serial number (currently +# Photometrics/PVCAM). Two same-vendor cameras on such a driver each +# need a distinct device_index. Note the enumeration order can change +# when the USB topology changes (replugging, adding hubs). # rotate_image_angle, flip, crop_width, crop_height, default_pixel_format, default_binning # flip: Vertical | Horizontal | Both # default_pixel_format: MONO8 | MONO10 | MONO12 | MONO14 | MONO16 | RGB24 | RGB32 | diff --git a/software/squid/config.py b/software/squid/config.py index 1ccde58dd..b381b24af 100644 --- a/software/squid/config.py +++ b/software/squid/config.py @@ -521,6 +521,10 @@ class CameraConfig(pydantic.BaseModel): # cameras using the same SDK/driver. serial_number: Optional[str] = None + # Driver enumeration index selecting the physical device, for drivers whose SDK cannot open by + # serial number (currently Photometrics/PVCAM). None means "first detected camera". + device_index: Optional[int] = None + # The default readout data bit depth of the camera. Note that this may depend on the gain mode being used. default_pixel_format: CameraPixelFormat @@ -621,7 +625,7 @@ def camera_config_from_definition(definition) -> CameraConfig: overlays any per-camera overrides the definition provides. The INI singleton is never mutated. """ - updates = {"serial_number": definition.serial_number} + updates = {"serial_number": definition.serial_number, "device_index": definition.device_index} if definition.type is not None: new_type = _old_camera_variant_to_enum(definition.type) updates["camera_type"] = new_type diff --git a/software/tests/control/test_camera_photometrics.py b/software/tests/control/test_camera_photometrics.py new file mode 100644 index 000000000..cb820ca2e --- /dev/null +++ b/software/tests/control/test_camera_photometrics.py @@ -0,0 +1,166 @@ +"""Tests for the Photometrics (PVCAM) driver that need no camera hardware. + +pyvcam imports without a camera attached, so `control.camera_photometrics` +imports fine on this machine. Every test swaps the module's `pvc` / `PVCam` +bindings for fakes so we exercise the real driver logic against a scripted +device list (the same approach as tests/control/test_camera_toupcam.py). +""" + +from types import SimpleNamespace + +import pytest + +import control.camera_photometrics as camera_photometrics +from squid.abc import CameraError +from squid.config import CameraConfig, CameraPixelFormat, CameraVariant + +# pyvcam accepts exp_mode as a name string but reads it back as the PVCAM +# integer code; the driver's _TRIGGER_CODE_MAPPING_KINETIX depends on that. +_EXP_MODE_CODES = { + "Internal Trigger": 1792, + "Edge Trigger": 2304, + "Software Trigger Edge": 3072, +} + + +class FakePVCamera: + """Stand-in for a pyvcam.camera.Camera as the driver uses it.""" + + def __init__(self, name: str): + self.name = name + self.is_open = False + self.exp_res = None + self.speed_table_index = None + self.exp_out_mode = None + self.readout_port = None + self.exp_time = None + self.temp_setpoint = None + self.temp = 0.0 + self._exp_mode_code = _EXP_MODE_CODES["Internal Trigger"] + self._roi = None + + @property + def exp_mode(self): + return self._exp_mode_code + + @exp_mode.setter + def exp_mode(self, mode_name: str): + self._exp_mode_code = _EXP_MODE_CODES[mode_name] + + def open(self): + self.is_open = True + + def close(self): + self.is_open = False + + def set_roi(self, offset_x, offset_y, width, height): + self._roi = (offset_x, offset_y, width, height) + + def shape(self, roi_index): + if self._roi is not None: + return (self._roi[2], self._roi[3]) + return (3200, 3200) + + def abort(self): + pass + + def start_live(self): + pass + + def finish(self): + pass + + def poll_frame(self, timeout_ms=0): + raise TimeoutError("no frames in the fake") + + def sw_trigger(self): + pass + + +@pytest.fixture +def fake_pvcam(monkeypatch): + class FakePvc: + def __init__(self): + self.init_calls = 0 + self.uninit_calls = 0 + + def init_pvcam(self): + self.init_calls += 1 + + def uninit_pvcam(self): + self.uninit_calls += 1 + + fake_pvc = FakePvc() + detected = [] + + class FakePVCamClass: + @staticmethod + def detect_camera(): + yield from detected + + monkeypatch.setattr(camera_photometrics, "pvc", fake_pvc) + monkeypatch.setattr(camera_photometrics, "PVCam", FakePVCamClass) + # The PVCAM init refcount is module state; start each test from zero. + monkeypatch.setattr(camera_photometrics, "_pvcam_user_count", 0, raising=False) + return SimpleNamespace(pvc=fake_pvc, detected=detected) + + +def _photometrics_config(device_index=None) -> CameraConfig: + kwargs = dict( + camera_type=CameraVariant.PHOTOMETRICS, + default_pixel_format=CameraPixelFormat.MONO16, + default_roi=(0, 0, 128, 128), + default_temperature=0, + ) + if device_index is not None: + kwargs["device_index"] = device_index + return CameraConfig(**kwargs) + + +def _open_camera(device_index=None) -> "camera_photometrics.PhotometricsCamera": + return camera_photometrics.PhotometricsCamera( + _photometrics_config(device_index), hw_trigger_fn=None, hw_set_strobe_delay_ms_fn=None + ) + + +def test_without_device_index_opens_the_first_detected_camera(fake_pvcam): + fake_pvcam.detected.extend([FakePVCamera("cam0"), FakePVCamera("cam1")]) + _open_camera() + assert fake_pvcam.detected[0].is_open + assert not fake_pvcam.detected[1].is_open + + +def test_device_index_selects_the_matching_detected_camera(fake_pvcam): + fake_pvcam.detected.extend([FakePVCamera("cam0"), FakePVCamera("cam1")]) + _open_camera(device_index=1) + assert fake_pvcam.detected[1].is_open + assert not fake_pvcam.detected[0].is_open + + +def test_out_of_range_device_index_raises_camera_error(fake_pvcam): + fake_pvcam.detected.append(FakePVCamera("cam0")) + with pytest.raises(CameraError): + _open_camera(device_index=1) + # A failed open must release its PVCAM hold so later opens start clean. + assert fake_pvcam.pvc.uninit_calls == fake_pvcam.pvc.init_calls + + +def test_no_detected_cameras_raises_camera_error(fake_pvcam): + with pytest.raises(CameraError): + _open_camera() + assert fake_pvcam.pvc.uninit_calls == fake_pvcam.pvc.init_calls + + +def test_pvcam_uninitializes_only_after_the_last_camera_closes(fake_pvcam): + fake_pvcam.detected.extend([FakePVCamera("cam0"), FakePVCamera("cam1")]) + first = _open_camera(device_index=0) + second = _open_camera(device_index=1) + assert fake_pvcam.pvc.init_calls == 1 + + first.close() + assert fake_pvcam.pvc.uninit_calls == 0 + assert fake_pvcam.detected[0].is_open is False + assert fake_pvcam.detected[1].is_open is True + + second.close() + assert fake_pvcam.pvc.uninit_calls == 1 diff --git a/software/tests/control/test_multi_camera_models.py b/software/tests/control/test_multi_camera_models.py index 2132ac090..a4577f9d0 100644 --- a/software/tests/control/test_multi_camera_models.py +++ b/software/tests/control/test_multi_camera_models.py @@ -158,6 +158,18 @@ def test_single_camera_type_optional(self): config = CameraRegistryConfig(cameras=[CameraDefinition(serial_number="SN1")]) assert config.cameras[0].type is None + def test_device_index_defaults_to_none(self): + cam = CameraDefinition(serial_number="SN1") + assert cam.device_index is None + + def test_device_index_accepted(self): + cam = CameraDefinition(serial_number="SN1", device_index=1) + assert cam.device_index == 1 + + def test_negative_device_index_rejected(self): + with pytest.raises(ValidationError): + CameraDefinition(serial_number="SN1", device_index=-1) + class TestCameraRegistryConfig: """Tests for CameraRegistryConfig model.""" diff --git a/software/tests/squid/test_camera_config_from_definition.py b/software/tests/squid/test_camera_config_from_definition.py index 508fb59ab..cccad990a 100644 --- a/software/tests/squid/test_camera_config_from_definition.py +++ b/software/tests/squid/test_camera_config_from_definition.py @@ -39,6 +39,18 @@ def test_overrides_applied(): assert cfg.default_binning == (2, 2) +def test_device_index_mapped_to_config(): + defn = CameraDefinition(serial_number="SN-IDX", device_index=1) + cfg = squid.config.camera_config_from_definition(defn) + assert cfg.device_index == 1 + + +def test_device_index_defaults_to_none_in_config(): + defn = CameraDefinition(serial_number="SN-NOIDX") + cfg = squid.config.camera_config_from_definition(defn) + assert cfg.device_index is None + + def test_type_change_clears_ini_camera_model(): base = squid.config.get_camera_config() other_type = "Hamamatsu" if base.camera_type != squid.config.CameraVariant.HAMAMATSU else "Toupcam" From d851f92f38a486cf1905931d4543d4de9335bc1d Mon Sep 17 00:00:00 2001 From: You Yan Date: Sat, 15 Aug 2026 12:12:57 -0700 Subject: [PATCH 50/52] fix(camera): raise a clear error when a camera driver fails to import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_camera used to catch the vendor-SDK ImportError and silently substitute the Daheng DefaultCamera, so a machine without (say) PVCAM installed reported confusing Daheng open errors — or silently imaged on the wrong camera. Raise a CameraError naming the camera type and the missing module instead. In multi-camera builds a secondary then gets cleanly marked unavailable and a failed primary stops startup with the real cause. Co-Authored-By: Claude Fable 5 --- software/squid/camera/utils.py | 14 ++++++-------- software/tests/squid/test_camera.py | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/software/squid/camera/utils.py b/software/squid/camera/utils.py index 18537a0d7..44360f6a1 100644 --- a/software/squid/camera/utils.py +++ b/software/squid/camera/utils.py @@ -104,14 +104,12 @@ def open_if_needed(camera): return camera except ImportError as e: - _log.warning(f"Camera of type: '{config.camera_type}' failed to import. Falling back to default camera impl.") - _log.warning(e) - - import control.camera - - return control.camera.DefaultCamera( - config, hw_trigger_fn=hw_trigger_fn, hw_set_strobe_delay_ms_fn=hw_set_strobe_delay_ms_fn - ) + # Do not fall back to another vendor's driver: a misleading Daheng error (or a + # silently wrong camera) is much harder to act on than the real cause. + raise CameraError( + f"Camera driver import failed for camera_type={config.camera_type.name}: {e}. " + "Install the vendor SDK for this camera (see 'drivers and libraries/' and setup_22.04.sh)." + ) from e class SimulatedCamera(AbstractCamera): diff --git a/software/tests/squid/test_camera.py b/software/tests/squid/test_camera.py index dd4bca437..54c132fd2 100644 --- a/software/tests/squid/test_camera.py +++ b/software/tests/squid/test_camera.py @@ -1,8 +1,11 @@ +import sys from typing import Optional, Sequence +import pytest + import squid.camera.utils import squid.config -from squid.abc import AbstractCamera, CameraFrame +from squid.abc import AbstractCamera, CameraError, CameraFrame from squid.camera.utils import SimulatedCamera from squid.config import CameraConfig @@ -11,6 +14,19 @@ def test_create_simulated_camera(): sim_cam = squid.camera.utils.get_camera(squid.config.get_camera_config(), simulated=True) +def test_get_camera_raises_clearly_when_the_driver_import_fails(monkeypatch): + # A None entry makes `import control.camera_photometrics` raise ImportError, which is + # what happens on a machine without the vendor SDK installed. get_camera must surface + # that clearly instead of silently substituting a different vendor's camera. + monkeypatch.setitem(sys.modules, "control.camera_photometrics", None) + config = squid.config.CameraConfig( + camera_type=squid.config.CameraVariant.PHOTOMETRICS, + default_pixel_format=squid.config.CameraPixelFormat.MONO16, + ) + with pytest.raises(CameraError, match="PHOTOMETRICS"): + squid.camera.utils.get_camera(config, simulated=False) + + def test_simulated_camera(): sim_cam_config: CameraConfig = squid.config.get_camera_config().model_copy( update={"rotate_image_angle": None, "flip": None} From 439aab8af1f478e7306e7b9a102fa2b84eafff78 Mon Sep 17 00:00:00 2001 From: You Yan Date: Sat, 15 Aug 2026 12:16:45 -0700 Subject: [PATCH 51/52] fix(gui): camera settings tabs derive options from each camera's driver type The primary camera's settings widget was gated on the global INI CAMERA_TYPE with the dead string "Kinetix" (real Photometrics INIs say camera_type = Photometrics), so Kinetix machines never saw the temperature controls, and the dual-camera secondary tabs hardcoded them off. Each tab now derives its options from its own camera's driver type: temperature controls for Toupcam/ Tucsen/Photometrics, the historical auto-WB pairing preserved (secondaries keep the WB button; the widget already shows it only for color formats). Photometrics raises NotImplementedError for the temperature reading callback (setpoint works, no live readout), so the widget's guard now catches that alongside AttributeError and leaves the measured label blank. Co-Authored-By: Claude Fable 5 --- software/control/gui_hcs.py | 25 ++++------- software/control/widgets.py | 26 ++++++++++- software/tests/control/test_widgets.py | 60 +++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 20 deletions(-) diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index f808935df..354085b03 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -953,21 +953,14 @@ def load_widgets(self): # Settings are per-camera identity state, so this widget drives the primary # concrete camera, not the facade (which would retarget on a camera switch). + # Options come from each camera's own driver type: in a multi-camera build the + # cameras.yaml type wins over the INI CAMERA_TYPE (whose old gate also checked + # the dead string "Kinetix" - Photometrics INIs say camera_type = Photometrics). primary_camera = self.microscope.cameras[PRIMARY_CAMERA_ID] - if CAMERA_TYPE in ["Toupcam", "Tucsen", "Kinetix"]: - self.cameraSettingWidget = widgets.CameraSettingsWidget( - primary_camera, - include_gain_exposure_time=False, - include_camera_temperature_setting=True, - include_camera_auto_wb_setting=False, - ) - else: - self.cameraSettingWidget = widgets.CameraSettingsWidget( - primary_camera, - include_gain_exposure_time=False, - include_camera_temperature_setting=False, - include_camera_auto_wb_setting=True, - ) + self.cameraSettingWidget = widgets.CameraSettingsWidget( + primary_camera, + **widgets.camera_settings_widget_options(primary_camera._config.camera_type, primary=True), + ) self._restore_cached_camera_settings() @@ -981,9 +974,7 @@ def load_widgets(self): continue self.cameraSettingWidgets[camera_id] = widgets.CameraSettingsWidget( concrete_camera, - include_gain_exposure_time=False, - include_camera_temperature_setting=False, - include_camera_auto_wb_setting=True, + **widgets.camera_settings_widget_options(concrete_camera._config.camera_type, primary=False), ) # Every per-camera lookup goes through this one map, primary included, so no caller # has to re-derive "extras plus the primary" and risk missing or double-counting it. diff --git a/software/control/widgets.py b/software/control/widgets.py index 75876aae3..0edb1e2af 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -28,7 +28,7 @@ import control._def # Import module for runtime access to MCP-modifiable settings from squid.abc import AbstractStage, AbstractCamera, AbstractFilterWheelController from squid.stage.utils import move_to_loading_position, move_to_scanning_position, move_z_axis_to_safety_position -from squid.config import CameraPixelFormat +from squid.config import CameraPixelFormat, CameraVariant # set QT_API environment variable os.environ["QT_API"] = "pyqt5" @@ -3806,6 +3806,26 @@ def on_objective_changed(self, objective_name): self.signal_objective_changed.emit() +# Camera types whose drivers implement temperature control (cooled cameras). +_TEMPERATURE_CONTROL_CAMERA_TYPES = {CameraVariant.TOUPCAM, CameraVariant.TUCSEN, CameraVariant.PHOTOMETRICS} + + +def camera_settings_widget_options(camera_type: CameraVariant, primary: bool) -> dict: + """CameraSettingsWidget kwargs for a camera, from its own driver type. + + The temperature controls only make sense for drivers that implement them. The + auto-WB button keeps its historical asymmetry: the primary pairs it off against + the temperature controls, while secondary tabs always allow it (the widget then + shows it only for color pixel formats). + """ + has_temperature = camera_type in _TEMPERATURE_CONTROL_CAMERA_TYPES + return { + "include_gain_exposure_time": False, + "include_camera_temperature_setting": has_temperature, + "include_camera_auto_wb_setting": True if not primary else not has_temperature, + } + + class CameraSettingsWidget(QFrame): signal_binning_changed = Signal() @@ -3964,7 +3984,9 @@ def add_components( try: self.entry_temperature.valueChanged.connect(self.set_temperature) self.camera.set_temperature_reading_callback(self.update_measured_temperature) - except AttributeError: + except (AttributeError, NotImplementedError): + # Some drivers (e.g. Photometrics) can set a temperature but offer no live + # readout; keep the setpoint control and leave the measured label blank. pass self.camera_layout.addLayout(temp_line) diff --git a/software/tests/control/test_widgets.py b/software/tests/control/test_widgets.py index bf561ea03..e8ee46562 100644 --- a/software/tests/control/test_widgets.py +++ b/software/tests/control/test_widgets.py @@ -14,7 +14,7 @@ from control.core import core as core_module from control.widgets import check_ram_available_with_error_dialog, NDViewerTab, RecordingWidget, SurfacePlotWidget from squid.abc import CameraFrame, CameraFrameFormat -from squid.config import CameraPixelFormat +from squid.config import CameraPixelFormat, CameraVariant import tests.control.test_stubs as ts @@ -2661,3 +2661,61 @@ def test_toggle_auto_wb_off_path_swallows_driver_errors(): control.widgets.CameraSettingsWidget.toggle_auto_wb(widget, False) widget.camera.set_white_balance_gains.assert_not_called() + + +# --------------------------------------------------------------------------- +# camera_settings_widget_options +# +# Each camera's settings widget must be configured from that camera's own driver +# type, not the global INI CAMERA_TYPE (whose old gate also checked the dead +# string "Kinetix" - real Photometrics INIs say camera_type = Photometrics). +# --------------------------------------------------------------------------- + + +def test_photometrics_primary_gets_temperature_controls(): + options = control.widgets.camera_settings_widget_options(CameraVariant.PHOTOMETRICS, primary=True) + assert options["include_camera_temperature_setting"] is True + assert options["include_camera_auto_wb_setting"] is False + assert options["include_gain_exposure_time"] is False + + +def test_photometrics_secondary_gets_temperature_controls_and_keeps_wb(): + options = control.widgets.camera_settings_widget_options(CameraVariant.PHOTOMETRICS, primary=False) + assert options["include_camera_temperature_setting"] is True + assert options["include_camera_auto_wb_setting"] is True + + +def test_color_capable_secondary_keeps_auto_wb(): + """Regression guard: a color Toupcam secondary must not lose its WB button.""" + options = control.widgets.camera_settings_widget_options(CameraVariant.TOUPCAM, primary=False) + assert options["include_camera_auto_wb_setting"] is True + assert options["include_camera_temperature_setting"] is True + + +def test_uncooled_primary_keeps_wb_and_gets_no_temperature_controls(): + options = control.widgets.camera_settings_widget_options(CameraVariant.GXIPY, primary=True) + assert options["include_camera_temperature_setting"] is False + assert options["include_camera_auto_wb_setting"] is True + + +def test_camera_settings_widget_survives_a_driver_without_temperature_callback(qtbot, monkeypatch): + """Photometrics implements set/get_temperature but not the reading callback + (it raises NotImplementedError); the temperature UI must still build.""" + import squid.camera.utils + import squid.config + + camera = squid.camera.utils.get_camera(squid.config.get_camera_config(), simulated=True) + + def _no_callback(callback): + raise NotImplementedError("Temperature reading callback is not supported by this camera.") + + monkeypatch.setattr(camera, "set_temperature_reading_callback", _no_callback) + + widget = control.widgets.CameraSettingsWidget( + camera, + include_gain_exposure_time=False, + include_camera_temperature_setting=True, + include_camera_auto_wb_setting=False, + ) + qtbot.addWidget(widget) + assert widget.entry_temperature is not None From 4a7206d39cc2ea409aad0a5c487d19f39fae2a13 Mon Sep 17 00:00:00 2001 From: You Yan Date: Sat, 15 Aug 2026 12:18:09 -0700 Subject: [PATCH 52/52] feat(camera): per-camera default_roi override in cameras.yaml Drivers apply config.default_roi at init, but every camera inherited the single INI [CAMERA_CONFIG] ROI, which is tuned for the primary sensor. A CameraDefinition can now carry its own default_roi ([offset_x, offset_y, width, height]), so e.g. a Kinetix secondary gets its 25mm-FOV crop instead of the primary's ROI. Co-Authored-By: Claude Fable 5 --- software/control/models/camera_registry.py | 11 +++++++++++ software/machine_configs/cameras.yaml.example | 7 ++++++- software/squid/config.py | 2 ++ .../tests/control/test_multi_camera_models.py | 16 ++++++++++++++++ .../squid/test_camera_config_from_definition.py | 13 +++++++++++++ 5 files changed, 48 insertions(+), 1 deletion(-) diff --git a/software/control/models/camera_registry.py b/software/control/models/camera_registry.py index 0ade56008..a6d96db84 100644 --- a/software/control/models/camera_registry.py +++ b/software/control/models/camera_registry.py @@ -59,6 +59,9 @@ class CameraDefinition(BaseModel): description="Driver enumeration index selecting the physical device, for drivers that " "cannot open by serial number (currently Photometrics/PVCAM)", ) + default_roi: Optional[List[int]] = Field( + None, description="Per-camera hardware ROI override, [offset_x, offset_y, width, height]" + ) model_config = {"extra": "forbid"} @@ -73,6 +76,14 @@ def validate_dual_camera_fields(self) -> "CameraDefinition": if self.default_binning is not None: if len(self.default_binning) != 2 or any(b < 1 for b in self.default_binning): raise ValueError(f"default_binning must be [x, y] with positive ints, got {self.default_binning}") + if self.default_roi is not None: + offsets_ok = len(self.default_roi) == 4 and all(v >= 0 for v in self.default_roi[:2]) + sizes_ok = len(self.default_roi) == 4 and all(v >= 1 for v in self.default_roi[2:]) + if not (offsets_ok and sizes_ok): + raise ValueError( + "default_roi must be [offset_x, offset_y, width, height] with non-negative " + f"offsets and positive sizes, got {self.default_roi}" + ) return self diff --git a/software/machine_configs/cameras.yaml.example b/software/machine_configs/cameras.yaml.example index 845c9160c..82c5098a5 100644 --- a/software/machine_configs/cameras.yaml.example +++ b/software/machine_configs/cameras.yaml.example @@ -31,11 +31,16 @@ # Photometrics/PVCAM). Two same-vendor cameras on such a driver each # need a distinct device_index. Note the enumeration order can change # when the USB topology changes (replugging, adding hubs). -# rotate_image_angle, flip, crop_width, crop_height, default_pixel_format, default_binning +# rotate_image_angle, flip, crop_width, crop_height, default_pixel_format, default_binning, +# default_roi # flip: Vertical | Horizontal | Both # default_pixel_format: MONO8 | MONO10 | MONO12 | MONO14 | MONO16 | RGB24 | RGB32 | # RGB48 | BAYER_RG8 | BAYER_RG12 # default_binning: [x, y] +# default_roi: [offset_x, offset_y, width, height] — per-camera hardware ROI +# applied at camera init. Without it, every camera inherits the +# single INI [CAMERA_CONFIG] ROI, which is usually tuned for the +# primary sensor. version: 1.0 diff --git a/software/squid/config.py b/software/squid/config.py index b381b24af..a37f4d2bf 100644 --- a/software/squid/config.py +++ b/software/squid/config.py @@ -644,6 +644,8 @@ def camera_config_from_definition(definition) -> CameraConfig: updates["default_pixel_format"] = CameraPixelFormat.from_string(definition.default_pixel_format) if definition.default_binning is not None: updates["default_binning"] = (definition.default_binning[0], definition.default_binning[1]) + if definition.default_roi is not None: + updates["default_roi"] = tuple(definition.default_roi) return _camera_config.model_copy(update=updates) diff --git a/software/tests/control/test_multi_camera_models.py b/software/tests/control/test_multi_camera_models.py index a4577f9d0..be3a505cf 100644 --- a/software/tests/control/test_multi_camera_models.py +++ b/software/tests/control/test_multi_camera_models.py @@ -170,6 +170,22 @@ def test_negative_device_index_rejected(self): with pytest.raises(ValidationError): CameraDefinition(serial_number="SN1", device_index=-1) + def test_default_roi_accepted(self): + cam = CameraDefinition(serial_number="SN1", default_roi=[240, 240, 2720, 2720]) + assert cam.default_roi == [240, 240, 2720, 2720] + + def test_default_roi_must_be_four_values(self): + with pytest.raises(ValidationError): + CameraDefinition(serial_number="SN1", default_roi=[0, 0, 100]) + + def test_default_roi_rejects_negative_offsets(self): + with pytest.raises(ValidationError): + CameraDefinition(serial_number="SN1", default_roi=[-1, 0, 100, 100]) + + def test_default_roi_rejects_nonpositive_size(self): + with pytest.raises(ValidationError): + CameraDefinition(serial_number="SN1", default_roi=[0, 0, 0, 100]) + class TestCameraRegistryConfig: """Tests for CameraRegistryConfig model.""" diff --git a/software/tests/squid/test_camera_config_from_definition.py b/software/tests/squid/test_camera_config_from_definition.py index cccad990a..be72a00d8 100644 --- a/software/tests/squid/test_camera_config_from_definition.py +++ b/software/tests/squid/test_camera_config_from_definition.py @@ -51,6 +51,19 @@ def test_device_index_defaults_to_none_in_config(): assert cfg.device_index is None +def test_default_roi_mapped_to_config(): + defn = CameraDefinition(serial_number="SN-ROI", default_roi=[240, 240, 2720, 2720]) + cfg = squid.config.camera_config_from_definition(defn) + assert cfg.default_roi == (240, 240, 2720, 2720) + + +def test_absent_default_roi_inherits_ini_default(): + base = squid.config.get_camera_config() + defn = CameraDefinition(serial_number="SN-NOROI") + cfg = squid.config.camera_config_from_definition(defn) + assert cfg.default_roi == base.default_roi + + def test_type_change_clears_ini_camera_model(): base = squid.config.get_camera_config() other_type = "Hamamatsu" if base.camera_type != squid.config.CameraVariant.HAMAMATSU else "Toupcam"