From f9e794df33d9ad1533afac993144c635705218e6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 3 Oct 2026 03:41:13 -0400 Subject: [PATCH 1/3] fix: project aggregate materialization into declared viewer layers (#515) --- .../core/steps/stream_component_semantics.py | 48 +++ openhcs/processing/materialization/core.py | 50 ++- ...est_aggregate_layer_materialization_515.py | 400 ++++++++++++++++++ 3 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_aggregate_layer_materialization_515.py diff --git a/openhcs/core/steps/stream_component_semantics.py b/openhcs/core/steps/stream_component_semantics.py index 39998938e8..82b3b487a9 100644 --- a/openhcs/core/steps/stream_component_semantics.py +++ b/openhcs/core/steps/stream_component_semantics.py @@ -29,8 +29,16 @@ ) from openhcs.constants.constants import AllComponents, get_multiprocessing_axis +from openhcs.core.config import FijiDimensionMode, NapariDimensionMode from openhcs.core.context.processing_context import ProcessingContext from openhcs.core.runtime_image_values import ImagePayloadMetadata +from openhcs.core.runtime_plane_projection import RuntimePlaneAxisValueProjection +from openhcs.core.runtime_slice_projection import ( + RuntimeProjectionData, + RuntimeProjectedPayloadItem, + RuntimeProjectionSourceIdentityRequest, + RuntimeProjectionSourceIdentityRequirement, +) from openhcs.core.source_image_provenance import ( SourceComponentMetadata, @@ -62,6 +70,46 @@ class StreamImagePayloadMetadataProjector: """Project image-axis declarations into viewer batch-item fields.""" + @classmethod + def payload_items_for_display( + cls, + payload: RuntimeProjectionData, + *, + metadata: ImagePayloadMetadata, + plane_components: tuple[AllComponents, ...], + component_modes: Mapping[str, str], + source_description: str, + ) -> tuple[RuntimeProjectedPayloadItem, ...]: + """Project declared planes when their display requires scalar identities.""" + item_fields = cls.item_fields_for_plane_components(metadata, plane_components) + component_values = item_fields.get( + ViewerWireField.PLANE_COMPONENT_VALUES.value, {} + ) + scalar_modes = ( + NapariDimensionMode.LAYER.value, + FijiDimensionMode.WINDOW.value, + ) + if not any( + component_modes[component] in scalar_modes + for component in component_values + ): + return (RuntimeProjectedPayloadItem(payload, source_description),) + projection = RuntimePlaneAxisValueProjection.preserve( + axis=metadata.plane_axis, + axis_size=metadata.source_provenance.source_plane_count, + ) + source_identity = ( + RuntimeProjectionSourceIdentityRequirement.REQUIRED_COMPONENT_METADATA + ) + return source_identity.project_payload_items( + RuntimeProjectionSourceIdentityRequest( + value=metadata.attach_to(payload), + source_description=source_description, + variable_components=plane_components, + plane_projection=projection, + ) + ) + @classmethod def partition_indices( cls, diff --git a/openhcs/processing/materialization/core.py b/openhcs/processing/materialization/core.py index faff29684a..815e602ea0 100644 --- a/openhcs/processing/materialization/core.py +++ b/openhcs/processing/materialization/core.py @@ -1526,7 +1526,10 @@ def to_filemanager_kwargs( self, output: Output, ) -> dict: - output_fields = self._output_fields(output) + projected_outputs = self.projected_outputs((output,)) + if len(projected_outputs) != 1 or projected_outputs[0] is not output: + raise ValueError("Projected viewer materialization requires batch saving.") + output_fields = self._output_fields(projected_outputs[0]) if output_fields is None: return self.values.to_kwargs() component_metadata, item_fields = output_fields @@ -1547,7 +1550,7 @@ def filemanager_batches( ] ] = [] unprojected_outputs: list[Output] = [] - for output in outputs: + for output in self.projected_outputs(outputs): output_fields = self._output_fields(output) if output_fields is None: unprojected_outputs.append(output) @@ -1596,6 +1599,49 @@ def filemanager_batches( ) return tuple(batches) + def projected_outputs(self, outputs: Sequence[Output]) -> tuple[Output, ...]: + """Derive stream items without changing persisted aggregate outputs.""" + result: list[Output] = [] + display_semantics = self.values.stream_request.display_semantics + parser = self.values.stream_request.source.identity.microscope_handler.parser + for output in outputs: + if output.metadata is None: + result.append(output) + continue + items = StreamImagePayloadMetadataProjector.payload_items_for_display( + output.content, + metadata=output.metadata, + plane_components=output.variable_components, + component_modes=display_semantics.component_modes, + source_description=output.path, + ) + if len(items) == 1 and items[0].value is output.content: + result.append(output) + continue + output_path = Path(output.path) + for item in items: + identity = FunctionOutputIdentityAuthority.identity_from_metadata( + parser, + item.metadata, + fallback_identity_path=output.path, + ) + if identity is None: + raise ValueError("Projected viewer output requires a filename identity.") + identity = replace(identity, extension=output_path.suffix) + filename = FunctionOutputPathAuthority.filename_for_identity( + parser, + identity.with_filename_qualifier(output_path.stem), + ) + result.append( + replace( + output, + path=str(output_path.parent / filename), + content=item.data, + metadata=item.metadata, + ) + ) + return tuple(result) + def _output_fields( self, output: Output, diff --git a/tests/unit/test_aggregate_layer_materialization_515.py b/tests/unit/test_aggregate_layer_materialization_515.py new file mode 100644 index 0000000000..f287820902 --- /dev/null +++ b/tests/unit/test_aggregate_layer_materialization_515.py @@ -0,0 +1,400 @@ +"""Issue #515: automatic label TIFFs must honor scalar LAYER routing. + +The renderer, disk TIFF writer and wire builder are real. Only the final viewer +transport is intercepted; no viewer or native process is started. +""" + +from dataclasses import replace + +import numpy as np +import pytest + +from openhcs.constants.constants import AllComponents, VariableComponents +from openhcs.core.artifacts import ArtifactOutputPlan, ObjectLabelsArtifactType +from openhcs.core.pipeline.artifact_planning import ( + AutomaticObjectLabelsArtifactOutputMaterializationStrategy, +) +from openhcs.core.runtime_artifact_values import RuntimeValue +from openhcs.core.runtime_image_values import ImagePayloadMetadata +from openhcs.core.runtime_object_label_domains import ObjectLabelDomain, ObjectLabelDomainScope +from openhcs.core.runtime_object_labels import ObjectLabelPayload, ObjectLabelVariantData +from openhcs.core.runtime_plane_projection import RuntimePlaneAxis, RuntimePlaneAxisValueProjection +from openhcs.core.runtime_slice_projection import ( + RuntimeProjectionSourceIdentityRequest, + RuntimeProjectionSourceIdentityRequirement, + RuntimeSliceProjectionDeclarationError, +) +from openhcs.core.source_image_provenance import SourceImageProvenancePlanes +from openhcs.core.source_metadata import SourceVoxelSpacing +from openhcs.core.source_spatial_domain import SourceSpatialDomain +from openhcs.runtime.napari_streaming_handlers import ( + NapariAggregateAxisBindingAuthority, + NapariStreamLayerAddress, + NapariStreamLayerItem, +) +from openhcs.runtime.viewer_component_system import ( + ViewerComponentAxisSemanticsAuthority, + ViewerComponentValueDomainPayload, + ViewerMappingDisplayConfigInput, +) +from openhcs.core.steps.function_artifact_materialization import ( + PersistentArtifactMaterializationTargetPlan, + materialize_artifact_outputs, +) +from polystore.napari_stream import NapariStreamingBackend +from polystore.base import ensure_storage_registry, storage_registry +from polystore.filemanager import FileManager +from polystore.streaming import StreamingBatchMessageBuilder, StreamingBatchMessageRequest +from polystore.streaming.receivers.napari.layer_key import build_route_key, normalize_component_layout +from polystore.streaming.viewer_transport import ViewerStreamKwarg +from polystore.streaming.viewer_transport import ViewerStreamBackendKwargs +from polystore.streaming_constants import StreamingDataType +from openhcs.processing.materialization.core import ( + BackendSaver, + Output, + ViewerStreamBackendCallKwargs, +) +from polystore.streaming.identity import StreamProducerIdentity + +from tests.unit.test_function_artifact_materialization import ( + StreamingConfigStub, + _context, + _plan, +) + + +class ComponentDisplayConfig(StreamingConfigStub): + def __init__(self, component, mode): + super().__init__(5555) + self.component = component + self.mode = mode + + def component_modes(self): + return {**super().component_modes(), self.component.value: self.mode} + + +def _source_bound_labels(component): + pixels = np.zeros((2, 4, 5), dtype=np.int32) + pixels[0, 1:3, 1:4] = 1 + pixels[1, 1:3, 2:5] = 2 + source_fields = tuple( + {"well": "A01", "site": 1, "channel": 1, "z_index": 1, + "timepoint": 1, component.value: value} + for value in (1, 2) + ) + paths = tuple( + f"/input/A01_s{fields['site']:03d}_w{fields['channel']}_z001_t001.tif" + for fields in source_fields + ) + labels = ObjectLabelPayload( + variant_data=ObjectLabelVariantData(labels=pixels), + plane_axis=RuntimePlaneAxis.SOURCE_BINDING, + domain=ObjectLabelDomain( + declared_object_id_domains=((1,), (2,)), scope=ObjectLabelDomainScope.PLANE, + ), + source_image_provenance_planes=SourceImageProvenancePlanes.from_components( + paths=paths, component_metadata=source_fields, + ), + source_component_metadata={ + key: value for key, value in source_fields[0].items() + if all(fields[key] == value for fields in source_fields) + }, + source_spatial_domain=SourceSpatialDomain(source_shape_yx=(4, 5)), + parent_image_source_voxel_spacing=SourceVoxelSpacing((0.75, 0.75)), + ) + return labels, pixels.copy() + + +def _automatic_label_tiff(component, mode, return_route, monkeypatch, tmp_path): + labels, pixels = _source_bound_labels(component) + output_plan = ArtifactOutputPlan( + name="Nuclei", path="/memory/Nuclei.pkl", artifact_type=ObjectLabelsArtifactType, + variable_components=(VariableComponents(component.value),), + materialization=AutomaticObjectLabelsArtifactOutputMaterializationStrategy().materialization(), + ) + ensure_storage_registry() + filemanager = FileManager(dict(storage_registry)) + stream_saves = [] + disk_outputs = [] + original_save_batch = filemanager.save_batch + original_save_all = BackendSaver.save_all + + def save_all(saver, outputs): + if "disk" in saver.backends: + disk_outputs.extend( + output for output in outputs if output.path.endswith(".labels.tif") + ) + return original_save_all(saver, outputs) + + def save_batch(contents, paths, backend, **kwargs): + if backend == "napari_stream": + stream_saves.extend( + (content, path, backend, kwargs) + for content, path in zip(contents, paths, strict=True) + ) + return + return original_save_batch(contents, paths, backend, **kwargs) + + monkeypatch.setattr(filemanager, "save_batch", save_batch) + monkeypatch.setattr(BackendSaver, "save_all", save_all) + context = _context(filemanager) + context.runtime_value_store.record( + RuntimeValue.normalize(output_plan, labels, axis_id="A01"), + path=output_plan.path, backend="memory", + ) + plan = replace(_plan( + output_plan, + streaming_configs={"napari_stream": ComponentDisplayConfig(component, mode)}, + variable_components=(VariableComponents(component.value),), + ), analysis_results_dir=str(tmp_path / "analysis"), output_dir=tmp_path / "images") + materialize_artifact_outputs( + filemanager, plan, PersistentArtifactMaterializationTargetPlan("disk"), context, + ) + assert len(disk_outputs) == 1 + import tifffile + + persisted_pixels = tifffile.imread(disk_outputs[0].path) + np.testing.assert_array_equal(persisted_pixels, pixels) + assert persisted_pixels.dtype == pixels.dtype + metadata = disk_outputs[0].metadata + assert metadata.plane_axis is RuntimePlaneAxis.SOURCE_BINDING + assert metadata.source_provenance.source_plane_count == 2 + assert metadata.source_voxel_spacing.values_zyx == (0.75, 0.75) + assert [metadata.source_provenance.for_source_plane(index).source_component_metadata[component.value] + for index in range(2)] == [1, 2] + np.testing.assert_array_equal(labels.labels, pixels) + tiff_saves = [saved for saved in stream_saves if saved[1].endswith(".labels.tif")] + assert tiff_saves, "Automatic object-label materialization did not reach the TIFF backend." + backend = NapariStreamingBackend() + wire_batches = [] + try: + for content, path, backend_name, kwargs in tiff_saves: + assert backend_name == "napari_stream" + assert content.dtype == pixels.dtype + request = kwargs[ViewerStreamKwarg.STREAM_REQUEST.value] + wire_batches.append(StreamingBatchMessageBuilder.build( + backend, + StreamingBatchMessageRequest( + return_route=return_route, data_list=[content], file_paths=[path], + stream_request=request, + component_names_request=backend.component_names_request(request), + display_payload_extra=backend.display_payload_extra(request), + ), + )) + return labels, pixels, tiff_saves, wire_batches + finally: + backend.cleanup() + + +def _route_keys(batches): + return [ + build_route_key( + item["producer_identity"], item["metadata"], + normalize_component_layout(batch.message["display_config"]), + StreamingDataType(item["data_type"]), + ) + for batch in batches for item in batch.batch_images + ] + + +def _received_item(batch, pixels): + wire = batch.batch_images[0] + item = NapariStreamLayerItem( + data=pixels, + producer=StreamProducerIdentity.from_payload(wire["producer_identity"]), + address=NapariStreamLayerAddress( + components=wire["metadata"], path=wire["path"], + stream_layer_data_type=StreamingDataType(wire["data_type"]), + ), + image_metadata=ImagePayloadMetadata.from_viewer_image_metadata(wire["image_metadata"]), + plane_component_domain=ViewerComponentValueDomainPayload.from_ordered_wire_mapping( + wire["plane_component_values"], context="actual automatic TIFF wire domain", + ), + ) + semantics = ViewerComponentAxisSemanticsAuthority.from_display_config( + ViewerMappingDisplayConfigInput(batch.message["display_config"]), + ViewerComponentValueDomainPayload.from_wire_mapping( + batch.message["component_value_domain"], context="actual automatic TIFF viewer domain", + ), + ) + return item, semantics + + +@pytest.mark.parametrize("component", (AllComponents.CHANNEL, AllComponents.SITE)) +def test_automatic_aggregate_label_tiff_honors_separate_layers(component, viewer_ack_return_route, monkeypatch, tmp_path): + labels, pixels, saves, batches = _automatic_label_tiff(component, "layer", viewer_ack_return_route, monkeypatch, tmp_path) + # This is the original strict receiver, not a test-side default or projection. + keys = _route_keys(batches) + items = [item for batch in batches for item in batch.batch_images] + assert [item["metadata"][component.value] for item in items] == [1, 2] + assert len(set(keys)) == 2 + assert [tuple(item["shape"]) for item in items] == [(4, 5), (4, 5)] + for index, (content, _path, _backend, _kwargs) in enumerate(saves): + np.testing.assert_array_equal(content, pixels[index]) + np.testing.assert_array_equal(labels.labels, pixels) + + +@pytest.mark.parametrize("component", (AllComponents.CHANNEL, AllComponents.SITE)) +def test_automatic_aggregate_label_tiff_preserves_stack(component, viewer_ack_return_route, monkeypatch, tmp_path): + labels, pixels, saves, batches = _automatic_label_tiff(component, "stack", viewer_ack_return_route, monkeypatch, tmp_path) + assert len(saves) == len(batches) == 1 + np.testing.assert_array_equal(saves[0][0], pixels) + item = batches[0].batch_images[0] + assert tuple(item["shape"]) == pixels.shape + assert item["dtype"] == str(pixels.dtype) + assert item["plane_axis"] == RuntimePlaneAxis.SOURCE_BINDING.value + assert item["plane_component_values"] == {component.value: ["1", "2"]} + decoded = ImagePayloadMetadata.from_viewer_image_metadata(item["image_metadata"]) + assert decoded.source_voxel_spacing.values_zyx == (0.75, 0.75) + assert decoded.source_spatial_domain.source_shape_yx == (4, 5) + assert component.value not in item["metadata"] + assert len(_route_keys(batches)) == 1 + received, semantics = _received_item(batches[0], saves[0][0]) + bindings = NapariAggregateAxisBindingAuthority.bindings((received,), semantics) + assert len(bindings.bindings) == 1 + assert bindings.bindings[0].component == component.value + assert bindings.bindings[0].values == (1, 2) + np.testing.assert_array_equal(labels.labels, pixels) + + +def test_strict_receiver_still_rejects_aggregate_without_scalar_layer(viewer_ack_return_route, monkeypatch, tmp_path): + _labels, _pixels, _saves, batches = _automatic_label_tiff( + AllComponents.CHANNEL, "stack", viewer_ack_return_route, monkeypatch, tmp_path, + ) + batch = batches[0] + layout = normalize_component_layout(batch.message["display_config"]) + layout = replace(layout, component_modes={**layout.component_modes, "channel": "layer"}) + item = batch.batch_images[0] + with pytest.raises(ValueError, match="missing layer component 'channel'"): + build_route_key(item["producer_identity"], item["metadata"], layout, StreamingDataType.IMAGE) + + +@pytest.mark.parametrize("plane_count", (1, 2)) +def test_single_output_kwargs_cannot_hide_projected_pixels( + plane_count, viewer_ack_return_route, monkeypatch, tmp_path, +): + labels, pixels, saves, _batches = _automatic_label_tiff( + AllComponents.CHANNEL, "stack", viewer_ack_return_route, monkeypatch, tmp_path, + ) + metadata = labels.metadata.replace_fields( + source_image_provenance_planes=SourceImageProvenancePlanes( + labels.source_image_provenance_planes.planes[:plane_count] + ), + ) + request = replace( + saves[0][3][ViewerStreamKwarg.STREAM_REQUEST.value], + display_config=ComponentDisplayConfig(AllComponents.CHANNEL, "layer"), + ) + kwargs = ViewerStreamBackendCallKwargs(ViewerStreamBackendKwargs(request)) + output = Output( + path=saves[0][1], content=pixels[:plane_count], metadata=metadata, + variable_components=(AllComponents.CHANNEL,), + ) + with pytest.raises(ValueError, match="requires batch saving"): + kwargs.to_filemanager_kwargs(output) + batches = kwargs.filemanager_batches((output,)) + projected = [item for items, _kwargs in batches for item in items] + assert len(projected) == plane_count + assert len({item.path for item in projected}) == plane_count + for index, item in enumerate(projected): + np.testing.assert_array_equal(item.content, pixels[index]) + assert np.shares_memory(item.content, pixels) + assert item.metadata.plane_axis is None + assert item.source_component_metadata["channel"] == index + 1 + assert metadata.plane_axis is RuntimePlaneAxis.SOURCE_BINDING + np.testing.assert_array_equal(output.content, pixels[:plane_count]) + + +@pytest.mark.parametrize( + ("domain", "error"), + (({"channel": [1]}, "cardinality mismatch"), + ({"channel": [1, 99]}, "outside the viewer domain"), + ({"site": [1, 2], "channel": [1, 2]}, "more component axes")), +) +def test_strict_aggregate_receiver_rejects_malformed_tiff_domains( + domain, error, viewer_ack_return_route, monkeypatch, tmp_path, +): + _labels, _pixels, saves, batches = _automatic_label_tiff( + AllComponents.CHANNEL, "stack", viewer_ack_return_route, monkeypatch, tmp_path, + ) + received, semantics = _received_item(batches[0], saves[0][0]) + received = replace(received, plane_component_domain=ViewerComponentValueDomainPayload.from_ordered_wire_mapping( + domain, context="deliberately malformed incoming TIFF domain", + )) + with pytest.raises(ValueError, match=error): + NapariAggregateAxisBindingAuthority.bindings((received,), semantics) + + +def test_strict_receiver_rejects_duplicate_plane_coordinates(viewer_ack_return_route, monkeypatch, tmp_path): + _labels, _pixels, _saves, batches = _automatic_label_tiff( + AllComponents.CHANNEL, "stack", viewer_ack_return_route, monkeypatch, tmp_path, + ) + wire = batches[0].batch_images[0] + domain = {**wire["plane_component_values"], "channel": ["1", "1"]} + with pytest.raises(ValueError, match="coordinates must be unique"): + ViewerComponentValueDomainPayload.from_ordered_wire_mapping( + domain, context="duplicate incoming pixel-plane coordinate", + ) + + +def test_strict_receiver_rejects_inconsistent_item_plane_domains(viewer_ack_return_route, monkeypatch, tmp_path): + _labels, _pixels, saves, batches = _automatic_label_tiff( + AllComponents.CHANNEL, "stack", viewer_ack_return_route, monkeypatch, tmp_path, + ) + received, semantics = _received_item(batches[0], saves[0][0]) + reversed_item = replace( + received, plane_component_domain=ViewerComponentValueDomainPayload.from_ordered_wire_mapping( + {"channel": [2, 1]}, context="contradictory second item pixel-plane order", + ), + ) + with pytest.raises(ValueError, match="inconsistent plane component domains"): + NapariAggregateAxisBindingAuthority.bindings((received, reversed_item), semantics) + + +@pytest.mark.parametrize("component", (AllComponents.CHANNEL, AllComponents.SITE)) +def test_existing_projection_owns_source_binding_label_planes(component): + labels, pixels = _source_bound_labels(component) + payload = labels.metadata.attach_to(labels.image_data()) + items = RuntimeProjectionSourceIdentityRequirement.REQUIRED_COMPONENT_METADATA.project_payload_items( + RuntimeProjectionSourceIdentityRequest( + value=payload, source_description="automatic label TIFF", + variable_components=(VariableComponents(component.value),), + plane_projection=RuntimePlaneAxisValueProjection.preserve( + axis=RuntimePlaneAxis.SOURCE_BINDING, axis_size=2, + ), + ) + ) + assert len(items) == 2 + for index, item in enumerate(items): + np.testing.assert_array_equal(item.data, pixels[index]) + assert item.metadata.plane_axis is None + assert item.require_source_component_metadata()[component.value] == index + 1 + assert item.metadata.source_voxel_spacing.values_zyx == (0.75, 0.75) + assert np.shares_memory(item.data, labels.image_data()) + + +def test_existing_projection_does_not_infer_runtime_axis_from_label_array(): + labels, _pixels = _source_bound_labels(AllComponents.CHANNEL) + with pytest.raises(RuntimeSliceProjectionDeclarationError, match="requires a nominal payload"): + RuntimeProjectionSourceIdentityRequirement.REQUIRED_COMPONENT_METADATA.project_payload_items( + RuntimeProjectionSourceIdentityRequest( + value=labels.metadata.attach_to(labels.image_data()), + source_description="automatic label TIFF", + variable_components=(VariableComponents.CHANNEL,), + ) + ) + + +def test_existing_projection_rejects_label_source_cardinality_mismatch(): + labels, _pixels = _source_bound_labels(AllComponents.CHANNEL) + with pytest.raises(ValueError, match="metadata cardinality mismatch"): + RuntimeProjectionSourceIdentityRequirement.REQUIRED_COMPONENT_METADATA.project_payload_items( + RuntimeProjectionSourceIdentityRequest( + value=labels.metadata.attach_to(labels.image_data()), + source_description="automatic label TIFF", + variable_components=(VariableComponents.CHANNEL,), + plane_projection=RuntimePlaneAxisValueProjection.preserve( + axis=RuntimePlaneAxis.SOURCE_BINDING, axis_size=3, + ), + ) + ) From 46463d66898bfa0ef81a1de200d70c10ce961757 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 3 Oct 2026 03:43:46 -0400 Subject: [PATCH 2/3] fix: import the existing output identity authority for stream projections --- openhcs/processing/materialization/core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openhcs/processing/materialization/core.py b/openhcs/processing/materialization/core.py index 815e602ea0..fd02a05444 100644 --- a/openhcs/processing/materialization/core.py +++ b/openhcs/processing/materialization/core.py @@ -73,6 +73,10 @@ source_metadata_value, ) from openhcs.core.source_spatial_domain import SourceSpatialDomain +from openhcs.core.steps.function_output_identity import ( + FunctionOutputIdentityAuthority, + FunctionOutputPathAuthority, +) from openhcs.core.steps.stream_component_semantics import ( StreamImagePayloadMetadataProjector, StreamViewerComponentMetadataProjector, From ec9cd1a63c550315e4ea8df80981ea98f9ecd625 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 3 Oct 2026 03:45:07 -0400 Subject: [PATCH 3/3] fix: derive plane display decisions from configured viewer axes --- openhcs/core/steps/stream_component_semantics.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openhcs/core/steps/stream_component_semantics.py b/openhcs/core/steps/stream_component_semantics.py index 82b3b487a9..2dec7baf4e 100644 --- a/openhcs/core/steps/stream_component_semantics.py +++ b/openhcs/core/steps/stream_component_semantics.py @@ -90,8 +90,9 @@ def payload_items_for_display( FijiDimensionMode.WINDOW.value, ) if not any( - component_modes[component] in scalar_modes - for component in component_values + mode in scalar_modes + for component, mode in component_modes.items() + if component in component_values ): return (RuntimeProjectedPayloadItem(payload, source_description),) projection = RuntimePlaneAxisValueProjection.preserve(