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
2 changes: 1 addition & 1 deletion software/control/_def.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def convert_to_var(option: Union[str, "TriggerMode"]) -> "TriggerMode":

class Acquisition:
NUMBER_OF_FOVS_PER_AF = 3
IMAGE_FORMAT = "bmp"
IMAGE_FORMAT = "tiff"
IMAGE_DISPLAY_SCALING_FACTOR = 0.3
DX = 0.9
DY = 0.9
Expand Down
194 changes: 147 additions & 47 deletions software/control/core/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@


from typing import List, Tuple, Optional, Dict, Any, Callable, TypeVar
from queue import Queue
from queue import Queue, Empty, Full
from threading import Thread, Lock
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass
from enum import Enum
from control.models import AcquisitionChannel
import time
import csv
import itertools
import json
import math
Expand Down Expand Up @@ -93,6 +95,23 @@ def set_display_resolution_scaling(self, display_resolution_scaling):
self._handler.set_display_resolution_scaling(display_resolution_scaling)


log = squid.logging.get_logger(__name__)


@dataclass
class _DefaultRecordingChannel:
"""Stand-in for an AcquisitionChannel when no live channel is set during recording.

save_image() and return_pseudo_colored_image() only consult `.name`; the other
fields keep frames.csv rows uniform when no channel is available.
"""

name: str = "live"
exposure_time: float = 0.0
analog_gain: float = 0.0
illumination_intensity: float = 0.0


class ImageSaver(QObject):
stop_recording = Signal()

Expand All @@ -110,53 +129,92 @@ def __init__(self, image_format=Acquisition.IMAGE_FORMAT):
self.counter = 0
self.recording_start_time = 0
self.recording_time_limit = -1
self._channel_provider: Optional[Callable[[], Optional[Any]]] = None
self._csv_file = None
self._csv_writer = None
self._dropped_count = 0
self._last_queue_full_warning_ts = 0.0

def set_channel_provider(self, provider):
"""Register a callable returning the active live channel (or None).

Called per-frame from the saver thread. Cross-thread reads of the
channel reference are GIL-atomic, so no lock is needed; the GUI thread
just swaps the reference when the user toggles channels.
"""
self._channel_provider = provider

def process_queue(self):
while True:
# stop the thread if stop signal is received
if self.stop_signal_received:
return
# process the queue
try:
[image, frame_ID, timestamp] = self.queue.get(timeout=0.1)
self.image_lock.acquire(True)
folder_ID = int(self.counter / self.max_num_image_per_folder)
file_ID = int(self.counter % self.max_num_image_per_folder)
# create a new folder
if file_ID == 0:
utils.ensure_directory_exists(os.path.join(self.base_path, self.experiment_ID, str(folder_ID)))

if image.dtype == np.uint16:
# need to use tiff when saving 16 bit images
saving_path = os.path.join(
self.base_path, self.experiment_ID, str(folder_ID), str(file_ID) + "_" + str(frame_ID) + ".tiff"
)
iio.imwrite(saving_path, image)
else:
saving_path = os.path.join(
self.base_path,
self.experiment_ID,
str(folder_ID),
str(file_ID) + "_" + str(frame_ID) + "." + self.image_format,
image, frame_id, timestamp = self.queue.get(timeout=0.1)
except Empty:
continue
try:
with self.image_lock:
folder_id = self.counter // self.max_num_image_per_folder
file_id = self.counter % self.max_num_image_per_folder
save_dir = os.path.join(self.base_path, self.experiment_ID, str(folder_id))
if file_id == 0:
utils.ensure_directory_exists(save_dir)

channel = (
self._channel_provider() if self._channel_provider else None
) or _DefaultRecordingChannel()
# Use get_image_filepath for channel-aware naming consistency with the
# multipoint pipeline, but write directly with cv2/iio for speed —
# save_image's imageio.imwrite path is ~10x slower than cv2.imwrite for
# uint8 BMPs and turns the saver into the FPS bottleneck.
saving_path = utils_acquisition.get_image_filepath(
save_dir, str(file_id), channel.name, image.dtype
)
cv2.imwrite(saving_path, image)

self.counter = self.counter + 1
if image.dtype == np.uint16:
iio.imwrite(saving_path, image)
else:
cv2.imwrite(saving_path, image)

if self._csv_writer is not None:
rel_path = os.path.relpath(saving_path, os.path.join(self.base_path, self.experiment_ID))
self._csv_writer.writerow(
[
frame_id,
datetime.fromtimestamp(timestamp).isoformat(),
getattr(channel, "name", "live"),
getattr(channel, "exposure_time", 0.0),
getattr(channel, "analog_gain", 0.0),
getattr(channel, "illumination_intensity", 0.0),
rel_path,
]
)
# No per-row flush: it's an fsync per frame on most filesystems
# and was the second-largest contributor to the FPS regression.
# stop_experiment() flushes via close().

self.counter += 1
except OSError as e:
log.error(f"Writer fatal error: {e}; stopping recording")
self.stop_recording.emit()
except Exception as e:
log.warning(f"Failed to write frame {frame_id}: {e}")
finally:
self.queue.task_done()
self.image_lock.release()
except:
pass

def enqueue(self, image, frame_ID, timestamp):
def enqueue(self, image, frame_id, timestamp):
try:
self.queue.put_nowait([image, frame_ID, timestamp])
if (self.recording_time_limit > 0) and (
time.time() - self.recording_start_time >= self.recording_time_limit
):
self.stop_recording.emit()
# when using self.queue.put(str_), program can be slowed down despite multithreading because of the block and the GIL
except:
print("imageSaver queue is full, image discarded")
self.queue.put_nowait([image, frame_id, timestamp])
except Full:
self._dropped_count += 1
now = time.time()
if now - self._last_queue_full_warning_ts >= 1.0:
log.warning(f"Image queue full; frame {frame_id} dropped")
self._last_queue_full_warning_ts = now
return
Comment on lines 205 to +213

if self.recording_time_limit > 0 and time.time() - self.recording_start_time >= self.recording_time_limit:
log.info(f"Auto-stopping: time limit reached ({self.recording_time_limit}s)")
self.stop_recording.emit()

def set_base_path(self, path):
self.base_path = path
Expand All @@ -165,23 +223,65 @@ def set_recording_time_limit(self, time_limit):
self.recording_time_limit = time_limit

def start_new_experiment(self, experiment_ID, add_timestamp=True):
# Defensively close any prior recording in case the caller didn't.
self.stop_experiment()

if add_timestamp:
# generate unique experiment ID
self.experiment_ID = experiment_ID + "_" + datetime.now().strftime("%Y-%m-%d_%H-%M-%S.%f")
else:
self.experiment_ID = experiment_ID
self.recording_start_time = time.time()
# create a new folder
try:
utils.ensure_directory_exists(os.path.join(self.base_path, self.experiment_ID))
# to do: save configuration
except:
pass
# reset the counter
self.counter = 0
self._dropped_count = 0

def close(self):
experiment_dir = os.path.join(self.base_path, self.experiment_ID)
try:
utils.ensure_directory_exists(experiment_dir)
except Exception as e:
log.error(f"Failed to create experiment directory {experiment_dir}: {e}")
raise

csv_path = os.path.join(experiment_dir, "frames.csv")
self._csv_file = open(csv_path, "w", newline="")
self._csv_writer = csv.writer(self._csv_file)
self._csv_writer.writerow(
Comment on lines +244 to +247
["frame_id", "timestamp_iso", "channel", "exposure_ms", "gain", "illumination_intensity", "file"]
)
self._csv_file.flush()

log.info(f"Recording started: id={self.experiment_ID}, dir={experiment_dir}")
if self._channel_provider is None:
log.warning("channel_provider not set; frames tagged with default 'live' channel")

def stop_experiment(self):
"""Finalize the current recording: drain the queue, close frames.csv, log summary.

Called by the widget on Stop and on time-limit auto-stop. Idempotent.
Drains the queue first so any buffered frames land in the current
experiment dir before experiment_ID is cleared — without that, the
saver thread races and writes to a non-existent directory.
"""
# Block until the saver thread has processed every buffered frame.
# Safe because the caller has already stopped the streamHandler, so no
# new items can be enqueued; queue.task_done() runs in finally so a
# write exception cannot leave us hanging here.
self.queue.join()
if self._csv_file is not None:
try:
self._csv_file.close()
finally:
self._csv_file = None
self._csv_writer = None
if self.experiment_ID:
duration = time.time() - self.recording_start_time
log.info(
f"Recording stopped: frames_saved={self.counter}, "
f"dropped={self._dropped_count}, duration={duration:.1f}s"
)
self.experiment_ID = ""

def close(self):
self.stop_experiment()
self.stop_signal_received = True
self.thread.join()

Expand Down
6 changes: 5 additions & 1 deletion software/control/gui_hcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -888,7 +888,11 @@ def load_widgets(self):
self.emission_filter_wheel, self.liveController, config_repo=self.microscope.config_repo
)

self.recordingControlWidget = widgets.RecordingWidget(self.streamHandler, self.imageSaver)
self.recordingControlWidget = widgets.RecordingWidget(
self.streamHandler,
self.imageSaver,
channel_provider=lambda: self.liveController.currentConfiguration,
)
self.wellplateFormatWidget = widgets.WellplateFormatWidget(
self.stage, self.navigationViewer, self.streamHandler, self.liveController
)
Expand Down
26 changes: 12 additions & 14 deletions software/control/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -4410,11 +4410,11 @@ def update_displacement_um_display(self, displacement=None):


class RecordingWidget(QFrame):
def __init__(self, streamHandler, imageSaver, main=None, *args, **kwargs):
def __init__(self, streamHandler, imageSaver, main=None, channel_provider=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.imageSaver = imageSaver # for saving path control
self.imageSaver = imageSaver
self.streamHandler = streamHandler
self.base_path_is_set = False
self._channel_provider = channel_provider
self.add_components()
self.setFrameStyle(QFrame.Panel | QFrame.Raised)

Expand All @@ -4425,8 +4425,6 @@ def add_components(self):

self.lineEdit_savingDir = QLineEdit()
self.lineEdit_savingDir.setReadOnly(True)
self.lineEdit_savingDir.setText("Choose a base saving directory")

self.lineEdit_savingDir.setText(DEFAULT_SAVING_PATH)
self.imageSaver.set_base_path(DEFAULT_SAVING_PATH)

Expand Down Expand Up @@ -4485,26 +4483,24 @@ def add_components(self):
self.imageSaver.stop_recording.connect(self.stop_recording)

def set_saving_dir(self):
dialog = QFileDialog()
save_dir_base = dialog.getExistingDirectory(None, "Select Folder")
save_dir_base = QFileDialog.getExistingDirectory(None, "Select Folder")
if not save_dir_base:
return
self.imageSaver.set_base_path(save_dir_base)
self.lineEdit_savingDir.setText(save_dir_base)
self.base_path_is_set = True

def toggle_recording(self, pressed):
if self.base_path_is_set == False:
self.btn_record.setChecked(False)
msg = QMessageBox()
msg.setText("Please choose base saving directory first")
msg.exec_()
return
if pressed:
self.lineEdit_experimentID.setEnabled(False)
self.btn_setSavingDir.setEnabled(False)
if self._channel_provider is not None:
self.imageSaver.set_channel_provider(self._channel_provider)
self.imageSaver.start_new_experiment(self.lineEdit_experimentID.text())
self.streamHandler.start_recording()
else:
self.streamHandler.stop_recording()
self.imageSaver.stop_experiment()
self.imageSaver.set_channel_provider(None)
self.lineEdit_experimentID.setEnabled(True)
Comment on lines 4499 to 4504
self.btn_setSavingDir.setEnabled(True)

Expand All @@ -4513,6 +4509,8 @@ def stop_recording(self):
self.lineEdit_experimentID.setEnabled(True)
self.btn_record.setChecked(False)
self.streamHandler.stop_recording()
self.imageSaver.stop_experiment()
self.imageSaver.set_channel_provider(None)
self.btn_setSavingDir.setEnabled(True)


Expand Down
13 changes: 13 additions & 0 deletions software/tests/control/test_def.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,16 @@ def test_unrecognized_values_default_to_false(self):
assert self._parse_sim_setting("fasle") is False
assert self._parse_sim_setting("simualte") is False
assert self._parse_sim_setting("invalid") is False


def test_default_image_format_is_tiff():
"""The in-code default for Acquisition.IMAGE_FORMAT is 'tiff' (was 'bmp').

INI files with [ACQUISITION] image_format=... override this at module
load time, so we assert against the source rather than the runtime value.
"""
import inspect
import control._def

source = inspect.getsource(control._def.Acquisition)
assert 'IMAGE_FORMAT = "tiff"' in source
Loading
Loading