diff --git a/software/control/_def.py b/software/control/_def.py index ee7fc7daa..294361df1 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" @@ -1163,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") 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/camera_toupcam.py b/software/control/camera_toupcam.py index 5102e7a0c..0658f1a5d 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 @@ -172,38 +210,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 +258,167 @@ 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, + 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 + ) -> 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: + # 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 + + 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(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: + # 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 +437,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 @@ -320,6 +511,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. @@ -349,16 +569,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( @@ -418,8 +640,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 @@ -484,8 +706,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: @@ -625,7 +850,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() @@ -634,7 +873,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: @@ -648,6 +905,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 @@ -845,10 +1112,24 @@ 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) -> 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/control/channel_sequence.py b/software/control/channel_sequence.py index a9a2bc0c0..bfafa9782 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,15 @@ _CACHE_PATH = "cache/channel_sequence.yaml" +# 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): + """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 +161,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 +197,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 +212,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 +273,21 @@ 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 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()) @@ -263,23 +306,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 +350,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 +391,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/core/contrast_manager.py b/software/control/core/contrast_manager.py index 60940461e..53b962882 100644 --- a/software/control/core/contrast_manager.py +++ b/software/control/core/contrast_manager.py @@ -1,53 +1,127 @@ +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): - 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 any other channel's 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_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 + def get_limits_for_dtype(self, channel, dtype): + """This channel's limits, expressed in `dtype`. - source_info = np.iinfo(self.acquisition_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. + """ + 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] + + if stored_dtype == dtype: + return self.contrast_limits[channel] + + 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 + + def convert(value): + return (value - source_info.min) / span * target_span + 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 + 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. - return scaled_min, scaled_max + 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/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/core/live_controller.py b/software/control/core/live_controller.py index cec8b85d8..cdf61a442 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 ( @@ -512,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/control/core/multi_point_controller.py b/software/control/core/multi_point_controller.py index f90e88cbf..d84f76a21 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 @@ -17,7 +17,15 @@ 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, + 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 from control.core.live_controller import LiveController @@ -192,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 @@ -450,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() @@ -496,6 +514,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") @@ -566,11 +589,95 @@ 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. + + 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 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 + 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: + # 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 _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: + 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 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)): @@ -581,7 +688,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... @@ -698,6 +805,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) @@ -759,12 +870,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 @@ -869,7 +982,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"), @@ -877,6 +991,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 @@ -1073,6 +1192,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..3a9dff13c 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,114 @@ 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 + + +# 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. + + 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 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. + + 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(pixel_format), + CameraPixelFormat.storage_bit_depth(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, storage bit depth, 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'} " 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 individual images, or make the cameras match via binning/crop, or select channels " + "from one camera." + ) 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/control/gui_hcs.py b/software/control/gui_hcs.py index a0e4efd7f..354085b03 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 @@ -604,6 +626,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, @@ -702,7 +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 + # 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 @@ -922,23 +951,35 @@ def load_widgets(self): self.nl5Wdiget = NL5Widget.NL5Widget(self.nl5) - if CAMERA_TYPE in ["Toupcam", "Tucsen", "Kinetix"]: - self.cameraSettingWidget = widgets.CameraSettingsWidget( - self.camera, - include_gain_exposure_time=False, - include_camera_temperature_setting=True, - include_camera_auto_wb_setting=False, - ) - else: - self.cameraSettingWidget = widgets.CameraSettingsWidget( - self.camera, - include_gain_exposure_time=False, - include_camera_temperature_setting=False, - include_camera_auto_wb_setting=True, - ) + # 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] + self.cameraSettingWidget = widgets.CameraSettingsWidget( + primary_camera, + **widgets.camera_settings_widget_options(primary_camera._config.camera_type, primary=True), + ) 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[camera_id] = widgets.CameraSettingsWidget( + concrete_camera, + **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. + self.cameraSettingWidgets[PRIMARY_CAMERA_ID] = self.cameraSettingWidget + self.profileWidget = widgets.ProfileWidget(self.microscope.config_repo) self.liveControlWidget = widgets.LiveControlWidget( self.streamHandler, @@ -1093,32 +1134,66 @@ 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) + # 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 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, 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 +1201,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 +1223,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 +1231,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): @@ -1317,6 +1394,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") @@ -1328,7 +1413,13 @@ 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 + 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") @@ -1489,12 +1580,25 @@ 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() + # Dual-camera: refresh GUI state on active-camera switch. The listener fires on + # 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() self.navigationViewer.signal_coordinates_clicked.connect(self.move_from_click_mm) @@ -1560,8 +1664,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) @@ -1732,8 +1836,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), ] ) @@ -1907,6 +2011,64 @@ def _on_live_controller_warning(self, message: str) -> None: self._live_warning_box = box box.show() + 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, 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. + """ + 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: + 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: + 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: + """GUI-thread handler for a Microscope active-camera switch. + + 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) + 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). @@ -1999,6 +2161,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") @@ -2334,6 +2502,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 @@ -2800,7 +2990,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/control/microscope.py b/software/control/microscope.py index 94e4ef186..0bfdf41d8 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,92 @@ 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: + # 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) + + 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).") + + 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 + + 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: + 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 +1238,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/control/models/camera_registry.py b/software/control/models/camera_registry.py index 00695ac1c..a6d96db84 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,48 @@ 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]") + 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)", + ) + default_roi: Optional[List[int]] = Field( + None, description="Per-camera hardware ROI override, [offset_x, offset_y, width, height]" + ) + 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}") + 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 + class CameraRegistryConfig(BaseModel): """ @@ -41,7 +99,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 +162,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/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/control/widgets.py b/software/control/widgets.py index b2231d05e..0edb1e2af 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -23,12 +23,12 @@ 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 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" @@ -57,6 +57,110 @@ 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 + + +# 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(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.setPen(Qt.NoPen) + 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. + + 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 + icon = camera_dot_icon(_camera_is_color(microscope, registry, camera_id)) + return channel_display_label(channel, registry), icon, available + + return decorate + + def error_dialog(message: str, title: str = "Error"): msg = QMessageBox() msg.setIcon(QMessageBox.Warning) @@ -1004,6 +1108,108 @@ 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``, ``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) + 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 _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() + 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) + + 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): """Dialog shown when hardware configuration doesn't match loaded YAML settings.""" @@ -3600,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() @@ -3758,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) @@ -3810,13 +4038,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) @@ -4027,13 +4269,21 @@ 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) + # 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) @@ -4057,16 +4307,103 @@ 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 + 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 + if not camera_available: + 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) + 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 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. + """ + clamped_mode = None + 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) + 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() + # 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() @@ -4079,8 +4416,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") @@ -4179,7 +4517,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) - self.dropdown_modeSelection.activated[str].connect(self.select_new_microscope_mode_by_name) + 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) @@ -4311,6 +4649,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) @@ -4326,7 +4670,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 @@ -4349,7 +4693,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) @@ -4369,6 +4714,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): @@ -5706,7 +6056,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 @@ -5746,6 +6096,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 @@ -5901,7 +6252,9 @@ 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._create_multi_camera_warning_label() self.checkbox_withAutofocus = QCheckBox("Contrast AF") self.checkbox_withAutofocus.setChecked(MULTIPOINT_CONTRAST_AUTOFOCUS_ENABLE_BY_DEFAULT) @@ -6098,9 +6451,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) @@ -6173,6 +6530,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) @@ -6456,6 +6814,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=}") @@ -6473,6 +6835,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() @@ -6940,6 +7305,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 @@ -7006,12 +7374,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) - def set_performance_mode(self, enabled): self.performance_mode = enabled @@ -7110,6 +7472,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 @@ -7175,7 +7541,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) @@ -7262,6 +7628,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() @@ -7377,7 +7744,9 @@ 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), ) + self._create_multi_camera_warning_label() # Add a combo box for shape selection self.combobox_shape = QComboBox() @@ -7633,6 +8002,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() @@ -7721,6 +8091,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) @@ -8835,6 +9206,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() @@ -9006,12 +9380,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) - def set_performance_mode(self, enabled): self.performance_mode = enabled @@ -9029,6 +9397,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 @@ -9062,6 +9433,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""" @@ -9329,6 +9704,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: @@ -9461,6 +9840,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 @@ -11523,6 +11903,37 @@ 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 + 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 + if not camera_available: + 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) + 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() @@ -11537,8 +11948,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 @@ -11599,15 +12011,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 @@ -11694,7 +12100,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) @@ -11735,7 +12142,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() @@ -11784,7 +12192,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: @@ -11798,7 +12209,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) @@ -11857,14 +12269,66 @@ 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: 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. + """ + clamped_mode = None + 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: + # 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() + # 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})") @@ -12016,10 +12480,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 @@ -12092,7 +12555,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: @@ -12103,30 +12575,44 @@ 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.""" - rgb = len(image.shape) == 3 + 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. - # 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 + 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) - 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) + 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"} @@ -12135,9 +12621,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.contrastManager.get_limits_for_dtype(channel_name, incoming_dtype) layer = self.viewer.add_image( canvas, name=channel_name, @@ -12146,11 +12632,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: @@ -12161,7 +12645,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: @@ -12172,10 +12656,12 @@ def updateLayers(self, image, x, y, k, channel_name): 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() @@ -12337,7 +12823,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: @@ -12368,7 +12854,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: @@ -16000,10 +16486,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 +16760,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 +16898,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/control/widgets_mosaic.py b/software/control/widgets_mosaic.py index 17a6ae662..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 @@ -549,7 +550,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 +694,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) --- @@ -861,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 @@ -908,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( @@ -944,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/docs/dual-camera.md b/software/docs/dual-camera.md new file mode 100644 index 000000000..a7fcb6b5a --- /dev/null +++ b/software/docs/dual-camera.md @@ -0,0 +1,233 @@ +# 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. +- **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`. + +### 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**, 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… + +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 **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 +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 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 (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). + +## 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 runs that mix cameras + +| 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.** 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 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. + +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. +- **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. 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. + +## 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..82c5098a5 100644 --- a/software/machine_configs/cameras.yaml.example +++ b/software/machine_configs/cameras.yaml.example @@ -1,30 +1,66 @@ # 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) +# 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, +# 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 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" 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/squid/camera/settings_cache.py b/software/squid/camera/settings_cache.py index 91c125175..3745632b5 100644 --- a/software/squid/camera/settings_cache.py +++ b/software/squid/camera/settings_cache.py @@ -1,22 +1,37 @@ """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: + + version: 2 + cameras: + SN1: {binning: [2, 2], pixel_format: MONO8} + 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 +"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 +43,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: @@ -37,24 +55,99 @@ 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 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. + + 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 Exception as e: + _log.error(f"Cannot read camera settings - camera may be disconnected: {e}") + return 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: + """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 +177,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 +216,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: @@ -125,9 +248,18 @@ def load_camera_settings(cache_path: Path = _DEFAULT_CACHE_PATH) -> Optional[Cac _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/squid/camera/utils.py b/software/squid/camera/utils.py index 0762c10fc..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): @@ -228,6 +226,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: @@ -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]: @@ -346,8 +352,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() @@ -400,6 +411,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/squid/config.py b/software/squid/config.py index c65237c2a..a37f4d2bf 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] @@ -500,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 @@ -593,6 +618,37 @@ 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, "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 + 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]) + if definition.default_roi is not None: + updates["default_roi"] = tuple(definition.default_roi) + 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/conftest.py b/software/tests/conftest.py index 253574ea5..4d055746a 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 @@ -69,6 +71,173 @@ 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 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. + + 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_template") + (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 + 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, + "default", + list(control._def.OBJECTIVES) if hasattr(control._def, "OBJECTIVES") else None, + include_confocal=False, + ) + 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(): + 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, 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/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 = profiles_root + + monkeypatch.setattr(ConfigRepository, "__init__", _init) + + @pytest.fixture(autouse=True) def cleanup_leaked_hardware(monkeypatch): """ 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/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) diff --git a/software/tests/control/test_HighContentScreeningGui.py b/software/tests/control/test_HighContentScreeningGui.py index 0a703421b..b66dc5de0 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 @@ -6,6 +8,16 @@ from qtpy.QtWidgets import QMessageBox 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 + +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 @@ -20,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. @@ -34,6 +90,176 @@ 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 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())] + 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).""" + 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) + + 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 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 + # 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) + # 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] + + +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.""" + monkeypatch.setattr(ConfigRepository, "get_camera_registry", lambda self: TWO_CAMERA_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 _build_two_camera_gui(qtbot, monkeypatch): + 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) + 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] + + _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() + + _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 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 channel's camera" + + # ...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, @@ -61,6 +287,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_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_camera_toupcam.py b/software/tests/control/test_camera_toupcam.py new file mode 100644 index 000000000..97f2a7068 --- /dev/null +++ b/software/tests/control/test_camera_toupcam.py @@ -0,0 +1,745 @@ +"""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 numpy as np +import pytest + +import control.toupcam as real_toupcam +import squid.logging +from control.camera_toupcam import ToupcamCamera, ToupCamCapabilities +from squid.abc import CameraFrameFormat, 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) + + 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__( + 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] = [] + # 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 + + class _FakeToupcam: + @staticmethod + def EnumV2(): + return [_FakeDevice(s) for s in sdk._specs] + + @staticmethod + def Open(cam_id): + # 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[device_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_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")]) + + 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_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() + + camera.set_auto_white_balance_gains(on=False) + + 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) 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..f63f1fe92 --- /dev/null +++ b/software/tests/control/test_channel_display_labels.py @@ -0,0 +1,318 @@ +"""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 + +import pytest +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 ( + LiveControlWidget, + NapariLiveWidget, + _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 _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): + 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" + + +# --------------------------------------------------------------------------- +# _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 + + 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 +# --------------------------------------------------------------------------- + + +class _DropdownStub: + """LiveControlWidget-shaped stub borrowing the REAL dropdown helper methods.""" + + _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 _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]) + 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 + + @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() + 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 _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: + """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 + # Same connection add_components makes, then a user activation: + combo.activated.connect(widget._on_mode_dropdown_activated) + combo.activated.emit(1) + assert widget.selected_names == ["BF Color"] # bare name, not the label + + 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 = _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"] 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 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() 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..802224380 --- /dev/null +++ b/software/tests/control/test_microscope_multi_camera.py @@ -0,0 +1,209 @@ +import numpy as np +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 + +# 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", + crop_width=64, + crop_height=48, + default_binning=[1, 1], + ), + CameraDefinition( + name="Side Camera", + id=2, + serial_number="SIM-2", + type="Toupcam", + hardware_trigger=False, + default_pixel_format="RGB24", + crop_width=64, + crop_height=48, + default_binning=[1, 1], + ), + ] +) + + +@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_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 = [] + for cam_id, cam in scope.cameras.items(): + 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 + + +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 + + +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 new file mode 100644 index 000000000..6cc8a2cb1 --- /dev/null +++ b/software/tests/control/test_multi_camera_acquisition_guards.py @@ -0,0 +1,894 @@ +"""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 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 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, 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 + +# 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 + # 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(): + 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_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)] + assert get_unavailable_camera_channels(channels, cameras) == ["Ghost1", "Ghost2"] + 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) + +# 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", + default_pixel_format="MONO16", + crop_width=64, + crop_height=48, + default_binning=[1, 1], + ), + CameraDefinition( + name="Side Camera", + id=2, + serial_number="SIM-2", + type="Toupcam", + hardware_trigger=False, + default_pixel_format="RGB24", + crop_width=64, + crop_height=48, + default_binning=[1, 1], + ), + ] +) + + +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 _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) + + +def _controller_stopped_after_guards(monkeypatch, registry): + """A simulated MultiPointController whose run_acquisition raises _PastTheGuards at + the first step after the camera guards.""" + mpc = _simulated_controller(monkeypatch, registry) + + 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() + + +# ------------------------------------------------- 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_starting_one(monkeypatch): + """Every camera the run will use pays its slow first frame before the run, not inside + 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 == [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 + + +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_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=2) + + with pytest.raises(RuntimeError): + mpc._warm_up_cameras_and_get_test_image() + + 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 _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]) + 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__ + + +# ------------------------------------------------- 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) + + +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, 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, + ) + 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, dropped=()): + host = _GuardHost(cameras, channels, dropped=dropped) + 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.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() diff --git a/software/tests/control/test_multi_camera_models.py b/software/tests/control/test_multi_camera_models.py index 723d22db2..be3a505cf 100644 --- a/software/tests/control/test_multi_camera_models.py +++ b/software/tests/control/test_multi_camera_models.py @@ -95,6 +95,98 @@ 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 + + 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) + + 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.""" @@ -119,8 +211,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 +232,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 +265,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 +287,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 +299,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 +310,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 +321,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) 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..8e0d756db --- /dev/null +++ b/software/tests/control/test_napari_multi_channel_widget.py @@ -0,0 +1,246 @@ +"""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, contrast_limits=(0, 1), scale=(1, 1, 1)): + self.data = data + self.name = name + self.rgb = rgb + self.contrast_limits = contrast_limits + self.scale = scale + 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, contrast_limits=contrast_limits, scale=scale) + 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.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_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.""" + 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 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..32e806dfd --- /dev/null +++ b/software/tests/control/test_trigger_options_per_camera.py @@ -0,0 +1,251 @@ +"""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 +from unittest.mock import MagicMock + +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._log = MagicMock() + 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.signal_emissions = [] + self._log = MagicMock() + 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): + # 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) + + 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 + + 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): + 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) + # 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( + "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" diff --git a/software/tests/control/test_unified_mosaic_widget.py b/software/tests/control/test_unified_mosaic_widget.py index 62a53aa17..63809939a 100644 --- a/software/tests/control/test_unified_mosaic_widget.py +++ b/software/tests/control/test_unified_mosaic_widget.py @@ -155,3 +155,149 @@ 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) + + +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"] diff --git a/software/tests/control/test_widgets.py b/software/tests/control/test_widgets.py index 51f2a5f38..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 @@ -2609,3 +2609,113 @@ 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() + + +# --------------------------------------------------------------------------- +# 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 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} 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..be72a00d8 --- /dev/null +++ b/software/tests/squid/test_camera_config_from_definition.py @@ -0,0 +1,72 @@ +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_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_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" + defn = CameraDefinition(serial_number="SN-C", type=other_type) + cfg = squid.config.camera_config_from_definition(defn) + assert cfg.camera_model is None diff --git a/software/tests/squid/test_camera_facade.py b/software/tests/squid/test_camera_facade.py new file mode 100644 index 000000000..7a48c9f42 --- /dev/null +++ b/software/tests/squid/test_camera_facade.py @@ -0,0 +1,130 @@ +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_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(): + cam.close = lambda cid=cam_id: closed.append(cid) + facade = ActiveCameraFacade(cameras, active_id=1) + facade.close() + assert sorted(closed) == [1, 2] diff --git a/software/tests/squid/test_settings_cache.py b/software/tests/squid/test_settings_cache.py index f024c45a4..ff7688f4c 100644 --- a/software/tests/squid/test_settings_cache.py +++ b/software/tests/squid/test_settings_cache.py @@ -240,3 +240,200 @@ 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) + + +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 + + +# --------------------------------------------------------------------------- +# 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 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..f1ac4b88e --- /dev/null +++ b/software/tests/squid/test_simulated_camera_rgb.py @@ -0,0 +1,183 @@ +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, **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() + 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 + + +# --- _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() + + +# --- 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 ------------------------------ + + +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() 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" + )