Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 57 additions & 6 deletions software/control/core/job_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,24 +150,52 @@ def _acquire_file_lock(lock_path: str, context: str = ""):
) from exc


@dataclass
class TiffWriteResult:
"""Result from a SaveImageJob, containing frame info for viewer notification."""

Comment on lines +155 to +156
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TiffWriteResult is used by both SaveImageJob and SaveOMETiffJob, but the docstring currently only mentions SaveImageJob. Updating the docstring to reflect all producers (and that filepath may include a page selector like "#") would prevent confusion for future consumers.

Suggested change
"""Result from a SaveImageJob, containing frame info for viewer notification."""
"""Result from SaveImageJob or SaveOMETiffJob, containing frame info for viewer notification.
The ``filepath`` may optionally include a page selector fragment (e.g. ``"#<idx>"``)
to indicate a specific page/frame within a multi-page TIFF/OME-TIFF file.
"""

Copilot uses AI. Check for mistakes.
filepath: str
fov: int
time_point: int
z_index: int
channel_name: str
region_id: int = 0

Comment on lines +153 to +163
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior relies on SaveImageJob/SaveOMETiffJob returning a TiffWriteResult that is then interpreted by MultiPointWorker to notify the viewer. There are existing tests for ZarrWriteResult and OME-TIFF saving; adding unit tests that assert (1) SaveImageJob.run() returns a TiffWriteResult with the actual on-disk path/extension and (2) SaveOMETiffJob returns a page-indexed filepath would help prevent regressions of the black-screen race.

Copilot uses AI. Check for mistakes.

class SaveImageJob(Job):
_log: ClassVar = squid.logging.get_logger("SaveImageJob")

def run(self) -> bool:
def _make_result(self) -> TiffWriteResult:
info = self.capture_info
filepath = utils_acquisition.get_image_filepath(
info.save_directory, info.file_id, info.configuration.name, np.uint16
Comment on lines +170 to +171
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SaveImageJob._make_result() hardcodes np.uint16 when constructing the filepath. If the captured image dtype is not uint16 (e.g., uint8 RGB or any non-uint16 format), utils_acquisition.save_image() will write a different extension/path than the one returned in TiffWriteResult, and NDViewer will be notified with a non-existent filepath. Construct the filepath using the actual image dtype (or otherwise mirror the exact path used by save_image()).

Suggested change
filepath = utils_acquisition.get_image_filepath(
info.save_directory, info.file_id, info.configuration.name, np.uint16
# Use the actual image dtype (if available) so the filepath matches the one used by save_image().
image_dtype = (
self.capture_image.image_array.dtype
if self.capture_image.image_array is not None
else np.uint16
)
filepath = utils_acquisition.get_image_filepath(
info.save_directory, info.file_id, info.configuration.name, image_dtype

Copilot uses AI. Check for mistakes.
)
return TiffWriteResult(
filepath=filepath,
fov=info.fov,
time_point=info.time_point or 0,
z_index=info.z_index,
channel_name=info.configuration.name,
region_id=info.region_id,
)

def run(self) -> TiffWriteResult:
from control.core.io_simulation import is_simulation_enabled, simulated_tiff_write

image = self.image_array()
result = self._make_result()

# Simulated disk I/O mode - encode to buffer, throttle, discard
if is_simulation_enabled():
bytes_written = simulated_tiff_write(image)
self._log.debug(
f"SaveImageJob {self.job_id}: simulated write of {bytes_written} bytes " f"(image shape={image.shape})"
)
return True
return result
Comment on lines 188 to +194
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In simulated disk I/O mode, SaveImageJob returns a TiffWriteResult (triggering viewer notification) but simulated_tiff_write() explicitly discards the buffer and does not create a file on disk. This will cause NDViewer to try to read a filepath that will never exist when SIMULATED_DISK_IO_ENABLED is true. Consider suppressing signal_tiff_frame_written in this mode or writing a real temporary file when the viewer is active.

Copilot uses AI. Check for mistakes.

is_color = len(image.shape) > 2
return self.save_image(image, self.capture_info, is_color)
self.save_image(image, self.capture_info, is_color)
return result

def save_image(self, image: np.array, info: CaptureInfo, is_color: bool):
# NOTE(imo): We silently fall back to individual image saving here. We should warn or do something.
Expand Down Expand Up @@ -235,7 +263,30 @@ class SaveOMETiffJob(Job):
_log: ClassVar = squid.logging.get_logger("SaveOMETiffJob")
acquisition_info: Optional[AcquisitionInfo] = field(default=None)

def run(self) -> bool:
def _make_result(self) -> TiffWriteResult:
info = self.capture_info
# Use the actual OME-TIFF path with page index for correct plane reading
# OME-TIFF stack layout: (T, Z, C, Y, X) — page = t * (Z * C) + z * C + c
ome_folder = ome_tiff_writer.ome_output_folder(self.acquisition_info, info)
base_name = ome_tiff_writer.ome_base_name(info)
ome_path = os.path.join(ome_folder, base_name + ".ome.tiff")
t = info.time_point or 0
z = info.z_index
c = info.configuration_idx
n_z = self.acquisition_info.total_z_levels
n_c = self.acquisition_info.total_channels
page_idx = t * (n_z * n_c) + z * n_c + c
filepath = f"{ome_path}#{page_idx}"
return TiffWriteResult(
filepath=filepath,
fov=info.fov,
time_point=t,
z_index=z,
channel_name=info.configuration.name,
region_id=info.region_id,
)

def run(self) -> TiffWriteResult:
if self.acquisition_info is None:
raise ValueError(
"SaveOMETiffJob.run() requires acquisition_info but it is None. "
Expand Down Expand Up @@ -275,10 +326,10 @@ def run(self) -> bool:
f"SaveOMETiffJob {self.job_id}: simulated write of {bytes_written} bytes "
f"(image shape={image.shape})"
)
return True
return self._make_result()

self._save_ome_tiff(image, self.capture_info)
return True
return self._make_result()

def _save_ome_tiff(self, image: np.ndarray, info: CaptureInfo) -> None:
# with reference to Talley's https://github.com/pymmcore-plus/pymmcore-plus/blob/main/src/pymmcore_plus/mda/handlers/_ome_tiff_writer.py and Christoph's https://forum.image.sc/t/how-to-create-an-image-series-ome-tiff-from-python/42730/7
Expand Down
3 changes: 3 additions & 0 deletions software/control/core/multi_point_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,6 @@ class MultiPointControllerFunctions:
# Zarr frame written callback - called when subprocess completes writing a frame
# Args: (fov, time_point, z_index, channel_name, region_idx)
signal_zarr_frame_written: Callable[[int, int, int, str, int], None] = lambda *a, **kw: None
# TIFF frame written callback - called when subprocess completes writing a frame
# Args: (filepath, fov, time_point, z_index, channel_name, region_id)
signal_tiff_frame_written: Callable[[str, int, int, int, str, int], None] = lambda *a, **kw: None
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

signal_tiff_frame_written uses a parameter named region_id typed as int, but region identifiers in multipoint scans appear to be region names/IDs coming from scan_region_names (strings). Using int typing here is misleading and can cause accidental key mismatches (e.g., dicts keyed by region name). Consider typing this as str (or a Union[str, int]) consistently across the callback and TiffWriteResult.

Suggested change
signal_tiff_frame_written: Callable[[str, int, int, int, str, int], None] = lambda *a, **kw: None
signal_tiff_frame_written: Callable[[str, int, int, int, str, Union[str, int]], None] = lambda *a, **kw: None

Copilot uses AI. Check for mistakes.
8 changes: 7 additions & 1 deletion software/control/core/multi_point_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from squid.abc import AbstractCamera, CameraFrame, CameraFrameFormat
import squid.logging
import control.core.job_processing
from control.core.job_processing import ZarrWriteResult
from control.core.job_processing import TiffWriteResult, ZarrWriteResult
from control.core.job_processing import (
CaptureInfo,
SaveImageJob,
Expand Down Expand Up @@ -833,6 +833,12 @@ def _summarize_job_result(self, job_result: JobResult) -> bool:
elif isinstance(job_result.result, ZarrWriteResult):
r = job_result.result
self.callbacks.signal_zarr_frame_written(r.fov, r.time_point, r.z_index, r.channel_name, r.region_idx)
# Handle TiffWriteResult - notify viewer that frame is written
elif isinstance(job_result.result, TiffWriteResult):
r = job_result.result
self.callbacks.signal_tiff_frame_written(
r.filepath, r.fov, r.time_point, r.z_index, r.channel_name, r.region_id
)
return True

def _handle_downsampled_view_result(self, result: DownsampledViewResult) -> None:
Expand Down
41 changes: 17 additions & 24 deletions software/control/gui_hcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ def __init__(
signal_slack_timepoint_notification=self._signal_slack_timepoint_notification_fn,
signal_slack_acquisition_finished=self._signal_slack_acquisition_finished_fn,
signal_zarr_frame_written=self._signal_zarr_frame_written_fn,
signal_tiff_frame_written=self._signal_tiff_frame_written_fn,
),
scan_coordinates=scan_coordinates,
laser_autofocus_controller=laser_autofocus_controller,
Expand Down Expand Up @@ -362,30 +363,9 @@ def _signal_new_image_fn(self, frame: squid.abc.CameraFrame, info: CaptureInfo):
frame.frame, info.position.x_mm, info.position.y_mm, info.z_index, napri_layer_name
)

# NDViewer push-based API: register image
# Compute flat FOV index from region and fov within region
region_offset = self._ndviewer_region_fov_offset.get(info.region_id)
if region_offset is None:
# This should not happen if start_acquisition was called correctly
self.log.warning(
f"Unknown region_id '{info.region_id}' in NDViewer registration. "
f"Available: {list(self._ndviewer_region_fov_offset.keys())}. Skipping."
)
return
flat_fov_idx = region_offset + info.fov

if self._ndviewer_mode in (NDViewerMode.ZARR_6D, NDViewerMode.ZARR_5D):
# Zarr modes: notification happens via signal_zarr_frame_written callback
# when the subprocess completes writing, not here (too early).
pass
else:
# TIFF mode: register with filepath (synchronous write, notification is correct here)
filepath = control.utils_acquisition.get_image_filepath(
info.save_directory, info.file_id, info.configuration.name, frame.frame.dtype
)
self.ndviewer_register_image.emit(
info.time_point, flat_fov_idx, info.z_index, info.configuration.name, filepath
)
# NDViewer notification for both TIFF and Zarr modes happens via post-write
# callbacks (signal_tiff_frame_written / signal_zarr_frame_written) to avoid
# race conditions with async file writes. Do not emit here.

def _signal_current_configuration_fn(self, config: AcquisitionChannel):
self.signal_current_configuration.emit(config)
Expand Down Expand Up @@ -449,6 +429,19 @@ def _signal_zarr_frame_written_fn(
flat_fov = fov
self.ndviewer_notify_zarr_frame.emit(time_point, flat_fov, z_index, channel_name, 0)

def _signal_tiff_frame_written_fn(
self, filepath: str, fov: int, time_point: int, z_index: int, channel_name: str, region_id: int
):
"""Called when subprocess completes writing a TIFF frame.

This is the correct time to notify the viewer - after data is on disk.
"""
region_offset = self._ndviewer_region_fov_offset.get(region_id)
if region_offset is None:
return
Comment on lines +439 to +441
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If region_id is not found in _ndviewer_region_fov_offset, this silently returns with no logging. This makes NDViewer failures hard to diagnose (and differs from the earlier warning behavior). Consider logging a warning (including region_id and available keys) before returning, or at least log at debug level.

Copilot uses AI. Check for mistakes.
flat_fov_idx = region_offset + fov
self.ndviewer_register_image.emit(time_point, flat_fov_idx, z_index, channel_name, filepath)

# -------------------------------------------------------------------------
# Helper methods for Zarr FOV path building
# -------------------------------------------------------------------------
Expand Down
Loading