From 50953302e52f25d7edbaaf25200e9ea6298c92d8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 29 Sep 2026 11:09:14 -0400 Subject: [PATCH 01/10] Document typed 3-D point-result closure boundary --- docs/plans/typed-3d-point-result-20260929.md | 66 ++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/plans/typed-3d-point-result-20260929.md diff --git a/docs/plans/typed-3d-point-result-20260929.md b/docs/plans/typed-3d-point-result-20260929.md new file mode 100644 index 0000000000..73dd64eafc --- /dev/null +++ b/docs/plans/typed-3d-point-result-20260929.md @@ -0,0 +1,66 @@ +# Typed 3-D point result: implementation boundary + +Status: design and source inspection only. No runtime code, tests, installed +entrypoint, or biological result is claimed by this checkpoint. + +Base: OpenHCSDev/openhcs `9644febe2785aace85bbc8bd1d2ce062525c56d2`. +Issue: OpenHCSDev/openhcs#134. Integration owner: blind-analysis coordinator. +Coordinate the worker/export crossing with open PR #157; do not edit its +`function_artifact_materialization.py` or worker/export paths competitively. + +## Current contract and failure + +- `MeasurementTable` owns schema-bearing rows, an object subject and optional + measurement-feature owner, plus source provenance + (`openhcs/core/runtime_measurements.py:41`). The subject's declared ID field + is supplied by `ObjectMeasurementSubjectRelation` + (`openhcs/core/artifacts.py:1344`). +- `MeasurementsArtifactType.materialization_payload` hands only `table.rows` + to every writer (`openhcs/core/artifacts.py:704`). The table's provenance, + subject and feature owner are therefore unavailable to a measurement writer. + `MaterializationSpec` already admits multiple writer options for the same + artifact; a separate hand-built file bundle is not required. +- The existing ROI writer extracts image-label contours, not measurement + coordinates. `ROIArchiveSourceMetadata` stores an existing + `ImagePayloadMetadata` declaration in the native archive sidecar; it is not + a second source schema (`openhcs/core/roi_source_metadata.py:15`). +- OpenHCS pins PolyStore `e430c331ad931edc92dfe9d4fcd0d837a3cfeea8`. + Its native `PointShape(y, x)` codec preserves fractional XY, but has no Z + member. Standard ImageJ native Z is a discrete plane. OpenHCS's Napari + `_build_nd_points` prepends route component indices to 2-D coordinates + (`openhcs/runtime/napari_viewer_server.py:1089`); this cannot represent + fractional centre Z by itself. +- Frozen H002 hand-wrote an ImageJ POINT ZIP with rounded Z and no source + sidecar. It remains AMBIGUOUS and must not be rewritten or rerun as a repair. + +## Required closure + +1. Keep coordinate roles and object ID on typed declarations: a producer must + declare which measurement features mean Z, Y, X and which subject owns the + object ID. Do not infer them from column spellings or filenames. Retain the + complete `MeasurementTable` through writer dispatch, adapting existing CSV + and JSON writers coherently rather than adding a side channel. +2. Persist exact fractional ZYX, stable object ID and canonical source binding + in one inspectable typed point result. If the ImageJ ROI ZIP is retained as + the external format, its metadata sidecar must carry information the native + format cannot express, under an owning typed contract. No rounded-Z-only + fallback or legacy reader for the frozen malformed archive. +3. Reopen the persisted result through the installed public MCP + `result_directory` route with the declared raw source, not a guessed plate + path. The managed viewer must show the exact fractional 3-D point location, + feature-row/object identity and orthogonal views. Preserve path policy and + reject foreign source identities and escaping paths. +4. Verify one continuous synthetic compile/run/materialize/inspect/reopen + journey, then a new bounded context-isolated blind trial with raw-only, + result-only and combined captures at spatially distributed matched native + coordinates. Keep held-out answers sealed until pipeline freeze. A focused + source test or synthetic viewer does not establish biological acceptance. + +## Current stop conditions + +Do not claim a complete ownership proof from a partial scan or duplicate +PR #157's worker/export owner. The resource guard currently warns on swap; +do not start parallel agents, a large scan/test, or a new blind image load +until headroom is restored. Source inspection and small isolated edits may +continue, but a coherent change still needs complete ownership and native +behavior evidence before merge. From fc4527981d93863e2cc976a58813a1fce3318798 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 29 Sep 2026 11:19:37 -0400 Subject: [PATCH 02/10] Retain measurement table provenance in materialization writers --- docs/plans/typed-3d-point-result-20260929.md | 13 +++-- openhcs/core/artifacts.py | 2 +- openhcs/core/runtime_measurements.py | 26 +++++++++- openhcs/processing/materialization/core.py | 8 ++++ .../test_function_artifact_materialization.py | 16 +++++-- tests/unit/test_materialization_core.py | 47 +++++++++++++++++++ 6 files changed, 100 insertions(+), 12 deletions(-) diff --git a/docs/plans/typed-3d-point-result-20260929.md b/docs/plans/typed-3d-point-result-20260929.md index 73dd64eafc..5db9fb6a83 100644 --- a/docs/plans/typed-3d-point-result-20260929.md +++ b/docs/plans/typed-3d-point-result-20260929.md @@ -1,7 +1,8 @@ # Typed 3-D point result: implementation boundary -Status: design and source inspection only. No runtime code, tests, installed -entrypoint, or biological result is claimed by this checkpoint. +Status: measurement payload retention implemented in this draft. The point +writer, native 3-D projection, installed entrypoint, and biological result are +not yet implemented or claimed. Base: OpenHCSDev/openhcs `9644febe2785aace85bbc8bd1d2ce062525c56d2`. Issue: OpenHCSDev/openhcs#134. Integration owner: blind-analysis coordinator. @@ -15,9 +16,11 @@ Coordinate the worker/export crossing with open PR #157; do not edit its (`openhcs/core/runtime_measurements.py:41`). The subject's declared ID field is supplied by `ObjectMeasurementSubjectRelation` (`openhcs/core/artifacts.py:1344`). -- `MeasurementsArtifactType.materialization_payload` hands only `table.rows` - to every writer (`openhcs/core/artifacts.py:704`). The table's provenance, - subject and feature owner are therefore unavailable to a measurement writer. +- Before this draft, `MeasurementsArtifactType.materialization_payload` handed + only `table.rows` to every writer (`openhcs/core/artifacts.py:704`). This + draft retains the complete table through the existing `ColumnarRows` + contract, while CSV and JSON still serialize only rows. Its provenance, + subject and feature owner are now available to a measurement writer. `MaterializationSpec` already admits multiple writer options for the same artifact; a separate hand-built file bundle is not required. - The existing ROI writer extracts image-label contours, not measurement diff --git a/openhcs/core/artifacts.py b/openhcs/core/artifacts.py index 81c419e4d7..72fe223dd8 100644 --- a/openhcs/core/artifacts.py +++ b/openhcs/core/artifacts.py @@ -707,7 +707,7 @@ def materialization_payload(cls, value: "RuntimeValue") -> object: table = value.data if not isinstance(table, MeasurementTable): cls.validate_runtime_payload(value.name, table) - return table.rows + return table @classmethod def uses_aggregate_materialization_identity(cls, data: object) -> bool: diff --git a/openhcs/core/runtime_measurements.py b/openhcs/core/runtime_measurements.py index 644cf3d3a3..0c2282fcbd 100644 --- a/openhcs/core/runtime_measurements.py +++ b/openhcs/core/runtime_measurements.py @@ -9,7 +9,11 @@ from openhcs.core.artifacts import ( NamedArtifactPayload, ) -from openhcs.core.runtime_tabular_values import ColumnarRows +from openhcs.core.runtime_tabular_values import ( + ColumnarRows, + FieldSpec, + MeasurementObjectRowIdentity, +) from openhcs.core.source_image_provenance import ( SourceImageProvenance, SourceImageProvenanceFields, @@ -41,6 +45,7 @@ class MeasurementTable( SourceImageProvenanceFields, NamedArtifactPayload, + ColumnarRows, ): """Native OpenHCS measurement table value.""" @@ -50,6 +55,25 @@ class MeasurementTable( subject: MeasurementSubject measurement_feature_owner: type[RuntimeMeasurementFeatureOwner] | None = None + @property + def columns(self) -> Any: + return self.rows.columns + + @property + def fields(self) -> tuple[FieldSpec, ...]: + return self.rows.fields + + @property + def object_row_identity(self) -> MeasurementObjectRowIdentity | None: + return self.rows.object_row_identity + + @property + def covers_declared_object_measurement_domain(self) -> bool: + return self.rows.covers_declared_object_measurement_domain + + def iter_row_mappings(self) -> Iterable[Mapping[str, object]]: + return self.rows.iter_row_mappings() + def __post_init__(self, *source_provenance_values: object) -> None: self.absorb_explicit_source_provenance( SourceImageProvenance.from_init_values(source_provenance_values) diff --git a/openhcs/processing/materialization/core.py b/openhcs/processing/materialization/core.py index 137a7dd9c4..994c4ebc96 100644 --- a/openhcs/processing/materialization/core.py +++ b/openhcs/processing/materialization/core.py @@ -2225,6 +2225,14 @@ def json_payload_mapping(value: dict) -> dict: return {key: JsonPayloadAuthority.jsonable(item) for key, item in value.items()} +@json_payload_value.register(ColumnarRows) +def json_payload_columnar_rows(value: ColumnarRows) -> list[dict]: + return [ + {key: JsonPayloadAuthority.jsonable(item) for key, item in row.items()} + for row in value.iter_row_mappings() + ] + + @json_payload_value.register(list) def json_payload_list(value: list) -> list: return [JsonPayloadAuthority.jsonable(item) for item in value] diff --git a/tests/unit/test_function_artifact_materialization.py b/tests/unit/test_function_artifact_materialization.py index fce6077a7e..98b4293b0c 100644 --- a/tests/unit/test_function_artifact_materialization.py +++ b/tests/unit/test_function_artifact_materialization.py @@ -1056,7 +1056,7 @@ def fake_materialize(_spec, data, path, *_args, **_kwargs): context, ) - assert [(tuple(data), path) for data, path in materialized] == [ + assert [(data.row_mappings(), path) for data, path in materialized] == [ (({"object_id": 1, "area": 42},), "/analysis/measurements_1.roi.zip") ] @@ -1113,7 +1113,8 @@ def fake_materialize(spec, data, path, *_args, **_kwargs): spec, data, path = materialized[0] assert isinstance(spec.outputs[0], CsvOptions) assert spec.outputs[0].filename_suffix == "_details.csv" - assert tuple(data) == ({"object_id": 1, "area": 42},) + assert isinstance(data, MeasurementTable) + assert data.row_mappings() == ({"object_id": 1, "area": 42},) assert path == "/analysis/A01_measurements_step7.roi.zip" @@ -1184,6 +1185,9 @@ def test_multi_plane_measurement_materialization_uses_aggregate_artifact_name(): [materialization] = runtime_artifact_materializations(plan, context) + assert isinstance(materialization.data, MeasurementTable) + assert materialization.data.subject == table.subject + assert len(materialization.data.source_image_provenance_planes.planes) == 4 assert str(materialization.base_path) == ("/analysis/A01_cell_counts_step7.roi.zip") assert tuple(output.path for output in materialization.outputs(plan, context)) == ( "/analysis/A01_cell_counts_step7_details.csv", @@ -2179,7 +2183,8 @@ def fake_materialize( spec, data, path, backends, backend_kwargs = materialized[0] assert isinstance(spec.outputs[0], CsvOptions) - assert tuple(data) == ({"object_id": 1, "area": 42},) + assert isinstance(data, MeasurementTable) + assert data.row_mappings() == ({"object_id": 1, "area": 42},) assert path == "/analysis/A01_measurements_step7.roi.zip" assert backends == ["disk"] assert dict(backend_kwargs["disk"]) == {} @@ -2243,7 +2248,8 @@ def fake_materialize(spec, data, path, *_args, **_kwargs): assert len(materialized) == 1 spec, data, path = materialized[0] assert isinstance(spec.outputs[0], CsvOptions) - assert tuple(data) == ({"site": "1", "area": 42},) + assert isinstance(data, MeasurementTable) + assert data.row_mappings() == ({"site": "1", "area": 42},) assert path == "/analysis/A01_w1_measurements_step7.roi.zip" @@ -2523,7 +2529,7 @@ def fake_materialize(spec, data, path, *_args, **_kwargs): "/analysis/A01_s001_w5_z001_t001_measurements_step7.roi.zip", "/analysis/A01_s002_w5_z001_t001_measurements_step7.roi.zip", ] - assert [tuple(data) for _spec, data, _path in materialized] == [ + assert [data.row_mappings() for _spec, data, _path in materialized] == [ ({"site": "1", "object_id": 1, "area": 42},), ({"site": "2", "object_id": 2, "area": 84},), ] diff --git a/tests/unit/test_materialization_core.py b/tests/unit/test_materialization_core.py index 4e7ccbe631..3ff76d72f0 100644 --- a/tests/unit/test_materialization_core.py +++ b/tests/unit/test_materialization_core.py @@ -43,6 +43,11 @@ ImageMetadataPayload, ImagePayloadMetadata, ) +from openhcs.core.runtime_measurements import ( + MeasurementScope, + MeasurementSubject, + MeasurementTable, +) from openhcs.core.runtime_object_label_building import ( SourceImageObjectLabelBuildRequest, ) @@ -63,6 +68,7 @@ from openhcs.core.runtime_sparse_labels import SparseIJVLabelRows from openhcs.core.runtime_slice_projection import RuntimeProjectionPlaneMetadata from openhcs.core.runtime_tabular_values import FieldSpec +from openhcs.core.measurement_row_materialization import MeasurementSparseColumnarRows from openhcs.core.source_image_provenance import ( SourceImageIdentity, SourceImageProvenancePlanes, @@ -704,6 +710,47 @@ def test_csv_materialization_preserves_declared_fields_for_empty_rows() -> None: assert fm.load(out, "memory").splitlines()[0] == "object_label,area" +@pytest.mark.unit +def test_measurement_table_materializes_csv_and_json_without_losing_its_owner() -> None: + table = MeasurementTable( + name="centres", + rows=MeasurementSparseColumnarRows.from_rows( + ({"object_label": 7, "z": 2.375, "y": 1.25, "x": 3.5},), + fields=( + FieldSpec("object_label", int), + FieldSpec("z", float), + FieldSpec("y", float), + FieldSpec("x", float), + ), + ), + source_path="/source/image.ome.tif", + subject=MeasurementSubject(MeasurementScope.OBJECT, "nuclei", "object_label"), + ) + fm = FileManager({"memory": MemoryStorageBackend()}) + + materialize( + MaterializationSpec( + CsvOptions(filename_suffix=".csv"), + JsonOptions(filename_suffix=".json"), + ), + data=table, + path="/tmp/centres", + filemanager=fm, + backends=["memory"], + backend_kwargs={}, + ) + + assert fm.load("/tmp/centres.csv", "memory").splitlines() == [ + "object_label,z,y,x", + "7,2.375,1.25,3.5", + ] + assert json.loads(fm.load("/tmp/centres.json", "memory")) == [ + {"object_label": 7, "z": 2.375, "y": 1.25, "x": 3.5} + ] + assert table.source_path == "/source/image.ome.tif" + assert table.subject.object_id_field == "object_label" + + @pytest.mark.unit def test_csv_materialization_preserves_declared_field_order_for_dict_rows() -> None: fm = FileManager({"memory": MemoryStorageBackend()}) From 5453dd2ddb12f6d1ba54d71844ed46583808ae6a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 29 Sep 2026 11:28:36 -0400 Subject: [PATCH 03/10] Materialize typed 3D point measurements as native ROI archives --- openhcs/core/roi_point_metadata.py | 33 ++++++ .../processing/materialization/__init__.py | 2 + openhcs/processing/materialization/core.py | 84 +++++++++++++++ openhcs/processing/materialization/options.py | 24 +++++ tests/unit/test_materialization_core.py | 101 ++++++++++++++++++ 5 files changed, 244 insertions(+) create mode 100644 openhcs/core/roi_point_metadata.py diff --git a/openhcs/core/roi_point_metadata.py b/openhcs/core/roi_point_metadata.py new file mode 100644 index 0000000000..cc4414e2a5 --- /dev/null +++ b/openhcs/core/roi_point_metadata.py @@ -0,0 +1,33 @@ +"""Exact fractional Z retained beside ImageJ's integer-plane point ROI.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from math import isfinite +from typing import ClassVar, Mapping + +from polystore.roi import ROI + + +@dataclass(frozen=True, slots=True) +class ROIFractionalZ: + """The Z coordinate ImageJ's 2D point shape cannot represent.""" + + value: float + FIELD: ClassVar[str] = "openhcs_fractional_z" + + def __post_init__(self) -> None: + if isinstance(self.value, bool) or not isfinite(self.value): + raise ValueError("ROI fractional Z must be a finite real coordinate.") + + def bind(self, roi: ROI) -> ROI: + return replace(roi, metadata={**roi.metadata, self.FIELD: float(self.value)}) + + @classmethod + def decode(cls, metadata: Mapping[str, object]) -> ROIFractionalZ | None: + value = metadata.get(cls.FIELD) + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError("ROI fractional Z must be numeric.") + return cls(float(value)) diff --git a/openhcs/processing/materialization/__init__.py b/openhcs/processing/materialization/__init__.py index f250dbc5de..1cf8d2a62b 100644 --- a/openhcs/processing/materialization/__init__.py +++ b/openhcs/processing/materialization/__init__.py @@ -22,6 +22,7 @@ ImageFileOptions, JsonOptions, MaterializedFilenameIdentity, + PointROIOptions, ROIOptions, SpatialGraphROIOptions, SWCOptions, @@ -58,6 +59,7 @@ "MaterializedFilenameIdentity", "CsvOptions", "JsonOptions", + "PointROIOptions", "ROIOptions", "SpatialGraphROIOptions", "SWCOptions", diff --git a/openhcs/processing/materialization/core.py b/openhcs/processing/materialization/core.py index 994c4ebc96..68e34a92c1 100644 --- a/openhcs/processing/materialization/core.py +++ b/openhcs/processing/materialization/core.py @@ -87,6 +87,7 @@ ImageFileOptions, JsonOptions, MaterializedFilenameIdentity, + PointROIOptions, ROIOptions, SourceOptions, SpatialGraphROIOptions, @@ -3181,6 +3182,89 @@ def append_descendants(node, parent_sample_id: int) -> None: ] +@writer_for(PointROIOptions, MaterializationFormat.ROI_ZIP) +def _write_point_roi_zip( + data: MaterializationValue, + options: PointROIOptions, + ctx: MaterializationContext, +) -> list[Output]: + """Write 3D measurement centres through the native ROI ZIP backend.""" + from math import isfinite + from polystore.roi import PointShape, ROI + from openhcs.core.roi_point_metadata import ROIFractionalZ + from openhcs.core.roi_source_metadata import ROIArchiveSourceMetadata + from openhcs.core.runtime_measurements import MeasurementTable + + payload = MaterializationInput.from_value(data, options).data + if not isinstance(payload, MeasurementTable): + raise TypeError("PointROIOptions requires a MeasurementTable payload.") + id_field = payload.subject.object_id_field + if id_field is None: + raise ValueError("PointROIOptions requires an object-measurement subject.") + features = (options.z_feature, options.y_feature, options.x_feature) + if payload.measurement_feature_owner is not None: + foreign = [ + feature.value + for feature in features + if not payload.measurement_feature_owner.owns_measurement_feature_name( + feature.value + ) + ] + if foreign: + raise ValueError( + f"Point coordinate features are not owned by the table: {foreign!r}." + ) + coordinate_fields = tuple( + feature.measurement_row_field_name for feature in features + ) + missing = {id_field, *coordinate_fields} - {field.name for field in payload.fields} + if missing: + raise ValueError(f"Point measurement fields are missing: {sorted(missing)!r}.") + provenance = payload.source_provenance.with_common_scalar_identity_from_planes() + if provenance.source_path is None: + raise ValueError("Point ROI ZIP requires an unambiguous source-image path.") + rois: list[ROI] = [] + seen_labels: set[int] = set() + for row in payload.iter_row_mappings(): + label = row[id_field] + if ( + isinstance(label, bool) + or not isinstance(label, (int, np.integer)) + or label <= 0 + ): + raise ValueError("Point ROI object labels must be positive integers.") + if int(label) in seen_labels: + raise ValueError(f"Duplicate point ROI object label: {label!r}.") + seen_labels.add(int(label)) + coordinates: list[float] = [] + for field_name in coordinate_fields: + value = row[field_name] + if isinstance(value, bool) or not isinstance( + value, (int, float, np.number) + ): + raise ValueError(f"Point coordinate {field_name!r} must be numeric.") + coordinate = float(value) + if not isfinite(coordinate): + raise ValueError(f"Point coordinate {field_name!r} must be finite.") + coordinates.append(coordinate) + z, y, x = coordinates + rois.append( + ROIFractionalZ(z).bind( + ROI(shapes=[PointShape(y=y, x=x)], metadata={"label": int(label)}) + ) + ) + if not rois: + raise ValueError("Point ROI ZIP requires at least one measured object.") + metadata = ImagePayloadMetadata(source_provenance=provenance) + return [ + Output( + path=ctx.paths(options).primary_output_path(options), + content=ROIArchiveSourceMetadata.bind(rois, metadata), + metadata=metadata, + ) + ] + + @writer_for( SpatialGraphROIOptions, MaterializationFormat.ROI_ZIP, diff --git a/openhcs/processing/materialization/options.py b/openhcs/processing/materialization/options.py index 30097194c1..d93a4e4a45 100644 --- a/openhcs/processing/materialization/options.py +++ b/openhcs/processing/materialization/options.py @@ -12,6 +12,8 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional +from openhcs.core.runtime_measurements import RuntimeMeasurementFeature + class MaterializedFilenameIdentity(str, Enum): """Semantic identity used to construct materialized artifact filenames.""" @@ -99,6 +101,28 @@ def primary_output_suffix(self) -> str: return self.roi_suffix +@dataclass(frozen=True, kw_only=True) +class PointROIOptions(FileOutputOptions, SourceOptions): + """Persist typed 3D object-location measurements as point ROIs.""" + + z_feature: RuntimeMeasurementFeature + y_feature: RuntimeMeasurementFeature + x_feature: RuntimeMeasurementFeature + filename_suffix: str = "_points.roi.zip" + + def __post_init__(self) -> None: + super().__post_init__() + features = (self.z_feature, self.y_feature, self.x_feature) + if not all( + isinstance(feature, RuntimeMeasurementFeature) for feature in features + ): + raise TypeError( + "PointROIOptions coordinates require runtime measurement features." + ) + if len({feature.measurement_row_field_name for feature in features}) != 3: + raise ValueError("PointROIOptions coordinates require distinct row fields.") + + @dataclass(frozen=True) class SWCOptions(FileOutputOptions, SourceOptions): """SWC writer options for a rooted neuronal morphology forest.""" diff --git a/tests/unit/test_materialization_core.py b/tests/unit/test_materialization_core.py index 3ff76d72f0..116d6ba5f7 100644 --- a/tests/unit/test_materialization_core.py +++ b/tests/unit/test_materialization_core.py @@ -7,7 +7,9 @@ from polystore.base import DataSink from polystore.fiji_stream import FijiStreamingBackend from polystore.filemanager import FileManager +from polystore.disk import DiskStorageBackend from polystore.memory import MemoryStorageBackend +from polystore.roi import PointShape, load_rois_from_zip from polystore.napari_stream import NapariStreamingBackend from polystore.streaming import ( StreamingBatchMessageBuilder, @@ -47,7 +49,10 @@ MeasurementScope, MeasurementSubject, MeasurementTable, + ObjectCoreMeasurementFeature, ) +from openhcs.core.roi_point_metadata import ROIFractionalZ +from openhcs.core.roi_source_metadata import ROIArchiveSourceMetadata from openhcs.core.runtime_object_label_building import ( SourceImageObjectLabelBuildRequest, ) @@ -86,6 +91,7 @@ JsonOptions, MaterializationSpec, MaterializedFilenameIdentity, + PointROIOptions, ROIOptions, TiffStackOptions, csv_only, @@ -751,6 +757,101 @@ def test_measurement_table_materializes_csv_and_json_without_losing_its_owner() assert table.subject.object_id_field == "object_label" +@pytest.mark.unit +def test_point_roi_materialization_native_reopen_preserves_fractional_z( + tmp_path, +) -> None: + table = MeasurementTable( + name="centres", + rows=MeasurementSparseColumnarRows.from_rows( + ( + { + "object_label": 7, + "center_z": 2.375, + "center_y": 1.25, + "center_x": 3.5, + }, + ), + fields=( + FieldSpec("object_label", int), + FieldSpec("center_z", float), + FieldSpec("center_y", float), + FieldSpec("center_x", float), + ), + ), + source_path="/source/image.ome.tif", + subject=MeasurementSubject(MeasurementScope.OBJECT, "nuclei", "object_label"), + ) + feature = ObjectCoreMeasurementFeature + archive = materialize( + MaterializationSpec( + PointROIOptions( + z_feature=feature.CENTER_Z, + y_feature=feature.CENTER_Y, + x_feature=feature.CENTER_X, + ) + ), + data=table, + path=str(tmp_path / "centres"), + filemanager=FileManager({"disk": DiskStorageBackend()}), + backends=["disk"], + backend_kwargs={}, + ) + rois = load_rois_from_zip(tmp_path / "centres_points.roi.zip") + assert archive == str(tmp_path / "centres_points.roi.zip") + assert len(rois) == 1 + assert rois[0].metadata["label"] == 7 + assert rois[0].shapes == [PointShape(y=1.25, x=3.5)] + assert ROIFractionalZ.decode(rois[0].metadata) == ROIFractionalZ(2.375) + assert ROIArchiveSourceMetadata.decode(rois).source_path == "/source/image.ome.tif" + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("source_path", "rows", "error"), + [ + (None, ((7, 2.375, 1.25, 3.5),), "source-image path"), + ("/source/image.ome.tif", ((7, float("nan"), 1.25, 3.5),), "finite"), + ("/source/image.ome.tif", (), "at least one"), + ], +) +def test_point_roi_materialization_rejects_unreopenable_results( + tmp_path, source_path, rows, error +) -> None: + fields = ( + FieldSpec("object_label", int), + FieldSpec("center_z", float), + FieldSpec("center_y", float), + FieldSpec("center_x", float), + ) + table = MeasurementTable( + name="centres", + rows=MeasurementSparseColumnarRows.from_rows( + tuple(dict(zip((field.name for field in fields), row)) for row in rows), + fields=fields, + ), + source_path=source_path, + subject=MeasurementSubject(MeasurementScope.OBJECT, "nuclei", "object_label"), + ) + feature = ObjectCoreMeasurementFeature + with pytest.raises(ValueError, match=error): + materialize( + MaterializationSpec( + PointROIOptions( + z_feature=feature.CENTER_Z, + y_feature=feature.CENTER_Y, + x_feature=feature.CENTER_X, + ) + ), + data=table, + path=str(tmp_path / "invalid"), + filemanager=FileManager({"disk": DiskStorageBackend()}), + backends=["disk"], + backend_kwargs={}, + ) + assert not (tmp_path / "invalid_points.roi.zip").exists() + + @pytest.mark.unit def test_csv_materialization_preserves_declared_field_order_for_dict_rows() -> None: fm = FileManager({"memory": MemoryStorageBackend()}) From 51921eb912f7649c51690effb0549e4aa3cdcbe8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 29 Sep 2026 11:33:51 -0400 Subject: [PATCH 04/10] Project persisted fractional-Z point ROIs into native viewer coordinates --- docs/plans/typed-3d-point-result-20260929.md | 14 ++--- openhcs/runtime/napari_viewer_server.py | 17 +++++- tests/unit/test_materialization_core.py | 21 ++++++++ tests/unit/test_napari_streaming_handlers.py | 56 ++++++++++++++++++++ 4 files changed, 101 insertions(+), 7 deletions(-) diff --git a/docs/plans/typed-3d-point-result-20260929.md b/docs/plans/typed-3d-point-result-20260929.md index 5db9fb6a83..51487702cf 100644 --- a/docs/plans/typed-3d-point-result-20260929.md +++ b/docs/plans/typed-3d-point-result-20260929.md @@ -1,8 +1,8 @@ # Typed 3-D point result: implementation boundary -Status: measurement payload retention implemented in this draft. The point -writer, native 3-D projection, installed entrypoint, and biological result are -not yet implemented or claimed. +Status: measurement payload retention, typed point writer, native ROI ZIP +round-trip and exact-Z Napari projection implemented in this draft. Synthetic +source tests pass; installed entrypoint and biological result are not claimed. Base: OpenHCSDev/openhcs `9644febe2785aace85bbc8bd1d2ce062525c56d2`. Issue: OpenHCSDev/openhcs#134. Integration owner: blind-analysis coordinator. @@ -24,15 +24,17 @@ Coordinate the worker/export crossing with open PR #157; do not edit its `MaterializationSpec` already admits multiple writer options for the same artifact; a separate hand-built file bundle is not required. - The existing ROI writer extracts image-label contours, not measurement - coordinates. `ROIArchiveSourceMetadata` stores an existing + coordinates. `PointROIOptions` owns the Z/Y/X measurement-feature roles; + its writer uses the existing ROI ZIP backend. `ROIArchiveSourceMetadata` stores an existing `ImagePayloadMetadata` declaration in the native archive sidecar; it is not a second source schema (`openhcs/core/roi_source_metadata.py:15`). - OpenHCS pins PolyStore `e430c331ad931edc92dfe9d4fcd0d837a3cfeea8`. Its native `PointShape(y, x)` codec preserves fractional XY, but has no Z member. Standard ImageJ native Z is a discrete plane. OpenHCS's Napari `_build_nd_points` prepends route component indices to 2-D coordinates - (`openhcs/runtime/napari_viewer_server.py:1089`); this cannot represent - fractional centre Z by itself. + (`openhcs/runtime/napari_viewer_server.py:1089`); this draft replaces the + projected Z coordinate with typed `ROIFractionalZ` when present and rejects + a fractional-Z ROI if no Z axis is projected. - Frozen H002 hand-wrote an ImageJ POINT ZIP with rounded Z and no source sidecar. It remains AMBIGUOUS and must not be rewritten or rerun as a repair. diff --git a/openhcs/runtime/napari_viewer_server.py b/openhcs/runtime/napari_viewer_server.py index 3fc2d727c2..9326180463 100644 --- a/openhcs/runtime/napari_viewer_server.py +++ b/openhcs/runtime/napari_viewer_server.py @@ -63,6 +63,7 @@ ) from openhcs.constants import AllComponents +from openhcs.core.roi_point_metadata import ROIFractionalZ from openhcs.core.artifacts import ObjectArtifactSubjectBinding from openhcs.core.config import ( NapariDisplayConfig, @@ -1119,9 +1120,23 @@ def _build_nd_points( coordinates = shape_payload.coordinates metadata = shape_payload.metadata + fractional_z = ROIFractionalZ.decode(metadata.metadata) + z_axis_index = None + if fractional_z is not None: + try: + z_axis_index = axis_projection.projected_axis_components.index( + AllComponents.Z_INDEX.value + ) + except ValueError as exc: + raise ValueError( + "Fractional-Z point ROI requires a projected z_index axis." + ) from exc for coord in coordinates: - nd_coord = prepend_dims + list(coord) + point_dims = prepend_dims.copy() + if z_axis_index is not None and fractional_z is not None: + point_dims[z_axis_index] = fractional_z.value + nd_coord = point_dims + list(coord) all_points_nd.append(nd_coord) all_properties["label"].append( diff --git a/tests/unit/test_materialization_core.py b/tests/unit/test_materialization_core.py index 116d6ba5f7..2d26d82e37 100644 --- a/tests/unit/test_materialization_core.py +++ b/tests/unit/test_materialization_core.py @@ -1,6 +1,7 @@ import json from functools import partial from multiprocessing.shared_memory import SharedMemory +from types import SimpleNamespace import numpy as np import pytest @@ -10,6 +11,7 @@ from polystore.disk import DiskStorageBackend from polystore.memory import MemoryStorageBackend from polystore.roi import PointShape, load_rois_from_zip +from polystore.roi_converters import NapariROIConverter from polystore.napari_stream import NapariStreamingBackend from polystore.streaming import ( StreamingBatchMessageBuilder, @@ -53,6 +55,7 @@ ) from openhcs.core.roi_point_metadata import ROIFractionalZ from openhcs.core.roi_source_metadata import ROIArchiveSourceMetadata +from openhcs.runtime.viewer_component_system import ViewerLayerAxisProjection from openhcs.core.runtime_object_label_building import ( SourceImageObjectLabelBuildRequest, ) @@ -804,6 +807,24 @@ def test_point_roi_materialization_native_reopen_preserves_fractional_z( assert rois[0].shapes == [PointShape(y=1.25, x=3.5)] assert ROIFractionalZ.decode(rois[0].metadata) == ROIFractionalZ(2.375) assert ROIArchiveSourceMetadata.decode(rois).source_path == "/source/image.ome.tif" + viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") + projection = ViewerLayerAxisProjection( + projected_axis_components=("z_index",), + component_values={"z_index": [0, 1, 2, 3]}, + routed_component_values={"z_index": [0, 1, 2, 3]}, + axis_offsets=(0,), + ) + points, properties = viewer_server._build_nd_points( + [ + SimpleNamespace( + data=NapariROIConverter.rois_to_shapes(rois), + address=SimpleNamespace(components={"z_index": 2}), + ) + ], + projection, + ) + assert points.tolist() == [[2.375, 1.25, 3.5]] + assert properties["label"] == [7] @pytest.mark.unit diff --git a/tests/unit/test_napari_streaming_handlers.py b/tests/unit/test_napari_streaming_handlers.py index ef58fecac2..aaebc043dc 100644 --- a/tests/unit/test_napari_streaming_handlers.py +++ b/tests/unit/test_napari_streaming_handlers.py @@ -5992,6 +5992,62 @@ def test_napari_points_layer_display_applies_route_global_axis_translate(): assert layer_kwargs["properties"] == {"label": [7], "component": [4]} +def test_napari_points_layer_uses_exact_fractional_z_from_native_roi_metadata(): + napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") + server = _FakeNapariServer() + server.layer_route_state = NapariLayerRouteStateStore.empty() + server.layer_route_state.set_title("centres", "Centres") + server.viewer = _FakeViewer() + pipeline = napari_viewer_server.NapariLayerDisplayPipeline(server) + presentation = _axis_presentation( + layer_key="centres", + projected_axis_components=("z_index",), + component_values={"z_index": [0, 1, 2, 3]}, + ) + item = _layer_item( + {"z_index": 2}, + [ + { + "type": "points", + "coordinates": [[1.25, 3.5]], + "metadata": {"label": 7, "openhcs_fractional_z": 2.375}, + } + ], + stream_layer_data_type=StreamingDataType.POINTS, + ) + napari_viewer_server.NapariPointsLayerDisplayHandler().handle( + napari_viewer_server.NapariLayerDisplayRequest( + pipeline=pipeline, + presentation=presentation, + items=[item], + display_config=NapariDisplayConfig(), + ) + ) + assert tuple(server.viewer.calls[-1][1][0]) == (2.375, 1.25, 3.5) + + +def test_napari_points_layer_rejects_fractional_z_without_z_axis(): + napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") + item = _layer_item( + {"channel": 1}, + [ + { + "type": "points", + "coordinates": [[1.25, 3.5]], + "metadata": {"openhcs_fractional_z": 2.375}, + } + ], + stream_layer_data_type=StreamingDataType.POINTS, + ) + presentation = _axis_presentation( + layer_key="centres", + projected_axis_components=("channel",), + component_values={"channel": [1]}, + ) + with pytest.raises(ValueError, match="projected z_index axis"): + napari_viewer_server._build_nd_points([item], presentation.projection) + + def test_native_image_intensity_command_preserves_data_and_navigation(): module = pytest.importorskip("openhcs.runtime.napari_viewer_server") from napari.layers import Image From 6a423fb6a6530c8e0b06b56261e8fc7776acbe54 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 29 Sep 2026 11:54:53 -0400 Subject: [PATCH 05/10] Preserve 3D point feature rows and source Z domain through ROI reopening --- docs/plans/typed-3d-point-result-20260929.md | 8 +- openhcs/core/roi_point_metadata.py | 60 +++++++++ openhcs/core/viewer_streaming_service.py | 21 ++- openhcs/processing/materialization/core.py | 6 +- openhcs/runtime/napari_viewer_server.py | 22 +++ tests/unit/test_materialization_core.py | 17 +++ tests/unit/test_streaming_service.py | 133 +++++++++++++++++++ 7 files changed, 261 insertions(+), 6 deletions(-) diff --git a/docs/plans/typed-3d-point-result-20260929.md b/docs/plans/typed-3d-point-result-20260929.md index 51487702cf..4b26d842bd 100644 --- a/docs/plans/typed-3d-point-result-20260929.md +++ b/docs/plans/typed-3d-point-result-20260929.md @@ -1,7 +1,9 @@ # Typed 3-D point result: implementation boundary Status: measurement payload retention, typed point writer, native ROI ZIP -round-trip and exact-Z Napari projection implemented in this draft. Synthetic +round-trip and exact-Z Napari projection implemented in this draft. The native +reopen path now derives its ordered Z domain from the table's declared source +planes and retains scalar measurement fields as Napari point features. Synthetic source tests pass; installed entrypoint and biological result are not claimed. Base: OpenHCSDev/openhcs `9644febe2785aace85bbc8bd1d2ce062525c56d2`. @@ -37,6 +39,10 @@ Coordinate the worker/export crossing with open PR #157; do not edit its a fractional-Z ROI if no Z axis is projected. - Frozen H002 hand-wrote an ImageJ POINT ZIP with rounded Z and no source sidecar. It remains AMBIGUOUS and must not be rewritten or rerun as a repair. +- The live desktop MCP process is healthy but uses an older dirty checkout whose + query tool does not expose `result_directory`. Remote main has the route; + source-worktree tests cannot substitute for a reviewed install and fresh MCP + handshake. ## Required closure diff --git a/openhcs/core/roi_point_metadata.py b/openhcs/core/roi_point_metadata.py index cc4414e2a5..f597dd2bec 100644 --- a/openhcs/core/roi_point_metadata.py +++ b/openhcs/core/roi_point_metadata.py @@ -8,6 +8,10 @@ from polystore.roi import ROI +from openhcs.constants.constants import AllComponents +from openhcs.core.runtime_image_values import ImagePayloadMetadata +from openhcs.core.source_image_provenance import SourceComponentMetadata + @dataclass(frozen=True, slots=True) class ROIFractionalZ: @@ -31,3 +35,59 @@ def decode(cls, metadata: Mapping[str, object]) -> ROIFractionalZ | None: if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError("ROI fractional Z must be numeric.") return cls(float(value)) + + @classmethod + def source_component_domain( + cls, + rois: list[ROI], + metadata: ImagePayloadMetadata, + ) -> tuple[SourceComponentMetadata, ...] | None: + """Derive the point route's ordered Z domain from source-plane provenance.""" + coordinates = tuple(cls.decode(roi.metadata) for roi in rois) + if not any(coordinate is not None for coordinate in coordinates): + return None + if any(coordinate is None for coordinate in coordinates): + raise ValueError("Point ROI archive mixes 3D and 2D point metadata.") + planes = metadata.source_provenance.source_image_provenance_planes + domain = planes.component_metadata + if not domain or any(plane is None for plane in domain): + raise ValueError("3D point ROI requires an exact source-plane Z domain.") + z_field = AllComponents.Z_INDEX.value + if any(z_field not in plane for plane in domain): + raise ValueError("3D point ROI source planes require z_index metadata.") + z_values = tuple(plane[z_field] for plane in domain) + if any( + isinstance(value, bool) or not isinstance(value, (str, int)) + for value in z_values + ): + raise ValueError("3D point ROI source z_index values must be integers.") + try: + integer_z = tuple(int(value) for value in z_values) + except ValueError as exc: + raise ValueError( + "3D point ROI source z_index values must be integers." + ) from exc + if any( + str(value).strip() != str(index) and not str(value).strip().isdigit() + for value, index in zip(z_values, integer_z, strict=True) + ): + raise ValueError("3D point ROI source z_index values must be integers.") + if integer_z != tuple(range(integer_z[0], integer_z[0] + len(domain))): + raise ValueError( + "3D point ROI source Z planes must be consecutive and ordered." + ) + if any( + coordinate.value < 0 or coordinate.value > len(domain) - 1 + for coordinate in coordinates + ): + raise ValueError( + "3D point ROI Z coordinate lies outside its source planes." + ) + common_keys = set(domain[0]) - {z_field} + if any(set(plane) - {z_field} != common_keys for plane in domain): + raise ValueError("3D point ROI source planes have inconsistent components.") + if any( + any(plane[key] != domain[0][key] for key in common_keys) for plane in domain + ): + raise ValueError("3D point ROI source planes vary outside Z.") + return tuple(domain) diff --git a/openhcs/core/viewer_streaming_service.py b/openhcs/core/viewer_streaming_service.py index 06c7d64c57..65ef96b921 100644 --- a/openhcs/core/viewer_streaming_service.py +++ b/openhcs/core/viewer_streaming_service.py @@ -31,13 +31,17 @@ from openhcs.core.runtime_image_loading import ImagePayloadSourceMetadataContext from openhcs.core.roi_source_metadata import ROIArchiveSourceMetadata +from openhcs.core.roi_point_metadata import ROIFractionalZ from openhcs.core.runtime_image_values import ( ImagePayloadMetadata, image_payload_data, image_payload_mask, image_payload_metadata, ) -from openhcs.core.source_image_provenance import SourceImageIdentity +from openhcs.core.source_image_provenance import ( + SourceComponentMetadata, + SourceImageIdentity, +) from openhcs.core.source_metadata import SourceVoxelSpacing from openhcs.core.source_workspace_projection import ( VirtualWorkspacePathLookup, @@ -778,6 +782,7 @@ def stream_rois( paths: list[str] = [] loaded_indices: list[int] = [] archive_metadata: list[ImagePayloadMetadata | None] = [] + point_domains: dict[str, tuple[SourceComponentMetadata, ...]] = {} for i, filename in enumerate(request.roi_filenames, 1): file_path = Path(self.source.plate_path) / filename @@ -796,6 +801,10 @@ def stream_rois( "artifact filenames do not establish source identity." ) archive_metadata.append(metadata) + if metadata is not None: + point_domain = ROIFractionalZ.source_component_domain(rois, metadata) + if point_domain is not None: + point_domains[filename] = point_domain data_list.append(ROIArchiveSourceMetadata.geometry(rois)) paths.append(filename) loaded_indices.append(i - 1) @@ -866,8 +875,13 @@ def stream_rois( for path, metadata in zip(paths, archive_metadata, strict=True) if metadata is not None ) + metadata_by_path.update( + (path, dict(domain[0])) for path, domain in point_domains.items() + ) source_metadata_items = StreamSourceComponentMetadataItems.from_values( - metadata_by_path[path] for path in paths + component_metadata + for path in paths + for component_metadata in point_domains.get(path, (metadata_by_path[path],)) ) message_authority = StreamComponentMessageExtraAuthority.from_viewer_surface( viewer_surface, @@ -884,8 +898,7 @@ def stream_rois( request.status_callback(message) payload_metadata = [ - self.source.calibrated_metadata(metadata) - for metadata in archive_metadata + self.source.calibrated_metadata(metadata) for metadata in archive_metadata ] component_order = message_authority.layout.component_order for indices in StreamImagePayloadMetadataProjector.partition_indices( diff --git a/openhcs/processing/materialization/core.py b/openhcs/processing/materialization/core.py index 68e34a92c1..426ea3daed 100644 --- a/openhcs/processing/materialization/core.py +++ b/openhcs/processing/materialization/core.py @@ -3250,12 +3250,16 @@ def _write_point_roi_zip( z, y, x = coordinates rois.append( ROIFractionalZ(z).bind( - ROI(shapes=[PointShape(y=y, x=x)], metadata={"label": int(label)}) + ROI( + shapes=[PointShape(y=y, x=x)], + metadata={**row, "label": int(label)}, + ) ) ) if not rois: raise ValueError("Point ROI ZIP requires at least one measured object.") metadata = ImagePayloadMetadata(source_provenance=provenance) + ROIFractionalZ.source_component_domain(rois, metadata) return [ Output( path=ctx.paths(options).primary_output_path(options), diff --git a/openhcs/runtime/napari_viewer_server.py b/openhcs/runtime/napari_viewer_server.py index 9326180463..cc50bfc0eb 100644 --- a/openhcs/runtime/napari_viewer_server.py +++ b/openhcs/runtime/napari_viewer_server.py @@ -64,6 +64,7 @@ from openhcs.constants import AllComponents from openhcs.core.roi_point_metadata import ROIFractionalZ +from openhcs.core.roi_source_metadata import ROIArchiveSourceMetadata from openhcs.core.artifacts import ObjectArtifactSubjectBinding from openhcs.core.config import ( NapariDisplayConfig, @@ -1103,6 +1104,7 @@ def _build_nd_points( """ all_points_nd = [] all_properties = {"label": [], "component": []} + point_metadata: list[Mapping[str, NapariWireValue]] = [] for item in layer_items: points_data = item.data @@ -1145,6 +1147,26 @@ def _build_nd_points( all_properties["component"].append( metadata.value(VisualMetadataField.COMPONENT, 0) ) + point_metadata.append(metadata.metadata) + + excluded = { + "label", + "component", + ROIFractionalZ.FIELD, + ROIArchiveSourceMetadata.FIELD, + } + feature_fields = { + key + for values in point_metadata + for key, value in values.items() + if key not in excluded and isinstance(value, (str, int, float, bool)) + } + for field in sorted(feature_fields): + if all( + field in values and isinstance(values[field], (str, int, float, bool)) + for values in point_metadata + ): + all_properties[field] = [values[field] for values in point_metadata] points_array = np.empty((0, 2 + len(axis_projection.projected_axis_components))) if all_points_nd: diff --git a/tests/unit/test_materialization_core.py b/tests/unit/test_materialization_core.py index 2d26d82e37..ac4d7480d6 100644 --- a/tests/unit/test_materialization_core.py +++ b/tests/unit/test_materialization_core.py @@ -773,6 +773,7 @@ def test_point_roi_materialization_native_reopen_preserves_fractional_z( "center_z": 2.375, "center_y": 1.25, "center_x": 3.5, + "response": 4.75, }, ), fields=( @@ -780,9 +781,17 @@ def test_point_roi_materialization_native_reopen_preserves_fractional_z( FieldSpec("center_z", float), FieldSpec("center_y", float), FieldSpec("center_x", float), + FieldSpec("response", float), ), ), source_path="/source/image.ome.tif", + source_image_provenance_planes=SourceImageProvenancePlanes.from_components( + paths=("/source/image.ome.tif",) * 4, + component_metadata=tuple( + {"well": "A01", "site": 1, "channel": 1, "z_index": z, "timepoint": 1} + for z in range(4) + ), + ), subject=MeasurementSubject(MeasurementScope.OBJECT, "nuclei", "object_label"), ) feature = ObjectCoreMeasurementFeature @@ -804,6 +813,8 @@ def test_point_roi_materialization_native_reopen_preserves_fractional_z( assert archive == str(tmp_path / "centres_points.roi.zip") assert len(rois) == 1 assert rois[0].metadata["label"] == 7 + assert rois[0].metadata["object_label"] == 7 + assert rois[0].metadata["response"] == 4.75 assert rois[0].shapes == [PointShape(y=1.25, x=3.5)] assert ROIFractionalZ.decode(rois[0].metadata) == ROIFractionalZ(2.375) assert ROIArchiveSourceMetadata.decode(rois).source_path == "/source/image.ome.tif" @@ -825,6 +836,12 @@ def test_point_roi_materialization_native_reopen_preserves_fractional_z( ) assert points.tolist() == [[2.375, 1.25, 3.5]] assert properties["label"] == [7] + assert properties["object_label"] == [7] + assert properties["response"] == [4.75] + from napari.layers import Points + + native_layer = Points(points, properties=properties) + assert native_layer.features.loc[0, "response"] == 4.75 @pytest.mark.unit diff --git a/tests/unit/test_streaming_service.py b/tests/unit/test_streaming_service.py index 60a63b1a9b..30b6b24429 100644 --- a/tests/unit/test_streaming_service.py +++ b/tests/unit/test_streaming_service.py @@ -7,7 +7,10 @@ import numpy as np import pytest +from polystore.disk import DiskStorageBackend +from polystore.filemanager import FileManager from polystore.roi import ROI, PointShape +from polystore.roi_converters import NapariROIConverter from polystore.streaming.identity import ( FixedStreamProducerIdentityKind, StreamProducerIdentity, @@ -36,6 +39,14 @@ image_payload_data, ) from openhcs.core.roi_source_metadata import ROIArchiveSourceMetadata +from openhcs.core.measurement_row_materialization import MeasurementSparseColumnarRows +from openhcs.core.runtime_measurements import ( + MeasurementScope, + MeasurementSubject, + MeasurementTable, + ObjectCoreMeasurementFeature, +) +from openhcs.core.runtime_tabular_values import FieldSpec from openhcs.core.runtime_plane_projection import RuntimePlaneAxis from openhcs.core.source_image_provenance import SourceImageProvenancePlanes from openhcs.core.source_metadata import SourceVoxelSpacing, SourceVoxelSpacingUnit @@ -61,6 +72,19 @@ ViewerLaunchContext, ) from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG +from openhcs.runtime.viewer_component_system import ( + ViewerComponentAxisSemantics, + ViewerComponentValueDomainPayload, + ViewerLayerAxisProjectionRequestAuthority, + ViewerLayerAxisProjector, + ViewerObjectDisplayConfigInput, + ViewerRouteComponentValueTracker, +) +from openhcs.processing.materialization import ( + MaterializationSpec, + PointROIOptions, + materialize, +) class FakeFileManager: @@ -930,6 +954,115 @@ def test_reopen_native_roi_archives_preserves_per_file_source_and_calibration( assert data[0][0].shapes == [PointShape(32.25, 40.5)] +def test_3d_point_archive_reopens_with_native_z_domain_and_features(tmp_path): + source_path = str(tmp_path / "source" / "image.ome.tif") + components = tuple( + {"well": "A01", "site": 1, "channel": 1, "z_index": z, "timepoint": 1} + for z in range(4) + ) + table = MeasurementTable( + name="centres", + rows=MeasurementSparseColumnarRows.from_rows( + ( + { + "object_label": 7, + "center_z": 2.375, + "center_y": 1.25, + "center_x": 3.5, + "response": 4.75, + }, + ), + fields=( + FieldSpec("object_label", int), + FieldSpec("center_z", float), + FieldSpec("center_y", float), + FieldSpec("center_x", float), + FieldSpec("response", float), + ), + ), + source_path=source_path, + source_image_provenance_planes=SourceImageProvenancePlanes.from_components( + paths=(source_path,) * 4, + component_metadata=components, + ), + subject=MeasurementSubject(MeasurementScope.OBJECT, "nuclei", "object_label"), + ) + features = ObjectCoreMeasurementFeature + archive = materialize( + MaterializationSpec( + PointROIOptions( + z_feature=features.CENTER_Z, + y_feature=features.CENTER_Y, + x_feature=features.CENTER_X, + ) + ), + data=table, + path=str(tmp_path / "centres"), + filemanager=FileManager({"disk": DiskStorageBackend()}), + backends=["disk"], + backend_kwargs={}, + ) + filemanager = FakeFileManager() + handler = SimpleNamespace( + metadata_handler=FakeMetadataHandler(), + parser=SimpleNamespace( + parse_filename=lambda _filename: pytest.fail( + "Native point archive must not infer provenance from filenames" + ) + ), + ) + config = NapariStreamingConfig(enabled=True) + result = StreamingService(filemanager, handler, tmp_path).stream_rois( + RoiStreamingRequest( + viewer=FakeViewer(), + config=config, + status_callback=lambda _status: None, + error_callback=lambda error: pytest.fail(error), + roi_filenames=(archive,), + require_source_metadata=True, + ) + ) + assert result.streamed_paths == (archive,) + assert len(filemanager.saved_batches) == 1 + data, paths, _backend, kwargs = filemanager.saved_batches[0] + stream = kwargs[ViewerStreamKwarg.STREAM_REQUEST.value] + assert paths == [archive] + assert data[0][0].metadata["response"] == 4.75 + assert data[0][0].metadata["openhcs_fractional_z"] == 2.375 + assert stream.source.metadata.metadata_by_path[archive]["z_index"] == 0 + assert stream.message_extra[ViewerBatchWireField.COMPONENT_VALUE_DOMAIN.value][ + "z_index" + ] == [0, 1, 2, 3] + domain = ViewerComponentValueDomainPayload.from_wire_mapping( + stream.message_extra[ViewerBatchWireField.COMPONENT_VALUE_DOMAIN.value], + context="native point archive", + ) + semantics = ViewerComponentAxisSemantics( + entries=domain.entries, + layout=ViewerObjectDisplayConfigInput(config).layout(), + ) + item = SimpleNamespace( + address=SimpleNamespace( + components=stream.source.metadata.metadata_by_path[archive] + ), + data=NapariROIConverter.rois_to_shapes(data[0]), + ) + request = ViewerLayerAxisProjectionRequestAuthority.from_component_axis_semantics( + route_key="centres", + component_axis_semantics=semantics, + layer_items=[item], + route_value_tracker=ViewerRouteComponentValueTracker(), + aggregate_component_values={}, + ) + projection = ViewerLayerAxisProjector().project(request) + assert "z_index" in projection.projected_axis_components + from openhcs.runtime.napari_viewer_server import _build_nd_points + + points, properties = _build_nd_points([item], projection) + assert points[0, projection.projected_axis_components.index("z_index")] == 2.375 + assert properties["response"] == [4.75] + + def test_explicit_native_reopening_rejects_an_external_roi_without_source(tmp_path): from polystore.disk import DiskStorageBackend From 971407106352954c849dcdf70e0c52c18d3a3286 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 29 Sep 2026 11:57:10 -0400 Subject: [PATCH 06/10] Exercise point ROI writer through runtime artifact plan --- .../test_function_artifact_materialization.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/unit/test_function_artifact_materialization.py b/tests/unit/test_function_artifact_materialization.py index 98b4293b0c..1559f16542 100644 --- a/tests/unit/test_function_artifact_materialization.py +++ b/tests/unit/test_function_artifact_materialization.py @@ -75,7 +75,10 @@ MeasurementScope, MeasurementSubject, MeasurementTable, + ObjectCoreMeasurementFeature, ) +from openhcs.core.roi_point_metadata import ROIFractionalZ +from openhcs.core.roi_source_metadata import ROIArchiveSourceMetadata from openhcs.core.runtime_object_label_domains import ( ObjectLabelDomain, ObjectLabelDomainScope, @@ -141,6 +144,7 @@ CsvOptions, FileBundleOptions, JsonOptions, + PointROIOptions, ROIOptions, TextOptions, csv_only, @@ -1118,6 +1122,70 @@ def fake_materialize(spec, data, path, *_args, **_kwargs): assert path == "/analysis/A01_measurements_step7.roi.zip" +def test_runtime_artifact_plan_materializes_3d_point_measurements(): + features = ObjectCoreMeasurementFeature + output_plan = ArtifactOutputPlan( + name="nuclei_centres", + path="/memory/A01_nuclei_centres_step7.pkl", + artifact_type=MeasurementsArtifactType, + materialization=MaterializationSpec( + PointROIOptions( + z_feature=features.CENTER_Z, + y_feature=features.CENTER_Y, + x_feature=features.CENTER_X, + ) + ), + variable_components=(AllComponents.Z_INDEX,), + ) + source_path = "/input/A01/image.ome.tif" + table = MeasurementTable( + name=output_plan.name, + rows=MeasurementSparseColumnarRows.from_rows( + ( + { + "object_label": 7, + "center_z": 2.375, + "center_y": 1.25, + "center_x": 3.5, + "response": 4.75, + }, + ), + fields=( + FieldSpec("object_label", int), + FieldSpec("center_z", float), + FieldSpec("center_y", float), + FieldSpec("center_x", float), + FieldSpec("response", float), + ), + ), + source_image_provenance_planes=SourceImageProvenancePlanes.from_components( + paths=(source_path,) * 4, + component_metadata=tuple( + {"well": "A01", "site": 1, "channel": 1, "z_index": z, "timepoint": 1} + for z in range(4) + ), + ), + subject=MeasurementSubject(MeasurementScope.OBJECT, "nuclei", "object_label"), + ) + context = _context(FileManagerStub()) + context.runtime_value_store.record( + RuntimeValue.normalize(output_plan, table, axis_id="A01"), + path=output_plan.path, + backend="memory", + ) + plan = _plan(output_plan, variable_components=(VariableComponents.Z_INDEX,)) + + [materialization] = runtime_artifact_materializations(plan, context) + [result] = materialization.outputs(plan, context) + + assert result.path == "/analysis/A01_nuclei_centres_step7_points.roi.zip" + assert len(result.content) == 1 + assert result.content[0].metadata["object_label"] == 7 + assert result.content[0].metadata["response"] == 4.75 + assert ROIFractionalZ.decode(result.content[0].metadata) == ROIFractionalZ(2.375) + assert ROIArchiveSourceMetadata.decode(result.content).source_path == source_path + + def test_multi_plane_measurement_materialization_uses_aggregate_artifact_name(): output_plan = ArtifactOutputPlan( name="cell_counts", From 4cdec75f8ccca0e6b791431b6f3493f513a25f6c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 29 Sep 2026 12:11:37 -0400 Subject: [PATCH 07/10] Preserve exact multi-file Z source provenance for point ROIs --- docs/plans/typed-3d-point-result-20260929.md | 12 ++++++++++-- openhcs/processing/materialization/core.py | 11 +++++++++-- tests/unit/test_streaming_service.py | 16 ++++++++++++++-- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/plans/typed-3d-point-result-20260929.md b/docs/plans/typed-3d-point-result-20260929.md index 4b26d842bd..0fd96f3415 100644 --- a/docs/plans/typed-3d-point-result-20260929.md +++ b/docs/plans/typed-3d-point-result-20260929.md @@ -3,8 +3,11 @@ Status: measurement payload retention, typed point writer, native ROI ZIP round-trip and exact-Z Napari projection implemented in this draft. The native reopen path now derives its ordered Z domain from the table's declared source -planes and retains scalar measurement fields as Napari point features. Synthetic -source tests pass; installed entrypoint and biological result are not claimed. +planes, including distinct physical files, and retains scalar measurement +fields as Napari point features. The fresh-source MCP result-directory route +inventoried and streamed a native ROI ZIP to an isolated viewer. Raw-image +streaming is blocked by #201; installed entrypoint and biological result are +not claimed. Base: OpenHCSDev/openhcs `9644febe2785aace85bbc8bd1d2ce062525c56d2`. Issue: OpenHCSDev/openhcs#134. Integration owner: blind-analysis coordinator. @@ -43,6 +46,11 @@ Coordinate the worker/export crossing with open PR #157; do not edit its query tool does not expose `result_directory`. Remote main has the route; source-worktree tests cannot substitute for a reviewed install and fresh MCP handshake. +- Fresh-source MCP on isolated display :91 streamed the native point ROI to + port 5791. BioFormats raw streaming then failed for both a four-plane OME + TIFF (ambiguous physical path) and four separate TIFF planes (fabricated + escaped-and-suffixed load path). Issue #201 has the reproducer and owner; + raw/result/combined visual QA remains open. ## Required closure diff --git a/openhcs/processing/materialization/core.py b/openhcs/processing/materialization/core.py index 426ea3daed..c2205ef136 100644 --- a/openhcs/processing/materialization/core.py +++ b/openhcs/processing/materialization/core.py @@ -3221,8 +3221,15 @@ def _write_point_roi_zip( if missing: raise ValueError(f"Point measurement fields are missing: {sorted(missing)!r}.") provenance = payload.source_provenance.with_common_scalar_identity_from_planes() - if provenance.source_path is None: - raise ValueError("Point ROI ZIP requires an unambiguous source-image path.") + source_planes = provenance.source_image_provenance_planes + if provenance.source_path is None and ( + not source_planes.has_values + or any(path is None for path in source_planes.paths) + ): + raise ValueError( + "Point ROI ZIP requires a source-image path or exact paths for every " + "source plane." + ) rois: list[ROI] = [] seen_labels: set[int] = set() for row in payload.iter_row_mappings(): diff --git a/tests/unit/test_streaming_service.py b/tests/unit/test_streaming_service.py index 30b6b24429..9db11e4b19 100644 --- a/tests/unit/test_streaming_service.py +++ b/tests/unit/test_streaming_service.py @@ -955,7 +955,19 @@ def test_reopen_native_roi_archives_preserves_per_file_source_and_calibration( def test_3d_point_archive_reopens_with_native_z_domain_and_features(tmp_path): - source_path = str(tmp_path / "source" / "image.ome.tif") + import tifffile + + source_directory = tmp_path / "source" + source_directory.mkdir() + source_paths = tuple( + str(source_directory / f"A01_s001_w1_z{z + 1:03d}_t001.tif") for z in range(4) + ) + for z, path in enumerate(source_paths): + source_pixels = np.zeros((8, 8), dtype=np.uint16) + if z == 2: + source_pixels[1:3, 3:5] = 2048 + tifffile.imwrite(path, source_pixels) + source_path = source_paths[0] components = tuple( {"well": "A01", "site": 1, "channel": 1, "z_index": z, "timepoint": 1} for z in range(4) @@ -982,7 +994,7 @@ def test_3d_point_archive_reopens_with_native_z_domain_and_features(tmp_path): ), source_path=source_path, source_image_provenance_planes=SourceImageProvenancePlanes.from_components( - paths=(source_path,) * 4, + paths=source_paths, component_metadata=components, ), subject=MeasurementSubject(MeasurementScope.OBJECT, "nuclei", "object_label"), From 156fd043f6290165ac6d409bd898c7eb5ac9ea9e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 29 Sep 2026 12:36:03 -0400 Subject: [PATCH 08/10] Keep fractional-Z point navigation and reviewer checks aligned --- openhcs/runtime/napari_viewer_server.py | 74 ++++++++++++++++++- openhcs/runtime/viewer_component_system.py | 7 ++ openhcs/runtime/viewer_controls.py | 25 ++++++- .../use-openhcs/references/viewer-qa.md | 9 +++ tests/unit/test_napari_streaming_handlers.py | 51 +++++++++++-- tests/unit/test_streaming_service.py | 1 + .../test_viewer_result_element_selection.py | 67 +++++++++++++++++ 7 files changed, 224 insertions(+), 10 deletions(-) diff --git a/openhcs/runtime/napari_viewer_server.py b/openhcs/runtime/napari_viewer_server.py index cc50bfc0eb..a1768ecc28 100644 --- a/openhcs/runtime/napari_viewer_server.py +++ b/openhcs/runtime/napari_viewer_server.py @@ -19,6 +19,7 @@ from dataclasses import dataclass, field, replace from enum import Enum from itertools import product +from math import ceil from numbers import Integral from types import MappingProxyType from typing import TYPE_CHECKING, ClassVar, Generic, Sequence, TypeAlias, TypeVar, cast @@ -127,6 +128,7 @@ ViewerStreamingDataTypeHandlerMeta, ) from openhcs.runtime.viewer_controls import ( + ViewerFractionalZPointCoordinateAuthority, ViewerIntensityWindowControlOptions, ViewerNativeDimensions, ViewerResultElementCoordinateAuthority, @@ -1137,7 +1139,7 @@ def _build_nd_points( for coord in coordinates: point_dims = prepend_dims.copy() if z_axis_index is not None and fractional_z is not None: - point_dims[z_axis_index] = fractional_z.value + point_dims[z_axis_index] += fractional_z.value nd_coord = point_dims + list(coord) all_points_nd.append(nd_coord) @@ -1718,6 +1720,14 @@ class NapariLayerDisplayHandler( title_suffix: ClassVar[str] = "" + def geometric_component_values( + self, + items: Sequence[NapariStreamLayerItem], + component_axis_semantics: ViewerComponentAxisSemantics, + ) -> ComponentValues: + """Return component values occupied by geometry beyond item anchors.""" + return {} + def display_work( self, request: NapariLayerDisplayRequest, @@ -1985,6 +1995,55 @@ class NapariPointsLayerDisplayHandler(NapariLayerDisplayHandler): streaming_data_type: ClassVar[StreamingDataType] = StreamingDataType.POINTS title_suffix: ClassVar[str] = "points" + def geometric_component_values( + self, + items: Sequence[NapariStreamLayerItem], + component_axis_semantics: ViewerComponentAxisSemantics, + ) -> ComponentValues: + """Keep the declared Z span containing fractional-Z points navigable.""" + z_component = AllComponents.Z_INDEX.value + coordinates = tuple( + (item, fractional_z) + for item in items + for shape in item.data + if (payload := ShapePayload(shape)).shape_type == "points" + if (fractional_z := ROIFractionalZ.decode(payload.metadata.metadata)) + is not None + ) + if not coordinates: + return {} + if z_component not in component_axis_semantics.layout.components_for_mode( + ViewerComponentMode.STACK + ): + raise ValueError( + "Fractional-Z point ROI requires a projected z_index axis." + ) + domain = component_axis_semantics.required_component_values((z_component,))[ + z_component + ] + occupied: set[ComponentValue] = set() + for item, fractional_z in coordinates: + anchor = ViewerComponentCoordinateAuthority.required_value( + item.address.components, + z_component, + context="Napari fractional-Z point", + ) + try: + start = domain.index(anchor) + except ValueError as exc: + raise ValueError( + f"Fractional-Z point anchor {anchor!r} is absent from the " + f"declared Z domain {domain!r}." + ) from exc + stop = start + ceil(fractional_z.value) + if stop >= len(domain): + raise ValueError( + f"Fractional-Z point {fractional_z.value!r} exceeds the " + f"declared Z domain from anchor {anchor!r}." + ) + occupied.update(domain[start : stop + 1]) + return {z_component: [value for value in domain if value in occupied]} + def handle(self, request: NapariLayerDisplayRequest) -> None: pipeline = request.pipeline presentation = request.presentation @@ -2058,6 +2117,11 @@ def display_axis_projection( if aggregate_axis_bindings is None: aggregate_axis_bindings = NapariAggregateAxisBindingSet() + data_type = layer_items[0].address.stream_layer_data_type + geometric_component_values = NapariLayerDisplayHandler.for_data_type( + data_type + ).geometric_component_values(layer_items, component_axis_semantics) + projection_request = ( ViewerLayerAxisProjectionRequestAuthority.from_component_axis_semantics( route_key=layer_key, @@ -2065,6 +2129,7 @@ def display_axis_projection( layer_items=layer_items, route_value_tracker=self.server.component_values, aggregate_component_values=aggregate_axis_bindings.component_values, + geometric_component_values=geometric_component_values, ) ) return self.axis_projector.project(projection_request) @@ -5143,7 +5208,12 @@ def result_element_axis_indices( if displayed_axis_indices is None else displayed_axis_indices ) - return ViewerResultElementCoordinateAuthority.axis_indices( + coordinate_authority = ( + ViewerFractionalZPointCoordinateAuthority + if isinstance(layer, napari.layers.Points) + else ViewerResultElementCoordinateAuthority + ) + return coordinate_authority.axis_indices( coordinates=cast(Sequence[object], coordinates), axis_labels=dimension_state.axis_labels, displayed_axis_indices=tuple( diff --git a/openhcs/runtime/viewer_component_system.py b/openhcs/runtime/viewer_component_system.py index b457d229c4..b8dd2d6d0b 100644 --- a/openhcs/runtime/viewer_component_system.py +++ b/openhcs/runtime/viewer_component_system.py @@ -1406,6 +1406,7 @@ def from_component_axis_semantics( layer_items: Sequence[ViewerComponentAddressedItem], route_value_tracker: ViewerRouteComponentValueTracker, aggregate_component_values: ComponentValues, + geometric_component_values: ComponentValues, ) -> ViewerLayerAxisProjectionRequest: axis_components = component_axis_semantics.layout.components_for_mode( ViewerComponentMode.STACK @@ -1417,6 +1418,12 @@ def from_component_axis_semantics( axis_components, aggregate_component_values, ) + if geometric_component_values: + route_value_tracker.update_component_values( + route_key, + axis_components, + geometric_component_values, + ) declared_component_values = component_axis_semantics.required_component_values( axis_components ) diff --git a/openhcs/runtime/viewer_controls.py b/openhcs/runtime/viewer_controls.py index 5eb8a2873e..39462b6e29 100644 --- a/openhcs/runtime/viewer_controls.py +++ b/openhcs/runtime/viewer_controls.py @@ -5,12 +5,14 @@ from collections.abc import Iterable, Mapping, Sequence from dataclasses import asdict, dataclass, field from enum import Enum -from math import isfinite +from math import floor, isfinite from numbers import Real from typing import ClassVar, Self, TypeAlias, TypeVar from zmqruntime.viewer_protocol import ViewerWireField +from openhcs.constants import AllComponents + ViewerScalar: TypeAlias = str | int | float | bool | None ViewerPayloadAxisIndices: TypeAlias = tuple[int, ...] | dict[str, int] ViewerShapePayloadValueT = TypeVar("ViewerShapePayloadValueT") @@ -148,7 +150,7 @@ def _slice_index( axis_label: str, ) -> int: coordinates = tuple( - cls._integral_coordinate( + cls._slice_coordinate( row[axis_position], axis_label=axis_label, ) @@ -162,7 +164,7 @@ def _slice_index( return coordinates[0] @staticmethod - def _integral_coordinate(value: object, *, axis_label: str) -> int: + def _slice_coordinate(value: object, *, axis_label: str) -> int: if isinstance(value, bool) or not isinstance(value, Real): raise TypeError( f"Viewer result element coordinate for axis {axis_label!r} " @@ -177,6 +179,23 @@ def _integral_coordinate(value: object, *, axis_label: str) -> int: return int(numeric_value) +class ViewerFractionalZPointCoordinateAuthority(ViewerResultElementCoordinateAuthority): + """Navigate to the nearest Z slice without rounding stored point geometry.""" + + @staticmethod + def _slice_coordinate(value: object, *, axis_label: str) -> int: + if axis_label != AllComponents.Z_INDEX.value: + return ViewerResultElementCoordinateAuthority._slice_coordinate( + value, axis_label=axis_label + ) + if isinstance(value, bool) or not isinstance(value, Real): + raise TypeError("Viewer point Z coordinate must be numeric.") + numeric_value = float(value) + if not isfinite(numeric_value): + raise ValueError("Viewer point Z coordinate must be finite.") + return floor(numeric_value + 0.5) + + @dataclass(frozen=True, slots=True) class ViewerNativeDimensions: """Actual native readback; canvas_size is logical Qt (width, height).""" diff --git a/packaging/codex/openhcs/skills/use-openhcs/references/viewer-qa.md b/packaging/codex/openhcs/skills/use-openhcs/references/viewer-qa.md index e7544f790c..6f22e614ef 100644 --- a/packaging/codex/openhcs/skills/use-openhcs/references/viewer-qa.md +++ b/packaging/codex/openhcs/skills/use-openhcs/references/viewer-qa.md @@ -23,6 +23,15 @@ different plates disagree, stream raw from the result's declared source and recheck. Reject black, wrong-channel, stale or misplaced captures; an invisible overlay is not evidence that the detector found nothing. +For feature-bearing 3-D point results, check the persisted point coordinates +against the viewer's native point geometry, declared Z domain, and selected +feature row. Select a point by its data index through MCP and verify that the +same row and exact Z coordinate remain attached. A result-only Points layer +may have a native slice range determined solely by its points, so a displayed +integer Z label or `current_step` alone does not establish the point's Z or +its alignment with raw planes. Confirm that alignment in the combined view; +record any disagreement rather than treating a visible point as a QA pass. + ## Choose a distributed, multiscale sample Inspect the whole field for illumination, tissue, focus and density variation. diff --git a/tests/unit/test_napari_streaming_handlers.py b/tests/unit/test_napari_streaming_handlers.py index aaebc043dc..3db8a010d5 100644 --- a/tests/unit/test_napari_streaming_handlers.py +++ b/tests/unit/test_napari_streaming_handlers.py @@ -5999,13 +5999,15 @@ def test_napari_points_layer_uses_exact_fractional_z_from_native_roi_metadata(): server.layer_route_state.set_title("centres", "Centres") server.viewer = _FakeViewer() pipeline = napari_viewer_server.NapariLayerDisplayPipeline(server) - presentation = _axis_presentation( - layer_key="centres", - projected_axis_components=("z_index",), - component_values={"z_index": [0, 1, 2, 3]}, + semantics = ViewerComponentAxisSemantics( + entries=_component_value_domain({"z_index": [0, 1, 2, 3]}).entries, + layout=ViewerComponentLayout.from_parts( + component_modes={"z_index": ViewerComponentMode.STACK}, + component_order=("z_index",), + ), ) item = _layer_item( - {"z_index": 2}, + {"z_index": 0}, [ { "type": "points", @@ -6015,6 +6017,16 @@ def test_napari_points_layer_uses_exact_fractional_z_from_native_roi_metadata(): ], stream_layer_data_type=StreamingDataType.POINTS, ) + projection = pipeline.display_axis_projection("centres", semantics, [item]) + assert projection.component_values == {"z_index": [0, 1, 2, 3]} + assert projection.routed_component_values == {"z_index": [0, 1, 2, 3]} + assert projection.routed_component_coordinates == ((0,),) + presentation = NapariAxisPresentation( + entries=semantics.entries, + layout=semantics.layout, + route_key="centres", + projection=projection, + ) napari_viewer_server.NapariPointsLayerDisplayHandler().handle( napari_viewer_server.NapariLayerDisplayRequest( pipeline=pipeline, @@ -6026,6 +6038,35 @@ def test_napari_points_layer_uses_exact_fractional_z_from_native_roi_metadata(): assert tuple(server.viewer.calls[-1][1][0]) == (2.375, 1.25, 3.5) +def test_napari_fractional_z_points_are_relative_to_the_declared_anchor(): + napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") + server = _FakeNapariServer() + pipeline = napari_viewer_server.NapariLayerDisplayPipeline(server) + semantics = ViewerComponentAxisSemantics( + entries=_component_value_domain({"z_index": [0, 1, 2, 3, 4]}).entries, + layout=ViewerComponentLayout.from_parts( + component_modes={"z_index": ViewerComponentMode.STACK}, + component_order=("z_index",), + ), + ) + item = _layer_item( + {"z_index": 2}, + [ + { + "type": "points", + "coordinates": [[1.25, 3.5]], + "metadata": {"openhcs_fractional_z": 1.25}, + } + ], + stream_layer_data_type=StreamingDataType.POINTS, + ) + projection = pipeline.display_axis_projection("centres", semantics, [item]) + assert projection.component_values == {"z_index": [2, 3, 4]} + points, _properties = napari_viewer_server._build_nd_points([item], projection) + assert tuple(points[0]) == (1.25, 1.25, 3.5) + assert projection.axis_offsets == (2,) + + def test_napari_points_layer_rejects_fractional_z_without_z_axis(): napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") item = _layer_item( diff --git a/tests/unit/test_streaming_service.py b/tests/unit/test_streaming_service.py index 9db11e4b19..a4cb55f0dd 100644 --- a/tests/unit/test_streaming_service.py +++ b/tests/unit/test_streaming_service.py @@ -1065,6 +1065,7 @@ def test_3d_point_archive_reopens_with_native_z_domain_and_features(tmp_path): layer_items=[item], route_value_tracker=ViewerRouteComponentValueTracker(), aggregate_component_values={}, + geometric_component_values={}, ) projection = ViewerLayerAxisProjector().project(request) assert "z_index" in projection.projected_axis_components diff --git a/tests/unit/test_viewer_result_element_selection.py b/tests/unit/test_viewer_result_element_selection.py index 14facdcfc6..d0d004d2a2 100644 --- a/tests/unit/test_viewer_result_element_selection.py +++ b/tests/unit/test_viewer_result_element_selection.py @@ -36,6 +36,7 @@ NapariViewerServer, ) from openhcs.runtime.viewer_controls import ( + ViewerFractionalZPointCoordinateAuthority, ViewerLayerIsolationControlOptions, ViewerNavigationControlOptions, ViewerResultElementCoordinateAuthority, @@ -552,6 +553,72 @@ def test_result_element_coordinate_authority_rejects_cross_slice_geometry() -> N ) +def test_fractional_z_point_navigation_chooses_slice_without_rounding_geometry() -> ( + None +): + assert ViewerFractionalZPointCoordinateAuthority.axis_indices( + coordinates=(2.375, 1.25, 3.5), + axis_labels=("z_index", "y", "x"), + displayed_axis_indices=(1, 2), + ) == {"z_index": 2} + with pytest.raises(ValueError, match="integral slice"): + ViewerResultElementCoordinateAuthority.axis_indices( + coordinates=(2.375, 1.25, 3.5), + axis_labels=("z_index", "y", "x"), + displayed_axis_indices=(1, 2), + ) + + +def test_napari_navigation_selects_fractional_z_point_feature(qtbot) -> None: + from napari.components import ViewerModel + + viewer = ViewerModel() + viewer.add_image(np.zeros((4, 8, 8), dtype=np.uint8), name="Source planes") + layer = viewer.add_points( + np.array([[2.375, 1.25, 3.5]], dtype=float), + features={"response": [4.75]}, + name="Centres", + ) + server, _overlay, _dock, _window = _viewer_server(viewer, layer, "centres") + projection = ViewerLayerAxisProjection( + projected_axis_components=("z_index",), + component_values={"z_index": [0, 1, 2, 3]}, + routed_component_values={"z_index": [0, 1, 2, 3]}, + axis_offsets=(0,), + ) + semantics = ViewerComponentAxisSemanticsAuthority.empty() + server.layer_route_state.set_dimension_state( + "centres", + NapariDimensionLayerState( + labels={"z_index": ("Z 0", "Z 1", "Z 2", "Z 3")}, + presentation=NapariAxisPresentation( + entries=semantics.entries, + layout=ViewerComponentLayout.from_parts( + component_modes={"z_index": ViewerComponentMode.STACK}, + component_order=("z_index",), + ), + route_key="centres", + projection=projection, + ), + ), + ) + + response = NapariNavigationControlMessageAction().handle( + server, + { + ViewerControlResponseField.PAYLOAD.value: ViewerNavigationControlOptions.from_overrides( + route_key="centres", visible=True, selected=True, data_index=0 + ) + }, + ) + + assert response["status"] == "success", response + assert layer.selected_data == {0} + assert viewer.dims.current_step[0] == 2 + assert layer.data[0, 0] == 2.375 + assert layer.features.loc[0, "response"] == 4.75 + + def test_napari_navigation_rejects_out_of_range_data_index(qtbot) -> None: from napari.components import ViewerModel From 39a6d0c89e75c6186f4ba21cc562960271c6c1ed Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 29 Sep 2026 12:49:01 -0400 Subject: [PATCH 09/10] Stream plate images from inventory-owned exact source refs --- .../agent/services/plate_streaming_service.py | 28 +++- openhcs/core/viewer_streaming_service.py | 25 ++- .../agent/test_plate_streaming_service.py | 147 +++++++++++++++++- tests/unit/test_streaming_service.py | 4 + 4 files changed, 197 insertions(+), 7 deletions(-) diff --git a/openhcs/agent/services/plate_streaming_service.py b/openhcs/agent/services/plate_streaming_service.py index 0f7696cacf..6e0358e47b 100644 --- a/openhcs/agent/services/plate_streaming_service.py +++ b/openhcs/agent/services/plate_streaming_service.py @@ -227,9 +227,15 @@ def stream_files( ) if request.result_directory is not None: read_backend = Backend.DISK.value - source_projection, producer = self._receipt_source_projection( - request, resolved_records, stream_context - ) + if request.source_receipt is None: + source_projection = self._inventory_source_projection( + resolved_records, stream_context + ) + producer = None + else: + source_projection, producer = self._receipt_source_projection( + request, resolved_records, stream_context + ) viewer = StreamingViewerLifecycle.get_or_create_visualizer( filemanager=stream_context.filemanager, @@ -322,6 +328,22 @@ def stream_files( warnings=all_warnings, ) + @staticmethod + def _inventory_source_projection( + records: tuple[PlateFileRecord, ...], + context: PlateInspectionContext, + ) -> VirtualWorkspaceSourceProjection | None: + """Carry inventory-owned physical image identities into viewer loading.""" + builder = VirtualWorkspaceSourceProjectionBuilder(Path(context.plate_path)) + for record in records: + image_path = record.streamable_image_path + if image_path is None or record.source_ref is None: + continue + builder.record_workspace_source_path(image_path, record.source_ref) + if record.metadata: + builder.record_source_metadata(image_path, record.metadata) + return builder.projection() if builder.workspace_source_refs else None + def _receipt_source_projection( self, request: PlateFileStreamRequest, diff --git a/openhcs/core/viewer_streaming_service.py b/openhcs/core/viewer_streaming_service.py index 65ef96b921..0ba1dbd1c0 100644 --- a/openhcs/core/viewer_streaming_service.py +++ b/openhcs/core/viewer_streaming_service.py @@ -363,7 +363,20 @@ def load_image( source_path = source_projection.resolved_source_path_for( lookup, self.filemanager ) - image = self.filemanager.load(source_path, backend) + source_address = ( + source_path + if source_ref is None + else str( + self.filemanager.resolve_address( + source_ref.backend_address, + backend, + base_path=Path(self.plate_path), + ) + ) + ) + image = self.filemanager.load(source_address, backend) + if source_ref is not None: + image = source_ref.project_source_axes(image) image = source_projection.project_unbound_payload(lookup, image) metadata = ImagePayloadSourceMetadataContext( source_identity=SourceImageIdentity( @@ -372,7 +385,7 @@ def load_image( ), read_backend=backend, filemanager=self.filemanager, - source_address=source_path, + source_address=source_address, ).metadata(image) metadata = self.calibrated_metadata(metadata) return metadata.payload_with( @@ -666,7 +679,13 @@ def stream_images( source_projection=source_projection, component_metadata=all_metadata_by_path[filename], ) - if request.source_projection is not None: + lookup = VirtualWorkspacePathLookup.from_paths( + filename, str(Path(self.source.plate_path) / filename) + ) + if ( + request.source_projection is not None + and source_projection.source_projection_for(lookup) is not None + ): self.source.require_projected_image_window( filename, image_data, source_projection ) diff --git a/tests/unit/agent/test_plate_streaming_service.py b/tests/unit/agent/test_plate_streaming_service.py index 256f231297..e995d8d20c 100644 --- a/tests/unit/agent/test_plate_streaming_service.py +++ b/tests/unit/agent/test_plate_streaming_service.py @@ -1,14 +1,21 @@ from pathlib import Path +import numpy as np +import tifffile +from polystore.bioformats_storage import BioFormatsPlaneRef +from polystore.virtual_workspace import SourcePixelRef from zmqruntime.config import TransportMode from openhcs.agent.dto.execution import ExecutionConnectionSpec from openhcs.agent.dto.plate import ( PlateFileStreamRequest, + PlatePathInspectionRequest, ) +from openhcs.agent.path_policy import AgentPathPolicy from openhcs.agent.services.plate_inspection_service import PlateInspectionContext +from openhcs.agent.services.plate_inspection_service import PlateInspectionService from openhcs.agent.services.plate_streaming_service import PlateStreamingService -from openhcs.constants.constants import FileFormat +from openhcs.constants.constants import Backend, FileFormat from openhcs.core.plate_image_inventory import ( PlateFileInventory, PlateFileKind, @@ -16,6 +23,9 @@ PlateResultFileRecord, ) from openhcs.core.streaming_config_declarations import ViewerType +from openhcs.core.source_workspace_projection import VirtualWorkspacePathLookup +from openhcs.core.runtime_image_values import image_payload_data +from openhcs.core.viewer_streaming_service import ViewerStreamingSource from openhcs.mcp.context import OpenHCSAgentContext from openhcs.runtime.viewer_protocol import ( DetachedViewerLaunchFailure, @@ -319,6 +329,141 @@ def fake_stream_images(self, request): assert result.resolved_records[0].virtual_path == "A01_s001_w1_z001_t001.tif" +def test_plate_streaming_service_preserves_inventory_source_refs_for_each_plane( + monkeypatch, +): + plate = Path("/plate") + records = tuple( + PlateImageRecord( + virtual_path=f"virtual_z{z}.tif", + full_virtual_path=str(plate / f"virtual_z{z}.tif"), + backend=Backend.BIOFORMATS.value, + source_path="/plate/image.ome.tif", + metadata={"well": "A01", "z_index": z}, + source_ref=SourcePixelRef( + backend=Backend.BIOFORMATS.value, + backend_address=BioFormatsPlaneRef( + source_path=Path("image.ome.tif"), + series_index=0, + plane_index=z - 1, + ).to_backend_address(), + ), + ) + for z in (1, 2) + ) + inventory = PlateFileInventory( + plate_path=plate, image_records=records, result_records=() + ) + captured = {} + monkeypatch.setattr( + "openhcs.agent.services.plate_streaming_service." + "StreamingViewerLifecycle.get_or_create_visualizer", + lambda **_kwargs: FakeViewer(), + ) + + def fake_stream_images(self, request): + del self + captured["request"] = request + + monkeypatch.setattr( + "openhcs.agent.services.plate_streaming_service.StreamingService.stream_images", + fake_stream_images, + ) + + result = plate_streaming_service(FakeInspectionService(inventory)).stream_files( + PlateFileStreamRequest(plate_path=str(plate), kind=PlateFileKind.IMAGE, limit=2) + ) + + assert result.errors == () + projection = captured["request"].source_projection + assert projection is not None + for record in records: + lookup = VirtualWorkspacePathLookup.from_paths( + record.virtual_path, record.full_virtual_path + ) + assert projection.source_ref_for(lookup) == record.source_ref + assert ( + projection.source_metadata_for(lookup)["z_index"] + == record.metadata["z_index"] + ) + + +def test_inventory_source_projection_loads_exact_ome_stack_planes(tmp_path): + plate = tmp_path / "plate" + plate.mkdir() + pixels = np.stack( + [np.full((8, 8), value, dtype=np.uint16) for value in (11, 22, 33, 44)] + ) + tifffile.imwrite( + plate / "image.ome.tif", pixels, ome=True, metadata={"axes": "ZYX"} + ) + inspection = PlateInspectionService( + AgentPathPolicy.with_roots( + readable_roots=(tmp_path,), writable_roots=(tmp_path,) + ) + ) + context, errors, _warnings = inspection.open_context( + PlatePathInspectionRequest(plate_path=str(plate)) + ) + assert errors == () + assert context is not None + inventory, _warnings = inspection.file_inventory(context, kind=PlateFileKind.IMAGE) + records = inventory.file_records(kinds=(PlateFileKind.IMAGE,)) + assert len(records) == 4 + projection = PlateStreamingService._inventory_source_projection(records, context) + assert projection is not None + source = ViewerStreamingSource( + filemanager=context.filemanager, + microscope_handler=context.handler, + plate_path=str(plate), + ) + for record, expected in zip(records, (11, 22, 33, 44), strict=True): + image = source.load_image( + record.streamable_image_path, + record.source_ref.backend, + source_projection=projection, + component_metadata=record.metadata, + ) + assert np.all(image_payload_data(image) == expected) + assert record.metadata["z_index"] == (11, 22, 33, 44).index(expected) + 1 + + +def test_inventory_source_projection_loads_exact_ordinary_tiff(tmp_path): + plate = tmp_path / "plate" + plate.mkdir() + pixels = np.arange(64, dtype=np.uint16).reshape(8, 8) + physical_path = plate / "A01_s001_w1_z001_t001.tif" + tifffile.imwrite(physical_path, pixels) + inspection = PlateInspectionService( + AgentPathPolicy.with_roots( + readable_roots=(tmp_path,), writable_roots=(tmp_path,) + ) + ) + context, errors, _warnings = inspection.open_context( + PlatePathInspectionRequest(plate_path=str(plate)) + ) + assert errors == () + assert context is not None + inventory, _warnings = inspection.file_inventory(context, kind=PlateFileKind.IMAGE) + (record,) = inventory.file_records(kinds=(PlateFileKind.IMAGE,)) + assert record.streamable_image_path != physical_path.name + assert record.source_path == str(physical_path) + projection = PlateStreamingService._inventory_source_projection((record,), context) + assert projection is not None + source = ViewerStreamingSource( + filemanager=context.filemanager, + microscope_handler=context.handler, + plate_path=str(plate), + ) + image = source.load_image( + record.streamable_image_path, + record.source_ref.backend, + source_projection=projection, + component_metadata=record.metadata, + ) + np.testing.assert_array_equal(image_payload_data(image), pixels) + + def test_plate_streaming_service_rejects_non_roi_result_files(monkeypatch): inventory = PlateFileInventory( plate_path=Path("/plate"), diff --git a/tests/unit/test_streaming_service.py b/tests/unit/test_streaming_service.py index a4cb55f0dd..c8a316d40d 100644 --- a/tests/unit/test_streaming_service.py +++ b/tests/unit/test_streaming_service.py @@ -94,6 +94,10 @@ def __init__(self) -> None: def load(self, path: str, read_backend: str): return np.zeros((4, 5), dtype=np.uint16) + def resolve_address(self, address: str, backend: str, *, base_path: Path): + del backend + return str(base_path / address) + def exists(self, path, backend): return False From b007eb4bd97f6b7965b84f409987145040af3a41 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 29 Sep 2026 12:58:50 -0400 Subject: [PATCH 10/10] Retain matched OME and 3-D point viewer witness fixture --- .../agent/test_plate_streaming_service.py | 73 ++++++++++++++++++- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/tests/unit/agent/test_plate_streaming_service.py b/tests/unit/agent/test_plate_streaming_service.py index e995d8d20c..98adf66c60 100644 --- a/tests/unit/agent/test_plate_streaming_service.py +++ b/tests/unit/agent/test_plate_streaming_service.py @@ -25,7 +25,23 @@ from openhcs.core.streaming_config_declarations import ViewerType from openhcs.core.source_workspace_projection import VirtualWorkspacePathLookup from openhcs.core.runtime_image_values import image_payload_data +from openhcs.core.measurement_row_materialization import MeasurementSparseColumnarRows +from openhcs.core.runtime_measurements import ( + MeasurementScope, + MeasurementSubject, + MeasurementTable, + ObjectCoreMeasurementFeature, +) +from openhcs.core.runtime_tabular_values import FieldSpec +from openhcs.core.source_image_provenance import SourceImageProvenancePlanes from openhcs.core.viewer_streaming_service import ViewerStreamingSource +from openhcs.processing.materialization import ( + MaterializationSpec, + PointROIOptions, + materialize, +) +from polystore.disk import DiskStorageBackend +from polystore.filemanager import FileManager from openhcs.mcp.context import OpenHCSAgentContext from openhcs.runtime.viewer_protocol import ( DetachedViewerLaunchFailure, @@ -394,6 +410,7 @@ def test_inventory_source_projection_loads_exact_ome_stack_planes(tmp_path): pixels = np.stack( [np.full((8, 8), value, dtype=np.uint16) for value in (11, 22, 33, 44)] ) + pixels[2, 1:3, 3:5] = 2048 tifffile.imwrite( plate / "image.ome.tif", pixels, ome=True, metadata={"axes": "ZYX"} ) @@ -417,15 +434,65 @@ def test_inventory_source_projection_loads_exact_ome_stack_planes(tmp_path): microscope_handler=context.handler, plate_path=str(plate), ) - for record, expected in zip(records, (11, 22, 33, 44), strict=True): + for index, record in enumerate(records): image = source.load_image( record.streamable_image_path, record.source_ref.backend, source_projection=projection, component_metadata=record.metadata, ) - assert np.all(image_payload_data(image) == expected) - assert record.metadata["z_index"] == (11, 22, 33, 44).index(expected) + 1 + np.testing.assert_array_equal(image_payload_data(image), pixels[index]) + assert record.metadata["z_index"] == index + 1 + + table = MeasurementTable( + name="centres", + rows=MeasurementSparseColumnarRows.from_rows( + ( + { + "object_label": 7, + "center_z": 2.375, + "center_y": 1.25, + "center_x": 3.5, + "response": 4.75, + }, + ), + fields=( + FieldSpec("object_label", int), + FieldSpec("center_z", float), + FieldSpec("center_y", float), + FieldSpec("center_x", float), + FieldSpec("response", float), + ), + ), + source_path=records[0].full_virtual_path, + source_image_provenance_planes=SourceImageProvenancePlanes.from_components( + paths=tuple(record.full_virtual_path for record in records), + component_metadata=tuple( + { + component: record.metadata[component] + for component in ("well", "site", "channel", "z_index", "timepoint") + } + for record in records + ), + ), + subject=MeasurementSubject(MeasurementScope.OBJECT, "nuclei", "object_label"), + ) + features = ObjectCoreMeasurementFeature + archive = materialize( + MaterializationSpec( + PointROIOptions( + z_feature=features.CENTER_Z, + y_feature=features.CENTER_Y, + x_feature=features.CENTER_X, + ) + ), + data=table, + path=str(tmp_path / "centres"), + filemanager=FileManager({"disk": DiskStorageBackend()}), + backends=["disk"], + backend_kwargs={}, + ) + assert Path(archive).exists() def test_inventory_source_projection_loads_exact_ordinary_tiff(tmp_path):