From b1639fa3423f9669af4438b3dc5ab049c1876715 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 12 May 2026 10:24:46 -0700 Subject: [PATCH 1/4] imager+segmenter: record actual frame count and interrupt state in metadata - imager: write acq_nb_frame (actuals from routine.progress) instead of nb_frame, and drop any stale nb_frame the imaging-command request count leaked into the merged metadata dict. Default configs (v2.6, v3.0) renamed to match Node-RED globals, which already use acq_nb_frame. - segmenter: when a run is interrupted (_interrupt_requested), persist "interrupted": true into the acquisition's metadata.json so the dashboard can distinguish a partial segmentation from a clean one. - lib/db.js: fix "interupted" -> "interrupted" typo on both the metadata key it reads and the field it returns (no other consumers reference the misspelling). Closes the writer half of fairscope/PlanktoScope3#337. --- controller/imager/main.py | 13 +++++++++---- default-configs/v2.6.config.json | 2 +- default-configs/v3.0.config.json | 2 +- lib/db.js | 2 +- segmenter/planktoscope/segmenter/__init__.py | 13 +++++++++++++ 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/controller/imager/main.py b/controller/imager/main.py index a1ddfb1b3..1d37cd0dc 100644 --- a/controller/imager/main.py +++ b/controller/imager/main.py @@ -278,7 +278,7 @@ def _initialize_acquisition_directory( """Make the directory where images will be saved for the current image-acquisition routine. This also initializes a file integrity log in the directory. The metadata is not written - here — it is finalized at the end of acquisition so that `nb_frame` reflects the actual + here — it is finalized at the end of acquisition so that `acq_nb_frame` reflects the actual number of frames captured (which may be less than planned if the run is interrupted). Args: @@ -335,7 +335,8 @@ def __init__( routine: the image-acquisition routine to run. mqtt_client: an MQTT client which will be used to broadcast updates. metadata: the acquisition metadata to write to `metadata.json` once the routine - finishes. `nb_frame` is overwritten with the actual number of frames captured. + finishes. `acq_nb_frame` is written with the actual number of frames captured; + any stale `nb_frame` (the requested count from the imaging command) is removed. """ super().__init__() self._routine = routine @@ -393,13 +394,17 @@ def run(self) -> None: # Finalize metadata.json with the actual frame count before announcing the final # status, so that downstream consumers (e.g. the segmenter) cannot observe the # acquisition as finished without a metadata file on disk. - self._metadata["nb_frame"] = self._routine.progress + # `acq_nb_frame` carries the count of frames actually captured (may be less than the + # requested count if the run was interrupted). `nb_frame` was the *requested* count + # from the imaging command — drop it so it can't be mistaken for actuals. + self._metadata.pop("nb_frame", None) + self._metadata["acq_nb_frame"] = self._routine.progress metadata_filepath = os.path.join(self._routine.output_path, "metadata.json") with open(metadata_filepath, "w", encoding="utf-8") as metadata_file: json.dump(self._metadata, metadata_file, indent=4) integrity.append_to_integrity_file(metadata_filepath) loguru.logger.debug( - f"Saved metadata to {metadata_filepath} with nb_frame={self._routine.progress}" + f"Saved metadata to {metadata_filepath} with acq_nb_frame={self._routine.progress}" ) if final_status: diff --git a/default-configs/v2.6.config.json b/default-configs/v2.6.config.json index 59112fec2..f12153af4 100644 --- a/default-configs/v2.6.config.json +++ b/default-configs/v2.6.config.json @@ -14,7 +14,7 @@ "object_depth_min": 1, "object_depth_max": 2, "process_id": 1, - "nb_frame": 100, + "acq_nb_frame": 100, "sleep_before": 0.5, "imaging_pump_volume": 0.01, "user_setup": false diff --git a/default-configs/v3.0.config.json b/default-configs/v3.0.config.json index 57d27fe1f..9958b7e91 100644 --- a/default-configs/v3.0.config.json +++ b/default-configs/v3.0.config.json @@ -14,7 +14,7 @@ "object_depth_min": 1, "object_depth_max": 2, "process_id": 1, - "nb_frame": 100, + "acq_nb_frame": 100, "sleep_before": 0.5, "imaging_pump_volume": 0.01, "user_setup": false diff --git a/lib/db.js b/lib/db.js index 6109f71ee..3b5a01f57 100644 --- a/lib/db.js +++ b/lib/db.js @@ -85,7 +85,7 @@ async function getAcquisitionFromPath(path) { is_segmented, path, gallery: getGalleryPath(path), - interupted: metadata.interupted, + interrupted: metadata.interrupted, date: metadata.acq_local_datetime, acq_magnification, } diff --git a/segmenter/planktoscope/segmenter/__init__.py b/segmenter/planktoscope/segmenter/__init__.py index 9453e0931..15eb06b79 100644 --- a/segmenter/planktoscope/segmenter/__init__.py +++ b/segmenter/planktoscope/segmenter/__init__.py @@ -955,6 +955,19 @@ def segment_path(self, path, ecotaxa_export): if self._interrupt_requested: logger.info(f"Pipeline interrupted by user for {path}, not marking as done") + # Persist an interrupted flag in metadata.json so downstream consumers + # (lib/db.js reads `metadata.interrupted`) can distinguish a partial + # segmentation from a clean one. + metadata_path = os.path.join(self.__working_path, "metadata.json") + try: + with open(metadata_path, "r", encoding="utf-8") as f: + metadata = json.load(f) + metadata["interrupted"] = True + with open(metadata_path, "w", encoding="utf-8") as f: + json.dump(metadata, f, indent=4) + logger.info(f"Marked {metadata_path} with interrupted=true") + except (OSError, json.JSONDecodeError) as exc: + logger.error(f"Could not mark {metadata_path} as interrupted: {exc}") else: # Add file 'done' to path to mark the folder as already segmented with open(os.path.join(self.__working_path, "done.txt"), "w") as done_file: From f701deeb561225d8ef847884f115bc48b7d6a88d Mon Sep 17 00:00:00 2001 From: Sonny Piers Date: Wed, 13 May 2026 11:59:07 +0000 Subject: [PATCH 2/4] f --- default-configs/v2.6.config.json | 2 +- default-configs/v3.0.config.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/default-configs/v2.6.config.json b/default-configs/v2.6.config.json index f12153af4..59112fec2 100644 --- a/default-configs/v2.6.config.json +++ b/default-configs/v2.6.config.json @@ -14,7 +14,7 @@ "object_depth_min": 1, "object_depth_max": 2, "process_id": 1, - "acq_nb_frame": 100, + "nb_frame": 100, "sleep_before": 0.5, "imaging_pump_volume": 0.01, "user_setup": false diff --git a/default-configs/v3.0.config.json b/default-configs/v3.0.config.json index 9958b7e91..57d27fe1f 100644 --- a/default-configs/v3.0.config.json +++ b/default-configs/v3.0.config.json @@ -14,7 +14,7 @@ "object_depth_min": 1, "object_depth_max": 2, "process_id": 1, - "acq_nb_frame": 100, + "nb_frame": 100, "sleep_before": 0.5, "imaging_pump_volume": 0.01, "user_setup": false From c5ec2a08b96090b613f59cc28f8fcee6754dfcfd Mon Sep 17 00:00:00 2001 From: Sonny Piers Date: Wed, 13 May 2026 12:05:58 +0000 Subject: [PATCH 3/4] f --- controller/imager/main.py | 1 + segmenter/planktoscope/segmenter/__init__.py | 13 ------------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/controller/imager/main.py b/controller/imager/main.py index 1d37cd0dc..7234174b6 100644 --- a/controller/imager/main.py +++ b/controller/imager/main.py @@ -391,6 +391,7 @@ def run(self) -> None: ), ) + self._metadata["interrupted"] = self._metadata["acq_nb_frame"] != self._metadata["nb_frame"] # Finalize metadata.json with the actual frame count before announcing the final # status, so that downstream consumers (e.g. the segmenter) cannot observe the # acquisition as finished without a metadata file on disk. diff --git a/segmenter/planktoscope/segmenter/__init__.py b/segmenter/planktoscope/segmenter/__init__.py index 15eb06b79..9453e0931 100644 --- a/segmenter/planktoscope/segmenter/__init__.py +++ b/segmenter/planktoscope/segmenter/__init__.py @@ -955,19 +955,6 @@ def segment_path(self, path, ecotaxa_export): if self._interrupt_requested: logger.info(f"Pipeline interrupted by user for {path}, not marking as done") - # Persist an interrupted flag in metadata.json so downstream consumers - # (lib/db.js reads `metadata.interrupted`) can distinguish a partial - # segmentation from a clean one. - metadata_path = os.path.join(self.__working_path, "metadata.json") - try: - with open(metadata_path, "r", encoding="utf-8") as f: - metadata = json.load(f) - metadata["interrupted"] = True - with open(metadata_path, "w", encoding="utf-8") as f: - json.dump(metadata, f, indent=4) - logger.info(f"Marked {metadata_path} with interrupted=true") - except (OSError, json.JSONDecodeError) as exc: - logger.error(f"Could not mark {metadata_path} as interrupted: {exc}") else: # Add file 'done' to path to mark the folder as already segmented with open(os.path.join(self.__working_path, "done.txt"), "w") as done_file: From 28a994370fb60ad2fdb150090f895069faad2e2b Mon Sep 17 00:00:00 2001 From: Sonny Piers Date: Wed, 13 May 2026 13:31:08 +0000 Subject: [PATCH 4/4] f --- controller/imager/main.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/controller/imager/main.py b/controller/imager/main.py index 7234174b6..3db9e4e24 100644 --- a/controller/imager/main.py +++ b/controller/imager/main.py @@ -347,11 +347,13 @@ def run(self) -> None: """Run a stop-flow image-acquisition routine until completion or interruption.""" self._mqtt_client.publish("status/imager", '{"status":"Started"}') final_status: str = "" + interrupted: bool = False while True: if (result := self._routine.run_step()) is None: if self._routine.interrupted: loguru.logger.debug("Image-acquisition routine was interrupted!") final_status = '{"status":"Interrupted"}' + interrupted = True break loguru.logger.debug("Image-acquisition routine ran to completion!") final_status = json.dumps( @@ -391,14 +393,13 @@ def run(self) -> None: ), ) - self._metadata["interrupted"] = self._metadata["acq_nb_frame"] != self._metadata["nb_frame"] + self._metadata["interrupted"] = interrupted # Finalize metadata.json with the actual frame count before announcing the final # status, so that downstream consumers (e.g. the segmenter) cannot observe the # acquisition as finished without a metadata file on disk. # `acq_nb_frame` carries the count of frames actually captured (may be less than the # requested count if the run was interrupted). `nb_frame` was the *requested* count - # from the imaging command — drop it so it can't be mistaken for actuals. - self._metadata.pop("nb_frame", None) + # from the imaging command self._metadata["acq_nb_frame"] = self._routine.progress metadata_filepath = os.path.join(self._routine.output_path, "metadata.json") with open(metadata_filepath, "w", encoding="utf-8") as metadata_file: