Skip to content

feat: dual-camera support (one active at a time, per-channel binding) - #607

Open
hongquanli wants to merge 52 commits into
masterfrom
feat/dual-camera-support
Open

feat: dual-camera support (one active at a time, per-channel binding)#607
hongquanli wants to merge 52 commits into
masterfrom
feat/dual-camera-support

Conversation

@hongquanli

Copy link
Copy Markdown
Contributor

Summary

Dual-camera support where only one camera is active at a time (e.g. a monochrome fluorescence camera + a color brightfield camera behind a beam splitter). Channels bind to cameras; live view and acquisitions switch automatically. The second camera runs software-trigger-only (no hardware trigger wiring required).

Distinct from the simultaneous multi-camera efforts — this is camera switching, built so single-camera systems are completely unaffected.

How it works

  • Config: declare cameras in machine_configs/cameras.yaml — each entry gains type (driver), hardware_trigger: true/false, and optional per-camera overrides (default_pixel_format, crop, rotate/flip, binning). Camera id: 1 is the primary. No cameras.yaml (or a single-camera one) → exactly today's behavior, no facade installed.
  • Core: all declared cameras are built at startup; microscope.camera becomes an ActiveCameraFacade delegating to the active concrete camera, so the ~15 components that cache a camera reference work unchanged. Microscope.set_active_camera() centralizes switching: per-camera trigger-mode memory, MCU trigger reprogramming, streaming handover, rollback on failure, and listener notification (delivered cross-thread to the GUI via a Qt signal).
  • Channel binding: AcquisitionChannel.camera (int id, null = primary) is now honored at runtime — LiveController.set_microscope_mode switches cameras before applying exposure/gain, which gives live view, MultiPointWorker, contrast AF, and the TCP/MCP server the behavior through one seam. (Also fixes the channel editor storing the camera name string into the int field.)
  • GUI: per-camera dot + "— Side Camera" suffix labels in all channel lists (bare channel names remain the identity everywhere — saved YAMLs and APIs are unaffected); trigger dropdown offers Hardware only when the active camera supports it; one settings tab per camera; per-serial settings cache (legacy cache migrates).
  • Acquisition safety: mixed-camera selections with incompatible frame geometry (size, color-ness, bit depth, pixel size) are blocked for Zarr with an inline warning + disabled Start, and a controller backstop guards headless runs. Mixed mono+color runs use individual images — a discovery from testing: the OME-TIFF writer is 2D-grayscale-only (docs/dual-camera.md documents all format constraints).
  • Metadata: per-channel pixel sizes recorded in acquisition parameters; per-camera warm-up frame at acquisition start.
  • Tracking / Simple Recording: primary-camera-only in v1 (auto-switch on tab entry).

Docs

software/docs/dual-camera.md (setup, channel binding, trigger behavior, format constraints, migration notes) + updated cameras.yaml.example.

Test plan

  • 487-test gate on the final tree: dual-camera suites (217), tests/squid (177), deterministic multipoint subset, firmware-sim whitelist, GUI construction incl. a cross-thread camera-change bridge test
  • Full per-directory sweep during development: 1722 passed / 0 failures
  • End-to-end simulated mixed acquisition (mono camera 1 + RGB camera 2): completion, per-channel files with per-camera geometry, switch sequence asserted; ran 6× green
  • Single-camera regression surface: no facade, byte-identical object graph, existing suites green throughout
  • Every task independently code-reviewed (spec + quality) with adversarial fix rounds; final whole-branch review passed
  • Visual GUI pass on a real display (labels, Start-blocked warning, live RGB) — programmatic equivalents verified in simulation
  • First CI run: watch the GUI test step (builds more GUIs per process than before)

Follow-up (non-blocking) items are triaged in AI-docs/Squid/to-do/2026-08-09-dual-camera-followups.md.

🤖 Generated with Claude Code

hongquanli and others added 30 commits August 9, 2026 03:46
…r-camera overrides

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…el.camera

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mera

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 <noreply@anthropic.com>
…_camera with per-camera trigger memory

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…nding

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… testing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ettings

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 <noreply@anthropic.com>
… via UserRole

Display decoration for multi-camera systems: live-view dropdowns and
multipoint channel lists show a per-camera dot icon and a
'<name> — <camera name>' 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 <noreply@anthropic.com>
…dropdown reader

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 <noreply@anthropic.com>
…o named method

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 <noreply@anthropic.com>
…ge 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 <noreply@anthropic.com>
…her 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 <noreply@anthropic.com>
…fferable 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 <noreply@anthropic.com>
…p) and unavailable-camera validation

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 <noreply@anthropic.com>
…eck the camera guard at Start

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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…isit the starting camera last

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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ak, sim auto-WB signature

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 <noreply@anthropic.com>
… disc color

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 '— <camera name>' suffix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ignature

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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Alpaca233 and others added 22 commits August 10, 2026 14:01
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…y path

Review follow-up to 8954e96 and 0040970, which each dropped one piece of
widget-wide state but left its neighbours in place.

Contrast limits (a regression introduced by 8954e96): 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) <noreply@anthropic.com>
Review follow-up to d94b203.

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) <noreply@anthropic.com>
…type

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) <noreply@anthropic.com>
…e-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 5c419a0/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 e92b314, 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…he stage"

This reverts commit 1471576. 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) <noreply@anthropic.com>
Reverts the four PyQt6 compatibility commits in a single commit,
restoring the PyQt5 pin at the entry points:

- 7cd5701 fix(qt6): replace removed QDesktopWidget with QScreen API
- b9634c8 fix(qt6): use binding-agnostic matplotlib backend_qtagg
- eaca176 feat(qt): entry points select Qt binding via squid.qt_binding
- c6130cc feat(qt): add binding selector preferring PyQt6 when installed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…er 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants