From 35dc3d641539c6fed21e7b627b383f21e1ef8870 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 30 Jul 2026 14:10:50 -0400 Subject: [PATCH 1/7] Update typed configuration infrastructure dependencies --- external/ObjectState | 2 +- external/PolyStore | 2 +- external/pyqt-reactive | 2 +- external/python-introspect | 2 +- pyproject.toml | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/external/ObjectState b/external/ObjectState index 804b4970b..4e0665ffb 160000 --- a/external/ObjectState +++ b/external/ObjectState @@ -1 +1 @@ -Subproject commit 804b4970bfc89820dd3b2b962499f5c74e6283fa +Subproject commit 4e0665ffbd15cf247bfc07979484f3474e520858 diff --git a/external/PolyStore b/external/PolyStore index eb72df44c..3c838f190 160000 --- a/external/PolyStore +++ b/external/PolyStore @@ -1 +1 @@ -Subproject commit eb72df44c44fae7a7b1e8bb99a57874a88b9e23c +Subproject commit 3c838f19043ae6f5ab4e89c2fdeb57ef385f8da3 diff --git a/external/pyqt-reactive b/external/pyqt-reactive index 321b4ae20..bbbe1224b 160000 --- a/external/pyqt-reactive +++ b/external/pyqt-reactive @@ -1 +1 @@ -Subproject commit 321b4ae20baf9ba57450a43a19ac5ef63b19cfde +Subproject commit bbbe1224bc509568678fc5fd163b88b16e720854 diff --git a/external/python-introspect b/external/python-introspect index 808d99efe..c4af75793 160000 --- a/external/python-introspect +++ b/external/python-introspect @@ -1 +1 @@ -Subproject commit 808d99efee1226ed10582877986bf81a51b6bba4 +Subproject commit c4af75793ebef320aa45c00c94144d4c3a2d309e diff --git a/pyproject.toml b/pyproject.toml index 3ba4c9ff1..1c02845bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,12 +82,12 @@ dependencies = [ # External dependencies (PyPI versions for production builds) "zmqruntime>=0.1.19", "pycodify>=0.1.3", - "objectstate>=1.0.20", - "python-introspect>=0.1.6", + "objectstate>=1.0.21", + "python-introspect>=0.1.7", "metaclass-registry>=0.1.6", "arraybridge>=0.2.11", - "polystore>=0.1.24", - "pyqt-reactive>=0.1.27", + "polystore>=0.1.25", + "pyqt-reactive>=0.1.28", ] [project.optional-dependencies] From 913ac35a63bf67dc9a3214c807b11a76e9b25b82 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 30 Jul 2026 14:11:21 -0400 Subject: [PATCH 2/7] Make MCP configuration diagnostics self-describing --- openhcs/agent/capabilities.py | 8 +- .../architecture_projection_service.py | 51 +++++- openhcs/agent/services/config_service.py | 151 +++++++++++++++++- tests/unit/agent/test_mcp_diagnostics.py | 137 ++++++++++++++++ 4 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 tests/unit/agent/test_mcp_diagnostics.py diff --git a/openhcs/agent/capabilities.py b/openhcs/agent/capabilities.py index 6e25fd8c2..8f2d1f161 100644 --- a/openhcs/agent/capabilities.py +++ b/openhcs/agent/capabilities.py @@ -2171,7 +2171,13 @@ class DescribeInternalSymbolCapability(ArchitectureCapability): cli_aliases = ("architecture-symbol",) kind = CapabilityKind.TOOL title = "Describe internal symbol" - description = "Returns read-only signature/doc/source-location facts for one internal OpenHCS symbol." + description = ( + "Returns read-only signature/doc/source-location facts for one symbol_id " + "exposed by the curated architecture topics. Discover topic_ids with " + f"{ListArchitectureTopicsCapability.name}, then inspect their symbol_ids " + f"with {ExplainArchitectureCapability.name}; arbitrary Python import paths " + "are not accepted." + ) service = "architecture_projection" input_contract = SYMBOL_ID_INPUT output_contract = InternalApiSymbol diff --git a/openhcs/agent/services/architecture_projection_service.py b/openhcs/agent/services/architecture_projection_service.py index cd932d033..1b23663f9 100644 --- a/openhcs/agent/services/architecture_projection_service.py +++ b/openhcs/agent/services/architecture_projection_service.py @@ -6,6 +6,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass +from difflib import get_close_matches from pathlib import Path from typing import ClassVar, TypeAlias @@ -18,6 +19,7 @@ InternalApiSymbol, ) from openhcs.agent.dto.common import SCHEMA_VERSION +from openhcs.agent.exceptions import AgentFacingErrorMixin InspectableSymbol: TypeAlias = Callable | type @@ -378,11 +380,58 @@ def explain_topic(self, topic_id: str) -> ArchitectureTopic: return projection.topic() def describe_internal_symbol(self, symbol_id: str) -> InternalApiSymbol: + curated_specs: list[tuple[str, InternalApiSymbolSpec]] = [] for projection in ArchitectureTopicProjection.projection_instances(): for spec in projection.symbol_specs(): + curated_specs.append((projection.required_topic_id(), spec)) if spec.symbol_id == symbol_id: return spec.project() - raise KeyError(f"Unknown OpenHCS architecture symbol_id: {symbol_id}") + raise ArchitectureSymbolNotCuratedError( + symbol_id, + tuple(curated_specs), + ) + + +class ArchitectureSymbolNotCuratedError(AgentFacingErrorMixin, ValueError): + """A requested identifier is absent from the curated architecture surface.""" + + agent_error_code = "architecture_symbol_not_curated" + + def __init__( + self, + symbol_id: str, + curated_specs: tuple[tuple[str, InternalApiSymbolSpec], ...], + ) -> None: + topic_by_symbol_id = { + spec.symbol_id: topic_id + for topic_id, spec in curated_specs + } + closest_ids = get_close_matches( + symbol_id, + tuple(topic_by_symbol_id), + n=3, + cutoff=0.55, + ) + suggestion = "" + if closest_ids: + formatted = ", ".join( + f"{candidate!r} (topic_id={topic_by_symbol_id[candidate]!r})" + for candidate in closest_ids + ) + suggestion = f" Closest curated symbol_id(s): {formatted}." + self._agent_error_hint = ( + "Call openhcs_list_architecture_topics to discover the curated topics, " + "then call openhcs_explain_architecture with a returned topic_id to " + f"inspect its symbol_ids.{suggestion}" + ) + super().__init__( + f"{symbol_id!r} is not a symbol_id in OpenHCS's curated architecture " + "namespace. Arbitrary Python import paths are not accepted." + ) + + @property + def agent_error_hint(self) -> str: + return self._agent_error_hint @dataclass(frozen=True, slots=True) diff --git a/openhcs/agent/services/config_service.py b/openhcs/agent/services/config_service.py index 92916fd31..79eec223b 100644 --- a/openhcs/agent/services/config_service.py +++ b/openhcs/agent/services/config_service.py @@ -3,9 +3,11 @@ from __future__ import annotations import inspect -from collections.abc import Mapping +import json from abc import ABC +from collections.abc import Mapping from dataclasses import MISSING, fields, is_dataclass +from difflib import get_close_matches from enum import Enum from itertools import count from pathlib import Path @@ -45,6 +47,7 @@ ConfigTypeSchema, ConfigValidationResult, ) +from openhcs.agent.exceptions import AgentFacingErrorMixin from openhcs.core.artifacts import ArtifactType from openhcs.core.config import ( GlobalPipelineConfig, @@ -241,10 +244,15 @@ def validate_patch( try: config_ref = self.create(config_type, patch) except Exception as exc: + error = ( + exc.to_agent_error() + if isinstance(exc, AgentFacingErrorMixin) + else AgentError.from_exception("config_patch_invalid", exc) + ) return ConfigValidationResult( schema_version=SCHEMA_VERSION, valid=False, - errors=(AgentError.from_exception("config_patch_invalid", exc),), + errors=(error,), ) return ConfigValidationResult( schema_version=SCHEMA_VERSION, @@ -290,9 +298,148 @@ def _patch_values( return {} if not is_dataclass(cls): return dict(patch.values) + authoring_path = f"{ConfigPatch.__name__}.{ConfigPatch.values.__name__}" + _validate_config_patch_authoring_shape( + patch.values, + schema_fields=_field_schema(cls), + config_type=cls.__name__, + authoring_path=authoring_path, + ) return coerce_dataclass_patch_values(cls, patch.values) +class ConfigPatchUnknownFieldError(AgentFacingErrorMixin, ValueError): + """One config-patch key is absent from the selected authoring schema.""" + + agent_error_code = "config_patch_unknown_field" + + def __init__( + self, + *, + config_type: str, + authoring_path: str, + requested_path: tuple[str, ...], + candidate_paths: tuple[tuple[str, ...], ...], + ) -> None: + requested_field = requested_path[-1] + path_by_field = { + candidate_path[-1]: candidate_path + for candidate_path in candidate_paths + } + closest_fields = get_close_matches( + requested_field, + tuple(path_by_field), + n=3, + cutoff=0.6, + ) + suggestion = "" + if closest_fields: + suggested_paths = ", ".join( + _format_config_authoring_path( + authoring_path, + path_by_field[field_name], + ) + for field_name in closest_fields + ) + suggestion = ( + f" Nearest accepted authoring_value_path(s): {suggested_paths}." + ) + self._agent_error_hint = ( + "Call openhcs_describe_config_schema with " + f"config_type={config_type!r}, then submit the nested JSON shape from " + f"its authoring_value_path entries.{suggestion}" + ) + requested_authoring_path = _format_config_authoring_path( + authoring_path, + requested_path, + ) + super().__init__( + f"Unknown config patch key at {requested_authoring_path}.", + path=requested_authoring_path, + ) + + @property + def agent_error_hint(self) -> str: + return self._agent_error_hint + + +def _validate_config_patch_authoring_shape( + values: Mapping[str, JsonValue], + *, + schema_fields: tuple[ConfigFieldSchema, ...], + config_type: str, + authoring_path: str, + parent_path: tuple[str, ...] = (), +) -> None: + """Validate object keys against the reflected nested authoring paths.""" + + schema_paths = tuple(field.authoring_value_path for field in schema_fields) + candidate_paths = tuple( + dict.fromkeys( + path[: len(parent_path) + 1] + for path in schema_paths + if len(path) > len(parent_path) + and path[: len(parent_path)] == parent_path + and path[len(parent_path)] != "[]" + ) + ) + candidate_names = frozenset(path[-1] for path in candidate_paths) + for field_name, value in values.items(): + requested_path = (*parent_path, field_name) + if field_name not in candidate_names: + raise ConfigPatchUnknownFieldError( + config_type=config_type, + authoring_path=authoring_path, + requested_path=requested_path, + candidate_paths=candidate_paths, + ) + + next_segments = tuple( + dict.fromkeys( + path[len(requested_path)] + for path in schema_paths + if len(path) > len(requested_path) + and path[: len(requested_path)] == requested_path + ) + ) + if isinstance(value, Mapping) and any( + segment != "[]" for segment in next_segments + ): + _validate_config_patch_authoring_shape( + value, + schema_fields=schema_fields, + config_type=config_type, + authoring_path=authoring_path, + parent_path=requested_path, + ) + elif isinstance(value, list) and "[]" in next_segments: + item_path = (*requested_path, "[]") + for item in value: + if isinstance(item, Mapping): + _validate_config_patch_authoring_shape( + item, + schema_fields=schema_fields, + config_type=config_type, + authoring_path=authoring_path, + parent_path=item_path, + ) + + +def _format_config_authoring_path( + root: str, + path: tuple[str, ...], +) -> str: + return "".join( + ( + root, + *( + "[]" if segment == "[]" else f"[{json.dumps(segment)}]" + for segment in path + ), + ) + ) + + def coerce_dataclass_patch_values( cls: type, values: Mapping[str, JsonValue], diff --git a/tests/unit/agent/test_mcp_diagnostics.py b/tests/unit/agent/test_mcp_diagnostics.py new file mode 100644 index 000000000..06cd53128 --- /dev/null +++ b/tests/unit/agent/test_mcp_diagnostics.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from openhcs.agent.capabilities import ( + DescribeConfigSchemaCapability, + DescribeInternalSymbolCapability, + ExplainArchitectureCapability, + ListArchitectureTopicsCapability, +) +from openhcs.agent.dto.config import ConfigPatch +from openhcs.agent.services.architecture_projection_service import ( + ArchitectureProjectionService, + ArchitectureSymbolNotCuratedError, +) +from openhcs.agent.services.config_service import ConfigService +from openhcs.mcp import server + + +def _structured_result(result) -> dict: + return result[1] if isinstance(result, tuple) else result.structuredContent + + +def test_unknown_architecture_symbol_reports_curated_discovery_route() -> None: + service = ArchitectureProjectionService() + known = service.describe_internal_symbol("core.GlobalPipelineConfig") + + assert "curated architecture topics" in ( + DescribeInternalSymbolCapability.description + ) + assert ( + ListArchitectureTopicsCapability.name + in DescribeInternalSymbolCapability.description + ) + assert ( + ExplainArchitectureCapability.name + in DescribeInternalSymbolCapability.description + ) + + with pytest.raises(ArchitectureSymbolNotCuratedError) as error: + service.describe_internal_symbol(known.import_path) + + projected = error.value.to_agent_error() + assert projected.code == "architecture_symbol_not_curated" + assert "curated architecture namespace" in projected.message + assert "Arbitrary Python import paths are not accepted" in projected.message + assert ListArchitectureTopicsCapability.name in (projected.hint or "") + assert ExplainArchitectureCapability.name in (projected.hint or "") + assert "core.GlobalPipelineConfig" in (projected.hint or "") + assert "pipeline_model" in (projected.hint or "") + + +def test_config_patch_root_near_miss_uses_reflected_authoring_path() -> None: + service = ConfigService() + + result = service.validate_patch( + "global", + ConfigPatch(config_type="global", values={"num_worker": 2}), + ) + + assert result.valid is False + error = result.errors[0] + assert error.code == "config_patch_unknown_field" + assert error.exception_type == "ConfigPatchUnknownFieldError" + assert error.path == 'ConfigPatch.values["num_worker"]' + assert 'ConfigPatch.values["num_workers"]' in (error.hint or "") + assert DescribeConfigSchemaCapability.name in (error.hint or "") + assert "unexpected keyword argument" not in error.message + + +def test_config_patch_nested_collection_near_miss_uses_json_shape() -> None: + service = ConfigService() + + result = service.validate_patch( + "pipeline", + ConfigPatch( + config_type="pipeline", + values={ + "source_bindings_config": { + "bindings": [ + { + "alias": "DNA", + "artifct_kind": "image", + } + ] + } + }, + ), + ) + + assert result.valid is False + error = result.errors[0] + assert error.path == ( + 'ConfigPatch.values["source_bindings_config"]["bindings"][]' + '["artifct_kind"]' + ) + assert ( + 'ConfigPatch.values["source_bindings_config"]["bindings"][]' + '["artifact_kind"]' + ) in (error.hint or "") + + +def test_mcp_projects_typed_architecture_and_config_diagnostics() -> None: + built = server.build_server() + + architecture_result = asyncio.run( + built.call_tool( + "openhcs_describe_internal_symbol", + {"symbol_id": "openhcs.core.config.GlobalPipelineConfig"}, + ) + ) + config_result = asyncio.run( + built.call_tool( + "openhcs_validate_config_patch", + { + "config_type": "global", + "values": {"num_worker": 2}, + }, + ) + ) + + architecture_payload = _structured_result(architecture_result) + config_payload = _structured_result(config_result) + architecture_error = architecture_payload["errors"][0] + config_error = config_payload["errors"][0] + + assert architecture_error["code"] == "architecture_symbol_not_curated" + assert ListArchitectureTopicsCapability.name in architecture_error["hint"] + assert architecture_error["exception_type"] == ( + "ArchitectureSymbolNotCuratedError" + ) + assert config_payload["valid"] is False + assert config_error["code"] == "config_patch_unknown_field" + assert DescribeConfigSchemaCapability.name in config_error["hint"] + assert 'ConfigPatch.values["num_workers"]' in config_error["hint"] From 762614220531e24987118909e1d8f93dda2bc421 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 30 Jul 2026 15:11:20 -0400 Subject: [PATCH 3/7] Resolve PolyStore Zarr config type ownership --- external/PolyStore | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/external/PolyStore b/external/PolyStore index 3c838f190..a7db3f2c9 160000 --- a/external/PolyStore +++ b/external/PolyStore @@ -1 +1 @@ -Subproject commit 3c838f19043ae6f5ab4e89c2fdeb57ef385f8da3 +Subproject commit a7db3f2c9b8ee7b1f4b9c753a643cbaa7a591d3e diff --git a/pyproject.toml b/pyproject.toml index 1c02845bc..1a8687b59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,7 @@ dependencies = [ "python-introspect>=0.1.7", "metaclass-registry>=0.1.6", "arraybridge>=0.2.11", - "polystore>=0.1.25", + "polystore>=0.1.26", "pyqt-reactive>=0.1.28", ] From c8abd9ed70454ba80310f616461babf1376c88d7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 30 Jul 2026 15:28:07 -0400 Subject: [PATCH 4/7] Make configuration contracts nominal and viewer-native Replace closed string choices and generated display-config mirrors with owning enum/dataclass declarations, remove inert public options, and route processor dispatch through existing registry strategy mixins. Apply the typed viewer contracts through Napari and Fiji while virtualizing the first-party ROI table and chunking Shapes updates for large result sets. Add generated configuration-consumer and mirror-deletion gates plus focused CellProfiler, streaming, and ROI regressions. --- benchmark/napari_roi_streaming.py | 359 ++++++ .../configuration_reference.rst | 7 +- openhcs/constants/__init__.py | 4 +- openhcs/constants/constants.py | 47 - openhcs/core/config.py | 314 +++-- openhcs/core/memory/__init__.py | 4 +- openhcs/core/pipeline/path_planner.py | 6 +- .../core/steps/stream_component_semantics.py | 34 +- .../interop/cellprofiler/analyst_export.py | 21 +- .../interop/cellprofiler/settings_binder.py | 20 +- openhcs/napari_roi_manager/_dataclasses.py | 27 +- openhcs/napari_roi_manager/ij/_convert.py | 25 +- .../napari_roi_manager/widgets/_dialogs.py | 8 +- .../widgets/_roi_manager.py | 279 +++-- .../backends/analysis/cell_counting_cpu.py | 6 +- .../analysis/consolidate_analysis_results.py | 3 +- .../analysis/consolidate_special_outputs.py | 23 +- .../backends/analysis/dxf_mask_pipeline.py | 17 +- .../analysis/multi_template_matching.py | 87 +- .../backends/analysis/region_properties.py | 49 +- .../backends/assemblers/assemble_stack_cpu.py | 12 +- .../assemblers/assemble_stack_cupy.py | 23 +- .../backends/assemblers/blending.py | 11 + .../backends/cellprofiler/_backend.py | 27 +- .../backends/cellprofiler/alignment.py | 38 +- .../backends/cellprofiler/area_occupied.py | 6 +- .../backends/cellprofiler/classification.py | 255 ++-- .../processing/backends/cellprofiler/color.py | 62 +- .../processing/backends/cellprofiler/crop.py | 37 +- .../backends/cellprofiler/display_modules.py | 50 +- .../processing/backends/cellprofiler/edge.py | 2 - .../cellprofiler/export_to_database.py | 170 +-- .../cellprofiler/feature_enhancement.py | 4 - .../processing/backends/cellprofiler/grid.py | 52 +- .../backends/cellprofiler/illumination.py | 28 +- .../backends/cellprofiler/image_geometry.py | 5 - .../backends/cellprofiler/image_math.py | 12 +- .../backends/cellprofiler/image_quality.py | 12 - .../backends/cellprofiler/intensity.py | 48 +- .../cellprofiler/intensity_distribution.py | 1 - .../backends/cellprofiler/maxima.py | 1 - .../backends/cellprofiler/median_filter.py | 85 +- .../backends/cellprofiler/morphology.py | 89 +- .../backends/cellprofiler/object_filtering.py | 5 - .../backends/cellprofiler/object_images.py | 24 +- .../backends/cellprofiler/outlines.py | 16 +- .../backends/cellprofiler/primary_objects.py | 7 - .../backends/cellprofiler/projection.py | 1 - .../backends/cellprofiler/relationships.py | 17 +- .../backends/cellprofiler/save_images.py | 59 +- .../backends/cellprofiler/secondary.py | 70 +- .../backends/cellprofiler/smoothing.py | 7 +- .../cellprofiler/structuring_elements.py | 18 +- .../backends/cellprofiler/thresholding.py | 183 +-- .../backends/cellprofiler/tracking.py | 17 +- .../backends/cellprofiler/watershed.py | 66 +- .../processing/backends/cellprofiler/worms.py | 1 - .../backends/cellprofiler/zernike.py | 5 +- .../backends/enhance/basic_processor_cupy.py | 15 +- .../backends/enhance/basic_processor_jax.py | 7 +- .../backends/enhance/basic_processor_numpy.py | 11 +- .../backends/enhance/deconvolution.py | 15 + .../processing/backends/enhance/flatfield.py | 17 + .../backends/enhance/focus_torch.py | 15 +- .../self_supervised_2d_deconvolution.py | 35 +- .../self_supervised_3d_deconvolution.py | 35 +- .../backends/pos_gen/mist/mist_main.py | 16 +- .../backends/processors/cupy_processor.py | 121 +- .../backends/processors/jax_processor.py | 46 +- .../backends/processors/method_axes.py | 66 +- .../backends/processors/numpy_processor.py | 95 +- .../processors/pyclesperanto_processor.py | 54 +- .../processors/tensorflow_processor.py | 46 +- .../backends/processors/torch_processor.py | 46 +- openhcs/processing/presets/README.md | 1 - openhcs/processing/presets/mfd_specs.py | 6 +- .../cy5_axon_cell_body_crop_analysis.py | 10 +- .../pipelines/czi_brain_axon_cellbody.py | 11 +- .../loose_operaphenix_neurite_outgrowth.py | 19 +- ...peraphenix_neurite_outgrowth_metaxpress.py | 3 +- ...uroncyto_ii_crossover_neurite_outgrowth.py | 11 +- .../pyqt_gui/services/llm_pipeline_service.py | 3 +- openhcs/runtime/fiji_viewer_server.py | 88 +- openhcs/runtime/napari_streaming_handlers.py | 154 +-- openhcs/runtime/napari_viewer_server.py | 281 +++-- openhcs/runtime/viewer_component_system.py | 4 +- openhcs/tests/basic_pipeline.py | 3 - openhcs/tests/test_pipeline.py | 3 +- openhcs/tests/test_pipeline_old.py | 8 +- openhcs/utils/display_config_factory.py | 364 ------ openhcs/utils/enum_factory.py | 211 ---- tests/integration/test_main.py | 9 +- .../test_end_to_end_workflow_foundation.py | 2 +- tests/unit/agent/test_mcp_server.py | 18 +- .../test_object_state_field_help_service.py | 12 +- .../pyqt_gui/test_main_config_propagation.py | 126 +- tests/unit/pyqt_gui/test_ui_agent_bridge.py | 4 +- ...est_cell_counting_cpu_artifact_contract.py | 1 - .../unit/test_cellprofiler_analyst_export.py | 2 - .../unit/test_cellprofiler_closing_hotpath.py | 13 +- ...ellprofiler_conditional_analysis_images.py | 22 +- ...t_cellprofiler_conditional_named_images.py | 6 +- ...cellprofiler_export_measurement_outputs.py | 7 +- .../test_cellprofiler_export_to_database.py | 50 +- ...profiler_extended_relationship_topology.py | 10 +- .../test_cellprofiler_image_math_hotpath.py | 2 +- .../unit/test_cellprofiler_library_loading.py | 327 ++++-- .../test_cellprofiler_measurement_rows.py | 2 +- .../test_cellprofiler_module_execution.py | 15 +- tests/unit/test_cellprofiler_morphology.py | 9 +- ...t_cellprofiler_nominal_policy_migration.py | 103 +- ..._cellprofiler_object_morphology_hotpath.py | 2 +- .../test_cellprofiler_processing_backend.py | 50 +- .../unit/test_cellprofiler_runtime_adapter.py | 44 +- tests/unit/test_cellprofiler_trackobjects.py | 15 +- .../test_configuration_consumer_coverage.py | 1044 +++++++++++++++++ .../test_configuration_mirror_deletion.py | 152 +++ .../test_core_config_annotation_validation.py | 42 + tests/unit/test_fiji_viewer_server.py | 16 +- .../test_function_artifact_materialization.py | 18 +- tests/unit/test_function_outputs.py | 7 +- tests/unit/test_function_patterns.py | 4 +- .../unit/test_invocation_contract_provider.py | 28 +- tests/unit/test_materialization_core.py | 7 +- tests/unit/test_mfd_preset_specs.py | 8 +- tests/unit/test_multi_template_matching.py | 55 + .../test_napari_roi_manager_integration.py | 78 +- tests/unit/test_napari_streaming_handlers.py | 255 +++- tests/unit/test_napari_transport_ownership.py | 12 +- .../unit/test_processor_method_strategies.py | 161 ++- tests/unit/test_pycodify_formatters.py | 2 +- tests/unit/test_settings_binder.py | 4 +- 132 files changed, 4850 insertions(+), 2580 deletions(-) create mode 100644 benchmark/napari_roi_streaming.py create mode 100644 openhcs/processing/backends/assemblers/blending.py create mode 100644 openhcs/processing/backends/enhance/deconvolution.py create mode 100644 openhcs/processing/backends/enhance/flatfield.py delete mode 100644 openhcs/utils/display_config_factory.py delete mode 100644 openhcs/utils/enum_factory.py create mode 100644 tests/unit/test_configuration_consumer_coverage.py create mode 100644 tests/unit/test_core_config_annotation_validation.py create mode 100644 tests/unit/test_multi_template_matching.py diff --git a/benchmark/napari_roi_streaming.py b/benchmark/napari_roi_streaming.py new file mode 100644 index 000000000..9ac8ceb36 --- /dev/null +++ b/benchmark/napari_roi_streaming.py @@ -0,0 +1,359 @@ +"""Reproducible phase benchmark for large native Napari ROI streams.""" + +from __future__ import annotations + +import argparse +import json +import math +import statistics +import time +import tracemalloc +import uuid +from dataclasses import asdict, dataclass, replace + +import numpy as np +import zmq +from napari.components import ViewerModel +from qtpy.QtWidgets import QApplication + +from polystore.streaming.identity import StreamProducerIdentity +from polystore.streaming_constants import StreamingDataType + +from openhcs.core.runtime_image_values import ImagePayloadMetadata +from openhcs.napari_roi_manager.widgets._roi_manager import QRoiManager +from openhcs.runtime.napari_streaming_handlers import ( + NapariShapeLayerPayload, + NapariStreamLayerAddress, + NapariStreamLayerItem, + VisualMetadataField, +) +from openhcs.runtime.napari_viewer_server import NapariShapesLayerDisplayHandler +from openhcs.runtime.viewer_component_system import ( + ViewerComponentValueDomainPayload, + ViewerLayerAxisProjection, +) + + +@dataclass(frozen=True, slots=True) +class NapariRoiStreamingBenchmarkSample: + """One measured large-ROI stream application.""" + + roi_count: int + vertices_per_roi: int + total_vertex_count: int + wire_bytes: int + serialization_seconds: float + transport_seconds: float + decoding_seconds: float + feature_projection_seconds: float + shapes_insertion_seconds: float + roi_manager_construction_seconds: float + roi_table_refresh_seconds: float + selection_synchronization_seconds: float + paint_settlement_seconds: float + peak_python_allocation_bytes: int | None = None + + +@dataclass(frozen=True, slots=True) +class NapariRoiStreamingBenchmarkSummary: + """Median timings plus every raw sample for one payload scale.""" + + roi_count: int + vertices_per_roi: int + median: NapariRoiStreamingBenchmarkSample + samples: tuple[NapariRoiStreamingBenchmarkSample, ...] + allocation_sample: NapariRoiStreamingBenchmarkSample | None + + def to_json_dict(self) -> dict[str, object]: + return asdict(self) + + +def benchmark_napari_roi_streaming( + *, + roi_count: int, + vertex_count: int = 12, + repeats: int = 3, + track_allocations: bool = True, +) -> NapariRoiStreamingBenchmarkSummary: + """Measure warm large-ROI phases and one allocation-instrumented sample.""" + + if roi_count <= 0 or vertex_count < 3 or repeats <= 0: + raise ValueError( + "ROI count/repeats must be positive and vertex count must be at least 3." + ) + application = QApplication.instance() or QApplication([]) + shape_records = _shape_records(roi_count, vertex_count) + _run_sample(application, shape_records, track_allocations=False) + samples = tuple( + _run_sample(application, shape_records, track_allocations=False) + for _ in range(repeats) + ) + allocation_sample = ( + _run_sample( + application, + shape_records, + track_allocations=True, + ) + if track_allocations + else None + ) + return NapariRoiStreamingBenchmarkSummary( + roi_count=roi_count, + vertices_per_roi=vertex_count, + median=_median_sample(samples), + samples=samples, + allocation_sample=allocation_sample, + ) + + +def _run_sample( + application: QApplication, + shape_records: list[dict[str, object]], + *, + track_allocations: bool, +) -> NapariRoiStreamingBenchmarkSample: + if track_allocations: + tracemalloc.start() + + serialization_started = time.perf_counter() + encoded = json.dumps({"shapes": shape_records}).encode() + serialization_seconds = time.perf_counter() - serialization_started + + transmitted, transport_seconds = _transport_round_trip(encoded) + decoding_started = time.perf_counter() + decoded = json.loads(transmitted) + decoding_seconds = time.perf_counter() - decoding_started + + projection_started = time.perf_counter() + payload = NapariShapeLayerPayload.build( + layer_items=[_stream_layer_item(decoded["shapes"])], + axis_projection=_plane_projection(), + ) + feature_projection_seconds = time.perf_counter() - projection_started + + insertion_started = time.perf_counter() + viewer = ViewerModel() + layer = _insert_native_shapes(viewer, payload) + shapes_insertion_seconds = time.perf_counter() - insertion_started + + viewer.layers.selection.clear() + manager_started = time.perf_counter() + manager = QRoiManager(viewer) + roi_manager_construction_seconds = time.perf_counter() - manager_started + table_started = time.perf_counter() + manager.connect_layer(layer) + roi_table_refresh_seconds = time.perf_counter() - table_started + + selection_started = time.perf_counter() + layer.selected_data = {len(payload.data) - 1} + application.processEvents() + selection_synchronization_seconds = time.perf_counter() - selection_started + + settlement_started = time.perf_counter() + layer.visible = True + application.processEvents() + paint_settlement_seconds = time.perf_counter() - settlement_started + + peak_python_allocation_bytes = None + if track_allocations: + _, peak_python_allocation_bytes = tracemalloc.get_traced_memory() + tracemalloc.stop() + + manager.close() + viewer.layers.clear() + application.processEvents() + return NapariRoiStreamingBenchmarkSample( + roi_count=len(payload.data), + vertices_per_roi=( + len(payload.data[0]) if payload.data else 0 + ), + total_vertex_count=sum(len(coordinates) for coordinates in payload.data), + wire_bytes=len(encoded), + serialization_seconds=serialization_seconds, + transport_seconds=transport_seconds, + decoding_seconds=decoding_seconds, + feature_projection_seconds=feature_projection_seconds, + shapes_insertion_seconds=shapes_insertion_seconds, + roi_manager_construction_seconds=roi_manager_construction_seconds, + roi_table_refresh_seconds=roi_table_refresh_seconds, + selection_synchronization_seconds=selection_synchronization_seconds, + paint_settlement_seconds=paint_settlement_seconds, + peak_python_allocation_bytes=peak_python_allocation_bytes, + ) + + +def _transport_round_trip(payload: bytes) -> tuple[bytes, float]: + context = zmq.Context.instance() + sender = context.socket(zmq.PAIR) + receiver = context.socket(zmq.PAIR) + endpoint = f"inproc://openhcs-napari-roi-benchmark-{uuid.uuid4()}" + sender.bind(endpoint) + receiver.connect(endpoint) + try: + started = time.perf_counter() + sender.send(payload) + received = receiver.recv() + return received, time.perf_counter() - started + finally: + sender.close() + receiver.close() + + +def _insert_native_shapes( + viewer: ViewerModel, + payload: NapariShapeLayerPayload, +): + chunks = payload.chunks( + max_shape_count=NapariShapesLayerDisplayHandler.MAX_SHAPES_PER_WORK_UNIT, + max_vertex_count=NapariShapesLayerDisplayHandler.MAX_VERTICES_PER_WORK_UNIT, + ) + color_projection = payload.color_projection + first = chunks[0] + layer = viewer.add_shapes( + first.data, + shape_type=first.shape_types, + features=first.features, + edge_color=VisualMetadataField.LABEL.value, + face_color=VisualMetadataField.LABEL.value, + edge_color_cycle=color_projection.cycle, + face_color_cycle=color_projection.cycle, + opacity=0.7, + ndim=payload.ndim, + visible=False, + ) + member_index = len(first.data) + for chunk in chunks[1:]: + next_member_index = member_index + len(chunk.data) + colors = color_projection.member_colors[member_index:next_member_index] + layer.add( + chunk.data, + shape_type=chunk.shape_types, + edge_color=colors, + face_color=colors, + ) + member_index = next_member_index + layer.features = payload.features + layer.edge_color_cycle = color_projection.cycle + layer.face_color_cycle = color_projection.cycle + layer.edge_color_mode = "cycle" + layer.face_color_mode = "cycle" + return layer + + +def _stream_layer_item( + shapes: list[dict[str, object]], +) -> NapariStreamLayerItem: + return NapariStreamLayerItem( + data=shapes, + producer=StreamProducerIdentity.pipeline_output( + output_kind="artifact", + output_key="benchmark_rois", + projection_key="benchmark_rois", + step_name="Napari ROI benchmark", + pipeline_position=0, + ), + address=NapariStreamLayerAddress( + components={}, + path="benchmark.roi.zip", + stream_layer_data_type=StreamingDataType.SHAPES, + ), + image_metadata=ImagePayloadMetadata(), + plane_component_domain=ViewerComponentValueDomainPayload(()), + ) + + +def _plane_projection() -> ViewerLayerAxisProjection: + return ViewerLayerAxisProjection( + projected_axis_components=(), + component_values={}, + routed_component_values={}, + axis_offsets=(), + scalar_component_values={}, + ) + + +def _shape_records( + roi_count: int, + vertex_count: int, +) -> list[dict[str, object]]: + angles = np.linspace(0.0, 2.0 * math.pi, vertex_count, endpoint=False) + unit_contour = np.stack((np.sin(angles), np.cos(angles)), axis=1) + column_count = math.ceil(math.sqrt(roi_count)) + records: list[dict[str, object]] = [] + for index in range(roi_count): + center = np.asarray( + ((index // column_count) * 4.0, (index % column_count) * 4.0) + ) + coordinates = center + unit_contour * (1.0 + (index % 5) * 0.08) + records.append( + { + "type": "polygon", + "coordinates": coordinates.tolist(), + "metadata": { + "name": f"ROI-{index:05d}", + "label": index % 97 + 1, + "area": float(math.pi * (1.0 + (index % 5) * 0.08) ** 2), + }, + } + ) + return records + + +def _median_sample( + samples: tuple[NapariRoiStreamingBenchmarkSample, ...], +) -> NapariRoiStreamingBenchmarkSample: + first = samples[0] + return replace( + first, + serialization_seconds=statistics.median( + sample.serialization_seconds for sample in samples + ), + transport_seconds=statistics.median( + sample.transport_seconds for sample in samples + ), + decoding_seconds=statistics.median( + sample.decoding_seconds for sample in samples + ), + feature_projection_seconds=statistics.median( + sample.feature_projection_seconds for sample in samples + ), + shapes_insertion_seconds=statistics.median( + sample.shapes_insertion_seconds for sample in samples + ), + roi_manager_construction_seconds=statistics.median( + sample.roi_manager_construction_seconds for sample in samples + ), + roi_table_refresh_seconds=statistics.median( + sample.roi_table_refresh_seconds for sample in samples + ), + selection_synchronization_seconds=statistics.median( + sample.selection_synchronization_seconds for sample in samples + ), + paint_settlement_seconds=statistics.median( + sample.paint_settlement_seconds for sample in samples + ), + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--roi-count", type=int, default=4_097) + parser.add_argument("--vertex-count", type=int, default=12) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument( + "--skip-allocation", + action="store_true", + help="Skip the slower tracemalloc-instrumented sample.", + ) + arguments = parser.parse_args() + summary = benchmark_napari_roi_streaming( + roi_count=arguments.roi_count, + vertex_count=arguments.vertex_count, + repeats=arguments.repeats, + track_allocations=not arguments.skip_allocation, + ) + print(json.dumps(summary.to_json_dict(), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/docs/source/guide_for_biologists/configuration_reference.rst b/docs/source/guide_for_biologists/configuration_reference.rst index f7a5fb528..e92ced03c 100644 --- a/docs/source/guide_for_biologists/configuration_reference.rst +++ b/docs/source/guide_for_biologists/configuration_reference.rst @@ -167,10 +167,9 @@ Post-run analysis and metadata Plate identity and acquisition metadata used by compatible consolidated outputs. -``ExperimentalAnalysisConfig`` - Experimental-design workbook, normalization, raw-result, and heatmap policy. - It consumes consolidated measurements and plate layouts; it does not change - pipeline axes or source bindings. +The standalone ``ExperimentalAnalysisEngine`` accepts an +``ExperimentalAnalysisConfig`` directly. That engine is not part of pipeline +execution, so these options are intentionally absent from Configure OpenHCS. How scopes interact ------------------- diff --git a/openhcs/constants/__init__.py b/openhcs/constants/__init__.py index 71e925ed7..2dac7c4a6 100644 --- a/openhcs/constants/__init__.py +++ b/openhcs/constants/__init__.py @@ -19,7 +19,7 @@ MEMORY_TYPE_TENSORFLOW, MEMORY_TYPE_TORCH, Microscope, READ_BACKEND, REQUIRES_DISK_READ, REQUIRES_DISK_WRITE, SUPPORTED_MEMORY_TYPES, VALID_GPU_MEMORY_TYPES, VALID_MEMORY_TYPES, WRITE_BACKEND, Backend, - AllComponents, GroupBy, MemoryType, SequentialComponents, VariableComponents, DtypeConversion, LiteralDtype) + AllComponents, GroupBy, MemoryType, SequentialComponents, VariableComponents) DEFAULT_VARIABLE_COMPONENTS = get_default_variable_components() DEFAULT_GROUP_BY = get_default_group_by() @@ -34,7 +34,7 @@ # Memory 'MemoryType', 'CPU_MEMORY_TYPES', 'GPU_MEMORY_TYPES', 'SUPPORTED_MEMORY_TYPES', 'MEMORY_TYPE_NUMPY', 'MEMORY_TYPE_CUPY', 'MEMORY_TYPE_TORCH', 'MEMORY_TYPE_TENSORFLOW', - 'MEMORY_TYPE_JAX', 'VALID_MEMORY_TYPES', 'VALID_GPU_MEMORY_TYPES', 'DtypeConversion', 'LiteralDtype', + 'MEMORY_TYPE_JAX', 'VALID_MEMORY_TYPES', 'VALID_GPU_MEMORY_TYPES', # I/O 'DEFAULT_IMAGE_EXTENSION', 'DEFAULT_IMAGE_EXTENSIONS', diff --git a/openhcs/constants/constants.py b/openhcs/constants/constants.py index 246c6cec8..26bc9fed2 100644 --- a/openhcs/constants/constants.py +++ b/openhcs/constants/constants.py @@ -28,53 +28,6 @@ class Microscope(Enum): BIOFORMATS = "bioformats" SOURCE_BINDINGS = "source_bindings" -class LiteralDtype(Enum): - """Concrete numpy dtype literals (single source of truth).""" - UINT8 = "uint8" - UINT16 = "uint16" - FLOAT32 = "float32" - - @property - def numpy_dtype(self): - """Get the corresponding numpy dtype.""" - import numpy as np - return { - self.UINT8: np.uint8, - self.UINT16: np.uint16, - self.FLOAT32: np.float32, - }[self] - - -# Build DtypeConversion from LiteralDtype + execution-specific modes -DtypeConversion = Enum('DtypeConversion', { - **{m.name: m.value for m in LiteralDtype}, - 'PRESERVE_INPUT': 'preserve', - 'NATIVE_OUTPUT': 'native', -}) -DtypeConversion.__doc__ = """Data type conversion modes for registered function execution.""" -DtypeConversion.__module__ = __name__ -DtypeConversion.__qualname__ = 'DtypeConversion' - - -def _add_numpy_dtype_property(): - """Add numpy_dtype property to DtypeConversion enum.""" - def numpy_dtype_getter(self): - """Get the corresponding numpy dtype (None for PRESERVE/NATIVE).""" - import numpy as np - dtype_map = { - DtypeConversion.UINT8: np.uint8, - DtypeConversion.UINT16: np.uint16, - DtypeConversion.FLOAT32: np.float32, - } - if self in dtype_map: - return dtype_map[self] - return None - - DtypeConversion.numpy_dtype = property(numpy_dtype_getter) - -_add_numpy_dtype_property() - - def get_openhcs_config(): """Get the OpenHCS configuration, initializing it if needed.""" from openhcs.components.framework import ComponentConfigurationFactory diff --git a/openhcs/core/config.py b/openhcs/core/config.py index ff1a7d5a1..fd26fca87 100644 --- a/openhcs/core/config.py +++ b/openhcs/core/config.py @@ -8,12 +8,13 @@ import logging import inspect +from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path from typing import Optional, Union, List, Annotated, ClassVar from enum import Enum from abc import ABC, abstractmethod -from arraybridge.decorators import DtypeConversionConfig +from arraybridge.decorators import DtypeConversion, DtypeConversionConfig from polystore import config as _polystore_config from openhcs.constants import ( AllComponents, @@ -21,19 +22,25 @@ SequentialComponents, VariableComponents, GroupBy, - DtypeConversion, ) from openhcs.constants.constants import ( Backend, - LiteralDtype, get_default_variable_components, get_default_group_by, ) from metaclass_registry import AutoRegisterMeta from openhcs.constants.input_source import InputSource -from python_introspect import Enableable +from python_introspect import AnnotatedDataclassValidationMixin, Enableable from python_introspect.enableable import EnableableMeta -from zmqruntime.config import TransportMode +from polystore.streaming.viewer_transport import ViewerDisplayConfigABC +from zmqruntime.config import ( + NonBlankString, + PositiveFloat, + PositiveInteger, + TcpPort, + TransportMode, +) +from zmqruntime.viewer_protocol import ViewerDisplayConfigWireField, ViewerWireValue from zmqruntime.transport import get_default_transport_mode from openhcs.core.runtime_plane_projection import RuntimeSliceInvariantValue @@ -108,7 +115,7 @@ class MultiprocessingStartMethod(Enum): @abbreviation("gpc") @auto_create_decorator @dataclass(frozen=True) -class GlobalPipelineConfig: +class GlobalPipelineConfig(AnnotatedDataclassValidationMixin): """ Root configuration object for an OpenHCS pipeline session. This object is intended to be instantiated at application startup and treated as immutable. @@ -137,7 +144,7 @@ class GlobalPipelineConfig: ] = True """Persist runtime artifacts such as measurements/tables to configured result files.""" - num_workers: Annotated[int, abbreviation("W")] = 1 + num_workers: Annotated[PositiveInteger, abbreviation("W")] = 1 """Number of worker processes/threads for parallelizable tasks.""" microscope: Annotated[Microscope, abbreviation("scope")] = field( @@ -179,7 +186,7 @@ class GlobalPipelineConfig: always_viewable_fields=["enabled"], ) @dataclass(frozen=True) -class CompilationDebugConfig(Enableable): +class CompilationDebugConfig(AnnotatedDataclassValidationMixin, Enableable): """Compiler diagnostics inherited by PipelineConfig.""" compiled_execution_bundle_path: Annotated[ @@ -191,18 +198,6 @@ class CompilationDebugConfig(Enableable): # PipelineConfig will be created automatically by the injection system # (GlobalPipelineConfig → PipelineConfig by removing "Global" prefix) -# Import utilities for dynamic config creation -from openhcs.utils.enum_factory import create_colormap_enum -from openhcs.utils.display_config_factory import ( - create_napari_display_config, - create_fiji_display_config, -) - -# Create colormap enum with minimal set to avoid importing napari (→ dask → GPU libs) -# The lazy=True parameter uses a hardcoded minimal set instead of introspecting napari -NapariColormap = create_colormap_enum(lazy=True) - - class NapariDimensionMode(Enum): """How to handle different dimensions in napari visualization.""" @@ -219,22 +214,104 @@ class NapariVariableSizeHandling(Enum): PAD_TO_MAX = "pad_to_max" # Pad smaller images to match largest (enables stacking) -# Visualization dtype normalization (alias to LiteralDtype - no duplication) -VisualizationDtype = LiteralDtype -VisualizationDtype.__doc__ = ( - """Dtype normalization for visualization streaming (Napari/Fiji stacking).""" -) - +@dataclass(frozen=True) +class NapariDisplayConfig( + ViewerDisplayConfigABC, + AnnotatedDataclassValidationMixin, +): + """Configuration for Napari display behavior.""" + + COMPONENT_ORDER: ClassVar[tuple[str, ...]] = AllComponents.ordered_names() + + colormap: NonBlankString = field( + default="gray", + metadata={ + "description": ( + "Name of a colormap registered in the installed Napari viewer. " + "Napari validates this extensible registry name when displaying " + "the layer." + ) + }, + ) + variable_size_handling: NapariVariableSizeHandling = field( + default=NapariVariableSizeHandling.PAD_TO_MAX, + metadata={ + "description": ( + "How Napari combines images with different spatial dimensions " + "into layers." + ) + }, + ) + site_mode: NapariDimensionMode = NapariDimensionMode.STACK + channel_mode: NapariDimensionMode = NapariDimensionMode.STACK + z_index_mode: NapariDimensionMode = NapariDimensionMode.STACK + timepoint_mode: NapariDimensionMode = NapariDimensionMode.STACK + well_mode: NapariDimensionMode = NapariDimensionMode.STACK + + def component_modes(self) -> dict[str, str]: + """Project typed component fields onto the viewer wire vocabulary.""" + + return { + AllComponents.SITE.value: self.site_mode.value, + AllComponents.CHANNEL.value: self.channel_mode.value, + AllComponents.Z_INDEX.value: self.z_index_mode.value, + AllComponents.TIMEPOINT.value: self.timepoint_mode.value, + AllComponents.WELL.value: self.well_mode.value, + } + + def display_payload_extra(self) -> dict[str, str]: + """Project Napari-specific display settings onto the wire payload.""" + + from polystore.napari_stream import NapariDisplayWireField + + return { + NapariDisplayWireField.COLORMAP.value: self.colormap, + NapariDisplayWireField.VARIABLE_SIZE_HANDLING.value: ( + self.variable_size_handling.value + ), + } -# Create NapariDisplayConfig using factory -# Note: Uses lazy colormap enum to avoid importing napari at module level -NapariDisplayConfig = create_napari_display_config( - colormap_enum=NapariColormap, - dimension_mode_enum=NapariDimensionMode, - variable_size_handling_enum=NapariVariableSizeHandling, - visualization_dtype_enum=VisualizationDtype, - component_order=AllComponents.ordered_names(), -) + @classmethod + def from_display_payload( + cls, + payload: Mapping[str, ViewerWireValue], + ) -> "NapariDisplayConfig": + """Rehydrate the typed config at the Napari viewer wire boundary.""" + + from polystore.napari_stream import NapariDisplayWireField + + component_modes = payload[ViewerDisplayConfigWireField.COMPONENT_MODES.value] + if not isinstance(component_modes, Mapping): + raise TypeError("Napari component_modes must be a mapping.") + if set(component_modes) != set(cls.COMPONENT_ORDER): + raise ValueError( + "Napari component_modes must exactly match " + f"{cls.COMPONENT_ORDER!r}; got {tuple(component_modes)!r}." + ) + colormap = str(payload[NapariDisplayWireField.COLORMAP.value]).strip() + if not colormap: + raise ValueError("Napari colormap name must not be blank.") + return cls( + colormap=colormap, + variable_size_handling=NapariVariableSizeHandling( + str(payload[NapariDisplayWireField.VARIABLE_SIZE_HANDLING.value]) + ), + site_mode=NapariDimensionMode( + str(component_modes[AllComponents.SITE.value]) + ), + channel_mode=NapariDimensionMode( + str(component_modes[AllComponents.CHANNEL.value]) + ), + z_index_mode=NapariDimensionMode( + str(component_modes[AllComponents.Z_INDEX.value]) + ), + timepoint_mode=NapariDimensionMode( + str(component_modes[AllComponents.TIMEPOINT.value]) + ), + well_mode=NapariDimensionMode( + str(component_modes[AllComponents.WELL.value]) + ), + ) # Apply the global pipeline config decorator with ui_hidden=True # This config is only inherited by NapariStreamingConfig, so hide it from UI @@ -246,18 +323,6 @@ class NapariVariableSizeHandling(Enum): # ============================================================================ -class FijiLUT(Enum): - """Fiji/ImageJ LUT options.""" - - GRAYS = "Grays" - FIRE = "Fire" - ICE = "Ice" - SPECTRUM = "Spectrum" - RED = "Red" - GREEN = "Green" - BLUE = "Blue" - - class FijiDimensionMode(Enum): """ How to map OpenHCS dimensions to ImageJ hyperstack dimensions. @@ -277,12 +342,104 @@ class FijiDimensionMode(Enum): FRAME = "frame" # ImageJ Frame dimension (T) -# Create FijiDisplayConfig using factory (with component-specific fields like Napari) -FijiDisplayConfig = create_fiji_display_config( - lut_enum=FijiLUT, - dimension_mode_enum=FijiDimensionMode, - component_order=AllComponents.ordered_names(), -) +@dataclass(frozen=True) +class FijiDisplayConfig( + ViewerDisplayConfigABC, + AnnotatedDataclassValidationMixin, +): + """Configuration for Fiji display behavior.""" + + COMPONENT_ORDER: ClassVar[tuple[str, ...]] = AllComponents.ordered_names() + + lut: NonBlankString = field( + default="Grays", + metadata={ + "description": ( + "Name of a lookup table available to the installed Fiji/ImageJ " + "runtime, including plugin-provided LUTs." + ) + }, + ) + auto_contrast: bool = field( + default=True, + metadata={ + "description": ( + "Automatically set Fiji display limits from the streamed image data." + ) + }, + ) + site_mode: FijiDimensionMode = FijiDimensionMode.FRAME + channel_mode: FijiDimensionMode = FijiDimensionMode.CHANNEL + z_index_mode: FijiDimensionMode = FijiDimensionMode.SLICE + timepoint_mode: FijiDimensionMode = FijiDimensionMode.FRAME + well_mode: FijiDimensionMode = FijiDimensionMode.FRAME + + def component_modes(self) -> dict[str, str]: + """Project typed component fields onto the viewer wire vocabulary.""" + + return { + AllComponents.SITE.value: self.site_mode.value, + AllComponents.CHANNEL.value: self.channel_mode.value, + AllComponents.Z_INDEX.value: self.z_index_mode.value, + AllComponents.TIMEPOINT.value: self.timepoint_mode.value, + AllComponents.WELL.value: self.well_mode.value, + } + + def display_payload_extra(self) -> dict[str, str | bool]: + """Project Fiji-specific display settings onto the wire payload.""" + + from polystore.fiji_stream import FijiDisplayWireField + + return { + FijiDisplayWireField.LUT.value: self.lut, + FijiDisplayWireField.AUTO_CONTRAST.value: self.auto_contrast, + } + + @classmethod + def from_display_payload( + cls, + payload: Mapping[str, ViewerWireValue], + ) -> "FijiDisplayConfig": + """Rehydrate the typed config at the Fiji viewer wire boundary.""" + + from polystore.fiji_stream import FijiDisplayWireField + + component_modes = payload[ViewerDisplayConfigWireField.COMPONENT_MODES.value] + if not isinstance(component_modes, Mapping): + raise TypeError("Fiji component_modes must be a mapping.") + if set(component_modes) != set(cls.COMPONENT_ORDER): + raise ValueError( + "Fiji component_modes must exactly match " + f"{cls.COMPONENT_ORDER!r}; got {tuple(component_modes)!r}." + ) + lut = str(payload[FijiDisplayWireField.LUT.value]).strip() + if not lut: + raise ValueError("Fiji LUT name must not be blank.") + auto_contrast = payload[FijiDisplayWireField.AUTO_CONTRAST.value] + if not isinstance(auto_contrast, bool): + raise TypeError( + "Fiji auto_contrast must be bool, " + f"got {type(auto_contrast).__name__}." + ) + return cls( + lut=lut, + auto_contrast=auto_contrast, + site_mode=FijiDimensionMode( + str(component_modes[AllComponents.SITE.value]) + ), + channel_mode=FijiDimensionMode( + str(component_modes[AllComponents.CHANNEL.value]) + ), + z_index_mode=FijiDimensionMode( + str(component_modes[AllComponents.Z_INDEX.value]) + ), + timepoint_mode=FijiDimensionMode( + str(component_modes[AllComponents.TIMEPOINT.value]) + ), + well_mode=FijiDimensionMode( + str(component_modes[AllComponents.WELL.value]) + ), + ) # Apply the global pipeline config decorator with ui_hidden=True # This config is only inherited by FijiStreamingConfig, so hide it from UI @@ -292,7 +449,7 @@ class FijiDimensionMode(Enum): @abbreviation("wfc") @global_pipeline_config @dataclass(frozen=True) -class WellFilterConfig: +class WellFilterConfig(AnnotatedDataclassValidationMixin): """Base execution-domain filter inherited by specialized well policies. At pipeline scope this constrains which wells compile and execute. Nominal @@ -340,7 +497,7 @@ def accepts_well_filter_provenance(cls, source_type: type | None) -> bool: }, ) @dataclass(frozen=True) -class ZarrConfig(_polystore_config.ZarrConfig): +class ZarrConfig(AnnotatedDataclassValidationMixin, _polystore_config.ZarrConfig): """OpenHCS registration of PolyStore's Zarr configuration owner. OME-ZARR metadata and plate metadata are always enabled for HCS compliance. @@ -351,7 +508,7 @@ class ZarrConfig(_polystore_config.ZarrConfig): @abbreviation("vfs") @global_pipeline_config(always_viewable_fields=["materialization_backend"]) @dataclass(frozen=True) -class VFSConfig: +class VFSConfig(AnnotatedDataclassValidationMixin): """Configuration for Virtual File System (VFS) related operations.""" read_backend: Annotated[Backend, abbreviation("read")] = Backend.AUTO @@ -372,6 +529,7 @@ class VFSConfig: @global_pipeline_config @dataclass(frozen=True) class DtypeConfig( + AnnotatedDataclassValidationMixin, RuntimeSliceInvariantValue, DtypeConversionConfig, ): @@ -399,7 +557,7 @@ def annotation_type(cls) -> type["DtypeConfig"]: always_viewable_fields=["variable_components", "group_by", "input_source"], ) @dataclass(frozen=True) -class ProcessingConfig: +class ProcessingConfig(AnnotatedDataclassValidationMixin): """Independent stack-axis, post-assembly grouping, and main-flow choices.""" variable_components: Annotated[List[VariableComponents], abbreviation("vars")] = ( @@ -451,7 +609,7 @@ class ProcessingConfig: @abbreviation("seq") @global_pipeline_config @dataclass(frozen=True) -class SequentialProcessingConfig: +class SequentialProcessingConfig(AnnotatedDataclassValidationMixin): """Pipeline-level configuration for sequential processing mode. Sequential processing changes the orchestrator's execution flow to process @@ -472,7 +630,7 @@ class SequentialProcessingConfig: @abbreviation("analysis") @global_pipeline_config @dataclass(frozen=True) -class AnalysisConsolidationConfig(Enableable): +class AnalysisConsolidationConfig(AnnotatedDataclassValidationMixin, Enableable): """Configuration for automatic analysis results consolidation. enabled controls whether consolidation runs after pipeline completion. @@ -483,9 +641,6 @@ class AnalysisConsolidationConfig(Enableable): metaxpress_style: Annotated[bool, abbreviation("mx_style")] = True """Whether to generate MetaXpress-compatible output format with headers.""" - well_pattern: Annotated[str, abbreviation("well_pat")] = r"([A-Z]\d{2})" - """Regex pattern for extracting well IDs from filenames.""" - file_extensions: Annotated[tuple[str, ...], abbreviation("exts")] = (".csv",) """File extensions to include in consolidation.""" @@ -510,7 +665,7 @@ class AnalysisConsolidationConfig(Enableable): @abbreviation("plate") @global_pipeline_config @dataclass(frozen=True) -class PlateMetadataConfig: +class PlateMetadataConfig(AnnotatedDataclassValidationMixin): """Configuration for plate metadata in MetaXpress-style output.""" barcode: Annotated[Optional[str], abbreviation("barcode")] = None @@ -528,15 +683,13 @@ class PlateMetadataConfig: acquisition_user: Annotated[str, abbreviation("user")] = "OpenHCS" """User who acquired the data.""" - z_step: Annotated[str, abbreviation("z_step")] = "1" - """Z-step information for MetaXpress compatibility.""" + z_step: Annotated[PositiveFloat, abbreviation("z_step")] = 1.0 + """Positive Z-plane spacing recorded in the MetaXpress-compatible header.""" -@abbreviation("exp") -@global_pipeline_config @dataclass(frozen=True) -class ExperimentalAnalysisConfig: - """Configuration for experimental analysis system.""" +class ExperimentalAnalysisConfig(AnnotatedDataclassValidationMixin): + """Standalone configuration for the experimental-analysis engine.""" config_file_name: Annotated[str, abbreviation("config")] = "config.xlsx" """Name of the experimental configuration Excel file.""" @@ -675,7 +828,7 @@ class StreamingDefaults(Enableable, StepWellFilterConfig): persistent: Annotated[bool, abbreviation("persist")] = True """Whether viewer stays open after pipeline completion.""" - host: Annotated[str, abbreviation("host")] = "localhost" + host: Annotated[NonBlankString, abbreviation("host")] = "localhost" """Host for streaming communication. Use 'localhost' for local, or remote IP for network streaming.""" transport_mode: Annotated[TransportMode, abbreviation("transport")] = ( @@ -686,14 +839,10 @@ class StreamingDefaults(Enableable, StepWellFilterConfig): enabled: Annotated[bool, abbreviation("enabled")] = False """Whether this streaming config is enabled. When False, config exists but streaming is disabled.""" - batch_size: Annotated[Optional[int], abbreviation("batch")] = None - """Number of images to batch before displaying. - - None = wait for all images in the current operation, then display once (fastest, default). - N = show incrementally every N images (provides visual feedback but slower). - """ - - scope_accent_color: Annotated[Optional[str], abbreviation("accent")] = None + scope_accent_color: Annotated[Optional[str], abbreviation("accent")] = field( + default=None, + metadata={"ui_hidden": True}, + ) """Exact owner-projected scope accent used to frame a managed viewer.""" @@ -723,7 +872,7 @@ class StreamingConfig(StreamingDefaults, ABC, metaclass=StreamingConfigMeta): @property @abstractmethod - def port(self) -> int: + def port(self) -> TcpPort: """Port for streaming communication. Each streamer type has its own default.""" pass @@ -819,7 +968,7 @@ class NapariStreamingConfig( _streaming_config_key: ClassVar[str] = NAPARI_STREAMING_CONFIG_SPEC.registry_key streaming_spec: ClassVar[StreamingViewerConfigSpec] = NAPARI_STREAMING_CONFIG_SPEC - port: int = 5555 + port: TcpPort = 5555 """Napari viewer transport port; choose a free local port when streaming is enabled.""" @@ -835,12 +984,9 @@ class FijiStreamingConfig( _streaming_config_key: ClassVar[str] = FIJI_STREAMING_CONFIG_SPEC.registry_key streaming_spec: ClassVar[StreamingViewerConfigSpec] = FIJI_STREAMING_CONFIG_SPEC - port: int = 5565 + port: TcpPort = 5565 """Fiji viewer transport port; choose a free local port when streaming is enabled.""" - fiji_executable_path: Optional[Path] = None - """Optional path to the Fiji executable; None uses normal executable discovery.""" - # Inject all accumulated fields at the end of module loading. # Use the ObjectState owner that registered the pending field declarations. from objectstate.lazy_factory import _inject_all_pending_fields diff --git a/openhcs/core/memory/__init__.py b/openhcs/core/memory/__init__.py index 8c6ee14f9..1d18b2e01 100644 --- a/openhcs/core/memory/__init__.py +++ b/openhcs/core/memory/__init__.py @@ -1,7 +1,7 @@ """Memory module for OpenHCS. This module re-exports from arraybridge for memory type conversion utilities. -MemoryType and DtypeConversion are kept in openhcs.constants for backward compatibility. +MemoryType remains an OpenHCS execution-domain declaration. """ from collections.abc import Sequence @@ -14,7 +14,6 @@ # Converters convert_memory, detect_memory_type, - DtypeConversion, # Stack utilities stack_slices, unstack_slices, @@ -152,7 +151,6 @@ def unstack_runtime_slices( 'jax', 'pyclesperanto', 'decorators', - 'DtypeConversion', # Stack utilities 'stack_slices', 'unstack_slices', diff --git a/openhcs/core/pipeline/path_planner.py b/openhcs/core/pipeline/path_planner.py index 090bd7c31..f9985041c 100644 --- a/openhcs/core/pipeline/path_planner.py +++ b/openhcs/core/pipeline/path_planner.py @@ -60,6 +60,7 @@ ) from openhcs.core.component_group_scope import ComponentGroupScope from openhcs.core.component_set import ComponentSet +from openhcs.core.config import PathPlanningConfig from openhcs.core.pipeline.artifact_planning import ( ArtifactGraph, ArtifactOutputMaterializationPlanner, @@ -2136,7 +2137,10 @@ def analysis_results_dir_for(image_dir: Path) -> Path: """Return the analysis-results sibling directory for an image directory.""" return Path(_cached_analysis_results_dir_for(str(image_dir))) - def build_output_path(self, path_config=None) -> Path: + def build_output_path( + self, + path_config: PathPlanningConfig | None = None, + ) -> Path: """Build complete output path: plate_root + sub_dir.""" config = path_config or self.planner.cfg if not config.output_dir_suffix: diff --git a/openhcs/core/steps/stream_component_semantics.py b/openhcs/core/steps/stream_component_semantics.py index c3924799e..8633720e4 100644 --- a/openhcs/core/steps/stream_component_semantics.py +++ b/openhcs/core/steps/stream_component_semantics.py @@ -11,6 +11,8 @@ from metaclass_registry import AutoRegisterMeta from polystore.exceptions import MetadataNotFoundError from polystore.streaming.viewer_transport import ( + DisplayComponentToken, + DisplayModeToken, IndexedViewerStreamSourceMetadata, PathMappedViewerStreamSourceMetadata, ViewerStreamBackendKwargs, @@ -18,6 +20,7 @@ ViewerStreamProducer, ViewerStreamSourceIdentity, ViewerStreamSourceMetadata, + ViewerDisplayConfigABC, ) from zmqruntime.viewer_protocol import ( ViewerComponentMetadataPayload, @@ -50,7 +53,6 @@ ViewerComponentValueDomainPayload, ViewerObjectDisplayConfigInput, ) -from openhcs.utils.display_config_factory import ViewerDisplayConfigObject StreamComponentMetadata = SourceComponentMetadata | None ComponentDisplayName: TypeAlias = str | int | float | bool | None @@ -170,25 +172,33 @@ class StreamComponentNameMetadata(dict[str, dict[str, ComponentDisplayName]]): """Component value display names keyed by component then raw value.""" @dataclass(frozen=True, slots=True) -class StreamScopedDisplayConfig(ViewerDisplayConfigObject): +class StreamScopedDisplayConfig(ViewerDisplayConfigABC): """Display config restricted to components addressable by one stream request.""" - base: ViewerDisplayConfigObject + base: ViewerDisplayConfigABC component_order: tuple[str, ...] - def __getattr__(self, name: str) -> object: - return getattr(object.__getattribute__(self, "base"), name) - @property def COMPONENT_ORDER(self) -> tuple[str, ...]: return self.component_order - def component_modes(self) -> dict[str, str]: - base_modes = self.base.component_modes() - return { - component: str(base_modes[component]) - for component in self.component_order - } + def component_modes( + self, + ) -> Mapping[DisplayComponentToken, DisplayModeToken]: + """Retain the complete typed display config across a scoped route. + + ``COMPONENT_ORDER`` controls which axes one stream request addresses. + Component modes remain properties of the display config itself and + must not be discarded merely because an axis is absent from this + particular request. + """ + + return self.base.component_modes() + + def display_payload_extra(self) -> dict[str, ViewerWireValue]: + """Retain backend-specific display fields while scoping component axes.""" + + return dict(self.base.display_payload_extra()) @dataclass(frozen=True) class StreamComponentMessageExtraPayload(ViewerComponentMetadataPayload): diff --git a/openhcs/interop/cellprofiler/analyst_export.py b/openhcs/interop/cellprofiler/analyst_export.py index 9f0eb8410..2a43e3799 100644 --- a/openhcs/interop/cellprofiler/analyst_export.py +++ b/openhcs/interop/cellprofiler/analyst_export.py @@ -270,7 +270,6 @@ def _thumbnail_png_base64(pixels: np.ndarray, *, auto_scale: bool) -> str: class CellProfilerDatabaseExportSettings: """Subset of CellProfiler ExportToDatabase settings needed for CPA projection.""" - database_type: Literal["sqlite"] sqlite_file: str experiment_name: str table_prefix: str @@ -280,7 +279,7 @@ class CellProfilerDatabaseExportSettings: wants_relationship_tables: bool maximum_column_name_length: int = 64 location_object: str | None = None - plate_type: str = "None" + plate_type: str | None = None plate_metadata: str = "Plate" well_metadata: str = "Well" image_url_prepend: str = "" @@ -295,10 +294,6 @@ class CellProfilerDatabaseExportSettings: auto_scale_thumbnail_intensities: bool = True def __post_init__(self) -> None: - if self.database_type != "sqlite": - raise ValueError( - "CellProfilerDatabaseExportSettings.database_type must be 'sqlite'." - ) if not self.sqlite_file: raise ValueError( "CellProfilerDatabaseExportSettings.sqlite_file is required." @@ -344,13 +339,13 @@ def __post_init__(self) -> None: "location_object", normalized_location or None, ) - plate_type = str(self.plate_type).strip() + plate_type = ( + None + if self.plate_type is None + else str(self.plate_type).strip() or None + ) plate_metadata = str(self.plate_metadata).strip() well_metadata = str(self.well_metadata).strip() - if not plate_type: - raise ValueError( - "CellProfilerDatabaseExportSettings.plate_type is required." - ) if not plate_metadata: raise ValueError( "CellProfilerDatabaseExportSettings.plate_metadata is required." @@ -1906,7 +1901,7 @@ def _properties( for property_name, fields in property_fields.items() } properties = { - CPAPropertyName.DATABASE_TYPE.value: settings.database_type, + CPAPropertyName.DATABASE_TYPE.value: "sqlite", CPAPropertyName.SQLITE_FILE.value: settings.sqlite_file, CPAPropertyName.IMAGE_TABLE.value: projection.image_table.table_name, CPAPropertyName.OBJECT_TABLE.value: object_table_name, @@ -1953,7 +1948,7 @@ def _properties( CPAPropertyName.IMAGE_CHANNEL_BLEND_MODES.value: "", CPAPropertyName.IMAGE_URL_PREPEND.value: settings.image_url_prepend, CPAPropertyName.OBJECT_NAME.value: "cell, cells,", - CPAPropertyName.PLATE_TYPE.value: settings.plate_type, + CPAPropertyName.PLATE_TYPE.value: settings.plate_type or "None", CPAPropertyName.CLASSIFIER_IGNORE_COLUMNS.value: ( "table_number_key_column, image_number_key_column, " "object_number_key_column" diff --git a/openhcs/interop/cellprofiler/settings_binder.py b/openhcs/interop/cellprofiler/settings_binder.py index 48862b29f..326532c8d 100644 --- a/openhcs/interop/cellprofiler/settings_binder.py +++ b/openhcs/interop/cellprofiler/settings_binder.py @@ -395,11 +395,27 @@ def feature_names(self, module: ModuleBlock) -> tuple[str, ...]: """Return the ordered measurement features selected by this binding.""" return tuple( - value + normalized for value in setting_values(module, self.setting_name) - if normalized_symbol_name(value) is not None + if (normalized := normalized_symbol_name(value)) is not None ) + def bind( + self, + module: ModuleBlock, + kwargs: dict[str, CellProfilerSettingValue], + binder: "SettingsBinder", + ) -> None: + """Bind only a genuine measurement feature, not CP blank sentinels.""" + + del binder + value = optional_setting_value(module, self.setting_name) + if value is None: + return + normalized = normalized_symbol_name(value) + if normalized is not None: + kwargs[self.require_parameter_name()] = normalized + class SettingsBinder: """Bind parsed .cppipe setting strings to typed Python kwargs.""" diff --git a/openhcs/napari_roi_manager/_dataclasses.py b/openhcs/napari_roi_manager/_dataclasses.py index e9bb962da..400360dfd 100644 --- a/openhcs/napari_roi_manager/_dataclasses.py +++ b/openhcs/napari_roi_manager/_dataclasses.py @@ -4,20 +4,32 @@ from typing import Any import numpy as np +from napari.layers.shapes._shapes_constants import ShapeType from numpy.typing import NDArray @dataclass(frozen=True) class RoiData: data: list[NDArray[np.number]] = field(default_factory=list) - shape_type: list[str] = field(default_factory=list) + shape_type: list[ShapeType] = field(default_factory=list) names: list[str] | None = field(default_factory=lambda: None) features: dict[str, list[Any]] | None = field(default_factory=lambda: None) def __post_init__(self) -> None: + row_count = len(self.data) + if len(self.shape_type) != row_count: + raise ValueError( + f"ROI shape types must contain {row_count} values" + ) + if any( + not isinstance(shape_type, ShapeType) + for shape_type in self.shape_type + ): + raise TypeError("ROI shape types must be Napari ShapeType members") + if self.names is not None and len(self.names) != row_count: + raise ValueError(f"ROI names must contain {row_count} values") if self.features is None: return - row_count = len(self.data) for column, values in self.features.items(): if not isinstance(column, str): raise TypeError("ROI feature column names must be strings") @@ -29,7 +41,10 @@ def __post_init__(self) -> None: def to_json_dict(self) -> dict[str, Any]: """Convert RoiData to a JSON serializable dictionary.""" data = [d.tolist() for d in self.data] - out = {"data": data, "shape_type": self.shape_type} + out = { + "data": data, + "shape_type": [shape_type.value for shape_type in self.shape_type], + } if self.names is not None: out["names"] = self.names if self.features is not None: @@ -43,7 +58,7 @@ def to_json_dict(self) -> dict[str, Any]: def from_json_dict(cls, js: dict[str, Any]) -> RoiData: """Create RoiData from a JSON serializable dictionary.""" data = [np.array(d) for d in js["data"]] - shape_type = js["shape_type"] + shape_type = [ShapeType(value) for value in js["shape_type"]] names = js.get("names") features = js.get("features") if features is not None and not isinstance(features, dict): @@ -68,12 +83,14 @@ def iter_shapes(self): @dataclass class RoiTuple: data: NDArray[np.number] - shape_type: str + shape_type: ShapeType name: str | None = None multidim: tuple[int, ...] = () def __post_init__(self): self.data = np.asarray(self.data) + if not isinstance(self.shape_type, ShapeType): + raise TypeError("ROI shape type must be a Napari ShapeType member") def _json_value(value: Any) -> Any: diff --git a/openhcs/napari_roi_manager/ij/_convert.py b/openhcs/napari_roi_manager/ij/_convert.py index e3084b90c..d9b33fc01 100644 --- a/openhcs/napari_roi_manager/ij/_convert.py +++ b/openhcs/napari_roi_manager/ij/_convert.py @@ -4,6 +4,7 @@ import warnings import numpy as np +from napari.layers.shapes._shapes_constants import ShapeType from numpy.typing import NDArray from roifile import ROI_SUBTYPE, ROI_TYPE, ImagejRoi @@ -37,7 +38,7 @@ def roi_to_shape(ijroi: ImagejRoi) -> RoiTuple | None: ) out = RoiTuple( data=data - 0.5, - shape_type="rectangle", + shape_type=ShapeType.RECTANGLE, name=name, multidim=multidim, ) @@ -46,7 +47,7 @@ def roi_to_shape(ijroi: ImagejRoi) -> RoiTuple | None: end = (ijroi.y2, ijroi.x2) out = RoiTuple( data=[start, end], - shape_type="line", + shape_type=ShapeType.LINE, name=name, multidim=multidim, ) @@ -62,7 +63,7 @@ def roi_to_shape(ijroi: ImagejRoi) -> RoiTuple | None: coords = _get_coords(ijroi) out = RoiTuple( data=coords, - shape_type="polygon", + shape_type=ShapeType.POLYGON, name=name, multidim=multidim, ) @@ -70,7 +71,7 @@ def roi_to_shape(ijroi: ImagejRoi) -> RoiTuple | None: coords = _get_coords(ijroi) out = RoiTuple( data=coords, - shape_type="path", + shape_type=ShapeType.PATH, name=name, multidim=multidim, ) @@ -92,7 +93,7 @@ def roi_to_shape(ijroi: ImagejRoi) -> RoiTuple | None: ) out = RoiTuple( data=data - 0.5, - shape_type="ellipse", + shape_type=ShapeType.ELLIPSE, name=name, multidim=multidim, ) @@ -102,7 +103,7 @@ def roi_to_shape(ijroi: ImagejRoi) -> RoiTuple | None: coords = _get_coords(ijroi) out = RoiTuple( data=coords, - shape_type="rectangle", + shape_type=ShapeType.RECTANGLE, name=name, multidim=multidim, ) @@ -131,7 +132,7 @@ def roi_to_shape(ijroi: ImagejRoi) -> RoiTuple | None: r11 = end + vec_lateral / 2 out = RoiTuple( data=np.array([[r00, r01, r11, r10]]), - shape_type="ellipse", + shape_type=ShapeType.ELLIPSE, name=name, multidim=multidim, ) @@ -150,7 +151,7 @@ def shape_to_roi(shape: RoiTuple) -> ImagejRoi: """Convert a shape to an ImageJ ROI.""" ys = shape.data[:, -2] xs = shape.data[:, -1] - if shape.shape_type == "rectangle": + if shape.shape_type is ShapeType.RECTANGLE: if ys[0] == ys[1] or xs[0] == xs[1]: # not rotated y0, y1 = ys.min() + 0.5, ys.max() + 0.5 @@ -186,7 +187,7 @@ def shape_to_roi(shape: RoiTuple) -> ImagejRoi: roi.rounded_rect_arc_size, ) = encode_rotated_roi_width(width, roi.byteorder) return roi - elif shape.shape_type == "line": + elif shape.shape_type is ShapeType.LINE: roi = ImagejRoi.frompoints( np.stack([xs, ys], axis=1), name=shape.name, @@ -198,7 +199,7 @@ def shape_to_roi(shape: RoiTuple) -> ImagejRoi: roi.y2 = ys[1] roi.roitype = ROI_TYPE.LINE return roi - elif shape.shape_type == "path": + elif shape.shape_type is ShapeType.PATH: roi = ImagejRoi.frompoints( np.stack([xs, ys], axis=1), name=shape.name, @@ -206,7 +207,7 @@ def shape_to_roi(shape: RoiTuple) -> ImagejRoi: ) roi.roitype = ROI_TYPE.POLYLINE return roi - elif shape.shape_type == "polygon": + elif shape.shape_type is ShapeType.POLYGON: roi = ImagejRoi.frompoints( np.stack([xs, ys], axis=1), name=shape.name, @@ -214,7 +215,7 @@ def shape_to_roi(shape: RoiTuple) -> ImagejRoi: ) roi.roitype = ROI_TYPE.POLYGON return roi - elif shape.shape_type == "ellipse": + elif shape.shape_type is ShapeType.ELLIPSE: if ys[0] == ys[1] or xs[0] == xs[1]: # not rotated y0, y1 = ys.min() + 0.5, ys.max() + 0.5 diff --git a/openhcs/napari_roi_manager/widgets/_dialogs.py b/openhcs/napari_roi_manager/widgets/_dialogs.py index 23c2a40ca..92b085e24 100644 --- a/openhcs/napari_roi_manager/widgets/_dialogs.py +++ b/openhcs/napari_roi_manager/widgets/_dialogs.py @@ -4,6 +4,7 @@ import numpy as np from napari.layers import Shapes +from napari.layers.shapes._shapes_constants import ShapeType from qtpy import QtCore, QtGui from qtpy import QtWidgets as QtW @@ -80,7 +81,10 @@ def __init__( selected = sorted(layer.selected_data) self._shape_index = ( selected[0] - if len(selected) == 1 and layer.shape_type[selected[0]] == "rectangle" + if ( + len(selected) == 1 + and ShapeType(layer.shape_type[selected[0]]) is ShapeType.RECTANGLE + ) else None ) layout = QtW.QVBoxLayout(self) @@ -145,7 +149,7 @@ def _on_value_changed(self): else: new_data = plane_data if self._shape_index is None: - self.layer.add(new_data, shape_type="rectangle") + self.layer.add(new_data, shape_type=ShapeType.RECTANGLE) self._shape_index = len(self.layer.data) - 1 else: data = list(self.layer.data) diff --git a/openhcs/napari_roi_manager/widgets/_roi_manager.py b/openhcs/napari_roi_manager/widgets/_roi_manager.py index 62c4a2cd1..3fc5a89fc 100644 --- a/openhcs/napari_roi_manager/widgets/_roi_manager.py +++ b/openhcs/napari_roi_manager/widgets/_roi_manager.py @@ -2,11 +2,14 @@ import json from collections.abc import Iterable +from enum import IntEnum from pathlib import Path import napari import numpy as np from napari.layers import Shapes +from napari.layers.base import ActionType +from napari.layers.shapes._shapes_constants import ShapeType from napari.utils._proxies import PublicOnlyProxy from qtpy import QtCore from qtpy import QtWidgets as QtW @@ -83,56 +86,164 @@ def set_layer_available(self, available: bool) -> None: widget.setEnabled(available) -class QRoiListWidget(QtW.QTableWidget): +class RoiTableColumn(IntEnum): + """Columns projected directly from one native Napari Shapes layer.""" + + def __new__(cls, value: int, header: str): + member = int.__new__(cls, value) + member._value_ = value + member.header = header + return member + + NAME = (0, "name") + SHAPE_TYPE = (1, "type") + + +class QRoiTableModel(QtCore.QAbstractTableModel): + """Virtual table projection over the authoritative native Shapes layer.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._layer: Shapes | None = None + + def bind_layer(self, layer: Shapes | None) -> None: + """Bind a native layer without copying its geometry or feature state.""" + + if layer is self._layer: + self.refresh() + return + self.beginResetModel() + self._layer = layer + self.endResetModel() + + def refresh(self) -> None: + """Invalidate the projection after the native owner emits a change.""" + + self.beginResetModel() + self.endResetModel() + + def rowCount(self, parent=QtCore.QModelIndex()) -> int: + if parent.isValid() or self._layer is None: + return 0 + return len(self._layer.data) + + def columnCount(self, parent=QtCore.QModelIndex()) -> int: + return 0 if parent.isValid() else len(RoiTableColumn) + + def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole): + if ( + not index.isValid() + or self._layer is None + or role + not in ( + QtCore.Qt.ItemDataRole.DisplayRole, + QtCore.Qt.ItemDataRole.EditRole, + ) + ): + return None + column = RoiTableColumn(index.column()) + if column is RoiTableColumn.NAME: + return self._name(index.row()) + return str(self._layer.shape_type[index.row()]) + + def headerData( + self, + section: int, + orientation, + role=QtCore.Qt.ItemDataRole.DisplayRole, + ): + if ( + orientation == QtCore.Qt.Orientation.Horizontal + and role == QtCore.Qt.ItemDataRole.DisplayRole + ): + return RoiTableColumn(section).header + return super().headerData(section, orientation, role) + + def flags(self, index): + flags = super().flags(index) + if index.isValid() and RoiTableColumn(index.column()) is RoiTableColumn.NAME: + flags |= QtCore.Qt.ItemFlag.ItemIsEditable + return flags + + def setData(self, index, value, role=QtCore.Qt.ItemDataRole.EditRole) -> bool: + if ( + role != QtCore.Qt.ItemDataRole.EditRole + or not index.isValid() + or self._layer is None + or RoiTableColumn(index.column()) is not RoiTableColumn.NAME + ): + return False + names = self.column_values(RoiTableColumn.NAME) + names[index.row()] = str(value) + features = self._layer.features.copy() + features[RoiTableColumn.NAME.header] = names + self._layer.features = features + return True + + def column_values(self, column: RoiTableColumn) -> list[str]: + """Project one complete column only for explicit bulk callers.""" + + if self._layer is None: + return [] + if column is RoiTableColumn.NAME: + features = self._layer.features + if RoiTableColumn.NAME.header in features.columns: + return [ + str(value) + for value in features[RoiTableColumn.NAME.header].tolist() + ] + return [ + f"ROI-{index:>04}" for index in range(self.rowCount()) + ] + return [str(value) for value in self._layer.shape_type] + + def _name(self, row: int) -> str: + if self._layer is None: + raise RuntimeError("ROI table model has no native Shapes layer.") + features = self._layer.features + if RoiTableColumn.NAME.header in features.columns: + return str(features[RoiTableColumn.NAME.header].iat[row]) + return f"ROI-{row:>04}" + + +class QRoiListWidget(QtW.QTableView): selected = QtCore.Signal(set) - renamed = QtCore.Signal(int, str) def __init__(self, parent=None): super().__init__(parent) - self.setColumnCount(2) - self.setHorizontalHeaderLabels(["name", "type"]) + self._roi_model = QRoiTableModel(self) + self.setModel(self._roi_model) horizontal_header = self.horizontalHeader() horizontal_header.setFixedHeight(18) - horizontal_header.setSectionResizeMode(0, QtW.QHeaderView.ResizeMode.Stretch) horizontal_header.setSectionResizeMode( - 1, QtW.QHeaderView.ResizeMode.ResizeToContents + RoiTableColumn.NAME, + QtW.QHeaderView.ResizeMode.Stretch, + ) + horizontal_header.setSectionResizeMode( + RoiTableColumn.SHAPE_TYPE, + QtW.QHeaderView.ResizeMode.Interactive, ) + horizontal_header.resizeSection(RoiTableColumn.SHAPE_TYPE, 96) self.verticalHeader().setSectionResizeMode(QtW.QHeaderView.ResizeMode.Fixed) + self.verticalHeader().setDefaultSectionSize(18) self.setSelectionBehavior(QtW.QAbstractItemView.SelectionBehavior.SelectRows) self.setSelectionMode(QtW.QAbstractItemView.SelectionMode.ExtendedSelection) - self._blocking_cell_changed = False - self.itemSelectionChanged.connect(self._selection_changed) - self.cellChanged.connect(self._cell_changed) + self.selectionModel().selectionChanged.connect(self._selection_changed) - def addRow(self, text: str, shape_type: str): - row = self.rowCount() - self._blocking_cell_changed = True - try: - self.insertRow(row) - self.setItem(row, 0, QtW.QTableWidgetItem(text)) - item = QtW.QTableWidgetItem(shape_type) - item.setFlags(item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) - self.setItem(row, 1, item) - finally: - self._blocking_cell_changed = False - self.setRowHeight(row, 18) + def bind_layer(self, layer: Shapes | None) -> None: + self._roi_model.bind_layer(layer) - def set_rows(self, names: Iterable[str], shape_types: Iterable[str]) -> None: - blocker = QtCore.QSignalBlocker(self) - self._blocking_cell_changed = True - try: - self.setRowCount(0) - for name, shape_type in zip(names, shape_types, strict=True): - self.addRow(str(name), str(shape_type)) - finally: - self._blocking_cell_changed = False - del blocker + def refresh(self) -> None: + self._roi_model.refresh() + + def rowCount(self) -> int: + return self._roi_model.rowCount() def select_rows(self, rows: Iterable[int]) -> None: - blocker = QtCore.QSignalBlocker(self) + selection_model = self.selectionModel() + blocker = QtCore.QSignalBlocker(selection_model) try: - self.clearSelection() - selection_model = self.selectionModel() + selection_model.clearSelection() flags = ( QtCore.QItemSelectionModel.SelectionFlag.Select | QtCore.QItemSelectionModel.SelectionFlag.Rows @@ -140,33 +251,36 @@ def select_rows(self, rows: Iterable[int]) -> None: valid_rows = [ row for row in sorted(set(rows)) if 0 <= row < self.rowCount() ] - for row in valid_rows: - selection_model.select(self.model().index(row, 0), flags) + selection = QtCore.QItemSelection() + if valid_rows: + span_start = valid_rows[0] + span_stop = span_start + for row in (*valid_rows[1:], None): + if row is not None and row == span_stop + 1: + span_stop = row + continue + selection.select( + self.model().index(span_start, RoiTableColumn.NAME), + self.model().index( + span_stop, + RoiTableColumn.SHAPE_TYPE, + ), + ) + if row is not None: + span_start = span_stop = row + selection_model.select(selection, flags) if valid_rows: self.scrollTo( - self.model().index(valid_rows[0], 0), + self.model().index(valid_rows[0], RoiTableColumn.NAME), QtW.QAbstractItemView.ScrollHint.EnsureVisible, ) finally: del blocker - def get_column(self, col: str) -> list[str]: - col_idx = self._get_column_index(col) - return [self.item(row, col_idx).text() for row in range(self.rowCount())] - - def _get_column_index(self, col: str) -> int: - for index in range(self.columnCount()): - if self.horizontalHeaderItem(index).text() == col: - return index - raise ValueError(f"Column {col!r} not found.") - - def _selection_changed(self): - self.selected.emit({index.row() for index in self.selectedIndexes()}) - - def _cell_changed(self, row: int, col: int): - if self._blocking_cell_changed or col != 0: - return - self.renamed.emit(row, self.item(row, col).text()) + def _selection_changed(self, _selected, _deselected): + self.selected.emit( + {index.row() for index in self.selectionModel().selectedRows()} + ) class QRoiManager(QtW.QWidget): @@ -216,7 +330,6 @@ def __init__(self, napari_viewer: napari.Viewer): self._btns._text_font_size.valueChanged.connect(self.set_text_font_size) self._btns._show_all_checkbox.toggled.connect(self.set_show_all) self._roilist.selected.connect(self._roi_selected) - self._roilist.renamed.connect(self._roi_renamed) self._connect_viewer_events() self._bind_active_layer() @@ -238,7 +351,6 @@ def connect_layer(self, layer: Shapes) -> None: if not isinstance(layer, Shapes): raise TypeError("ROI Manager can only bind a native napari Shapes layer") if layer is self._layer: - self._refresh_from_layer() return self._disconnect_layer() self._layer = layer @@ -262,7 +374,7 @@ def _disconnect_layer(self) -> None: emitter.disconnect(callback) self._layer_connections.clear() self._layer = None - self._roilist.set_rows((), ()) + self._roilist.bind_layer(None) self._layer_name.setText("No Shapes layer selected") self._refresh_text_features() self._update_controls() @@ -294,18 +406,25 @@ def _bind_active_layer(self) -> None: self._disconnect_layer() def _on_layer_data(self, event) -> None: - action = getattr(event, "action", None) - action_value = str(getattr(action, "value", action)) - if action_value in {"adding", "removing", "changing"}: + action = event.action + if action in ( + ActionType.ADDING, + ActionType.REMOVING, + ActionType.CHANGING, + ): self._data_mutating = True if self._visible_style is not None: - if action_value == "added": + if action is ActionType.ADDED: self._extend_hidden_style() - elif action_value == "removed": - self._remove_from_hidden_style(getattr(event, "data_indices", ())) - if action_value in {"added", "removed", "changed"}: + elif action is ActionType.REMOVED: + self._remove_from_hidden_style(event.data_indices) + if action in ( + ActionType.ADDED, + ActionType.REMOVED, + ActionType.CHANGED, + ): self._data_mutating = False - if action is None or action_value not in {"adding", "removing"}: + if action not in (ActionType.ADDING, ActionType.REMOVING): self._refresh_from_layer() def _on_layer_features(self, _event) -> None: @@ -361,7 +480,7 @@ def _refresh_from_layer(self) -> None: self._disconnect_layer() return self._layer_name.setText(layer.name) - self._roilist.set_rows(self._roi_names(), layer.shape_type) + self._roilist.bind_layer(layer) self._roilist.select_rows(layer.selected_data) self._refresh_text_features() self._update_controls() @@ -407,7 +526,11 @@ def _require_layer(self, *, ndim: int | None = None) -> Shapes: return self.new_layer(ndim=ndim) return self._layer - def add(self, data, shape_type: str = "rectangle"): + def add( + self, + data, + shape_type: ShapeType = ShapeType.RECTANGLE, + ): array = np.asarray(data) layer = self._require_layer(ndim=array.shape[-1]) if array.shape[-1] != layer.ndim: @@ -420,7 +543,11 @@ def add(self, data, shape_type: str = "rectangle"): layer.selected_data = set(range(before, len(layer.data))) return None - def register(self, data=None, shape_type: str = "rectangle"): + def register( + self, + data=None, + shape_type: ShapeType = ShapeType.RECTANGLE, + ): """Register native geometry; supplied data is added exactly once.""" if data is not None: self.add(data, shape_type=shape_type) @@ -487,16 +614,6 @@ def _roi_selected(self, indices: set[int]) -> None: index for index in indices if index < len(self._layer.data) } - def _roi_renamed(self, index: int, name: str) -> None: - layer = self._layer - if layer is None or not 0 <= index < len(layer.data): - return - names = self._roi_names() - names[index] = name - features = layer.features.copy() - features["name"] = names - layer.features = features - def set_text_feature_name(self, _index: int): layer = self._layer feature_name = self._btns._text_feature_name.currentText() @@ -598,7 +715,7 @@ def _roi_data(self) -> RoiData: } return RoiData( data=[np.asarray(data) for data in layer.data], - shape_type=list(layer.shape_type), + shape_type=[ShapeType(value) for value in layer.shape_type], names=names, features=features, ) @@ -707,7 +824,7 @@ def read_ij_roi(self, path, append: bool = True): point = np.asarray(layer.world_to_data(self._viewer.dims.point), dtype=float) current_leading = point[:-2] data: list[np.ndarray] = [] - shape_types: list[str] = [] + shape_types: list[ShapeType] = [] names: list[str] = [] for index, shape in enumerate(converted): plane = np.asarray(shape.data) @@ -748,7 +865,7 @@ def write_ij_roi(self, path): shape_to_roi( RoiTuple( data=array[:, -2:], - shape_type=shape_type, + shape_type=ShapeType(shape_type), name=names[index], multidim=multidim, ) diff --git a/openhcs/processing/backends/analysis/cell_counting_cpu.py b/openhcs/processing/backends/analysis/cell_counting_cpu.py index 3ebba5007..434e36ca6 100644 --- a/openhcs/processing/backends/analysis/cell_counting_cpu.py +++ b/openhcs/processing/backends/analysis/cell_counting_cpu.py @@ -129,8 +129,6 @@ def count_cells_single_channel( min_cell_area: int = 10, # Minimum cell area (pixels) max_cell_area: int = 1000, # Maximum cell area (pixels) remove_border_cells: bool = True, # Remove cells touching image border - # Output parameters - return_segmentation_mask: bool = False ) -> tuple[np.ndarray, DataclassMeasurementColumnarRows, np.ndarray]: """ Count cells in single-channel image stack using various detection methods. @@ -153,12 +151,10 @@ def count_cells_single_channel( min_cell_area: Minimum area for valid cells max_cell_area: Maximum area for valid cells remove_border_cells: Remove cells touching image borders - return_segmentation_mask: Return segmentation masks in output - Returns: output_stack: Original image stack unchanged (Z, Y, X) cell_count_results: List of CellCountResult objects for each slice - segmentation_masks: (Special output) List of segmentation mask arrays if return_segmentation_mask=True + segmentation_masks: Aligned segmentation labels for each slice """ if image_stack.ndim != 3: raise ValueError(f"Expected 3D image stack, got {image_stack.ndim}D") diff --git a/openhcs/processing/backends/analysis/consolidate_analysis_results.py b/openhcs/processing/backends/analysis/consolidate_analysis_results.py index 8da0673a9..f666a0e0e 100644 --- a/openhcs/processing/backends/analysis/consolidate_analysis_results.py +++ b/openhcs/processing/backends/analysis/consolidate_analysis_results.py @@ -593,7 +593,7 @@ def consolidated_plate_metadata( f"{len(summary_df)} wells analyzed" ), "acquisition_user": plate_metadata_config.acquisition_user, - "z_step": plate_metadata_config.z_step, + "z_step": str(plate_metadata_config.z_step), } @@ -662,7 +662,6 @@ def consolidate_analysis_results( "analysis_consolidation_config type: %s", type(analysis_consolidation_config), ) - logger.debug("well_pattern: %r", analysis_consolidation_config.well_pattern) logger.debug("file_extensions: %r", analysis_consolidation_config.file_extensions) logger.debug("exclude_patterns: %r", analysis_consolidation_config.exclude_patterns) diff --git a/openhcs/processing/backends/analysis/consolidate_special_outputs.py b/openhcs/processing/backends/analysis/consolidate_special_outputs.py index b23091f39..e2699f0c6 100644 --- a/openhcs/processing/backends/analysis/consolidate_special_outputs.py +++ b/openhcs/processing/backends/analysis/consolidate_special_outputs.py @@ -18,7 +18,7 @@ import re import logging from pathlib import Path -from typing import Dict, List, Tuple, Any, Optional +from typing import Dict, Tuple, Any, Optional from enum import Enum from openhcs.core.memory import numpy as numpy_func @@ -38,17 +38,16 @@ class AggregationStrategy(Enum): MIXED = "mixed" -class WellPatternType(Enum): - """Common well ID patterns for different plate formats.""" - STANDARD_96 = r"([A-H]\d{2})" # A01, B02, etc. - STANDARD_384 = r"([A-P]\d{2})" # A01-P24 - CUSTOM = "custom" +STANDARD_96_WELL_PATTERN = r"([A-H]\d{2})" ## Greenfield: materialization is writer-driven (no custom materializers). -def extract_well_id(filename: str, pattern: str = WellPatternType.STANDARD_96.value) -> Optional[str]: +def extract_well_id( + filename: str, + pattern: str = STANDARD_96_WELL_PATTERN, +) -> Optional[str]: """ Extract well ID from filename using regex pattern. @@ -154,11 +153,10 @@ def aggregate_series(series: pd.Series, strategy: AggregationStrategy) -> Dict[s def consolidate_special_outputs( image_stack: np.ndarray, results_directory: PlateInputDirectory, - well_pattern: str = WellPatternType.STANDARD_96.value, - file_extensions: List[str] = [".csv"], - include_patterns: Optional[List[str]] = None, - exclude_patterns: Optional[List[str]] = None, - custom_aggregations: Optional[Dict[str, Dict[str, str]]] = None + well_pattern: str = STANDARD_96_WELL_PATTERN, + file_extensions: tuple[str, ...] = (".csv",), + include_patterns: tuple[str, ...] | None = None, + exclude_patterns: tuple[str, ...] | None = None, ) -> Tuple[np.ndarray, Dict[str, Any], Dict[str, Any]]: """ Consolidate special outputs from OpenHCS analysis into summary tables. @@ -173,7 +171,6 @@ def consolidate_special_outputs( file_extensions: List of file extensions to process include_patterns: Optional list of filename patterns to include exclude_patterns: Optional list of filename patterns to exclude - custom_aggregations: Optional custom aggregation rules per output type Returns: Tuple of (image_stack, consolidated_summary, detailed_report) diff --git a/openhcs/processing/backends/analysis/dxf_mask_pipeline.py b/openhcs/processing/backends/analysis/dxf_mask_pipeline.py index 00d720257..0c857c235 100644 --- a/openhcs/processing/backends/analysis/dxf_mask_pipeline.py +++ b/openhcs/processing/backends/analysis/dxf_mask_pipeline.py @@ -2,6 +2,7 @@ import logging from dataclasses import dataclass +from enum import Enum from typing import TYPE_CHECKING, List, Tuple, Union from openhcs.utils.import_utils import optional_import, create_placeholder_class @@ -45,6 +46,14 @@ logger = logging.getLogger(__name__) +class DxfMaskingMode(Enum): + """How a registered DXF mask is applied to image values.""" + + ZERO_OUT = "zero_out" + MULTIPLY = "multiply" + NAN_OUT = "nan_out" + + @dataclass(frozen=True, slots=True) class DXFMaskStackProjection: """Registration view of a 3D or 4D image stack.""" @@ -258,7 +267,7 @@ def dxf_mask_pipeline( image_stack, # Expected to be a torch.Tensor if torch_func is used dxf_polygons: List[List[Tuple[float, float]]], apply_mask: bool = False, - masking_mode: str = "zero_out", + masking_mode: DxfMaskingMode = DxfMaskingMode.ZERO_OUT, smoothing_sigma_z: float = 0.0, **kwargs ) -> Union["torch.Tensor", "cp.ndarray", "jnp.ndarray", "tf.Tensor"]: # type: ignore @@ -321,14 +330,12 @@ def dxf_mask_pipeline( if image_stack.ndim == 4: # Z,C,H,W mask_to_apply = mask_to_apply.unsqueeze(1) # -> (Z,1,H,W) - if masking_mode == "zero_out" or masking_mode == "multiply": + if masking_mode in {DxfMaskingMode.ZERO_OUT, DxfMaskingMode.MULTIPLY}: masked_img = image_stack.float() * mask_to_apply return masked_img.to(original_dtype) - elif masking_mode == "nan_out": + else: masked_img_float = image_stack.float() nans = torch.full_like(masked_img_float, float('nan')) return torch.where(mask_to_apply.bool(), masked_img_float, nans) # Nan where mask is False - else: - raise ValueError(f"Unknown masking_mode: {masking_mode}") else: return aligned_mask_stack_bool diff --git a/openhcs/processing/backends/analysis/multi_template_matching.py b/openhcs/processing/backends/analysis/multi_template_matching.py index 89d64fd7b..e0ffac059 100644 --- a/openhcs/processing/backends/analysis/multi_template_matching.py +++ b/openhcs/processing/backends/analysis/multi_template_matching.py @@ -9,6 +9,7 @@ import numpy as np import cv2 +from enum import Enum, IntEnum from typing import Tuple, List, Dict, Any, Optional, Union from dataclasses import dataclass import logging @@ -25,6 +26,27 @@ from openhcs.processing.materialization import CsvOptions, MaterializationSpec +class TemplatePaddingMode(Enum): + """NumPy padding modes supported when stacking template crops.""" + + CONSTANT = "constant" + EDGE = "edge" + REFLECT = "reflect" + SYMMETRIC = "symmetric" + WRAP = "wrap" + + +class OpenCVTemplateMatchMethod(IntEnum): + """OpenCV template matching methods accepted by Multi-Template-Matching.""" + + SQDIFF = cv2.TM_SQDIFF + SQDIFF_NORMED = cv2.TM_SQDIFF_NORMED + CCORR = cv2.TM_CCORR + CCORR_NORMED = cv2.TM_CCORR_NORMED + CCOEFF = cv2.TM_CCOEFF + CCOEFF_NORMED = cv2.TM_CCOEFF_NORMED + + def _mtm_row_unpacker(result: TemplateMatchResult) -> List[Dict[str, Any]]: """Expand one TemplateMatchResult into multiple CSV rows.""" rows: List[Dict[str, Any]] = [] @@ -91,10 +113,10 @@ def multi_template_crop_reference_channel( score_threshold: float = 0.8, max_matches: int = 1, crop_margin: int = 0, - method: int = cv2.TM_CCOEFF_NORMED, + method: OpenCVTemplateMatchMethod = OpenCVTemplateMatchMethod.CCOEFF_NORMED, use_best_match_only: bool = True, normalize_input: bool = True, - pad_mode: str = "constant", + pad_mode: TemplatePaddingMode = TemplatePaddingMode.CONSTANT, rotation_range: float = 0.0, rotation_step: float = 45.0, rotate_result: bool = True, @@ -167,7 +189,8 @@ def multi_template_crop_reference_channel( max_matches, crop_margin, use_best_match_only, - normalize_input + normalize_input, + method=method, ) logging.info(f"Reference channel {reference_channel} matching: {reference_result.num_matches} matches, " @@ -238,10 +261,10 @@ def multi_template_crop_subset( score_threshold: float = 0.8, max_matches: int = 1, crop_margin: int = 0, - method: int = cv2.TM_CCOEFF, + method: OpenCVTemplateMatchMethod = OpenCVTemplateMatchMethod.CCOEFF, use_best_match_only: bool = True, normalize_input: bool = True, - pad_mode: str = "constant", + pad_mode: TemplatePaddingMode = TemplatePaddingMode.CONSTANT, rotation_range: float = 0.0, rotation_step: float = 45.0, rotate_result: bool = True, @@ -294,10 +317,20 @@ def multi_template_crop_subset( # Use the reference-channel function to get crop coordinates _, full_results = multi_template_crop_reference_channel( - image_stack, template_path, reference_channel, - score_threshold, max_matches, crop_margin, method, - use_best_match_only, normalize_input, pad_mode, - rotation_range, rotation_step, rotate_result, crop_enabled + image_stack, + template_path, + reference_channel, + score_threshold, + max_matches, + crop_margin, + method=method, + use_best_match_only=use_best_match_only, + normalize_input=normalize_input, + pad_mode=pad_mode, + rotation_range=rotation_range, + rotation_step=rotation_step, + rotate_result=rotate_result, + crop_enabled=crop_enabled, ) # Extract only the target channels @@ -364,10 +397,10 @@ def multi_template_crop( score_threshold: float = 0.8, max_matches: int = 1, crop_margin: int = 0, - method: int = cv2.TM_CCOEFF_NORMED, + method: OpenCVTemplateMatchMethod = OpenCVTemplateMatchMethod.CCOEFF_NORMED, use_best_match_only: bool = True, normalize_input: bool = True, - pad_mode: str = "constant", + pad_mode: TemplatePaddingMode = TemplatePaddingMode.CONSTANT, rotation_range: float = 0.0, rotation_step: float = 45.0, rotate_result: bool = True, @@ -390,8 +423,8 @@ def multi_template_crop( Maximum number of matches to find per slice crop_margin : int, default=0 Additional pixels to include around the matched template region - method : int, default=cv2.TM_CCOEFF_NORMED - OpenCV template matching method (currently not used by MTM) + method : OpenCVTemplateMatchMethod + OpenCV template matching method used by MTM. use_best_match_only : bool, default=True If True, only crop around the best match per slice normalize_input : bool, default=True @@ -473,7 +506,8 @@ def multi_template_crop( max_matches, crop_margin, use_best_match_only, - normalize_input + normalize_input, + method=method, ) match_results.append(result) @@ -573,7 +607,7 @@ def _process_single_slice( crop_margin: int, use_best_match_only: bool, normalize_input: bool, - method: int = cv2.TM_CCOEFF_NORMED + method: OpenCVTemplateMatchMethod = OpenCVTemplateMatchMethod.CCOEFF_NORMED, ) -> TemplateMatchResult: """Process a single slice for template matching.""" @@ -605,7 +639,7 @@ def _process_single_slice( score_threshold=score_threshold, maxOverlap=0.25, # Prevent overlapping matches N_object=max_matches, - method=method + method=int(method), ) # Process results @@ -653,7 +687,10 @@ def _process_single_slice( # REMOVED: Exception handling - let errors fail loud instead of silent warnings -def _stack_with_padding(cropped_slices: List[np.ndarray], pad_mode: str) -> np.ndarray: +def _stack_with_padding( + cropped_slices: List[np.ndarray], + pad_mode: TemplatePaddingMode, +) -> np.ndarray: """Stack cropped slices with padding to ensure consistent dimensions.""" if not cropped_slices: @@ -672,12 +709,16 @@ def _stack_with_padding(cropped_slices: List[np.ndarray], pad_mode: str) -> np.n if pad_h > 0 or pad_w > 0: # Pad with specified mode - padded = np.pad( - slice_arr, - ((0, pad_h), (0, pad_w)), - mode=pad_mode, - constant_values=0 if pad_mode == 'constant' else None - ) + pad_width = ((0, pad_h), (0, pad_w)) + if pad_mode is TemplatePaddingMode.CONSTANT: + padded = np.pad( + slice_arr, + pad_width, + mode=pad_mode.value, + constant_values=0, + ) + else: + padded = np.pad(slice_arr, pad_width, mode=pad_mode.value) else: padded = slice_arr diff --git a/openhcs/processing/backends/analysis/region_properties.py b/openhcs/processing/backends/analysis/region_properties.py index afb6cace3..0d89e993f 100644 --- a/openhcs/processing/backends/analysis/region_properties.py +++ b/openhcs/processing/backends/analysis/region_properties.py @@ -109,10 +109,6 @@ class AnalysisBackendProvider(str, Enum): ) -def _normalize_memory_type(memory_type: MemoryType | str = MemoryType.NUMPY) -> MemoryType: - return memory_type if isinstance(memory_type, MemoryType) else MemoryType(str(memory_type)) - - def _normalize_backend_provider( backend_provider: BackendProviderInput = DEFAULT_ANALYSIS_BACKEND_PROVIDER, ) -> AnalysisBackendProvider: @@ -122,17 +118,22 @@ def _normalize_backend_provider( def analysis_backend_key( - memory_type: MemoryType | str = MemoryType.NUMPY, + memory_type: MemoryType = MemoryType.NUMPY, backend_provider: BackendProviderInput = DEFAULT_ANALYSIS_BACKEND_PROVIDER, ) -> str: """Return the registry key for one reusable analysis backend implementation.""" + if not isinstance(memory_type, MemoryType): + raise TypeError("Analysis backend memory type must be a MemoryType enum value") provider = _normalize_backend_provider(backend_provider) - return _normalize_memory_type(memory_type).value + _BACKEND_KEY_SEPARATOR + provider.value + return memory_type.value + _BACKEND_KEY_SEPARATOR + provider.value class AnalysisBackendStrategyMixin: """Backend lookup for reusable OpenHCS analysis primitives.""" + __registry__: ClassVar[ + dict[str, type["AnalysisBackendStrategyMixin"]] + ] backend_key: ClassVar[str | None] = None memory_type: ClassVar[MemoryType | None] = None backend_provider: ClassVar[AnalysisBackendProvider] = DEFAULT_ANALYSIS_BACKEND_PROVIDER @@ -141,7 +142,7 @@ class AnalysisBackendStrategyMixin: @classmethod def for_memory_type( cls: type[BackendStrategyT], - memory_type: MemoryType | str = MemoryType.NUMPY, + memory_type: MemoryType = MemoryType.NUMPY, *, backend_provider: BackendProviderInput | None = None, ) -> BackendStrategyT: @@ -150,12 +151,15 @@ def for_memory_type( @classmethod def available_backend_providers( cls, - memory_type: MemoryType | str | None = None, + memory_type: MemoryType | None = None, ) -> tuple[AnalysisBackendProvider, ...]: - resolved = None if memory_type is None else _normalize_memory_type(memory_type) + if memory_type is not None and not isinstance(memory_type, MemoryType): + raise TypeError( + "Analysis backend memory type must be a MemoryType enum value" + ) providers: list[AnalysisBackendProvider] = [] - for strategy_cls in getattr(cls, "__registry__", {}).values(): - if resolved is not None and strategy_cls.memory_type is not resolved: + for strategy_cls in cls.__registry__.values(): + if memory_type is not None and strategy_cls.memory_type is not memory_type: continue providers.append(_normalize_backend_provider(strategy_cls.backend_provider)) return tuple(sorted(set(providers), key=lambda provider: provider.value)) @@ -163,27 +167,30 @@ def available_backend_providers( @classmethod def _resolve_backend_class( cls: type[BackendStrategyT], - memory_type: MemoryType | str, + memory_type: MemoryType, backend_provider: BackendProviderInput | None, ) -> type[BackendStrategyT]: - resolved = _normalize_memory_type(memory_type) - registry: dict[str, type[BackendStrategyT]] = getattr(cls, "__registry__", {}) + if not isinstance(memory_type, MemoryType): + raise TypeError( + "Analysis backend memory type must be a MemoryType enum value" + ) + registry = cls.__registry__ if backend_provider is not None: provider = _normalize_backend_provider(backend_provider) - key = analysis_backend_key(resolved, provider) + key = analysis_backend_key(memory_type, provider) if key not in registry: raise NotImplementedError( f"No {cls.__name__} backend is registered for memory type " - f"{resolved.value!r} and provider {provider.value!r}. Registered " + f"{memory_type.value!r} and provider {provider.value!r}. Registered " f"providers for this memory type: " - f"{cls.available_backend_providers(resolved)!r}." + f"{cls.available_backend_providers(memory_type)!r}." ) return registry[key] matches = [ strategy_cls for strategy_cls in registry.values() - if strategy_cls.memory_type is resolved + if strategy_cls.memory_type is memory_type and bool(strategy_cls.is_default_backend) ] if len(matches) == 1: @@ -191,12 +198,12 @@ def _resolve_backend_class( if not matches: raise NotImplementedError( f"No default {cls.__name__} backend is registered for memory type " - f"{resolved.value!r}. Registered providers for this memory type: " - f"{cls.available_backend_providers(resolved)!r}." + f"{memory_type.value!r}. Registered providers for this memory type: " + f"{cls.available_backend_providers(memory_type)!r}." ) raise RuntimeError( f"Multiple default {cls.__name__} backends are registered for memory " - f"type {resolved.value!r}: " + f"type {memory_type.value!r}: " f"{tuple(strategy.__name__ for strategy in matches)!r}." ) diff --git a/openhcs/processing/backends/assemblers/assemble_stack_cpu.py b/openhcs/processing/backends/assemblers/assemble_stack_cpu.py index 2885af91c..4d0ebdda0 100644 --- a/openhcs/processing/backends/assemblers/assemble_stack_cpu.py +++ b/openhcs/processing/backends/assemblers/assemble_stack_cpu.py @@ -9,6 +9,7 @@ from openhcs.core.memory import numpy as numpy_func from openhcs.core.pipeline.function_contracts import artifact_inputs from openhcs.processing.backends.lib_registry.unified_registry import ProcessingContract +from openhcs.processing.backends.assemblers.blending import TileBlendMethod # For type checking only if TYPE_CHECKING: @@ -162,7 +163,7 @@ def _create_dynamic_blend_mask( def assemble_stack_cpu( image_tiles: "np.ndarray", positions: Union[List[Tuple[float, float]], "np.ndarray"], - blend_method: str = "fixed", + blend_method: TileBlendMethod = TileBlendMethod.FIXED, fixed_margin_ratio: float = 0.1, overlap_blend_fraction: float = 1.0 ) -> "np.ndarray": @@ -229,7 +230,7 @@ def assemble_stack_cpu( weight_accum = np.zeros((canvas_height, canvas_width), dtype=np.float32) # --- 3. Create blend masks --- - if blend_method == "none": + if blend_method is TileBlendMethod.NONE: blend_masks = [np.ones(tile_shape, dtype=np.float32) for _ in range(num_tiles)] else: @@ -250,21 +251,18 @@ def assemble_stack_cpu( # Create masks using WORKING logic from old version blend_masks = [] for i in range(num_tiles): - if blend_method == "fixed": + if blend_method is TileBlendMethod.FIXED: mask = _create_fixed_blend_mask( tile_shape, tile_overlaps[i], margin_ratio=fixed_margin_ratio ) - elif blend_method == "dynamic": + else: mask = _create_dynamic_blend_mask( tile_shape, tile_overlaps[i], overlap_fraction=overlap_blend_fraction ) - else: - raise ValueError(f"Unknown blend_method: {blend_method}") - blend_masks.append(mask) # --- 4. Place tiles --- diff --git a/openhcs/processing/backends/assemblers/assemble_stack_cupy.py b/openhcs/processing/backends/assemblers/assemble_stack_cupy.py index 4bfa156bf..1b1eb6654 100644 --- a/openhcs/processing/backends/assemblers/assemble_stack_cupy.py +++ b/openhcs/processing/backends/assemblers/assemble_stack_cupy.py @@ -12,6 +12,7 @@ from openhcs.core.memory import cupy as cupy_func from openhcs.core.pipeline.function_contracts import artifact_inputs from openhcs.core.utils import optional_import +from openhcs.processing.backends.assemblers.blending import TileBlendMethod # For type checking only if TYPE_CHECKING: @@ -282,23 +283,12 @@ def _create_dynamic_blend_mask_gpu( return mask.astype(cp.float32) -# Removed old complex function - using simpler _create_simple_dynamic_mask_gpu instead - - -def _create_gaussian_blend_mask(tile_shape: tuple, blend_radius: float) -> "cp.ndarray": # type: ignore - """ - Legacy function for backward compatibility. - Use _create_blend_mask with blend_method="gaussian" instead. - """ - return _create_blend_mask(tile_shape, "gaussian", blend_radius) - - @artifact_inputs("positions") # The input name is "positions" @cupy_func def assemble_stack_cupy( image_tiles: "cp.ndarray", # type: ignore positions: Union[List[Tuple[float, float]], "cp.ndarray"], # type: ignore - blend_method: str = "fixed", + blend_method: TileBlendMethod = TileBlendMethod.FIXED, fixed_margin_ratio: float = 0.1, overlap_blend_fraction: float = 1.0 ) -> "cp.ndarray": # type: ignore @@ -405,7 +395,7 @@ def assemble_stack_cupy( weight_accum = cp.zeros((int(canvas_height), int(canvas_width)), dtype=cp.float32) # --- 3. Generate blend masks using WORKING logic from CPU version --- - if blend_method == "none": + if blend_method is TileBlendMethod.NONE: blend_masks = [cp.ones(first_tile_shape, dtype=cp.float32) for _ in range(num_tiles)] else: @@ -424,23 +414,20 @@ def assemble_stack_cupy( ) # VECTORIZED: Create all masks at once using batch operations - if blend_method == "fixed": + if blend_method is TileBlendMethod.FIXED: # Create all fixed masks in one batch operation masks_batch = _create_batch_fixed_masks_gpu( first_tile_shape, tile_overlaps, margin_ratio=fixed_margin_ratio ) - elif blend_method == "dynamic": + else: # Create all dynamic masks in one batch operation masks_batch = _create_batch_dynamic_masks_gpu( first_tile_shape, tile_overlaps, overlap_fraction=overlap_blend_fraction ) - else: - raise ValueError(f"Unknown blend_method: {blend_method}") - # Convert batch tensor to list for compatibility with existing code blend_masks = [masks_batch[i] for i in range(num_tiles)] diff --git a/openhcs/processing/backends/assemblers/blending.py b/openhcs/processing/backends/assemblers/blending.py new file mode 100644 index 000000000..e92488827 --- /dev/null +++ b/openhcs/processing/backends/assemblers/blending.py @@ -0,0 +1,11 @@ +"""Nominal image-tile blending contract shared by assembly backends.""" + +from enum import Enum + + +class TileBlendMethod(Enum): + """Weighting strategy used where assembled image tiles overlap.""" + + NONE = "none" + FIXED = "fixed" + DYNAMIC = "dynamic" diff --git a/openhcs/processing/backends/cellprofiler/_backend.py b/openhcs/processing/backends/cellprofiler/_backend.py index 1df24494c..d4d6751bd 100644 --- a/openhcs/processing/backends/cellprofiler/_backend.py +++ b/openhcs/processing/backends/cellprofiler/_backend.py @@ -206,13 +206,13 @@ class CellProfilerBackendAuthority: """Nominal authority for CellProfiler backend identity and selection.""" @classmethod - def memory_type(cls, memory_type: MemoryType | str = MemoryType.NUMPY) -> MemoryType: - """Resolve a memory type value using OpenHCS' canonical enum.""" - return ( - memory_type - if isinstance(memory_type, MemoryType) - else MemoryType(str(memory_type)) - ) + def memory_type(cls, memory_type: MemoryType = MemoryType.NUMPY) -> MemoryType: + """Validate one memory type against OpenHCS' canonical enum.""" + if not isinstance(memory_type, MemoryType): + raise TypeError( + "CellProfiler backend memory type must be a MemoryType enum value" + ) + return memory_type @classmethod def provider( @@ -254,7 +254,7 @@ def selection_identity( @classmethod def backend_key( cls, - memory_type: MemoryType | str = MemoryType.NUMPY, + memory_type: MemoryType = MemoryType.NUMPY, backend_provider: CellProfilerBackendProvider = ( DEFAULT_CELLPROFILER_BACKEND_PROVIDER ), @@ -307,7 +307,7 @@ def requires_explicit_prepare_backend(cls) -> bool: @classmethod def for_memory_type( cls: type[BackendStrategyT], - memory_type: MemoryType | str = MemoryType.NUMPY, + memory_type: MemoryType = MemoryType.NUMPY, *, backend_provider: BackendProviderInput = DEFAULT_CELLPROFILER_BACKEND_SELECTION, ) -> BackendStrategyT: @@ -332,12 +332,15 @@ def for_callable( or contract.output_memory_type or MemoryType.NUMPY.value ) - return cls.for_memory_type(memory_type, backend_provider=backend_provider) + return cls.for_memory_type( + MemoryType(memory_type), + backend_provider=backend_provider, + ) @classmethod def available_backend_providers( cls, - memory_type: MemoryType | str | None = None, + memory_type: MemoryType | None = None, ) -> tuple[CellProfilerBackendProvider, ...]: """Return registered providers, optionally filtered by memory type.""" resolved = ( @@ -357,7 +360,7 @@ def available_backend_providers( @classmethod def _resolve_backend_class( cls: type[BackendStrategyT], - memory_type: MemoryType | str, + memory_type: MemoryType, backend_provider: BackendProviderInput, ) -> type[BackendStrategyT]: snapshot = CellProfilerBackendRegistrySnapshot.for_family( diff --git a/openhcs/processing/backends/cellprofiler/alignment.py b/openhcs/processing/backends/cellprofiler/alignment.py index cfd5762fb..9ef9b11ba 100644 --- a/openhcs/processing/backends/cellprofiler/alignment.py +++ b/openhcs/processing/backends/cellprofiler/alignment.py @@ -208,6 +208,12 @@ class AlignModule( group_by = GroupBy.SITE confidence = 1.0 + class Method(Enum): + """Image-registration metric supported by CellProfiler Align.""" + + MUTUAL_INFORMATION = "Mutual Information" + NORMALIZED_CROSS_CORRELATION = "Normalized Cross Correlation" + @classmethod def measurement_output_relations( cls, @@ -335,16 +341,18 @@ def postprocess_bound_settings( module: "ModuleBlock", bound: BoundModuleSettings, ) -> BoundModuleSettings: + method_text = optional_setting_value(module, cls.method_setting) kwargs: dict[str, Any] = { - "method": optional_setting_value(module, cls.method_setting) - or "Mutual Information", - "crop_mode": cls.crop_mode(module).value, + "method": ( + cls.Method(method_text) + if method_text is not None + else cls.Method.MUTUAL_INFORMATION + ), + "crop_mode": cls.crop_mode(module), } additional_modes = cls.additional_alignment_modes(module) if additional_modes: - kwargs["additional_alignment_modes"] = tuple( - (mode.value for mode in additional_modes) - ) + kwargs["additional_alignment_modes"] = additional_modes return bound.with_kwargs(kwargs) @classmethod @@ -545,7 +553,7 @@ def mutual_information_offset( ) -AlignAdditionalModes = tuple[AlignModule.AdditionalMode | str, ...] +AlignAdditionalModes = tuple[AlignModule.AdditionalMode, ...] AlignImageGeometry = tuple[tuple[int, int], tuple[int, int]] AlignGeometryPair = tuple[AlignImageGeometry, AlignImageGeometry] @@ -566,7 +574,7 @@ class TranslationOffsetRequest: reference_image: np.ndarray moving_image: np.ndarray - method: str + method: AlignModule.Method first_mask: np.ndarray | None second_mask: np.ndarray | None alignment_backend_provider: BackendProviderInput @@ -580,7 +588,7 @@ def offset(self) -> tuple[int, int]: "Align offset computation requires explicitly projected 2-D " "registration images." ) - if self.method.strip().lower() == "normalized cross correlation": + if self.method is AlignModule.Method.NORMALIZED_CROSS_CORRELATION: column_offset, row_offset = cross_correlation_offset( reference_pixels, moving_pixels ) @@ -750,8 +758,8 @@ class AlignExecution: """Execute legacy CellProfiler Align semantics for stacked image payloads.""" image: object - method: str - crop_mode: AlignModule.CropMode | str + method: AlignModule.Method + crop_mode: AlignModule.CropMode additional_alignment_modes: AlignAdditionalModes alignment_backend_provider: BackendProviderInput @@ -828,7 +836,7 @@ def execute( second_mask=masks[1], alignment_backend_provider=self.alignment_backend_provider, ).offset() - normalized_crop_mode = AlignModule.CropMode.from_literal(self.crop_mode) + normalized_crop_mode = self.crop_mode offsets, shapes = AlignCropModeStrategy.for_crop_mode( normalized_crop_mode ).apply( @@ -1065,7 +1073,7 @@ def prepare_align() -> None: TranslationOffsetRequest( reference_image=reference, moving_image=moving, - method="Mutual Information", + method=AlignModule.Method.MUTUAL_INFORMATION, first_mask=None, second_mask=None, alignment_backend_provider=DEFAULT_CELLPROFILER_BACKEND_SELECTION, @@ -1077,8 +1085,8 @@ def prepare_align() -> None: def align( image: np.ndarray, *, - method: str = "Mutual Information", - crop_mode: AlignModule.CropMode | str = AlignModule.CropMode.KEEP_SIZE, + method: AlignModule.Method = AlignModule.Method.MUTUAL_INFORMATION, + crop_mode: AlignModule.CropMode = AlignModule.CropMode.KEEP_SIZE, additional_alignment_modes: AlignAdditionalModes = (), alignment_backend_provider: BackendProviderInput = DEFAULT_CELLPROFILER_BACKEND_SELECTION, ) -> tuple[AlignedImageStack, DataclassMeasurementColumnarRows]: diff --git a/openhcs/processing/backends/cellprofiler/area_occupied.py b/openhcs/processing/backends/cellprofiler/area_occupied.py index ebbc68f07..46441065a 100644 --- a/openhcs/processing/backends/cellprofiler/area_occupied.py +++ b/openhcs/processing/backends/cellprofiler/area_occupied.py @@ -725,7 +725,7 @@ def measure(self) -> AreaOccupiedMeasurement: def measure_image_area_occupied( image: np.ndarray, *, - operand_choices: Sequence[OperandChoice | str] = (OperandChoice.BINARY_IMAGE,), + operand_choices: Sequence[OperandChoice] = (OperandChoice.BINARY_IMAGE,), area_occupied_rows: Sequence[AreaOccupiedRow] = (), object_labels: Sequence[ObjectLabelValue] = (), slice_by_slice: bool = True, @@ -748,9 +748,7 @@ def measure_image_area_occupied( "MeasureImageAreaOccupied area_occupied_rows must contain only " "AreaOccupiedRow values." ) - configured_operands = tuple( - OperandChoice.from_literal(value) for value in operand_choices - ) + configured_operands = tuple(operand_choices) row_operands = tuple(row.operand for row in rows) if configured_operands != row_operands: raise ValueError( diff --git a/openhcs/processing/backends/cellprofiler/classification.py b/openhcs/processing/backends/cellprofiler/classification.py index 70f3a25b2..f3e3743f3 100644 --- a/openhcs/processing/backends/cellprofiler/classification.py +++ b/openhcs/processing/backends/cellprofiler/classification.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping -from dataclasses import dataclass, replace +from dataclasses import asdict, dataclass, replace from typing import TYPE_CHECKING, Annotated, Any, ClassVar import numpy as np @@ -21,6 +21,7 @@ MeasurementSparseColumnarRows, ) from openhcs.core.runtime_measurements import MeasurementRowAxisField +from openhcs.core.runtime_plane_projection import RuntimeSliceInvariantValue from openhcs.core.runtime_array_values import RuntimeArrayData from openhcs.core.runtime_image_values import ( image_payload_metadata, @@ -249,14 +250,16 @@ def classified_image_rule_indices( def _classification_rule_measurement_feature( rule: RuntimeCallableArgument, module_name: str ) -> str: - if not isinstance(rule, Mapping): - raise ValueError(f"{module_name} classification rule must be a mapping.") - value = rule.get("measurement_feature") - if not isinstance(value, str) or not value.strip(): + if not isinstance(rule, SingleMeasurementClassificationRule): raise ValueError( - f"{module_name} classification rule requires non-empty 'measurement_feature'." + f"{module_name} classification rule must be a " + "SingleMeasurementClassificationRule." ) - return value + if not rule.measurement_feature.strip(): + raise ValueError( + f"{module_name} classification rule requires a measurement feature." + ) + return rule.measurement_feature class ClassifyObjectsSingleMeasurementModule( @@ -631,12 +634,13 @@ def finalize_module_blocks_for_invocation( ) for block in blocks ) - if not isinstance(rules, (tuple, list)) or any( - not isinstance(rule, Mapping) for rule in rules + if not isinstance(rules, tuple) or any( + not isinstance(rule, SingleMeasurementClassificationRule) + for rule in rules ): raise TypeError( - "ClassifyObjects classification_rules must be an ordered tuple or " - "list of mappings." + "ClassifyObjects classification_rules must be a tuple of " + "SingleMeasurementClassificationRule values." ) reconstructed = [] for block in blocks: @@ -722,20 +726,12 @@ def _single_group_setting_name(cls, name: str) -> bool: @classmethod def _single_rule_setting_records( cls, - rule: Mapping[str, object], + rule: "SingleMeasurementClassificationRule", ) -> tuple[ModuleSetting, ...]: - feature_name = _classification_rule_measurement_feature( - rule, - str(cls.module_name), - ) - bin_choice = coerce_cellprofiler_enum( - ClassificationBinChoice, - rule.get("bin_choice", ClassificationBinChoice.EVEN), - ) - bin_names = rule.get("bin_names") - retained_image_name = normalized_symbol_name( - str(rule.get("retained_image_name") or "") - ) + feature_name = rule.measurement_feature + bin_choice = rule.bin_choice + bin_names = rule.bin_names + retained_image_name = rule.retained_image_name return ( ModuleSetting( cls.single_measurement_feature_setting.canonical, feature_name @@ -750,27 +746,30 @@ def _single_rule_setting_records( ), ModuleSetting( cls.bin_count_setting.canonical, - cellprofiler_setting_literal(rule.get("bin_count", 3)), + cellprofiler_setting_literal(rule.bin_count), ), ModuleSetting( cls.low_threshold_setting.canonical, - cellprofiler_setting_literal(rule.get("low_threshold", 0.0)), + cellprofiler_setting_literal(rule.low_threshold), ), ModuleSetting( cls.wants_low_bin_setting.canonical, - cellprofiler_setting_literal(rule.get("wants_low_bin", False)), + cellprofiler_setting_literal(rule.wants_low_bin), ), ModuleSetting( cls.high_threshold_setting.canonical, - cellprofiler_setting_literal(rule.get("high_threshold", 1.0)), + cellprofiler_setting_literal(rule.high_threshold), ), ModuleSetting( cls.wants_high_bin_setting.canonical, - cellprofiler_setting_literal(rule.get("wants_high_bin", False)), + cellprofiler_setting_literal(rule.wants_high_bin), ), ModuleSetting( cls.custom_thresholds_setting.canonical, - str(rule.get("custom_thresholds", "0,1")), + ",".join( + cellprofiler_setting_literal(value) + for value in rule.custom_thresholds + ), ), ModuleSetting( cls.give_bin_names_setting.canonical, @@ -778,7 +777,7 @@ def _single_rule_setting_records( ), ModuleSetting( cls.bin_names_setting.canonical, - "" if bin_names is None else str(bin_names), + "" if bin_names is None else ",".join(bin_names), ), ModuleSetting( cls.retain_image_setting.canonical, @@ -836,12 +835,12 @@ def _required_indexed_setting_value( return value @staticmethod - def _bin_choice(value: str) -> str: - return coerce_cellprofiler_enum(ClassificationBinChoice, value).value + def _bin_choice(value: str) -> "ClassificationBinChoice": + return coerce_cellprofiler_enum(ClassificationBinChoice, value) @staticmethod - def _threshold_method(value: str) -> str: - return coerce_cellprofiler_enum(ClassificationThresholdMethod, value).value + def _threshold_method(value: str) -> "ClassificationThresholdMethod": + return coerce_cellprofiler_enum(ClassificationThresholdMethod, value) @classmethod def _single_measurement_kwargs( @@ -854,17 +853,17 @@ def _single_measurement_kwargs( return { "classification_rules": tuple( ( - cls._single_measurement_rule_kwargs(module, binder, index) + cls._single_measurement_rule(module, binder, index) for index in range(len(measurement_features)) ) ) } - return cls._single_measurement_rule_kwargs(module, binder, 0) + return asdict(cls._single_measurement_rule(module, binder, 0)) @classmethod - def _single_measurement_rule_kwargs( + def _single_measurement_rule( cls, module: "ModuleBlock", binder: "SettingsBinder", value_index: int - ) -> "RuntimeCallableKwargs": + ) -> "SingleMeasurementClassificationRule": bin_names = cls.indexed_setting_value( module, cls.bin_names_setting, @@ -880,14 +879,29 @@ def _single_measurement_rule_kwargs( value_index=value_index, ) ) - return { - "measurement_feature": cls._required_indexed_setting_value( + custom_thresholds = tuple( + float(value.strip()) + for value in cls.indexed_setting_value( + module, + cls.custom_thresholds_setting, + default=cls.custom_thresholds_default, + value_index=value_index, + ).split(",") + if value.strip() + ) + parsed_bin_names = ( + tuple(name.strip() for name in bin_names.split(",") if name.strip()) + if give_bin_names and bin_names + else None + ) + return SingleMeasurementClassificationRule( + measurement_feature=cls._required_indexed_setting_value( module, cls.single_measurement_feature_setting, default=cls.measurement_feature_default, value_index=value_index, ), - "bin_choice": cls._bin_choice( + bin_choice=cls._bin_choice( cls.indexed_setting_value( module, cls.bin_spacing_setting, @@ -895,53 +909,48 @@ def _single_measurement_rule_kwargs( value_index=value_index, ) ), - "bin_count": cls._typed_setting_value( + bin_count=cls._typed_setting_value( module, binder, cls.bin_count_setting, default=cls.bin_count_default, value_index=value_index, ), - "low_threshold": cls._typed_setting_value( + low_threshold=cls._typed_setting_value( module, binder, cls.low_threshold_setting, default=cls.low_threshold_default, value_index=value_index, ), - "high_threshold": cls._typed_setting_value( + high_threshold=cls._typed_setting_value( module, binder, cls.high_threshold_setting, default=cls.high_threshold_default, value_index=value_index, ), - "wants_low_bin": cls._typed_setting_value( + wants_low_bin=cls._typed_setting_value( module, binder, cls.wants_low_bin_setting, default=cls.wants_low_bin_default, value_index=value_index, ), - "wants_high_bin": cls._typed_setting_value( + wants_high_bin=cls._typed_setting_value( module, binder, cls.wants_high_bin_setting, default=cls.wants_high_bin_default, value_index=value_index, ), - "custom_thresholds": cls.indexed_setting_value( - module, - cls.custom_thresholds_setting, - default=cls.custom_thresholds_default, - value_index=value_index, - ), - "bin_names": bin_names if give_bin_names and bin_names else None, - "retained_image_name": cls._single_measurement_retained_image_name( + custom_thresholds=custom_thresholds, + bin_names=parsed_bin_names, + retained_image_name=cls._single_measurement_retained_image_name( module, value_index, ), - } + ) @classmethod def _single_measurement_retained_image_name( @@ -1288,6 +1297,35 @@ def __new__( CUSTOM = ("custom", "Custom-defined bins") +@dataclass(frozen=True, slots=True) +class SingleMeasurementClassificationRule(RuntimeSliceInvariantValue): + """One typed ClassifyObjects single-measurement policy.""" + + measurement_feature: str + bin_choice: ClassificationBinChoice = ClassificationBinChoice.EVEN + bin_count: int = 3 + low_threshold: float = 0.0 + high_threshold: float = 1.0 + wants_low_bin: bool = False + wants_high_bin: bool = False + custom_thresholds: tuple[float, ...] = (0.0, 1.0) + bin_names: tuple[str, ...] | None = None + retained_image_name: str | None = None + + def __post_init__(self) -> None: + if not self.custom_thresholds: + raise ValueError( + "SingleMeasurementClassificationRule requires at least one " + "custom threshold." + ) + if self.bin_names is not None and any( + not name.strip() for name in self.bin_names + ): + raise ValueError( + "SingleMeasurementClassificationRule bin names must not be blank." + ) + + class ClassificationThresholdStrategy(ABC, metaclass=AutoRegisterMeta): """Threshold calculation strategy for ClassifyObjects measurement vectors.""" @@ -1422,15 +1460,8 @@ def aligned_to_labels(self, label_ids: np.ndarray) -> np.ndarray: class SingleMeasurementClassificationRequest: """Semantic request for single-measurement object classification.""" + rule: SingleMeasurementClassificationRule measurement_values: np.ndarray | None = None - bin_choice: ClassificationBinChoice | str = ClassificationBinChoice.EVEN - bin_count: int = 3 - low_threshold: float = 0.0 - high_threshold: float = 1.0 - wants_low_bin: bool = False - wants_high_bin: bool = False - custom_thresholds: str = "0,1" - bin_names: str | None = None def classify( self, @@ -1438,29 +1469,30 @@ def classify( labels: np.ndarray, backend: ObjectClassificationBackendStrategy, ) -> tuple[np.ndarray, ClassificationResult]: - bin_choice = coerce_cellprofiler_enum(ClassificationBinChoice, self.bin_choice) unique_labels = backend.positive_label_ids(labels) num_objects = len(unique_labels) - if bin_choice == ClassificationBinChoice.EVEN: - low_threshold = self.low_threshold - high_threshold = self.high_threshold + if self.rule.bin_choice is ClassificationBinChoice.EVEN: + low_threshold = self.rule.low_threshold + high_threshold = self.rule.high_threshold if low_threshold >= high_threshold: low_threshold, high_threshold = (high_threshold, low_threshold) - thresholds = np.linspace(low_threshold, high_threshold, self.bin_count + 1) - else: - thresholds = np.array( - [float(x.strip()) for x in self.custom_thresholds.split(",")] + thresholds = np.linspace( + low_threshold, + high_threshold, + self.rule.bin_count + 1, ) + else: + thresholds = np.asarray(self.rule.custom_thresholds, dtype=float) threshold_list = [] - if self.wants_low_bin: + if self.rule.wants_low_bin: threshold_list.append(-np.inf) threshold_list.extend(thresholds.tolist()) - if self.wants_high_bin: + if self.rule.wants_high_bin: threshold_list.append(np.inf) thresholds = np.array(threshold_list) num_bins = len(thresholds) - 1 - if self.bin_names is not None: - names = [name.strip() for name in self.bin_names.split(",")] + if self.rule.bin_names is not None: + names = list(self.rule.bin_names) else: names = [f"Bin_{index + 1}" for index in range(num_bins)] while len(names) < num_bins: @@ -1501,11 +1533,11 @@ class TwoMeasurementClassificationRequest: measurement1_values: np.ndarray | None = None measurement2_values: np.ndarray | None = None - threshold1_method: ClassificationThresholdMethod | str = ( + threshold1_method: ClassificationThresholdMethod = ( ClassificationThresholdMethod.MEAN ) threshold1_value: float = 0.5 - threshold2_method: ClassificationThresholdMethod | str = ( + threshold2_method: ClassificationThresholdMethod = ( ClassificationThresholdMethod.MEAN ) threshold2_value: float = 0.5 @@ -1520,12 +1552,6 @@ def classify( labels: np.ndarray, backend: ObjectClassificationBackendStrategy, ) -> tuple[np.ndarray, ClassificationResult]: - threshold1_method = coerce_cellprofiler_enum( - ClassificationThresholdMethod, self.threshold1_method - ) - threshold2_method = coerce_cellprofiler_enum( - ClassificationThresholdMethod, self.threshold2_method - ) unique_labels = backend.positive_label_ids(labels) num_objects = len(unique_labels) names = [ @@ -1558,8 +1584,16 @@ def classify( values2 = ClassificationMeasurementVector.from_value( self.measurement2_values ).aligned_to_labels(unique_labels) - t1 = classification_threshold(values1, threshold1_method, self.threshold1_value) - t2 = classification_threshold(values2, threshold2_method, self.threshold2_value) + t1 = classification_threshold( + values1, + self.threshold1_method, + self.threshold1_value, + ) + t2 = classification_threshold( + values2, + self.threshold2_method, + self.threshold2_value, + ) high1 = values1 >= t1 high2 = values2 >= t2 has_nan = np.isnan(values1) | np.isnan(values2) @@ -1639,7 +1673,6 @@ def classification_threshold( values: np.ndarray, method: ClassificationThresholdMethod, custom_value: float ) -> float: """Return the threshold for one ClassifyObjects measurement vector.""" - method = coerce_cellprofiler_enum(ClassificationThresholdMethod, method) return ClassificationThresholdStrategy.for_method(method).threshold( values, custom_value ) @@ -1767,15 +1800,15 @@ def classify_objects_single_measurement( measurement_feature: str = "", measurement_values: np.ndarray | None = None, measurement_values_by_rule: tuple[np.ndarray, ...] = (), - classification_rules: tuple[dict[str, object], ...] = (), + classification_rules: tuple[SingleMeasurementClassificationRule, ...] = (), bin_choice: ClassificationBinChoice = ClassificationBinChoice.EVEN, bin_count: int = 3, low_threshold: float = 0.0, high_threshold: float = 1.0, wants_low_bin: bool = False, wants_high_bin: bool = False, - custom_thresholds: str = "0,1", - bin_names: str | None = None, + custom_thresholds: tuple[float, ...] = (0.0, 1.0), + bin_names: tuple[str, ...] | None = None, retained_image_name: str | None = None, classified_image_rule_indices: tuple[int, ...] = (), classification_backend_provider: BackendProviderInput = DEFAULT_CELLPROFILER_BACKEND_SELECTION, @@ -1784,17 +1817,16 @@ def classify_objects_single_measurement( Args: labels: Object regions assigned to measurement bins or rule classes. - classification_rules: Ordered rule records; each record may override the + classification_rules: Ordered typed rules; each rule may override the single-measurement bin controls for one classified output. - bin_choice: Use evenly spaced boundaries or comma-separated custom limits. + bin_choice: Use evenly spaced boundaries or explicit custom limits. bin_count: Number of equal-width bins between the low and high thresholds. low_threshold: Lower edge of the evenly spaced classification range. high_threshold: Upper edge of the evenly spaced classification range. wants_low_bin: Add a bin for values at or below the configured range. wants_high_bin: Add a bin for values above the configured range. - custom_thresholds: Comma-separated ascending bin boundaries used by the - custom bin policy. - bin_names: Optional comma-separated display names in resulting bin order. + custom_thresholds: Ascending bin boundaries used by the custom bin policy. + bin_names: Optional display names in resulting bin order. """ labels = object_label_dense_array(labels, dtype=np.int32) image_array = np.asarray(image) @@ -1836,15 +1868,8 @@ def classify_objects_single_measurement( else None ) classified_labels, result = SingleMeasurementClassificationRequest( + rule=rule, measurement_values=rule_values, - bin_choice=rule.get("bin_choice", ClassificationBinChoice.EVEN), - bin_count=int(rule.get("bin_count", 3)), - low_threshold=float(rule.get("low_threshold", 0.0)), - high_threshold=float(rule.get("high_threshold", 1.0)), - wants_low_bin=bool(rule.get("wants_low_bin", False)), - wants_high_bin=bool(rule.get("wants_high_bin", False)), - custom_thresholds=str(rule.get("custom_thresholds", "0,1")), - bin_names=rule.get("bin_names"), ).classify(image, labels, backend) classified_images.append( with_image_payload_data( @@ -1857,8 +1882,7 @@ def classify_objects_single_measurement( configured_output_indices = tuple( rule_index for rule_index, rule in enumerate(classification_rules) - if normalized_symbol_name(str(rule.get("retained_image_name") or "")) - is not None + if rule.retained_image_name is not None ) if classified_image_rule_indices != configured_output_indices: raise ValueError( @@ -1877,15 +1901,19 @@ def classify_objects_single_measurement( ) return (output, ClassificationResult.columnar(tuple(results))) classified_labels, result = SingleMeasurementClassificationRequest( + rule=SingleMeasurementClassificationRule( + measurement_feature=measurement_feature, + bin_choice=bin_choice, + bin_count=bin_count, + low_threshold=low_threshold, + high_threshold=high_threshold, + wants_low_bin=wants_low_bin, + wants_high_bin=wants_high_bin, + custom_thresholds=custom_thresholds, + bin_names=bin_names, + retained_image_name=retained_image_name, + ), measurement_values=measurement_values, - bin_choice=bin_choice, - bin_count=bin_count, - low_threshold=low_threshold, - high_threshold=high_threshold, - wants_low_bin=wants_low_bin, - wants_high_bin=wants_high_bin, - custom_thresholds=custom_thresholds, - bin_names=bin_names, ).classify(image, labels, backend) configured_output_indices = () if retained_image_name is None else (0,) if classified_image_rule_indices != configured_output_indices: @@ -2093,6 +2121,7 @@ def _apply_object_bins_numba( IntensityBinsClassificationRequest, NumbaNumpyObjectClassificationBackendStrategy, ObjectClassificationBackendStrategy, + SingleMeasurementClassificationRule, SingleMeasurementClassificationRequest, TwoMeasurementClassificationRequest, classify_objects_by_intensity_bins, diff --git a/openhcs/processing/backends/cellprofiler/color.py b/openhcs/processing/backends/cellprofiler/color.py index 8331a6347..8ca32cea6 100644 --- a/openhcs/processing/backends/cellprofiler/color.py +++ b/openhcs/processing/backends/cellprofiler/color.py @@ -1466,7 +1466,7 @@ def run(self, request: GrayToColorRequest) -> np.ndarray: @numpy(contract=ProcessingContract.PURE_3D) def gray_to_color( image: np.ndarray, - color_scheme: GrayToColorModule.Scheme | str = GrayToColorModule.Scheme.RGB.value, + color_scheme: GrayToColorModule.Scheme = GrayToColorModule.Scheme.RGB, rescale_intensity: bool = True, red_channel: int = -1, green_channel: int = -1, @@ -1503,7 +1503,7 @@ def gray_to_color( channel_weights: Per-channel intensity multipliers for composite mode; an empty sequence uses unit weights. """ - scheme = GrayToColorModule.coerce_scheme(color_scheme) + scheme = color_scheme request = GrayToColorRequest( image=image, rescale_intensity=rescale_intensity, @@ -1562,8 +1562,8 @@ def gray_to_color( @numpy(contract=ProcessingContract.FLEXIBLE) def color_to_gray( image: np.ndarray, - mode: ColorToGrayMode | str = ColorToGrayMode.SPLIT, - image_type: ImageChannelType | str = ImageChannelType.RGB, + mode: ColorToGrayMode = ColorToGrayMode.SPLIT, + image_type: ImageChannelType = ImageChannelType.RGB, channel_indices: tuple[int, ...] = ColorToGrayModule.default_channel_indices, contributions: tuple[float, ...] = (1.0, 1.0, 1.0), ) -> np.ndarray | AlignedImageStack: @@ -1574,9 +1574,7 @@ def color_to_gray( contributions: Relative weights for the selected channels in combine mode; values cannot all be zero. """ - resolved_mode = coerce_cellprofiler_enum(ColorToGrayMode, mode) - resolved_image_type = coerce_cellprofiler_enum(ImageChannelType, image_type) - if resolved_mode is ColorToGrayMode.COMBINE: + if mode is ColorToGrayMode.COMBINE: output = combine_color_to_gray(image, channel_indices, contributions) return with_image_payload_data( image, @@ -1584,8 +1582,8 @@ def color_to_gray( metadata=color_to_gray_combine_output_metadata(image), ) image_data = image_payload_data(image) - outputs = split_color_to_gray(image, resolved_image_type, channel_indices) - if resolved_image_type is ImageChannelType.RGB: + outputs = split_color_to_gray(image, image_type, channel_indices) + if image_type is ImageChannelType.RGB: return pack_aligned_image_outputs( tuple( image_payload_metadata(image).project_channel_payload( @@ -1606,16 +1604,15 @@ def color_to_gray( def _invert_for_printing_channels( image: np.ndarray, *, - input_mode: InvertInputMode | str, + input_mode: InvertInputMode, use_red_input: bool, use_green_input: bool, use_blue_input: bool, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Return CellProfiler red, green, and blue input planes.""" - resolved_mode = coerce_cellprofiler_enum(InvertInputMode, input_mode) image_data = np.asarray(image_payload_data(image)) - if resolved_mode is InvertInputMode.COLOR: + if input_mode is InvertInputMode.COLOR: if image_data.ndim != 3 or image_data.shape[-1] != 3: raise ValueError( "InvertForPrinting color input requires an HxWx3 image, got " @@ -1649,11 +1646,11 @@ def _invert_for_printing_channels( def _invert_for_printing_result( image: np.ndarray, *, - input_mode: InvertInputMode | str, + input_mode: InvertInputMode, use_red_input: bool, use_green_input: bool, use_blue_input: bool, - output_mode: OutputMode | str, + output_mode: OutputMode, output_red: bool, output_green: bool, output_blue: bool, @@ -1674,8 +1671,7 @@ def _invert_for_printing_result( ((1.0 - red_image) * (1.0 - blue_image)).astype(np.float32), ((1.0 - red_image) * (1.0 - green_image)).astype(np.float32), ) - resolved_output_mode = coerce_cellprofiler_enum(OutputMode, output_mode) - if resolved_output_mode is OutputMode.COLOR: + if output_mode is OutputMode.COLOR: outputs = ( with_image_payload_data( image, @@ -1731,11 +1727,11 @@ def _invert_for_printing_result( @numpy(contract=ProcessingContract.PURE_3D) def invert_for_printing( image: np.ndarray, - input_mode: InvertInputMode | str = InvertInputMode.COLOR, + input_mode: InvertInputMode = InvertInputMode.COLOR, use_red_input: bool = True, use_green_input: bool = True, use_blue_input: bool = True, - output_mode: OutputMode | str = OutputMode.COLOR, + output_mode: OutputMode = OutputMode.COLOR, output_red: bool = True, output_green: bool = True, output_blue: bool = True, @@ -1767,11 +1763,11 @@ def invert_for_printing( @numpy(contract=ProcessingContract.PURE_3D) def invert_for_printing_grayscale( image: np.ndarray, - input_mode: InvertInputMode | str = InvertInputMode.COLOR, + input_mode: InvertInputMode = InvertInputMode.COLOR, use_red_input: bool = True, use_green_input: bool = True, use_blue_input: bool = True, - output_mode: OutputMode | str = OutputMode.GRAYSCALE, + output_mode: OutputMode = OutputMode.GRAYSCALE, output_red: bool = True, output_green: bool = True, output_blue: bool = True, @@ -1782,7 +1778,7 @@ def invert_for_printing_grayscale( ) -> np.ndarray | AlignedImageStack: """Invert into one or more enabled grayscale output channels.""" - if coerce_cellprofiler_enum(OutputMode, output_mode) is not OutputMode.GRAYSCALE: + if output_mode is not OutputMode.GRAYSCALE: raise ValueError("invert_for_printing_grayscale requires grayscale output.") result = _invert_for_printing_result( image, @@ -1810,11 +1806,11 @@ def invert_for_printing_grayscale( @numpy(contract=ProcessingContract.PURE_3D) def invert_for_printing_without_output( image: np.ndarray, - input_mode: InvertInputMode | str = InvertInputMode.COLOR, + input_mode: InvertInputMode = InvertInputMode.COLOR, use_red_input: bool = True, use_green_input: bool = True, use_blue_input: bool = True, - output_mode: OutputMode | str = OutputMode.GRAYSCALE, + output_mode: OutputMode = OutputMode.GRAYSCALE, output_red: bool = False, output_green: bool = False, output_blue: bool = False, @@ -1990,11 +1986,11 @@ def rgb_to_hsv_stack(rgb_stack: np.ndarray) -> np.ndarray: @numpy(contract=ProcessingContract.FLEXIBLE) def unmix_colors( image: np.ndarray, - stain_names: Sequence[StainType | str] = (), + stain_names: Sequence[StainType] = (), custom_absorbances: Sequence[Sequence[float] | None] = (), - stain1: StainType | str = StainType.HEMATOXYLIN, - stain2: StainType | str = StainType.EOSIN, - stain3: StainType | str | None = None, + stain1: StainType = StainType.HEMATOXYLIN, + stain2: StainType = StainType.EOSIN, + stain3: StainType | None = None, output_stain_index: int = 0, custom_red_absorbance_1: float = 0.5, custom_green_absorbance_1: float = 0.5, @@ -2080,7 +2076,7 @@ def unmix_colors( def _stain_definitions( - stain_names: Sequence[StainType | str], + stain_names: Sequence[StainType], custom_absorbances: Sequence[Sequence[float] | None], ) -> tuple[StainDefinition, ...]: if len(stain_names) != len(custom_absorbances): @@ -2090,7 +2086,7 @@ def _stain_definitions( return tuple( ( StainDefinition( - stain=coerce_cellprofiler_enum(StainType, stain_name), + stain=stain_name, custom_absorbance=_coerce_custom_absorbance(custom_absorbance), ) for stain_name, custom_absorbance in zip( @@ -2102,9 +2098,9 @@ def _stain_definitions( def _legacy_stain_definitions( *, - stain1: StainType | str, - stain2: StainType | str, - stain3: StainType | str | None, + stain1: StainType, + stain2: StainType, + stain3: StainType | None, custom_absorbances: tuple[ tuple[float, float, float], tuple[float, float, float], @@ -2115,7 +2111,7 @@ def _legacy_stain_definitions( return tuple( ( StainDefinition( - stain=coerce_cellprofiler_enum(StainType, stain), + stain=stain, custom_absorbance=custom_absorbances[index], ) for index, stain in enumerate(stains) diff --git a/openhcs/processing/backends/cellprofiler/crop.py b/openhcs/processing/backends/cellprofiler/crop.py index a69dbceb2..05b8f1401 100644 --- a/openhcs/processing/backends/cellprofiler/crop.py +++ b/openhcs/processing/backends/cellprofiler/crop.py @@ -22,7 +22,6 @@ DataclassMeasurementColumnarRows, ) from openhcs.core.pipeline.function_contracts import special_inputs -from openhcs.core.enum_utils import coerce_enum from openhcs.core.runtime_measurements import RuntimeMeasurementFeature from openhcs.core.runtime_image_values import ( ImagePayloadMetadata, @@ -453,21 +452,6 @@ class CropMaskRequest: ellipse_y_radius: float | None cropping_labels: ObjectLabelValue | None - def __post_init__(self) -> None: - object.__setattr__( - self, - "crop_shape", - coerce_enum(CropModule.Shape, self.crop_shape, "Crop.crop_shape"), - ) - object.__setattr__( - self, - "cropping_method", - coerce_enum( - CropModule.Method, self.cropping_method, "Crop.cropping_method" - ), - ) - - @dataclass(frozen=True, slots=True) class CropSpatialBounds: """Spatial context for a crop output in the parent image domain.""" @@ -619,9 +603,9 @@ class CropRequest: image: np.ndarray mask_plane: np.ndarray | None = None - crop_shape: CropModule.Shape | str = CropModule.Shape.RECTANGLE - cropping_method: CropModule.Method | str = CropModule.Method.COORDINATES - removal_method: CropModule.RemovalMethod | str = CropModule.RemovalMethod.NO + crop_shape: CropModule.Shape = CropModule.Shape.RECTANGLE + cropping_method: CropModule.Method = CropModule.Method.COORDINATES + removal_method: CropModule.RemovalMethod = CropModule.RemovalMethod.NO left_right_rectangle_positions: CropBoundaryPair = None top_bottom_rectangle_positions: CropBoundaryPair = None ellipse_center: tuple[float, float] | None = None @@ -656,9 +640,7 @@ def execute( ellipse_y_radius=self.ellipse_y_radius, cropping_labels=self.cropping_labels, ) - removal_method = coerce_enum( - CropModule.RemovalMethod, self.removal_method, "Crop.removal_method" - ) + removal_method = self.removal_method cropping = CropShapeMaskStrategy.for_shape(request.crop_shape).mask(request) cropped_mask = cropped_mask_for(cropping, None, removal_method) output_image_mask = cropped_image_mask( @@ -869,9 +851,9 @@ def crop_output_metadata( def crop( image: np.ndarray, topology_inputs: tuple[np.ndarray | ObjectLabelValue, ...] = (), - crop_shape: CropModule.Shape | str = CropModule.Shape.RECTANGLE, - cropping_method: CropModule.Method | str = CropModule.Method.COORDINATES, - removal_method: CropModule.RemovalMethod | str = CropModule.RemovalMethod.NO, + crop_shape: CropModule.Shape = CropModule.Shape.RECTANGLE, + cropping_method: CropModule.Method = CropModule.Method.COORDINATES, + removal_method: CropModule.RemovalMethod = CropModule.RemovalMethod.NO, left_right_rectangle_positions: CropBoundaryPair = None, top_bottom_rectangle_positions: CropBoundaryPair = None, ellipse_center: tuple[float, float] | None = None, @@ -885,12 +867,11 @@ def crop( one prior crop-mask sidecar for ``Previous cropping``, or one object- label value for ``Objects``; empty for rectangle and ellipse modes. """ - shape = coerce_enum(CropModule.Shape, crop_shape, "crop_shape") - mask_plane, cropping_labels = shape.runtime_topology_inputs(topology_inputs) + mask_plane, cropping_labels = crop_shape.runtime_topology_inputs(topology_inputs) return CropRequest( image=image, mask_plane=mask_plane, - crop_shape=shape, + crop_shape=crop_shape, cropping_method=cropping_method, removal_method=removal_method, left_right_rectangle_positions=left_right_rectangle_positions, diff --git a/openhcs/processing/backends/cellprofiler/display_modules.py b/openhcs/processing/backends/cellprofiler/display_modules.py index 0a2edbfdd..4a46c29b9 100644 --- a/openhcs/processing/backends/cellprofiler/display_modules.py +++ b/openhcs/processing/backends/cellprofiler/display_modules.py @@ -71,6 +71,7 @@ from openhcs.interop.cellprofiler.parser import ModuleSetting from openhcs.interop.cellprofiler.setting_names import ( SettingNameFamily, + normalized_symbol_name, optional_setting_value, setting_names, ) @@ -177,11 +178,13 @@ def bind_runtime_inputs( def _measurement_vector( measurement_tables: tuple[MeasurementTable, ...], *, - feature_name: str, + feature_name: str | None, object_name: str | None, ) -> np.ndarray: """Resolve one exact numeric feature vector from declared measurement tables.""" + if feature_name is None: + raise ValueError("Display modules require a selected measurement feature.") if not measurement_tables: raise ValueError( "Display modules require declared prior measurement artifacts." @@ -601,10 +604,10 @@ class DensityPlotData: @runtime_bound_parameters(_DisplayMeasurementTablesRuntimeParameter) def display_density_plot( image: np.ndarray, - x_object_name: str = "None", - x_measurement_feature: str = "None", - y_object_name: str = "None", - y_measurement_feature: str = "None", + x_object_name: str | None = None, + x_measurement_feature: str | None = None, + y_object_name: str | None = None, + y_measurement_feature: str | None = None, gridsize: int = 100, x_scale: DensityPlotScaleType = DensityPlotScaleType.LINEAR, y_scale: DensityPlotScaleType = DensityPlotScaleType.LINEAR, @@ -785,8 +788,8 @@ class HistogramResult: @runtime_bound_parameters(_DisplayMeasurementTablesRuntimeParameter) def display_histogram( image: np.ndarray, - object_name: str = "None", - measurement_feature: str = "None", + object_name: str | None = None, + measurement_feature: str | None = None, num_bins: int = 100, x_scale: AxisScale = AxisScale.LINEAR, y_scale: AxisScale = AxisScale.LINEAR, @@ -1015,8 +1018,8 @@ def _aggregate_values(values: np.ndarray, method: AggregationMethod) -> float: def display_platemap( image: np.ndarray, objects_or_image: ObjectOrImage = ObjectOrImage.OBJECTS, - object_name: str = "None", - measurement_feature: str = "None", + object_name: str | None = None, + measurement_feature: str | None = None, plate_metadata_feature: str = "Metadata_Plate", plate_type: PlateType = PlateType.PLATE_96, well_format: WellFormat = WellFormat.NAME, @@ -1222,11 +1225,11 @@ class ScatterPlotData: def display_scatter_plot( image: np.ndarray, x_source: MeasurementSource = MeasurementSource.IMAGE, - x_object_name: str = "None", - x_measurement_feature: str = "None", + x_object_name: str | None = None, + x_measurement_feature: str | None = None, y_source: MeasurementSource = MeasurementSource.IMAGE, - y_object_name: str = "None", - y_measurement_feature: str = "None", + y_object_name: str | None = None, + y_measurement_feature: str | None = None, x_scale: ScatterPlotScaleType = ScatterPlotScaleType.LINEAR, y_scale: ScatterPlotScaleType = ScatterPlotScaleType.LINEAR, title: str = "", @@ -1535,9 +1538,17 @@ class DisplayDensityPlotModule( "y_measurement_feature", ) setting_bindings = ( - SettingToKeywordBinding(x_object_setting, "x_object_name"), + SettingToKeywordBinding( + x_object_setting, + "x_object_name", + normalized_symbol_name, + ), x_measurement_binding, - SettingToKeywordBinding(y_object_setting, "y_object_name"), + SettingToKeywordBinding( + y_object_setting, + "y_object_name", + normalized_symbol_name, + ), y_measurement_binding, SettingToKeywordBinding( "Select the grid size", "gridsize", parse_cellprofiler_int @@ -1584,7 +1595,11 @@ class DisplayHistogramModule( "measurement_feature", ) setting_bindings = ( - SettingToKeywordBinding(object_setting, "object_name"), + SettingToKeywordBinding( + object_setting, + "object_name", + normalized_symbol_name, + ), measurement_binding, SettingToKeywordBinding("Number of bins", "num_bins", parse_cellprofiler_int), SettingToKeywordBinding( @@ -1658,6 +1673,7 @@ class DisplayPlatemapModule( SettingToKeywordBinding( "Select the object whose measurements will be displayed", "object_name", + normalized_symbol_name, ), measurement_binding, plate_metadata_binding, @@ -1719,6 +1735,7 @@ class DisplayScatterPlotModule( SettingToKeywordBinding( "Select the object to plot on the X-axis", "x_object_name", + normalized_symbol_name, ), x_measurement_binding, SettingToKeywordBinding( @@ -1729,6 +1746,7 @@ class DisplayScatterPlotModule( SettingToKeywordBinding( "Select the object to plot on the Y-axis", "y_object_name", + normalized_symbol_name, ), y_measurement_binding, SettingToKeywordBinding( diff --git a/openhcs/processing/backends/cellprofiler/edge.py b/openhcs/processing/backends/cellprofiler/edge.py index 9ff6786d8..b97cf8779 100644 --- a/openhcs/processing/backends/cellprofiler/edge.py +++ b/openhcs/processing/backends/cellprofiler/edge.py @@ -386,8 +386,6 @@ def enhance_edges( low_threshold: float = 0.1, ) -> np.ndarray: """Enhance edges using CellProfiler-compatible edge detection semantics.""" - method = coerce_cellprofiler_enum(EdgeMethod, method) - direction = coerce_cellprofiler_enum(EdgeDirection, direction) if not 0 <= low_threshold <= 1: warnings.warn( f"low_threshold value of {low_threshold} is outside of the [0-1] range.", diff --git a/openhcs/processing/backends/cellprofiler/export_to_database.py b/openhcs/processing/backends/cellprofiler/export_to_database.py index 34ed24752..64a1a5213 100644 --- a/openhcs/processing/backends/cellprofiler/export_to_database.py +++ b/openhcs/processing/backends/cellprofiler/export_to_database.py @@ -213,11 +213,6 @@ class ExportToDatabaseModule(ArtifactExportModule): workspace_y_index_setting = SettingNameFamily("Select the Y-axis index") setting_bindings = ( - SettingToKeywordBinding( - database_type_setting, - "database_type", - _parse_sqlite_database_type, - ), SettingToKeywordBinding(sqlite_file_setting, "sqlite_file"), SettingToKeywordBinding(experiment_name_setting, "experiment_name"), SettingToKeywordBinding( @@ -261,21 +256,6 @@ class ExportToDatabaseModule(ArtifactExportModule): "calculate_per_image_standard_deviation", parse_cellprofiler_bool, ), - SettingToKeywordBinding( - aggregate_well_mean_setting, - "calculate_per_well_mean", - parse_cellprofiler_bool, - ), - SettingToKeywordBinding( - aggregate_well_median_setting, - "calculate_per_well_median", - parse_cellprofiler_bool, - ), - SettingToKeywordBinding( - aggregate_well_standard_deviation_setting, - "calculate_per_well_standard_deviation", - parse_cellprofiler_bool, - ), SettingToKeywordBinding( maximum_column_name_length_setting, "maximum_column_name_length", @@ -294,14 +274,18 @@ class ExportToDatabaseModule(ArtifactExportModule): SettingToKeywordBinding( thumbnail_images_setting, "thumbnail_image_names", - str, + split_symbol_names, ), SettingToKeywordBinding( auto_scale_thumbnails_setting, "auto_scale_thumbnail_intensities", parse_cellprofiler_bool, ), - SettingToKeywordBinding(plate_type_setting, "plate_type", str), + SettingToKeywordBinding( + plate_type_setting, + "plate_type", + normalized_symbol_name, + ), SettingToKeywordBinding(plate_metadata_setting, "plate_metadata", str), SettingToKeywordBinding(well_metadata_setting, "well_metadata", str), SettingToKeywordBinding( @@ -309,26 +293,11 @@ class ExportToDatabaseModule(ArtifactExportModule): "wants_group_fields", parse_cellprofiler_bool, ), - SettingToKeywordBinding( - wants_filter_fields_setting, - "wants_filter_fields", - parse_cellprofiler_bool, - ), - SettingToKeywordBinding( - create_plate_filters_setting, - "create_plate_filters", - parse_cellprofiler_bool, - ), SettingToKeywordBinding( phenotype_class_table_setting, "phenotype_class_table", str, ), - SettingToKeywordBinding( - overwrite_mode_setting, - "overwrite_mode", - _parse_overwrite_mode, - ), SettingToKeywordBinding( access_images_via_url_setting, "access_images_via_url", @@ -339,11 +308,6 @@ class ExportToDatabaseModule(ArtifactExportModule): "classification_type", _parse_classification_type, ), - SettingToKeywordBinding( - wants_workspace_file_setting, - "wants_workspace_file", - parse_cellprofiler_bool, - ), ) ignored_settings = ( "Database name", @@ -426,6 +390,21 @@ def postprocess_bound_settings( ) -> BoundModuleSettings: """Bind compound object selection and repeated CPA image-group rows.""" + _parse_sqlite_database_type( + required_setting_value(module, cls.database_type_setting) + ) + for unsupported_setting in ( + cls.aggregate_well_mean_setting, + cls.aggregate_well_median_setting, + cls.aggregate_well_standard_deviation_setting, + cls.wants_filter_fields_setting, + cls.create_plate_filters_setting, + ): + cls._require_disabled(module, unsupported_setting) + overwrite_value = optional_setting_value(module, cls.overwrite_mode_setting) + if overwrite_value is not None: + _parse_overwrite_mode(overwrite_value) + cls._validate_workspace_settings(module) location_value = optional_setting_value(module, cls.location_object_setting) filter_count_value = optional_setting_value( module, @@ -437,7 +416,9 @@ def postprocess_bound_settings( "ExportToDatabase custom CPA filter fields are not represented " "by the public callable." ) - bound = bound.with_kwargs( + bound = bound.with_consumed_settings( + cls.database_type_setting, + ).with_kwargs( {"selected_objects": cls._selected_objects(module)} ).with_consumed_settings( cls.objects_choice_setting, @@ -471,9 +452,14 @@ def postprocess_bound_settings( bound = bound.with_consumed_settings( cls.property_filter_count_setting, ) - return bound.with_kwargs( - {"workspace_measurements": cls._workspace_measurements(module)} - ).with_consumed_settings( + return bound.with_consumed_settings( + cls.aggregate_well_mean_setting, + cls.aggregate_well_median_setting, + cls.aggregate_well_standard_deviation_setting, + cls.wants_filter_fields_setting, + cls.create_plate_filters_setting, + cls.overwrite_mode_setting, + cls.wants_workspace_file_setting, cls.workspace_measurement_count_setting, cls.workspace_display_tool_setting, cls.workspace_x_type_setting, @@ -509,6 +495,34 @@ def finalize_module_blocks_for_invocation( else () ) compound_records = ( + ModuleSetting(cls.database_type_setting.canonical, "SQLite"), + ModuleSetting( + cls.aggregate_well_mean_setting.canonical, + cellprofiler_setting_literal(False), + ), + ModuleSetting( + cls.aggregate_well_median_setting.canonical, + cellprofiler_setting_literal(False), + ), + ModuleSetting( + cls.aggregate_well_standard_deviation_setting.canonical, + cellprofiler_setting_literal(False), + ), + ModuleSetting(cls.property_filter_count_setting.canonical, "0"), + ModuleSetting( + cls.wants_filter_fields_setting.canonical, + cellprofiler_setting_literal(False), + ), + ModuleSetting( + cls.create_plate_filters_setting.canonical, + cellprofiler_setting_literal(False), + ), + ModuleSetting(cls.overwrite_mode_setting.canonical, "Never"), + ModuleSetting(cls.workspace_measurement_count_setting.canonical, "0"), + ModuleSetting( + cls.wants_workspace_file_setting.canonical, + cellprofiler_setting_literal(False), + ), *cls._selected_object_setting_records(selected_objects), *cls._property_image_setting_records(image_channels), *cls._group_setting_records(group_fields), @@ -617,6 +631,19 @@ def _required_bool( ) -> bool: return parse_cellprofiler_bool(required_setting_value(module, setting_name)) + @classmethod + def _require_disabled( + cls, + module: ModuleBlock, + setting_name: SettingNameFamily, + ) -> None: + value = optional_setting_value(module, setting_name) + if value is not None and parse_cellprofiler_bool(value): + raise ValueError( + "OpenHCS ExportToDatabase does not support enabled " + f"{setting_name.canonical!r}." + ) + @classmethod def _table_prefix(cls, module: ModuleBlock) -> str: if not cls._required_bool(module, cls.want_table_prefix_setting): @@ -689,13 +716,15 @@ def _group_fields(cls, module: ModuleBlock) -> tuple[tuple[str, str], ...]: ) @classmethod - def _workspace_measurements( + def _validate_workspace_settings( cls, module: ModuleBlock, - ) -> tuple[tuple[str, str, str, str, str, str, str, str, str], ...]: - declared_count = int( - required_setting_value(module, cls.workspace_measurement_count_setting) + ) -> None: + count_value = optional_setting_value( + module, + cls.workspace_measurement_count_setting, ) + declared_count = 0 if count_value is None else int(count_value) display_tools = module.get_setting_values( cls.workspace_display_tool_setting.canonical ) @@ -727,22 +756,12 @@ def _workspace_measurements( cls.workspace_object_name_setting, object_names, ) - if not cls._required_bool(module, cls.wants_workspace_file_setting): - return () - return tuple( - ( - display_tools[index].strip(), - x_types[index].strip(), - object_names[index * 2].strip(), - x_measurements[index].strip(), - x_indices[index].strip(), - y_types[index].strip(), - object_names[index * 2 + 1].strip(), - y_measurements[index].strip(), - y_indices[index].strip(), - ) - for index in range(declared_count) + wants_workspace_value = optional_setting_value( + module, + cls.wants_workspace_file_setting, ) + if wants_workspace_value is not None: + parse_cellprofiler_bool(wants_workspace_value) @classmethod def _require_record_count( @@ -866,7 +885,6 @@ def export_to_database( *, artifact_batch: RuntimeArtifactBatch, context: ProcessingContext, - database_type: Literal["sqlite"] = "sqlite", sqlite_file: str = "DefaultDB.db", experiment_name: str = "MyExpt", add_table_prefix: bool = False, @@ -881,29 +899,19 @@ def export_to_database( calculate_per_image_mean: bool = False, calculate_per_image_median: bool = False, calculate_per_image_standard_deviation: bool = False, - calculate_per_well_mean: bool = False, - calculate_per_well_median: bool = False, - calculate_per_well_standard_deviation: bool = False, maximum_column_name_length: int = 64, image_url_prepend: str = "", write_image_thumbnails: bool = False, - thumbnail_image_names: str = "", + thumbnail_image_names: tuple[str, ...] = (), auto_scale_thumbnail_intensities: bool = True, - plate_type: str = "None", + plate_type: str | None = None, plate_metadata: str = "Plate", well_metadata: str = "Well", wants_group_fields: bool = False, group_fields: tuple[tuple[str, str], ...] = (), - wants_filter_fields: bool = False, - create_plate_filters: bool = False, phenotype_class_table: str = "", - overwrite_mode: Literal["never", "data_only", "data_and_schema"] = "never", access_images_via_url: bool = False, classification_type: Literal["object", "image"] = "object", - wants_workspace_file: bool = False, - workspace_measurements: tuple[ - tuple[str, str, str, str, str, str, str, str, str], ... - ] = (), ) -> dict[str, bytes | str]: """Render exact contract-selected plate artifacts as SQLite and CPA files. @@ -918,13 +926,9 @@ def export_to_database( group_fields: CellProfiler Analyst group rows as name and comma-separated per-image-column pairs, used when ``wants_group_fields`` is enabled. - workspace_measurements: CellProfiler Analyst workspace display rows - containing the tool, X-axis, and Y-axis measurement settings, used - when ``wants_workspace_file`` is enabled. """ settings = CellProfilerDatabaseExportSettings( - database_type=database_type, sqlite_file=sqlite_file, experiment_name=experiment_name, table_prefix=table_prefix if add_table_prefix else "", @@ -946,7 +950,7 @@ def export_to_database( calculate_per_image_standard_deviation=(calculate_per_image_standard_deviation), write_image_thumbnails=write_image_thumbnails, thumbnail_image_names=( - split_symbol_names(thumbnail_image_names) if write_image_thumbnails else () + thumbnail_image_names if write_image_thumbnails else () ), auto_scale_thumbnail_intensities=auto_scale_thumbnail_intensities, ) diff --git a/openhcs/processing/backends/cellprofiler/feature_enhancement.py b/openhcs/processing/backends/cellprofiler/feature_enhancement.py index 27d3043d3..8ec163df2 100644 --- a/openhcs/processing/backends/cellprofiler/feature_enhancement.py +++ b/openhcs/processing/backends/cellprofiler/feature_enhancement.py @@ -84,10 +84,6 @@ def enhance_or_suppress_features( dark_hole_radius_min: Smallest dark-hole radius to enhance, in pixels. dark_hole_radius_max: Largest dark-hole radius to enhance, in pixels. """ - method = coerce_cellprofiler_enum(OperationMethod, method) - enhance_method = coerce_cellprofiler_enum(EnhanceMethod, enhance_method) - speckle_accuracy = coerce_cellprofiler_enum(SpeckleAccuracy, speckle_accuracy) - neurite_method = coerce_cellprofiler_enum(NeuriteMethod, neurite_method) image_data = np.asarray(image_payload_data(image)) if image_data.dtype != np.float32 and image_data.dtype != np.float64: image_data = image_data.astype(np.float32) diff --git a/openhcs/processing/backends/cellprofiler/grid.py b/openhcs/processing/backends/cellprofiler/grid.py index 0cb0fc8f4..4564b5987 100644 --- a/openhcs/processing/backends/cellprofiler/grid.py +++ b/openhcs/processing/backends/cellprofiler/grid.py @@ -29,6 +29,7 @@ ) from openhcs.core.pipeline.function_contracts import special_inputs from openhcs.core.public_api import public_names_from_objects +from openhcs.core.registry_strategies import EnumKeyedStrategyMixin from openhcs.core.runtime_object_labels import ( ObjectLabelPayload, ObjectLabelValue, @@ -649,7 +650,7 @@ def active_artifact_bindings( cls.setting_value(module, cls.shape_choice_setting) or ShapeChoice.RECTANGLE.value ) - if not GridShapeStrategy.for_shape_choice(shape_choice).requires_guides: + if not GridShapeStrategy.for_enum_member(shape_choice).requires_guides: return tuple( binding for binding in bindings @@ -676,7 +677,7 @@ def artifact_contract_inputs( cls.setting_value(module, cls.shape_choice_setting) or ShapeChoice.RECTANGLE.value ) - if GridShapeStrategy.for_shape_choice(shape_choice).requires_guides: + if GridShapeStrategy.for_enum_member(shape_choice).requires_guides: return declared_inputs spatial_grids = ArtifactSpecCollection(declared_inputs).of_artifact_type( SpatialGridArtifactType @@ -991,7 +992,7 @@ def spatial_grid(self) -> SpatialGrid: y_spacing=self.y_spacing, x_origin=self.x_origin, y_origin=self.y_origin, - ordering=coerce_cellprofiler_enum(SpatialGridOrdering, self.ordering), + ordering=self.ordering, source_spatial_shape_yx=tuple((int(value) for value in self.image_shape)), ) @@ -1250,7 +1251,7 @@ class GridShapeRequest(GridShapeContext): def labels(self, shape_choice: ShapeChoice) -> np.ndarray: """Materialize labels through the registered strategy family.""" - strategy = GridShapeStrategy.for_shape_choice(shape_choice) + strategy = GridShapeStrategy.for_enum_member(shape_choice) return strategy.labels(self) @property @@ -1291,8 +1292,8 @@ def from_runtime( *, image: np.ndarray, grid_definition: GridRuntimeDefinitionRequest, - shape_choice: ShapeChoice | str, - diameter_choice: DiameterChoice | str, + shape_choice: ShapeChoice, + diameter_choice: DiameterChoice, circle_diameter: int, guiding_labels: np.ndarray | None = None, ) -> "IdentifyObjectsInGridRequest": @@ -1300,8 +1301,8 @@ def from_runtime( return cls( image=image, grid=GridDefinition.from_runtime_request(grid_definition), - shape_choice=coerce_cellprofiler_enum(ShapeChoice, shape_choice), - diameter_choice=coerce_cellprofiler_enum(DiameterChoice, diameter_choice), + shape_choice=shape_choice, + diameter_choice=diameter_choice, circle_diameter=circle_diameter, guiding_labels=guiding_labels, ) @@ -1394,8 +1395,8 @@ def define_grid_manual( second_spot=GridSpotReference( second_spot_x, second_spot_y, second_spot_row, second_spot_col ), - origin=coerce_cellprofiler_enum(SpatialGridOrigin, origin), - ordering=coerce_cellprofiler_enum(SpatialGridOrdering, ordering), + origin=origin, + ordering=ordering, image_shape_yx=tuple((int(value) for value in image.shape[-2:])), ).spatial_grid() return (image, grid) @@ -1427,8 +1428,8 @@ def define_grid_automatic( rows=grid_rows, columns=grid_columns, labels=object_label_dense_array(labels, dtype=np.int32), - origin=coerce_cellprofiler_enum(SpatialGridOrigin, origin), - ordering=coerce_cellprofiler_enum(SpatialGridOrdering, ordering), + origin=origin, + ordering=ordering, image_shape_yx=tuple((int(value) for value in image.shape[-2:])), ).spatial_grid() return (image, grid) @@ -1504,7 +1505,7 @@ def identify_objects_in_grid( y_origin: Compatibility vertical center coordinate of the first spot; the supplied ``SpatialGrid`` takes precedence. """ - shape_strategy = GridShapeStrategy.for_shape_choice(shape_choice) + shape_strategy = GridShapeStrategy.for_enum_member(shape_choice) expected_input_count = 2 if shape_strategy.requires_guides else 1 if len(topology_inputs) != expected_input_count: required_inputs = ( @@ -1748,20 +1749,17 @@ def _mask_grid_labels_by_filtered_guides_numba( return labels -class GridShapeStrategy(ABC, metaclass=AutoRegisterMeta): +class GridShapeStrategy( + EnumKeyedStrategyMixin[ShapeChoice], + ABC, + metaclass=AutoRegisterMeta, +): """Nominal strategy for materializing grid object labels.""" - __registry_key__ = "shape_choice" - __skip_if_no_key__ = True - shape_choice: ClassVar[str | None] = None + __enum_member_attr__ = "shape_choice" + shape_choice: ClassVar[ShapeChoice | None] = None requires_guides: ClassVar[bool] = False - @classmethod - def for_shape_choice(cls, shape_choice: ShapeChoice | str) -> "GridShapeStrategy": - resolved = coerce_cellprofiler_enum(ShapeChoice, shape_choice) - strategy_type = cls.__registry__[resolved.value] - return strategy_type() - @abstractmethod def labels(self, request: GridShapeRequest) -> np.ndarray: """Return dense labels for one grid shape mode.""" @@ -1770,7 +1768,7 @@ def labels(self, request: GridShapeRequest) -> np.ndarray: class RectangleGridShapeStrategy(GridShapeStrategy): """Fill each grid rectangle with its object label.""" - shape_choice = ShapeChoice.RECTANGLE.value + shape_choice = ShapeChoice.RECTANGLE def labels(self, request: GridShapeRequest) -> np.ndarray: return request.grid.filled_labels() @@ -1779,7 +1777,7 @@ def labels(self, request: GridShapeRequest) -> np.ndarray: class ForcedCircleGridShapeStrategy(GridShapeStrategy): """Draw fixed-diameter circles at grid centers.""" - shape_choice = ShapeChoice.CIRCLE_FORCED.value + shape_choice = ShapeChoice.CIRCLE_FORCED def labels(self, request: GridShapeRequest) -> np.ndarray: return request.grid.forced_circle_labels(request.circle_diameter / 2.0) @@ -1788,7 +1786,7 @@ def labels(self, request: GridShapeRequest) -> np.ndarray: class NaturalCircleGridShapeStrategy(GridShapeStrategy): """Draw automatic circles using accepted guide objects for centers/area.""" - shape_choice = ShapeChoice.CIRCLE_NATURAL.value + shape_choice = ShapeChoice.CIRCLE_NATURAL requires_guides = True def labels(self, request: GridShapeRequest) -> np.ndarray: @@ -1814,7 +1812,7 @@ def labels(self, request: GridShapeRequest) -> np.ndarray: class NaturalGridShapeStrategy(GridShapeStrategy): """Preserve accepted guide-object shapes and relabel by center grid cell.""" - shape_choice = ShapeChoice.NATURAL.value + shape_choice = ShapeChoice.NATURAL requires_guides = True def labels(self, request: GridShapeRequest) -> np.ndarray: diff --git a/openhcs/processing/backends/cellprofiler/illumination.py b/openhcs/processing/backends/cellprofiler/illumination.py index 286b9d98e..752925e41 100644 --- a/openhcs/processing/backends/cellprofiler/illumination.py +++ b/openhcs/processing/backends/cellprofiler/illumination.py @@ -385,10 +385,7 @@ def execution_mode( variable_components: tuple[VariableComponents, ...], ) -> ImagePayloadExecutionMode: del cls, image, variable_components - scope = coerce_cellprofiler_enum( - CalculationScope, - kwargs.get("calculation_scope", CalculationScope.EACH), - ) + scope = kwargs.get("calculation_scope", CalculationScope.EACH) if scope.uses_all_images: return ImagePayloadExecutionMode.FULL_STACK return default @@ -922,23 +919,23 @@ def image_payload( @numpy(contract=ProcessingContract.FLEXIBLE) def correct_illumination_calculate( image: np.ndarray, - intensity_choice: IntensityChoice | str = IntensityChoice.REGULAR, + intensity_choice: IntensityChoice = IntensityChoice.REGULAR, dilate_objects: bool = False, object_dilation_radius: int = 1, block_size: int = 60, - rescale_option: RescaleOption | str = RescaleOption.YES, - smoothing_method: SmoothingMethod | str = SmoothingMethod.FIT_POLYNOMIAL, - filter_size_method: FilterSizeMethod | str = FilterSizeMethod.AUTOMATIC, + rescale_option: RescaleOption = RescaleOption.YES, + smoothing_method: SmoothingMethod = SmoothingMethod.FIT_POLYNOMIAL, + filter_size_method: FilterSizeMethod = FilterSizeMethod.AUTOMATIC, object_width: int = 10, manual_filter_size: int = 10, automatic_splines: bool = True, - spline_bg_mode: SplineBgMode | str = SplineBgMode.AUTO, + spline_bg_mode: SplineBgMode = SplineBgMode.AUTO, spline_points: int = 5, spline_threshold: float = 2.0, spline_rescale: float = 2.0, spline_max_iterations: int = 40, spline_convergence: float = 0.001, - calculation_scope: CalculationScope | str = CalculationScope.EACH, + calculation_scope: CalculationScope = CalculationScope.EACH, retain_average: bool = False, retain_dilated: bool = False, convex_hull_backend_provider: BackendProviderInput = DEFAULT_CELLPROFILER_BACKEND_SELECTION, @@ -958,12 +955,6 @@ def correct_illumination_calculate( raw/corrected images across representative plate positions; it should track acquisition bias rather than foreground biology. """ - intensity_choice = coerce_cellprofiler_enum(IntensityChoice, intensity_choice) - rescale_option = coerce_cellprofiler_enum(RescaleOption, rescale_option) - smoothing_method = coerce_cellprofiler_enum(SmoothingMethod, smoothing_method) - filter_size_method = coerce_cellprofiler_enum(FilterSizeMethod, filter_size_method) - spline_bg_mode = coerce_cellprofiler_enum(SplineBgMode, spline_bg_mode) - calculation_scope = coerce_cellprofiler_enum(CalculationScope, calculation_scope) morphology = MorphologyBackendStrategy.for_callable(correct_illumination_calculate) pixel_data = np.asarray(image_payload_data(image)) raw_mask = image_payload_mask(image) @@ -1049,7 +1040,7 @@ def correct_illumination_apply( image: RuntimeArrayData, *, illumination_function: RuntimeArrayData, - method: IlluminationCorrectionMethod | str = IlluminationCorrectionMethod.DIVIDE, + method: IlluminationCorrectionMethod = IlluminationCorrectionMethod.DIVIDE, truncate_low: bool = True, truncate_high: bool = True, ) -> RuntimeArrayData: @@ -1067,9 +1058,8 @@ def correct_illumination_apply( f"Input image shape {image_pixels.shape} and illumination function " f"shape {illumination_pixels.shape} must be equal." ) - resolved_method = coerce_cellprofiler_enum(IlluminationCorrectionMethod, method) output_pixels = IlluminationCorrectionStrategy.for_enum_member( - resolved_method + method ).apply(image_pixels, illumination_pixels) if truncate_low: np.maximum(output_pixels, 0.0, out=output_pixels) diff --git a/openhcs/processing/backends/cellprofiler/image_geometry.py b/openhcs/processing/backends/cellprofiler/image_geometry.py index 6034e261c..7698895d7 100644 --- a/openhcs/processing/backends/cellprofiler/image_geometry.py +++ b/openhcs/processing/backends/cellprofiler/image_geometry.py @@ -1096,7 +1096,6 @@ def mask_image( binary_threshold: Image-mask pixels above this value are foreground; ignored when masking from object labels. """ - mask_source = coerce_cellprofiler_enum(MaskSource, mask_source) masked_plane_results = tuple( ( masked_image_plane(plane.image, plane.mask, invert_mask=invert_mask) @@ -1275,8 +1274,6 @@ def resize( interpolation: InterpolationMethod = InterpolationMethod.NEAREST_NEIGHBOR, ) -> np.ndarray: """Resize a CellProfiler image plane by factor or explicit dimensions.""" - resize_method = coerce_cellprofiler_enum(ResizeMethod, resize_method) - interpolation = coerce_cellprofiler_enum(InterpolationMethod, interpolation) pixels = image_payload_data(image) geometry = ResizeGeometry.from_parameters( tuple(np.asarray(pixels).shape[:2]), @@ -1301,8 +1298,6 @@ def resize_volumetric( interpolation: InterpolationMethod = InterpolationMethod.NEAREST_NEIGHBOR, ) -> np.ndarray: """Resize a CellProfiler ZYX image volume by factor or explicit dimensions.""" - resize_method = coerce_cellprofiler_enum(ResizeMethod, resize_method) - interpolation = coerce_cellprofiler_enum(InterpolationMethod, interpolation) pixels = image_payload_data(image) geometry = ResizeGeometry.from_trailing_spatial_parameters( tuple(np.asarray(pixels).shape), diff --git a/openhcs/processing/backends/cellprofiler/image_math.py b/openhcs/processing/backends/cellprofiler/image_math.py index 27afb9b33..e621c6bd7 100644 --- a/openhcs/processing/backends/cellprofiler/image_math.py +++ b/openhcs/processing/backends/cellprofiler/image_math.py @@ -282,7 +282,9 @@ def _active_image_operand_bindings( ) -> tuple[SettingToKeywordBinding, ...]: operation_value = optional_setting_value(module, cls.operation_setting) if operation_value is not None: - operation_strategy = ImageMathOperationStrategy.coerce(operation_value) + operation_strategy = ImageMathOperationStrategy.for_operation( + ImageMathOperation.from_cellprofiler_literal(operation_value) + ) if operation_strategy.single_image: return cls.declared_artifact_bindings(plan_type = ArtifactInputPlan, artifact_type = ImageArtifactType)[:1] return tuple( @@ -330,7 +332,7 @@ def _operation_strategy_from_records(cls, existing_records): raise ValueError(f"ImageMath declares multiple operation rows: {values!r}.") if not values: return None - return ImageMathOperationStrategy.coerce( + return ImageMathOperationStrategy.for_operation( ImageMathOperation.from_cellprofiler_literal(values[0]) ) @@ -444,8 +446,8 @@ def operands_are_logical(pixel_data: list[np.ndarray]) -> bool: return all((pd.dtype == bool for pd in pixel_data if not np.isscalar(pd))) @classmethod - def coerce(cls, value: MathOperation | str) -> "ImageMathOperationStrategy": - return cls.for_enum_member(coerce_cellprofiler_enum(MathOperation, value)) + def for_operation(cls, value: MathOperation) -> "ImageMathOperationStrategy": + return cls.for_enum_member(value) def prepare_initial_output( self, @@ -904,7 +906,7 @@ def image_math( factors: Multipliers for the ordered input images; omitted trailing factors default to 1 and binary-output operations ignore them. """ - operation_strategy = ImageMathOperationStrategy.coerce(operation) + operation_strategy = ImageMathOperationStrategy.for_operation(operation) mask_policy = ImageMathMaskPolicy(ignore_masks=ignore_masks) prepared_operands = ImageMathPreparedOperands.from_inputs( image=image, diff --git a/openhcs/processing/backends/cellprofiler/image_quality.py b/openhcs/processing/backends/cellprofiler/image_quality.py index c5af9e012..e7b133257 100644 --- a/openhcs/processing/backends/cellprofiler/image_quality.py +++ b/openhcs/processing/backends/cellprofiler/image_quality.py @@ -1566,18 +1566,6 @@ def measure_image_quality( records.append(intensity_metrics) if calculate_threshold: phase_started_at = time.perf_counter() - threshold_method = coerce_cellprofiler_enum( - ImageQualityThresholdMethod, threshold_method - ) - otsu_class_count = coerce_cellprofiler_enum( - CellProfilerOtsuMethod, otsu_class_count - ) - otsu_objective = coerce_cellprofiler_enum( - ImageQualityOtsuObjective, otsu_objective - ) - assign_middle_to_foreground = coerce_cellprofiler_enum( - CellProfilerThresholdAssignment, assign_middle_to_foreground - ) threshold_metrics = ImageQualityThresholdMetrics( slice_index=0, feature_name=threshold_method.feature_field_name, diff --git a/openhcs/processing/backends/cellprofiler/intensity.py b/openhcs/processing/backends/cellprofiler/intensity.py index 6b593a04d..dc8379147 100644 --- a/openhcs/processing/backends/cellprofiler/intensity.py +++ b/openhcs/processing/backends/cellprofiler/intensity.py @@ -98,6 +98,16 @@ class RescaleIntensityMethod(Enum): DIVIDE_BY_VALUE = "divide_by_value" +def _parse_image_intensity_percentiles(value: str) -> tuple[int, ...]: + """Parse CellProfiler's comma-delimited percentile setting.""" + + return tuple( + int(percentile.strip()) + for percentile in value.split(",") + if percentile.strip() + ) + + class MeasureImageIntensityModule( LabelsObjectInputPolicy, PerObjectMeasurementExecutionModule, @@ -134,7 +144,11 @@ class MeasureImageIntensityModule( "calculate_percentiles", parse_cellprofiler_bool, ), - SettingToKeywordBinding(percentiles_setting, "percentiles", str), + SettingToKeywordBinding( + percentiles_setting, + "percentiles", + _parse_image_intensity_percentiles, + ), ) ignored_settings = ("Measure the intensity only from areas enclosed by objects?",) @@ -520,17 +534,20 @@ class ImageIntensityPercentileSpec: """Percentile calculation policy for image-intensity rows.""" enabled: bool = False - raw_percentiles: str = "10,90" + percentiles: tuple[int, ...] = (10, 90) + + def __post_init__(self) -> None: + if any( + isinstance(percentile, bool) + or not isinstance(percentile, int) + or not 0 <= percentile <= 100 + for percentile in self.percentiles + ): + raise ValueError("Image intensity percentiles must be integers from 0 to 100.") @property - def values(self) -> list[int]: - percentiles = [] - for percentile in self.raw_percentiles.replace(" ", "").split(","): - if percentile == "": - continue - if percentile.isdigit() and 0 <= int(percentile) <= 100: - percentiles.append(int(percentile)) - return sorted(set(percentiles)) + def values(self) -> tuple[int, ...]: + return tuple(sorted(set(self.percentiles))) def measurements_for(self, pixels: np.ndarray) -> dict[int, float]: if not self.enabled: @@ -1580,14 +1597,15 @@ def prepare_measure_object_intensity() -> None: def measure_image_intensity( image: np.ndarray, calculate_percentiles: bool = False, - percentiles: str = "10,90", + percentiles: tuple[int, ...] = (10, 90), ) -> tuple[np.ndarray, DataclassMeasurementColumnarRows]: """Measure intensity across the declared image.""" image_array = np.asarray(image) measurements = ImageIntensityMeasurement.from_pixels( image_array.flatten(), percentile_spec=ImageIntensityPercentileSpec( - enabled=calculate_percentiles, raw_percentiles=percentiles + enabled=calculate_percentiles, + percentiles=percentiles, ), ) return ( @@ -1605,7 +1623,7 @@ def measure_image_intensity_objects( image: np.ndarray, labels: ObjectLabelValue, calculate_percentiles: bool = False, - percentiles: str = "10,90", + percentiles: tuple[int, ...] = (10, 90), ) -> tuple[np.ndarray, DataclassMeasurementColumnarRows]: """Measure image intensity within one declared object set. @@ -1630,7 +1648,8 @@ def measure_image_intensity_objects( measurements = ImageIntensityMeasurement.from_pixels( image_array[label_array > 0].flatten(), percentile_spec=ImageIntensityPercentileSpec( - enabled=calculate_percentiles, raw_percentiles=percentiles + enabled=calculate_percentiles, + percentiles=percentiles, ), ) return ( @@ -1835,7 +1854,6 @@ def rescale_intensity( dest_high: Output intensity assigned to the upper destination endpoint when the selected method maps into a declared output range. """ - rescale_method = coerce_cellprofiler_enum(RescaleMethod, rescale_method) context = RescaleIntensityContext.from_settings( image, automatic_low=automatic_low, diff --git a/openhcs/processing/backends/cellprofiler/intensity_distribution.py b/openhcs/processing/backends/cellprofiler/intensity_distribution.py index d07556226..8fd0056be 100644 --- a/openhcs/processing/backends/cellprofiler/intensity_distribution.py +++ b/openhcs/processing/backends/cellprofiler/intensity_distribution.py @@ -2290,7 +2290,6 @@ def measure_object_intensity_distribution( function_name="measure_object_intensity_distribution" ) del center_choice - wants_zernikes = coerce_cellprofiler_enum(ZernikeMode, wants_zernikes) source_image_names = image_payload_metadata(image).source_image_names if len(source_image_names) != 1: raise ValueError( diff --git a/openhcs/processing/backends/cellprofiler/maxima.py b/openhcs/processing/backends/cellprofiler/maxima.py index 372729e7f..4a7fbb28a 100644 --- a/openhcs/processing/backends/cellprofiler/maxima.py +++ b/openhcs/processing/backends/cellprofiler/maxima.py @@ -167,7 +167,6 @@ def find_maxima( label_maxima: Assign a distinct positive label to each peak instead of a binary peak mask. """ - exclude_mode = coerce_cellprofiler_enum(ExcludeMode, exclude_mode) maxima, result = MaximaRequest( image=MaximaInputStrategy.for_exclude_mode(exclude_mode).image(image), min_distance=min_distance, diff --git a/openhcs/processing/backends/cellprofiler/median_filter.py b/openhcs/processing/backends/cellprofiler/median_filter.py index 5e6fc5a1b..33cb2c74e 100644 --- a/openhcs/processing/backends/cellprofiler/median_filter.py +++ b/openhcs/processing/backends/cellprofiler/median_filter.py @@ -38,6 +38,7 @@ from openhcs.processing.backends.cellprofiler.perf_fixtures import ( capture_array_fixture, ) +from openhcs.processing.backends.processors.method_axes import ScipyBoundaryMode from openhcs.processing.backends.lib_registry.unified_registry import ( ProcessingContract, ) @@ -55,10 +56,6 @@ class MedianFilterModule(CellProfilerModule): ) -CONSTANT_PADDING_MODE = "constant" -REFLECT_PADDING_MODE = "reflect" - - @dataclass(frozen=True, slots=True) class VectorizedMedianFilterPlan: """Execution plan for exact vectorized 3-D constant-mode median filtering.""" @@ -84,10 +81,10 @@ def __post_init__(self) -> None: raise ValueError("max_chunk_bytes must be positive.") def plan( - self, image: np.ndarray, *, window_size: int, mode: str + self, image: np.ndarray, *, window_size: int, mode: ScipyBoundaryMode ) -> VectorizedMedianFilterPlan | None: """Return an exact vectorized plan when the image fits this policy.""" - if image.ndim != 3 or mode != CONSTANT_PADDING_MODE: + if image.ndim != 3 or mode is not ScipyBoundaryMode.CONSTANT: return None if not np.issubdtype(image.dtype, np.number): return None @@ -136,7 +133,13 @@ class MedianFilterBackendStrategy( __skip_if_no_key__ = True @abstractmethod - def filter(self, image: np.ndarray, *, window_size: int, mode: str) -> np.ndarray: + def filter( + self, + image: np.ndarray, + *, + window_size: int, + mode: ScipyBoundaryMode, + ) -> np.ndarray: """Return a CellProfiler-compatible median-filtered image.""" @abstractmethod @@ -172,16 +175,22 @@ def prepare_backend(self) -> None: image = np.linspace(0.0, 1.0, 8 * 16 * 16, dtype=np.float32).reshape( (8, 16, 16) ) - self.filter(image, window_size=5, mode=CONSTANT_PADDING_MODE) - - def filter(self, image: np.ndarray, *, window_size: int, mode: str) -> np.ndarray: + self.filter(image, window_size=5, mode=ScipyBoundaryMode.CONSTANT) + + def filter( + self, + image: np.ndarray, + *, + window_size: int, + mode: ScipyBoundaryMode, + ) -> np.ndarray: data = np.asarray(image) normalized_window = self.normalized_window_size(window_size) capture_array_fixture( "medianfilter_input", image=data, window_size=np.asarray(normalized_window, dtype=np.int64), - mode=np.asarray(str(mode)), + mode=np.asarray(mode.value), ) if normalized_window <= 1: return data @@ -213,7 +222,11 @@ def filter_batch(self, request: RuntimePure2DSliceBatchRequest) -> list[np.ndarr ) if normalized_window <= 1: return list(slices_2d) - mode = kwargs.get("mode", CONSTANT_PADDING_MODE) + mode = kwargs.get("mode", ScipyBoundaryMode.CONSTANT) + if not isinstance(mode, ScipyBoundaryMode): + raise TypeError( + "MedianFilter runtime mode must be a ScipyBoundaryMode." + ) outputs = [ self.filter(np.asarray(slice_2d), window_size=normalized_window, mode=mode) for slice_2d in slices_2d @@ -221,7 +234,7 @@ def filter_batch(self, request: RuntimePure2DSliceBatchRequest) -> list[np.ndarr return outputs def vectorized_window_filter( - self, image: np.ndarray, window_size: int, mode: str + self, image: np.ndarray, window_size: int, mode: ScipyBoundaryMode ) -> np.ndarray | None: """Return an exact constant-mode median using NumPy's vectorized partition.""" plan = self.vectorized_memory_policy.plan( @@ -232,7 +245,12 @@ def vectorized_window_filter( from numpy.lib.stride_tricks import sliding_window_view pad_width = int(window_size) // 2 - padded = np.pad(image, pad_width, mode=CONSTANT_PADDING_MODE, constant_values=0) + padded = np.pad( + image, + pad_width, + mode=ScipyBoundaryMode.CONSTANT.value, + constant_values=0, + ) if plan.image_shape[0] <= plan.chunk_plane_capacity: windows = sliding_window_view(padded, plan.window_shape) flattened_windows = windows.reshape( @@ -261,19 +279,26 @@ def vectorized_window_filter( return filtered.astype(image.dtype, copy=False) def scipy_filter( - self, image: np.ndarray, window_size: int, mode: str + self, image: np.ndarray, window_size: int, mode: ScipyBoundaryMode ) -> np.ndarray: """Return SciPy's median filter result for the requested domain.""" from scipy.ndimage import median_filter as scipy_median_filter - filtered = scipy_median_filter(image, size=int(window_size), mode=mode) + filtered = scipy_median_filter( + image, + size=int(window_size), + mode=mode.value, + ) return filtered.astype(image.dtype, copy=False) def opencv_filter_2d( - self, image: np.ndarray, window_size: int, mode: str + self, image: np.ndarray, window_size: int, mode: ScipyBoundaryMode ) -> np.ndarray | None: """Return OpenCV's exact 2-D median result when its border mode matches.""" - if mode not in {CONSTANT_PADDING_MODE, REFLECT_PADDING_MODE}: + if mode not in { + ScipyBoundaryMode.CONSTANT, + ScipyBoundaryMode.REFLECT, + }: return None if image.dtype not in (np.uint8, np.uint16, np.float32, np.float64): return None @@ -283,12 +308,15 @@ def opencv_filter_2d( return None cv2_input_dtype = np.float32 if image.dtype == np.float64 else image.dtype cv2_input = np.ascontiguousarray(image, dtype=cv2_input_dtype) - if mode == REFLECT_PADDING_MODE: + if mode is ScipyBoundaryMode.REFLECT: filtered = cv2.medianBlur(cv2_input, int(window_size)) return filtered.astype(image.dtype, copy=False) pad_width = int(window_size) // 2 padded = np.pad( - cv2_input, pad_width, mode=CONSTANT_PADDING_MODE, constant_values=0 + cv2_input, + pad_width, + mode=ScipyBoundaryMode.CONSTANT.value, + constant_values=0, ) filtered = cv2.medianBlur(padded, int(window_size))[ pad_width:-pad_width, pad_width:-pad_width @@ -296,10 +324,10 @@ def opencv_filter_2d( return filtered.astype(image.dtype, copy=False) def rank_order_filter( - self, image: np.ndarray, window_size: int, mode: str + self, image: np.ndarray, window_size: int, mode: ScipyBoundaryMode ) -> np.ndarray | None: """Return an exact rank-median result for finite constant-mode volumes.""" - if image.ndim != 3 or mode != CONSTANT_PADDING_MODE: + if image.ndim != 3 or mode is not ScipyBoundaryMode.CONSTANT: return None if not np.issubdtype(image.dtype, np.integer) and ( not np.issubdtype(image.dtype, np.floating) @@ -318,7 +346,10 @@ def rank_order_filter( codes = np.searchsorted(levels, image).astype(np.uint16) pad_width = int(window_size) // 2 padded_codes = np.pad( - codes, pad_width, mode=CONSTANT_PADDING_MODE, constant_values=0 + codes, + pad_width, + mode=ScipyBoundaryMode.CONSTANT.value, + constant_values=0, ) filtered_codes = rank.median( padded_codes, @@ -342,7 +373,9 @@ def median_filter_backend( @runtime_image_execution_mode(ImagePayloadExecutionMode.FULL_STACK) @numpy(contract=ProcessingContract.FLEXIBLE) def medianfilter( - image: np.ndarray, window_size: int = 3, mode: str = CONSTANT_PADDING_MODE + image: np.ndarray, + window_size: int = 3, + mode: ScipyBoundaryMode = ScipyBoundaryMode.CONSTANT, ) -> np.ndarray: """Apply CellProfiler-compatible median filtering. @@ -352,7 +385,9 @@ def medianfilter( """ pixel_data = image_payload_data(image) filtered = median_filter_backend().filter( - np.asarray(pixel_data), window_size=int(window_size), mode=str(mode) + np.asarray(pixel_data), + window_size=int(window_size), + mode=mode, ) return with_image_payload_data(image, filtered) diff --git a/openhcs/processing/backends/cellprofiler/morphology.py b/openhcs/processing/backends/cellprofiler/morphology.py index 3760b07cd..4d5418ab0 100644 --- a/openhcs/processing/backends/cellprofiler/morphology.py +++ b/openhcs/processing/backends/cellprofiler/morphology.py @@ -817,8 +817,8 @@ class FillObjectsModeStrategy( strategy_label: ClassVar[str | None] = None @classmethod - def for_mode(cls, mode: FillMode | str) -> "FillObjectsModeStrategy": - return cls.for_enum_member(coerce_cellprofiler_enum(FillMode, mode)) + def for_mode(cls, mode: FillMode) -> "FillObjectsModeStrategy": + return cls.for_enum_member(mode) @abstractmethod def fill(self, request: FillObjectsRequest) -> np.ndarray: @@ -1470,7 +1470,7 @@ def morph( def _morph_image_pixels( image: np.ndarray, - structuring_element: StructuringElement | str, + structuring_element: StructuringElement, size: int, operation: Callable[[np.ndarray, np.ndarray], np.ndarray], ) -> np.ndarray: @@ -1482,7 +1482,7 @@ def _morph_image_pixels( def _morph_image_payload( image: np.ndarray, - structuring_element: StructuringElement | str, + structuring_element: StructuringElement, size: int, operation: Callable[[np.ndarray, np.ndarray], np.ndarray], ) -> np.ndarray: @@ -1860,7 +1860,7 @@ class MorphologyBackendStrategy( @classmethod def for_memory_type( cls, - memory_type: MemoryType | str = MemoryType.NUMPY, + memory_type: MemoryType = MemoryType.NUMPY, *, backend_provider: BackendProviderInput = DEFAULT_CELLPROFILER_BACKEND_SELECTION, prefer_centrosome: bool = False, @@ -4538,9 +4538,8 @@ class ExpandShrinkOperationStrategy( ] = () @classmethod - def for_mode(cls, mode: ExpandShrinkMode | str) -> "ExpandShrinkOperationStrategy": - resolved = coerce_cellprofiler_enum(ExpandShrinkMode, mode) - return cls.for_enum_member(resolved) + def for_mode(cls, mode: ExpandShrinkMode) -> "ExpandShrinkOperationStrategy": + return cls.for_enum_member(mode) @classmethod def mode_for_cellprofiler_operation( @@ -4931,7 +4930,7 @@ def prepare_expand_or_shrink_objects() -> None: def expand_or_shrink_objects( image: np.ndarray, labels: ObjectLabelValue, - mode: ExpandShrinkMode | str = ExpandShrinkMode.EXPAND_DEFINED_PIXELS, + mode: ExpandShrinkMode = ExpandShrinkMode.EXPAND_DEFINED_PIXELS, iterations: int = 1, fill_holes: bool = True, ) -> tuple[object, MeasurementSparseColumnarRows, ObjectLabelValue]: @@ -5280,10 +5279,6 @@ def mask_objects( mask: Binary image or object-label mask selecting the spatial region used to evaluate each source object. """ - overlap_handling = coerce_cellprofiler_enum( - MaskObjectsOverlapHandling, overlap_handling - ) - numbering = coerce_cellprofiler_enum(MaskObjectsNumberingChoice, numbering) relationship_backend = ObjectRelationshipBackendStrategy.for_memory_type( backend_provider=relationship_backend_provider ) @@ -5405,10 +5400,8 @@ class CombineObjectsStrategy( method: ClassVar[CombineObjectsMethod | None] = None @classmethod - def for_method(cls, method: CombineObjectsMethod | str) -> "CombineObjectsStrategy": - return cls.for_enum_member( - coerce_cellprofiler_enum(CombineObjectsMethod, method) - ) + def for_method(cls, method: CombineObjectsMethod) -> "CombineObjectsStrategy": + return cls.for_enum_member(method) @abstractmethod def combine(self, labels_x: np.ndarray, labels_y: np.ndarray) -> np.ndarray: @@ -5508,7 +5501,7 @@ def combine(self, labels_x: np.ndarray, labels_y: np.ndarray) -> np.ndarray: def combineobjects( image: np.ndarray, object_labels: tuple[ObjectLabelValue, ...], - method: CombineObjectsMethod | str = CombineObjectsMethod.MERGE, + method: CombineObjectsMethod = CombineObjectsMethod.MERGE, ) -> tuple[np.ndarray, DataclassMeasurementColumnarRows, ObjectLabelValue]: """Combine objects from two label images using CellProfiler policies. @@ -5572,21 +5565,13 @@ class SplitOrMergeInputTopology(Enum): def from_values( cls, *, - operation: SplitOrMergeOperation | str, - merge_method: SplitOrMergeMergeMethod | str, + operation: SplitOrMergeOperation, + merge_method: SplitOrMergeMergeMethod, use_guide_image: bool, ) -> "SplitOrMergeInputTopology": - operation_member = coerce_cellprofiler_enum( - SplitOrMergeOperation, - operation, - ) - if operation_member is SplitOrMergeOperation.SPLIT: + if operation is SplitOrMergeOperation.SPLIT: return cls.LABELS_ONLY - merge_method_member = coerce_cellprofiler_enum( - SplitOrMergeMergeMethod, - merge_method, - ) - if merge_method_member is SplitOrMergeMergeMethod.PER_PARENT: + if merge_method is SplitOrMergeMergeMethod.PER_PARENT: return cls.PARENT_OBJECTS return cls.GUIDE_IMAGE if use_guide_image else cls.LABELS_ONLY @@ -5632,11 +5617,9 @@ class SplitOrMergeOperationStrategy( @classmethod def for_operation( - cls, operation: SplitOrMergeOperation | str + cls, operation: SplitOrMergeOperation ) -> "SplitOrMergeOperationStrategy": - return cls.for_enum_member( - coerce_cellprofiler_enum(SplitOrMergeOperation, operation) - ) + return cls.for_enum_member(operation) @abstractmethod def execute(self, request: SplitOrMergeRequest) -> np.ndarray: @@ -5681,11 +5664,9 @@ class SplitOrMergeMergeMethodStrategy( @classmethod def for_method( - cls, method: SplitOrMergeMergeMethod | str + cls, method: SplitOrMergeMergeMethod ) -> "SplitOrMergeMergeMethodStrategy": - return cls.for_enum_member( - coerce_cellprofiler_enum(SplitOrMergeMergeMethod, method) - ) + return cls.for_enum_member(method) @abstractmethod def merge(self, request: SplitOrMergeRequest) -> np.ndarray: @@ -5823,17 +5804,13 @@ def _execute_split_or_merge_objects( request = SplitOrMergeRequest( image=image, labels=labels_array, - operation=coerce_cellprofiler_enum(SplitOrMergeOperation, operation), - merge_method=coerce_cellprofiler_enum(SplitOrMergeMergeMethod, merge_method), - output_object_type=coerce_cellprofiler_enum( - SplitOrMergeOutputObjectType, output_object_type - ), + operation=operation, + merge_method=merge_method, + output_object_type=output_object_type, distance_threshold=distance_threshold, use_guide_image=use_guide_image, minimum_intensity_fraction=minimum_intensity_fraction, - intensity_method=coerce_cellprofiler_enum( - SplitOrMergeIntensityMethod, intensity_method - ), + intensity_method=intensity_method, parent_labels=parent_array, morphology_backend_provider=morphology_backend_provider, ) @@ -6637,7 +6614,7 @@ def resize_objects( original_shape = labels.shape request = ResizeObjectsRequest( labels=labels, - method=coerce_cellprofiler_enum(ResizeObjectsMethod, method), + method=method, factor_x=factor_x, factor_y=factor_y, factor_z=factor_z, @@ -6757,7 +6734,7 @@ def resize_objects_3d( original_shape = labels.shape request = ResizeObjectsRequest( labels=labels, - method=coerce_cellprofiler_enum(ResizeObjectsMethod, method), + method=method, factor_x=factor_x, factor_y=factor_y, factor_z=factor_z, @@ -6957,12 +6934,18 @@ class SplitOrMergeObjectsModule( @classmethod def input_topology(cls, module: "ModuleBlock") -> SplitOrMergeInputTopology: return SplitOrMergeInputTopology.from_values( - operation=required_setting_value( - module, cls.operation_binding.setting_name + operation=coerce_cellprofiler_enum( + SplitOrMergeOperation, + required_setting_value( + module, cls.operation_binding.setting_name + ), ), - merge_method=required_setting_value( - module, - cls.merge_method_binding.setting_name, + merge_method=coerce_cellprofiler_enum( + SplitOrMergeMergeMethod, + required_setting_value( + module, + cls.merge_method_binding.setting_name, + ), ), use_guide_image=parse_cellprofiler_bool( required_setting_value( diff --git a/openhcs/processing/backends/cellprofiler/object_filtering.py b/openhcs/processing/backends/cellprofiler/object_filtering.py index b7b7d9bb6..d79bb5167 100644 --- a/openhcs/processing/backends/cellprofiler/object_filtering.py +++ b/openhcs/processing/backends/cellprofiler/object_filtering.py @@ -2121,11 +2121,6 @@ def filter_objects( """ if len(object_labels) == 0: raise ValueError("FilterObjects requires at least one object label input.") - mode = coerce_cellprofiler_enum(FilterMode, mode) - filter_method = coerce_cellprofiler_enum(FilterMethod, filter_method) - per_object_assignment = coerce_cellprofiler_enum( - PerObjectAssignment, per_object_assignment - ) if additional_object_count != len(object_labels) - 1: raise ValueError( "FilterObjects additional_object_count must match additional object label inputs." diff --git a/openhcs/processing/backends/cellprofiler/object_images.py b/openhcs/processing/backends/cellprofiler/object_images.py index 2d86791c4..cd5c3904d 100644 --- a/openhcs/processing/backends/cellprofiler/object_images.py +++ b/openhcs/processing/backends/cellprofiler/object_images.py @@ -21,6 +21,7 @@ special_inputs, ) from openhcs.core.public_api import public_names_from_objects +from openhcs.core.registry_strategies import EnumKeyedStrategyMixin from openhcs.core.runtime_image_values import ( ImagePayloadMetadata, image_payload_metadata, @@ -108,18 +109,16 @@ class ObjectConversionStats(MeasurementFeatureRecord): total_area: int -class ImageModeRenderer(ABC, metaclass=AutoRegisterMeta): +class ImageModeRenderer( + EnumKeyedStrategyMixin[ImageMode], + ABC, + metaclass=AutoRegisterMeta, +): """Render object labels for one closed ImageMode case.""" - __registry_key__ = "image_mode_label" - __skip_if_no_key__ = True - image_mode_label: ClassVar[str | None] = None + __enum_member_attr__ = "image_mode" image_mode: ClassVar[ImageMode | None] = None - @classmethod - def for_image_mode(cls, image_mode: ImageMode) -> "ImageModeRenderer": - return cls.__registry__[image_mode.value]() - @abstractmethod def render(self, labels: np.ndarray, *, colormap_value: str) -> np.ndarray: """Return one rendered image payload for the requested ImageMode.""" @@ -127,7 +126,6 @@ def render(self, labels: np.ndarray, *, colormap_value: str) -> np.ndarray: class BinaryImageModeRenderer(ImageModeRenderer): image_mode = ImageMode.BINARY - image_mode_label = image_mode.value def render(self, labels: np.ndarray, *, colormap_value: str) -> np.ndarray: del colormap_value @@ -136,7 +134,6 @@ def render(self, labels: np.ndarray, *, colormap_value: str) -> np.ndarray: class GrayscaleImageModeRenderer(ImageModeRenderer): image_mode = ImageMode.GRAYSCALE - image_mode_label = image_mode.value def render(self, labels: np.ndarray, *, colormap_value: str) -> np.ndarray: del colormap_value @@ -148,7 +145,6 @@ def render(self, labels: np.ndarray, *, colormap_value: str) -> np.ndarray: class ColorImageModeRenderer(ImageModeRenderer): image_mode = ImageMode.COLOR - image_mode_label = image_mode.value def render(self, labels: np.ndarray, *, colormap_value: str) -> np.ndarray: max_label = labels.max() @@ -163,7 +159,6 @@ def render(self, labels: np.ndarray, *, colormap_value: str) -> np.ndarray: class Uint16ImageModeRenderer(ImageModeRenderer): image_mode = ImageMode.UINT16 - image_mode_label = image_mode.value def render(self, labels: np.ndarray, *, colormap_value: str) -> np.ndarray: del colormap_value @@ -258,8 +253,7 @@ def convert_objects_to_image( """ del image label_array = object_label_dense_array(labels, dtype=np.int32) - resolved_image_mode = coerce_cellprofiler_enum(ImageMode, image_mode) - rendered = ImageModeRenderer.for_image_mode(resolved_image_mode).render( + rendered = ImageModeRenderer.for_enum_member(image_mode).render( label_array, colormap_value=colormap_value ) label_metadata = image_payload_metadata(labels) @@ -267,7 +261,7 @@ def convert_objects_to_image( ImagePayloadMetadata(intensity_scale=1.0).with_source_context_from( label_metadata ) - if resolved_image_mode is ImageMode.UINT16 + if image_mode is ImageMode.UINT16 else label_metadata ) return with_image_payload_data( diff --git a/openhcs/processing/backends/cellprofiler/outlines.py b/openhcs/processing/backends/cellprofiler/outlines.py index 94269a17b..50e304e36 100644 --- a/openhcs/processing/backends/cellprofiler/outlines.py +++ b/openhcs/processing/backends/cellprofiler/outlines.py @@ -879,10 +879,10 @@ def overlay_outlines( image: np.ndarray, *, blank_image: bool = False, - display_mode: OutlineDisplayMode | str = OutlineDisplayMode.COLOR, - line_mode: LineMode | str = LineMode.INNER, - max_type: MaxType | str = MaxType.MAX_IMAGE, - outline_source_kinds: Sequence[OutlineSourceKind | str] = ( + display_mode: OutlineDisplayMode = OutlineDisplayMode.COLOR, + line_mode: LineMode = LineMode.INNER, + max_type: MaxType = MaxType.MAX_IMAGE, + outline_source_kinds: Sequence[OutlineSourceKind] = ( OutlineSourceKind.OBJECTS, ), outline_colors: Sequence[str | Sequence[float]] = ("Red",), @@ -898,9 +898,9 @@ def overlay_outlines( rows=_runtime_rows(outline_source_kinds, outline_colors), object_labels=tuple(object_labels), blank_image=blank_image, - display_mode=coerce_cellprofiler_enum(OutlineDisplayMode, display_mode), - line_mode=coerce_cellprofiler_enum(LineMode, line_mode), - max_type=coerce_cellprofiler_enum(MaxType, max_type), + display_mode=display_mode, + line_mode=line_mode, + max_type=max_type, ) image_sources = _image_sources_from_payload( image, blank_image=context.blank_image, image_row_count=context.image_row_count @@ -1064,7 +1064,7 @@ def _overlay_objects_color_table(colormap: str, label_count: int) -> np.ndarray: def _runtime_rows( - source_kinds: Sequence[OutlineSourceKind | str], + source_kinds: Sequence[OutlineSourceKind], colors: Sequence[str | Sequence[float]], ) -> tuple[OverlayOutlineRuntimeRow, ...]: if not source_kinds: diff --git a/openhcs/processing/backends/cellprofiler/primary_objects.py b/openhcs/processing/backends/cellprofiler/primary_objects.py index 4cdb0807a..bdbee9f10 100644 --- a/openhcs/processing/backends/cellprofiler/primary_objects.py +++ b/openhcs/processing/backends/cellprofiler/primary_objects.py @@ -223,13 +223,6 @@ def identify_primary_objects( morphology = MorphologyBackendStrategy.for_callable( identify_primary_objects, backend_provider=morphology_backend_provider ) - unclump_method = coerce_cellprofiler_enum(UnclumpMethod, unclump_method) - watershed_method = coerce_cellprofiler_enum(WatershedMethod, watershed_method) - fill_holes = coerce_cellprofiler_enum(FillHolesOption, fill_holes) - limit_erase = coerce_cellprofiler_enum(ExcessObjectHandling, limit_erase) - threshold_method = coerce_cellprofiler_enum( - CellProfilerThresholdMethod, threshold_method - ) runtime_profiler.log( "ipo_prepare_inputs", time.perf_counter() - phase_started_at, diff --git a/openhcs/processing/backends/cellprofiler/projection.py b/openhcs/processing/backends/cellprofiler/projection.py index fbf805fb2..6f2b4917b 100644 --- a/openhcs/processing/backends/cellprofiler/projection.py +++ b/openhcs/processing/backends/cellprofiler/projection.py @@ -191,7 +191,6 @@ def make_projection( frequency: float = 6.0, ) -> tuple[np.ndarray, DataclassMeasurementColumnarRows]: """Combine a stack of 2-D images into a single 2-D projection image.""" - projection_type = coerce_cellprofiler_enum(ProjectionType, projection_type) request = ProjectionRequest( image=image, projection_type=projection_type, frequency=frequency ) diff --git a/openhcs/processing/backends/cellprofiler/relationships.py b/openhcs/processing/backends/cellprofiler/relationships.py index 3b003718d..d5514389f 100644 --- a/openhcs/processing/backends/cellprofiler/relationships.py +++ b/openhcs/processing/backends/cellprofiler/relationships.py @@ -1249,10 +1249,7 @@ def distance_method(self) -> RelateObjectsDistanceMethod: **runtime_callable_defaults(func), **self.request.call_kwargs, } - return coerce_cellprofiler_enum( - RelateObjectsDistanceMethod, - call_kwargs["calculate_distances"], - ) + return call_kwargs["calculate_distances"] def distance_measurements_declared(self) -> bool: distance_method = self.distance_method() @@ -1915,9 +1912,7 @@ def _relate_objects_result( image: np.ndarray, parent_labels: ObjectLabelValue, child_labels: ObjectLabelValue, - calculate_distances: ( - RelateObjectsDistanceMethod | str - ) = RelateObjectsDistanceMethod.BOTH, + calculate_distances: RelateObjectsDistanceMethod = RelateObjectsDistanceMethod.BOTH, calculate_per_parent_means: bool = False, save_children_with_parents: bool = False, relationship_backend_provider: BackendProviderInput = DEFAULT_CELLPROFILER_BACKEND_SELECTION, @@ -2018,9 +2013,7 @@ def relate_objects( image: np.ndarray, parent_labels: ParentObjectLabelsInput, child_labels: ChildObjectLabelsInput, - calculate_distances: ( - RelateObjectsDistanceMethod | str - ) = RelateObjectsDistanceMethod.BOTH, + calculate_distances: RelateObjectsDistanceMethod = RelateObjectsDistanceMethod.BOTH, calculate_per_parent_means: bool = False, calculate_distances_to_other_parents: bool = False, relationship_backend_provider: BackendProviderInput = DEFAULT_CELLPROFILER_BACKEND_SELECTION, @@ -2060,9 +2053,7 @@ def relate_objects_with_saved_children( image: np.ndarray, parent_labels: ParentObjectLabelsInput, child_labels: ChildObjectLabelsInput, - calculate_distances: ( - RelateObjectsDistanceMethod | str - ) = RelateObjectsDistanceMethod.BOTH, + calculate_distances: RelateObjectsDistanceMethod = RelateObjectsDistanceMethod.BOTH, calculate_per_parent_means: bool = False, calculate_distances_to_other_parents: bool = False, relationship_backend_provider: BackendProviderInput = DEFAULT_CELLPROFILER_BACKEND_SELECTION, diff --git a/openhcs/processing/backends/cellprofiler/save_images.py b/openhcs/processing/backends/cellprofiler/save_images.py index 16623abbd..e518e54cc 100644 --- a/openhcs/processing/backends/cellprofiler/save_images.py +++ b/openhcs/processing/backends/cellprofiler/save_images.py @@ -172,7 +172,7 @@ class SaveImagesOutputLocation(str, Enum): DEFAULT_OUTPUT_SUBFOLDER = "default_output_subfolder" @classmethod - def relative_directory(cls, value: str | None) -> str | None: + def relative_directory_from_literal(cls, value: str | None) -> str | None: """Lower one CellProfiler output location to a relative directory.""" if value is None: @@ -268,7 +268,7 @@ def _optional_text(value: str) -> str | None: def _parse_output_location_setting(value: str) -> str | None: - return SaveImagesOutputLocation.relative_directory(value) + return SaveImagesOutputLocation.relative_directory_from_literal(value) def _relative_template(directory: str | None, filename: str) -> str: @@ -869,7 +869,7 @@ def _materialization_spec( optional_setting_value(module, cls.single_file_name_setting) or "SavedImage" ).strip() sequential_prefix = single_name - directory = SaveImagesOutputLocation.relative_directory( + directory = SaveImagesOutputLocation.relative_directory_from_literal( optional_setting_value(module, cls.output_location_setting) ) relative_path_template = filename_method.relative_path_template( @@ -898,14 +898,12 @@ def _materialization_spec( def _converted_saved_image( image_to_save: RuntimeArrayData, *, - image_kind: SaveImagesImageKind | str, - bit_depth: SaveImagesBitDepth | str, + image_kind: SaveImagesImageKind, + bit_depth: SaveImagesBitDepth, ) -> RuntimeArrayData: - resolved_image_kind = coerce_cellprofiler_enum(SaveImagesImageKind, image_kind) - if resolved_image_kind is not SaveImagesImageKind.IMAGE: + if image_kind is not SaveImagesImageKind.IMAGE: raise ValueError("save_images supports image payloads only.") - resolved_bit_depth = coerce_cellprofiler_enum(SaveImagesBitDepth, bit_depth) - return resolved_bit_depth.convert(image_to_save) + return bit_depth.convert(image_to_save) def _recorded_save_images_rows( @@ -913,34 +911,29 @@ def _recorded_save_images_rows( *, slice_index: int, saved_image_name: str, - filename_method: SaveImagesFilenameMethod | str, + filename_method: SaveImagesFilenameMethod, single_file_name: str, number_of_digits: int, append_suffix: bool, filename_suffix: str, - file_format: SaveImagesFileFormat | str, + file_format: SaveImagesFileFormat, output_location: str | None, ) -> DataclassMeasurementColumnarRows: - resolved_method = coerce_cellprofiler_enum( - SaveImagesFilenameMethod, - filename_method, - ) - resolved_format = coerce_cellprofiler_enum(SaveImagesFileFormat, file_format) suffix = filename_suffix if append_suffix else "" - if resolved_method is SaveImagesFilenameMethod.FROM_IMAGE_FILENAME: + if filename_method is SaveImagesFilenameMethod.FROM_IMAGE_FILENAME: source_path = image_payload_metadata(filename_source).source_path source_stem = ( PurePosixPath(str(source_path).replace("\\", "/")).stem if source_path is not None else saved_image_name ) - filename = f"{source_stem}{suffix}{resolved_format.value}" - elif resolved_method is SaveImagesFilenameMethod.SEQUENTIAL_NUMBERS: + filename = f"{source_stem}{suffix}{file_format.value}" + elif filename_method is SaveImagesFilenameMethod.SEQUENTIAL_NUMBERS: filename = ( - f"{single_file_name}{1:0{number_of_digits}d}{suffix}{resolved_format.value}" + f"{single_file_name}{1:0{number_of_digits}d}{suffix}{file_format.value}" ) else: - filename = f"{single_file_name}{suffix}{resolved_format.value}" + filename = f"{single_file_name}{suffix}{file_format.value}" relative_path = _relative_template(output_location, filename) pathname = PurePosixPath(relative_path).parent.as_posix() if pathname == ".": @@ -970,22 +963,22 @@ def save_images( image: RuntimeArrayData, *, image_to_save: RuntimeArrayData, - image_kind: SaveImagesImageKind | str = SaveImagesImageKind.IMAGE, - filename_method: SaveImagesFilenameMethod | str = ( + image_kind: SaveImagesImageKind = SaveImagesImageKind.IMAGE, + filename_method: SaveImagesFilenameMethod = ( SaveImagesFilenameMethod.FROM_IMAGE_FILENAME ), single_file_name: str = "SavedImage", number_of_digits: int = 4, append_suffix: bool = False, filename_suffix: str = "", - file_format: SaveImagesFileFormat | str = SaveImagesFileFormat.TIFF, + file_format: SaveImagesFileFormat = SaveImagesFileFormat.TIFF, output_location: str | None = None, - bit_depth: SaveImagesBitDepth | str = SaveImagesBitDepth.NATIVE, + bit_depth: SaveImagesBitDepth = SaveImagesBitDepth.NATIVE, overwrite: bool = True, - when_to_save: SaveImagesWhen | str = SaveImagesWhen.EVERY_CYCLE, + when_to_save: SaveImagesWhen = SaveImagesWhen.EVERY_CYCLE, create_subfolders: bool = False, base_image_folder: str | None = None, - series_axis: SaveImagesSeriesAxis | str = SaveImagesSeriesAxis.TIMEPOINT, + series_axis: SaveImagesSeriesAxis = SaveImagesSeriesAxis.TIMEPOINT, lossless_compression: bool = True, ) -> tuple[RuntimeArrayData, RuntimeArrayData]: """Prepare a selected image or object set for saving without replacing the pipeline image. @@ -1024,22 +1017,22 @@ def save_images_with_measurements( *, image_to_save: RuntimeArrayData, saved_image_name: str, - image_kind: SaveImagesImageKind | str = SaveImagesImageKind.IMAGE, - filename_method: SaveImagesFilenameMethod | str = ( + image_kind: SaveImagesImageKind = SaveImagesImageKind.IMAGE, + filename_method: SaveImagesFilenameMethod = ( SaveImagesFilenameMethod.FROM_IMAGE_FILENAME ), single_file_name: str = "SavedImage", number_of_digits: int = 4, append_suffix: bool = False, filename_suffix: str = "", - file_format: SaveImagesFileFormat | str = SaveImagesFileFormat.TIFF, + file_format: SaveImagesFileFormat = SaveImagesFileFormat.TIFF, output_location: str | None = None, - bit_depth: SaveImagesBitDepth | str = SaveImagesBitDepth.NATIVE, + bit_depth: SaveImagesBitDepth = SaveImagesBitDepth.NATIVE, overwrite: bool = True, - when_to_save: SaveImagesWhen | str = SaveImagesWhen.EVERY_CYCLE, + when_to_save: SaveImagesWhen = SaveImagesWhen.EVERY_CYCLE, create_subfolders: bool = False, base_image_folder: str | None = None, - series_axis: SaveImagesSeriesAxis | str = SaveImagesSeriesAxis.TIMEPOINT, + series_axis: SaveImagesSeriesAxis = SaveImagesSeriesAxis.TIMEPOINT, lossless_compression: bool = True, slice_index: int = 0, ) -> tuple[RuntimeArrayData, RuntimeArrayData, DataclassMeasurementColumnarRows]: diff --git a/openhcs/processing/backends/cellprofiler/secondary.py b/openhcs/processing/backends/cellprofiler/secondary.py index 95485691a..ec17c51a7 100644 --- a/openhcs/processing/backends/cellprofiler/secondary.py +++ b/openhcs/processing/backends/cellprofiler/secondary.py @@ -34,7 +34,10 @@ special_inputs, ) from openhcs.core.public_api import public_names_from_objects -from openhcs.core.registry_strategies import RegisteredLeafClassSpec +from openhcs.core.registry_strategies import ( + EnumKeyedStrategyMixin, + RegisteredLeafClassSpec, +) from openhcs.core.runtime_image_values import ( image_payload_data, image_payload_mask, @@ -308,7 +311,6 @@ def artifact_contract_outputs( logger = logging.getLogger(__name__) runtime_profiler = CellProfilerRuntimeProfiler(logger) -METHOD_LABEL_REGISTRY_KEY = "method_label" ClassNamespaceValue: TypeAlias = ( str | bool @@ -410,39 +412,14 @@ def image_plane(self) -> np.ndarray: return np.asarray(self.inputs.image) -class MethodLabelAutoRegisterMeta(AutoRegisterMeta): - """AutoRegisterMeta variant for method-label keyed strategy families.""" - - __registry_key__ = METHOD_LABEL_REGISTRY_KEY - __skip_if_no_key__ = True - - def __new__( - mcs, - name: str, - bases: tuple[type, ...], - attrs: dict[str, ClassNamespaceValue], - **kwargs: ClassNamespaceValue, - ) -> type: - attrs.setdefault("__registry_key__", mcs.__registry_key__) - attrs.setdefault("__skip_if_no_key__", mcs.__skip_if_no_key__) - return super().__new__(mcs, name, bases, attrs, **kwargs) - - def for_method(cls, method: Enum) -> "MethodLabelRegistryRoot": - return cls.__registry__[method.value]() - - -class MethodLabelRegistryRoot(ABC): - """Shared declaration surface for method-label keyed registries.""" - - stable_key_axis: ClassVar[str] = METHOD_LABEL_REGISTRY_KEY - method_label: ClassVar[str | None] = None - - class SecondarySegmentationStrategy( - MethodLabelRegistryRoot, metaclass=MethodLabelAutoRegisterMeta + EnumKeyedStrategyMixin[SecondaryMethod], + ABC, + metaclass=AutoRegisterMeta, ): """Segmentation strategy for one closed secondary-object method.""" + __enum_member_attr__ = "method" method: ClassVar[SecondaryMethod | None] = None default_propagation_backend_provider: ClassVar[BackendProviderInput] = ( DEFAULT_CELLPROFILER_BACKEND_SELECTION @@ -501,7 +478,6 @@ def _segment_non_empty(self, request: SecondarySegmentationRequest) -> np.ndarra class DistanceOnlySegmentationStrategy(SecondarySegmentationStrategy): method = SecondaryMethod.DISTANCE_N - method_label = method.value def _segment_non_empty(self, request: SecondarySegmentationRequest) -> np.ndarray: return SecondaryDistanceTransformBackendStrategy.for_memory_type( @@ -513,7 +489,6 @@ def _segment_non_empty(self, request: SecondarySegmentationRequest) -> np.ndarra class DistanceMaskedSegmentationStrategy(SecondarySegmentationStrategy): method = SecondaryMethod.DISTANCE_B - method_label = method.value default_propagation_backend_provider = CellProfilerBackendProvider.NUMBA def _segment_non_empty(self, request: SecondarySegmentationRequest) -> np.ndarray: @@ -530,7 +505,6 @@ def _segment_non_empty(self, request: SecondarySegmentationRequest) -> np.ndarra class PropagationSegmentationStrategy(SecondarySegmentationStrategy): method = SecondaryMethod.PROPAGATION - method_label = method.value default_propagation_backend_provider = CellProfilerBackendProvider.NUMBA def _segment_non_empty(self, request: SecondarySegmentationRequest) -> np.ndarray: @@ -541,7 +515,6 @@ def _segment_non_empty(self, request: SecondarySegmentationRequest) -> np.ndarra class GradientWatershedSegmentationStrategy(SecondarySegmentationStrategy): method = SecondaryMethod.WATERSHED_GRADIENT - method_label = method.value def _segment_non_empty(self, request: SecondarySegmentationRequest) -> np.ndarray: from scipy.ndimage import sobel @@ -554,7 +527,6 @@ def _segment_non_empty(self, request: SecondarySegmentationRequest) -> np.ndarra class ImageWatershedSegmentationStrategy(SecondarySegmentationStrategy): method = SecondaryMethod.WATERSHED_IMAGE - method_label = method.value def _segment_non_empty(self, request: SecondarySegmentationRequest) -> np.ndarray: return self.watershed_secondary_labels(request, 1.0 - request.image_plane) @@ -946,10 +918,13 @@ def object_count(self) -> int: class ThresholdCalculator( - MethodLabelRegistryRoot, metaclass=MethodLabelAutoRegisterMeta + EnumKeyedStrategyMixin[ThresholdMethod], + ABC, + metaclass=AutoRegisterMeta, ): """Threshold strategy for one closed CellProfiler threshold method.""" + __enum_member_attr__ = "method" method: ClassVar[ThresholdMethod | None] = None primitive: ClassVar[ Callable[[ThresholdPrimitiveBackendStrategy, np.ndarray], float] @@ -983,7 +958,6 @@ def concrete_primitive( return { "method": self.method, - "method_label": self.method.value, "primitive": staticmethod(concrete_primitive), } @@ -1058,21 +1032,6 @@ def _secondary_seed_label_filter_numba( return output -def _coerce_threshold_method( - threshold_method: CellProfilerThresholdMethod | ThresholdMethod | str, -) -> CellProfilerThresholdMethod: - if isinstance(threshold_method, CellProfilerThresholdMethod): - return threshold_method - if isinstance(threshold_method, str): - return coerce_cellprofiler_enum(CellProfilerThresholdMethod, threshold_method) - return { - ThresholdMethod.OTSU: CellProfilerThresholdMethod.OTSU, - ThresholdMethod.LI: CellProfilerThresholdMethod.LI, - ThresholdMethod.MINIMUM: CellProfilerThresholdMethod.MINIMUM_CROSS_ENTROPY, - ThresholdMethod.TRIANGLE: CellProfilerThresholdMethod.TRIANGLE, - }[threshold_method] - - def _filter_labels(labels_out: np.ndarray, primary_labels: np.ndarray) -> np.ndarray: """Keep secondary labels associated with accepted primary labels.""" max_out = int(np.max(labels_out)) @@ -1267,7 +1226,6 @@ def _execute_identify_secondary_objects( """ profile_total_started_at = time.perf_counter() phase_started_at = time.perf_counter() - method = coerce_cellprofiler_enum(SecondaryMethod, method) morphology = MorphologyBackendStrategy.for_callable( identify_secondary_objects, backend_provider=morphology_backend_provider ) @@ -1299,7 +1257,7 @@ def _execute_identify_secondary_objects( settings=CellProfilerThresholdSettings( use_advanced_settings=True, threshold_scope=threshold_scope, - threshold_method=_coerce_threshold_method(threshold_method), + threshold_method=threshold_method, threshold_smoothing_scale=threshold_smoothing_scale, threshold_correction_factor=threshold_correction_factor, threshold_min=threshold_min, @@ -1325,7 +1283,7 @@ def _execute_identify_secondary_objects( method=method.value, ) phase_started_at = time.perf_counter() - raw_labels = SecondarySegmentationStrategy.for_method(method).segment( + raw_labels = SecondarySegmentationStrategy.for_enum_member(method).segment( SecondarySegmentationRequest( inputs=SourceImageObjectLabelBuildRequest( image=img, labels=inputs.labels, unedited_labels=inputs.unedited_labels diff --git a/openhcs/processing/backends/cellprofiler/smoothing.py b/openhcs/processing/backends/cellprofiler/smoothing.py index a7bb1e54f..0e75c7f4e 100644 --- a/openhcs/processing/backends/cellprofiler/smoothing.py +++ b/openhcs/processing/backends/cellprofiler/smoothing.py @@ -627,7 +627,7 @@ def _fit_polynomial( def smooth_image( image: np.ndarray, - smoothing_method: SmoothingMethod | str = SmoothingMethod.GAUSSIAN_FILTER, + smoothing_method: SmoothingMethod = SmoothingMethod.GAUSSIAN_FILTER, auto_object_size: bool = True, object_size: float = 16.0, edge_intensity_difference: float = 0.1, @@ -635,7 +635,6 @@ def smooth_image( smoothing_backend_provider: BackendProviderInput = DEFAULT_CELLPROFILER_BACKEND_SELECTION, ) -> np.ndarray: """Smooth one image payload using CellProfiler-compatible semantics.""" - smoothing_method = coerce_cellprofiler_enum(SmoothingMethod, smoothing_method) pixel_data = np.asarray(image_payload_data(image), dtype=np.float32) backend_provider = SmoothingBackendProviderPolicy.resolve( smoothing_method, @@ -724,8 +723,8 @@ def reducenoise( def smooth_batch(request: RuntimePure2DSliceBatchRequest) -> list[Any]: slices_2d = request.slices_2d kwargs = request.kwargs - smoothing_method = coerce_cellprofiler_enum( - SmoothingMethod, kwargs.get("smoothing_method", SmoothingMethod.GAUSSIAN_FILTER) + smoothing_method = kwargs.get( + "smoothing_method", SmoothingMethod.GAUSSIAN_FILTER ) pixel_stack = np.ascontiguousarray( np.stack( diff --git a/openhcs/processing/backends/cellprofiler/structuring_elements.py b/openhcs/processing/backends/cellprofiler/structuring_elements.py index 5b8f5f1e4..df847ee6b 100644 --- a/openhcs/processing/backends/cellprofiler/structuring_elements.py +++ b/openhcs/processing/backends/cellprofiler/structuring_elements.py @@ -133,27 +133,17 @@ def build(self, size: int) -> np.ndarray: return octahedron(size) -def coerce_structuring_element( - structuring_element: StructuringElement | str, -) -> StructuringElement: - """Coerce CellProfiler setting text into the closed shape enum.""" - return ( - structuring_element - if isinstance(structuring_element, StructuringElement) - else StructuringElement(structuring_element.casefold()) - ) - - def build_structuring_element( - structuring_element: StructuringElement | str, + structuring_element: StructuringElement, size: int, ) -> np.ndarray: """Build the requested skimage structuring element.""" if size <= 0: raise ValueError(f"Structuring element size must be positive: {size!r}") - resolved_structuring_element = coerce_structuring_element(structuring_element) + if not isinstance(structuring_element, StructuringElement): + raise TypeError("Structuring element must be a StructuringElement member") return StructuringElementFactory.for_structuring_element( - resolved_structuring_element + structuring_element ).build(size) diff --git a/openhcs/processing/backends/cellprofiler/thresholding.py b/openhcs/processing/backends/cellprofiler/thresholding.py index 547290f77..2b87bc6e7 100644 --- a/openhcs/processing/backends/cellprofiler/thresholding.py +++ b/openhcs/processing/backends/cellprofiler/thresholding.py @@ -263,8 +263,8 @@ class GlobalThresholdKeywordArguments(TypedDict, total=False): lower_outlier_fraction: float upper_outlier_fraction: float - averaging_method: CellProfilerAveragingMethod | str - variance_method: CellProfilerVarianceMethod | str + averaging_method: CellProfilerAveragingMethod + variance_method: CellProfilerVarianceMethod number_of_deviations: float nbins: int window_size: int @@ -286,15 +286,11 @@ def __call__( image: np.ndarray, *, mask: np.ndarray | None = None, - threshold_method: ( - CellProfilerThresholdMethod | str - ) = CellProfilerThresholdMethod.OTSU, + threshold_method: CellProfilerThresholdMethod = CellProfilerThresholdMethod.OTSU, threshold_min: float = 0, threshold_max: float = 1, threshold_correction_factor: float = 1, - assign_middle_to_foreground: ( - CellProfilerThresholdAssignment | str - ) = CellProfilerThresholdAssignment.FOREGROUND, + assign_middle_to_foreground: CellProfilerThresholdAssignment = CellProfilerThresholdAssignment.FOREGROUND, log_transform: bool = False, proven_unit_interval_scale: int | None = None, method_parameters: "GlobalThresholdMethodParameters | None" = None, @@ -311,16 +307,12 @@ def __call__( image: np.ndarray, *, mask: np.ndarray | None = None, - threshold_method: ( - CellProfilerThresholdMethod | str - ) = CellProfilerThresholdMethod.OTSU, + threshold_method: CellProfilerThresholdMethod = CellProfilerThresholdMethod.OTSU, window_size: int = 50, threshold_min: float = 0, threshold_max: float = 1, threshold_correction_factor: float = 1, - assign_middle_to_foreground: ( - CellProfilerThresholdAssignment | str - ) = CellProfilerThresholdAssignment.FOREGROUND, + assign_middle_to_foreground: CellProfilerThresholdAssignment = CellProfilerThresholdAssignment.FOREGROUND, global_limits: tuple[float, float] = (0.7, 1.5), log_transform: bool = False, global_threshold_function: GlobalThresholdFunction | None = None, @@ -358,12 +350,9 @@ class RobustBackgroundCenterStrategy( @classmethod def for_averaging_method( - cls, averaging_method: CellProfilerAveragingMethod | str + cls, averaging_method: CellProfilerAveragingMethod ) -> "RobustBackgroundCenterStrategy": - resolved = coerce_cellprofiler_enum( - CellProfilerAveragingMethod, averaging_method - ) - return cls.for_enum_member(resolved) + return cls.for_enum_member(averaging_method) def center(self, values: np.ndarray) -> float: """Return the robust-background center for trimmed values.""" @@ -464,10 +453,9 @@ class RobustBackgroundSpreadStrategy( @classmethod def for_variance_method( - cls, variance_method: CellProfilerVarianceMethod | str + cls, variance_method: CellProfilerVarianceMethod ) -> "RobustBackgroundSpreadStrategy": - resolved = coerce_cellprofiler_enum(CellProfilerVarianceMethod, variance_method) - return cls.for_enum_member(resolved) + return cls.for_enum_member(variance_method) @abstractmethod def spread(self, values: np.ndarray) -> float: @@ -518,24 +506,16 @@ def from_values( *, lower_outlier_fraction: float = 0.05, upper_outlier_fraction: float = 0.05, - averaging_method: ( - CellProfilerAveragingMethod | str - ) = CellProfilerAveragingMethod.MEAN, - variance_method: ( - CellProfilerVarianceMethod | str - ) = CellProfilerVarianceMethod.STANDARD_DEVIATION, + averaging_method: CellProfilerAveragingMethod = CellProfilerAveragingMethod.MEAN, + variance_method: CellProfilerVarianceMethod = CellProfilerVarianceMethod.STANDARD_DEVIATION, number_of_deviations: float = 2.0, ) -> Self: """Return canonical robust-background settings.""" return cls( lower_outlier_fraction=float(lower_outlier_fraction), upper_outlier_fraction=float(upper_outlier_fraction), - averaging_method=coerce_cellprofiler_enum( - CellProfilerAveragingMethod, averaging_method - ), - variance_method=coerce_cellprofiler_enum( - CellProfilerVarianceMethod, variance_method - ), + averaging_method=averaging_method, + variance_method=variance_method, number_of_deviations=float(number_of_deviations), ) @@ -1054,24 +1034,19 @@ def from_inputs( ) -class ThresholdDiagnosticDomainStrategy(ABC, metaclass=AutoRegisterMeta): +class ThresholdDiagnosticDomainStrategy( + EnumKeyedStrategyMixin[ThresholdDiagnosticDomain], + ABC, + metaclass=AutoRegisterMeta, +): """Apply CellProfiler threshold diagnostics in the correct image domain.""" - __registry_key__ = "domain_key" - __skip_if_no_key__ = True + __enum_member_attr__ = "domain" domain: ClassVar[ThresholdDiagnosticDomain | None] = None - domain_key: ClassVar[str | None] = None - - def __init_subclass__(cls, **kwargs: object) -> None: - super().__init_subclass__(**kwargs) - domain = cls.__dict__.get("domain") - if isinstance(domain, ThresholdDiagnosticDomain): - cls.domain_key = domain.value @classmethod def evaluate(cls, request: ThresholdDiagnosticRequest) -> tuple[float, float]: - strategy_type = cls.__registry__[request.domain.value] - return strategy_type().diagnostics(request) + return cls.for_enum_member(request.domain).diagnostics(request) @abstractmethod def diagnostics(self, request: ThresholdDiagnosticRequest) -> tuple[float, float]: @@ -1813,51 +1788,30 @@ class CellProfilerThresholdSettings: """CellProfiler threshold settings independent of image provenance.""" use_advanced_settings: bool - threshold_scope: CellProfilerThresholdScope | str - threshold_method: CellProfilerThresholdMethod | str + threshold_scope: CellProfilerThresholdScope + threshold_method: CellProfilerThresholdMethod threshold_correction_factor: float threshold_min: float threshold_max: float threshold_smoothing_scale: float - otsu_class_count: CellProfilerOtsuMethod | str - assign_middle_to_foreground: CellProfilerThresholdAssignment | str + otsu_class_count: CellProfilerOtsuMethod + assign_middle_to_foreground: CellProfilerThresholdAssignment log_transform: bool adaptive_window_size: int lower_outlier_fraction: float upper_outlier_fraction: float - averaging_method: CellProfilerAveragingMethod | str - variance_method: CellProfilerVarianceMethod | str + averaging_method: CellProfilerAveragingMethod + variance_method: CellProfilerVarianceMethod number_of_deviations: float manual_threshold: float smooth_threshold_application: bool = True def normalized(self) -> Self: - """Return settings with CellProfiler enum values and basic-mode defaults.""" - settings = replace( - self, - threshold_scope=coerce_cellprofiler_enum( - CellProfilerThresholdScope, self.threshold_scope - ), - threshold_method=coerce_cellprofiler_enum( - CellProfilerThresholdMethod, self.threshold_method - ), - otsu_class_count=coerce_cellprofiler_enum( - CellProfilerOtsuMethod, self.otsu_class_count - ), - assign_middle_to_foreground=coerce_cellprofiler_enum( - CellProfilerThresholdAssignment, self.assign_middle_to_foreground - ), - averaging_method=coerce_cellprofiler_enum( - CellProfilerAveragingMethod, self.averaging_method - ), - variance_method=coerce_cellprofiler_enum( - CellProfilerVarianceMethod, self.variance_method - ), - ) - if settings.use_advanced_settings: - return settings + """Return settings with CellProfiler basic-mode defaults applied.""" + if self.use_advanced_settings: + return self return replace( - settings, + self, threshold_scope=CellProfilerThresholdScope.GLOBAL, threshold_method=CellProfilerThresholdMethod.MINIMUM_CROSS_ENTROPY, log_transform=False, @@ -1867,10 +1821,8 @@ def normalized(self) -> Self: def effective_method(self) -> CellProfilerThresholdMethod: """Return the method CP actually evaluates for these settings.""" return threshold_method_for_class_count( - coerce_cellprofiler_enum( - CellProfilerThresholdMethod, self.threshold_method - ), - coerce_cellprofiler_enum(CellProfilerOtsuMethod, self.otsu_class_count), + self.threshold_method, + self.otsu_class_count, ) def method_parameters(self) -> GlobalThresholdMethodParameters: @@ -1904,9 +1856,7 @@ def with_threshold_module_controls( log_transform=False, threshold_smoothing_scale=1.0, ) - method = coerce_cellprofiler_enum( - CellProfilerThresholdMethod, settings.threshold_method - ) + method = settings.threshold_method manual_threshold = predefined_threshold if method is CellProfilerThresholdMethod.MANUAL and manual_threshold is None: manual_threshold = 0.0 @@ -1934,10 +1884,7 @@ def calculate(self) -> CellProfilerThresholdResult: """Apply thresholding and compute CP threshold diagnostics.""" threshold_values = self.threshold_tuple() thresholded, threshold_value, original_threshold = threshold_values - threshold_scope = coerce_cellprofiler_enum( - CellProfilerThresholdScope, - self.settings.normalized().threshold_scope, - ) + threshold_scope = self.settings.normalized().threshold_scope guide_threshold = ( threshold_values.guide_threshold if isinstance(threshold_values, CellProfilerThresholdTuple) @@ -2339,15 +2286,11 @@ def cellprofiler_get_global_threshold( image: np.ndarray, *, mask: np.ndarray | None = None, - threshold_method: ( - CellProfilerThresholdMethod | str - ) = CellProfilerThresholdMethod.OTSU, + threshold_method: CellProfilerThresholdMethod = CellProfilerThresholdMethod.OTSU, threshold_min: float = 0, threshold_max: float = 1, threshold_correction_factor: float = 1, - assign_middle_to_foreground: ( - CellProfilerThresholdAssignment | str - ) = CellProfilerThresholdAssignment.FOREGROUND, + assign_middle_to_foreground: CellProfilerThresholdAssignment = CellProfilerThresholdAssignment.FOREGROUND, log_transform: bool = False, proven_unit_interval_scale: int | None = None, method_parameters: GlobalThresholdMethodParameters | None = None, @@ -2355,10 +2298,8 @@ def cellprofiler_get_global_threshold( ) -> float: """Compute one global threshold using independent CP-compatible semantics.""" primitives = threshold_primitives() - method = coerce_cellprofiler_enum(CellProfilerThresholdMethod, threshold_method) - assignment = coerce_cellprofiler_enum( - CellProfilerThresholdAssignment, assign_middle_to_foreground - ) + method = threshold_method + assignment = assign_middle_to_foreground if method_parameters is not None and kwargs: raise TypeError( "Pass either method_parameters or individual threshold method keyword arguments, not both." @@ -2413,16 +2354,12 @@ def cellprofiler_get_adaptive_threshold( image: np.ndarray, *, mask: np.ndarray | None = None, - threshold_method: ( - CellProfilerThresholdMethod | str - ) = CellProfilerThresholdMethod.OTSU, + threshold_method: CellProfilerThresholdMethod = CellProfilerThresholdMethod.OTSU, window_size: int = 50, threshold_min: float = 0, threshold_max: float = 1, threshold_correction_factor: float = 1, - assign_middle_to_foreground: ( - CellProfilerThresholdAssignment | str - ) = CellProfilerThresholdAssignment.FOREGROUND, + assign_middle_to_foreground: CellProfilerThresholdAssignment = CellProfilerThresholdAssignment.FOREGROUND, global_limits: tuple[float, float] = (0.7, 1.5), log_transform: bool = False, global_threshold_function: GlobalThresholdFunction | None = None, @@ -2445,10 +2382,8 @@ def cellprofiler_get_adaptive_threshold( if method_parameters is None else method_parameters ) - method = coerce_cellprofiler_enum(CellProfilerThresholdMethod, threshold_method) - assignment = coerce_cellprofiler_enum( - CellProfilerThresholdAssignment, assign_middle_to_foreground - ) + method = threshold_method + assignment = assign_middle_to_foreground data = np.asarray(image, dtype=np.float32) if mask is not None: data = np.where(np.asarray(mask, dtype=bool), data, False) @@ -2522,12 +2457,8 @@ def get_threshold_robust_background( *, lower_outlier_fraction: float = 0.05, upper_outlier_fraction: float = 0.05, - averaging_method: ( - CellProfilerAveragingMethod | str - ) = CellProfilerAveragingMethod.MEAN, - variance_method: ( - CellProfilerVarianceMethod | str - ) = CellProfilerVarianceMethod.STANDARD_DEVIATION, + averaging_method: CellProfilerAveragingMethod = CellProfilerAveragingMethod.MEAN, + variance_method: CellProfilerVarianceMethod = CellProfilerVarianceMethod.STANDARD_DEVIATION, number_of_deviations: float = 2, ) -> float: return RobustBackgroundThresholdSettings.from_values( @@ -2648,10 +2579,10 @@ def cellprofiler_threshold( image: np.ndarray, *, use_advanced_settings: bool, - threshold_scope: CellProfilerThresholdScope | str, - threshold_method: CellProfilerThresholdMethod | str, - otsu_class_count: CellProfilerOtsuMethod | str, - assign_middle_to_foreground: CellProfilerThresholdAssignment | str, + threshold_scope: CellProfilerThresholdScope, + threshold_method: CellProfilerThresholdMethod, + otsu_class_count: CellProfilerOtsuMethod, + assign_middle_to_foreground: CellProfilerThresholdAssignment, log_transform: bool, threshold_correction_factor: float, threshold_min: float, @@ -2660,8 +2591,8 @@ def cellprofiler_threshold( adaptive_window_size: int, lower_outlier_fraction: float, upper_outlier_fraction: float, - averaging_method: CellProfilerAveragingMethod | str, - variance_method: CellProfilerVarianceMethod | str, + averaging_method: CellProfilerAveragingMethod, + variance_method: CellProfilerVarianceMethod, number_of_deviations: float, manual_threshold: float, mask: np.ndarray | None = None, @@ -2852,9 +2783,9 @@ def threshold_measurement_record(self) -> MeasurementFeatureRecord: def threshold( image: np.ndarray, mask: np.ndarray | None = None, - threshold_scope: ThresholdScope | str = ThresholdScope.GLOBAL, - threshold_method: ThresholdMethod | str = ThresholdMethod.OTSU, - assign_middle_to_foreground: Assignment | str = Assignment.FOREGROUND, + threshold_scope: ThresholdScope = ThresholdScope.GLOBAL, + threshold_method: ThresholdMethod = ThresholdMethod.OTSU, + assign_middle_to_foreground: Assignment = Assignment.FOREGROUND, log_transform: bool = False, threshold_correction_factor: float = 1.0, threshold_min: float = 0.0, @@ -2863,12 +2794,12 @@ def threshold( smoothing: float = 0.0, lower_outlier_fraction: float = 0.05, upper_outlier_fraction: float = 0.05, - averaging_method: AveragingMethod | str = AveragingMethod.MEAN, - variance_method: VarianceMethod | str = VarianceMethod.STANDARD_DEVIATION, + averaging_method: AveragingMethod = AveragingMethod.MEAN, + variance_method: VarianceMethod = VarianceMethod.STANDARD_DEVIATION, number_of_deviations: float = 2.0, predefined_threshold: float | None = None, automatic: bool = False, - otsu_class_count: CellProfilerOtsuMethod | str = CellProfilerOtsuMethod.TWO_CLASS, + otsu_class_count: CellProfilerOtsuMethod = CellProfilerOtsuMethod.TWO_CLASS, use_advanced_settings: bool = True, ) -> tuple[np.ndarray, DataclassMeasurementColumnarRows]: """Apply CP-compatible thresholding and emit the module measurement row. diff --git a/openhcs/processing/backends/cellprofiler/tracking.py b/openhcs/processing/backends/cellprofiler/tracking.py index 2121d908d..0cd1c2ab6 100644 --- a/openhcs/processing/backends/cellprofiler/tracking.py +++ b/openhcs/processing/backends/cellprofiler/tracking.py @@ -590,7 +590,13 @@ def postprocess_bound_settings( ) -> "BoundModuleSettings": kwargs = dict(bound.kwargs) tracking_method = cls.require_supported_tracking_method(module) - kwargs["tracking_method"] = tracking_method.value + kwargs["tracking_method"] = tracking_method + movement_model = optional_setting_value(module, cls.movement_model_setting) + if movement_model is not None: + kwargs["movement_model"] = coerce_cellprofiler_enum( + MovementModel, + movement_model, + ) unmapped_kwargs = dict(bound.unmapped_kwargs) search_radius = optional_setting_value( module, @@ -974,9 +980,8 @@ class TrackObjectsMethodStrategy( method_label: ClassVar[str | None] = None @classmethod - def for_method(cls, method: str | TrackingMethod) -> "TrackObjectsMethodStrategy": - resolved = coerce_cellprofiler_enum(TrackingMethod, method) - return cls.for_enum_member(resolved) + def for_method(cls, method: TrackingMethod) -> "TrackObjectsMethodStrategy": + return cls.for_enum_member(method) def track( self, @@ -1108,9 +1113,9 @@ def _reverse_relationship_pairs( def track_objects( image: np.ndarray, labels: ObjectLabelValue, - tracking_method: str = "overlap", + tracking_method: TrackingMethod = TrackingMethod.OVERLAP, pixel_radius: int = 50, - movement_model: str = "both", + movement_model: MovementModel = MovementModel.BOTH, radius_std: float = 3.0, radius_limit_min: float = 2.0, radius_limit_max: float = 10.0, diff --git a/openhcs/processing/backends/cellprofiler/watershed.py b/openhcs/processing/backends/cellprofiler/watershed.py index a4b1ac017..8980be891 100644 --- a/openhcs/processing/backends/cellprofiler/watershed.py +++ b/openhcs/processing/backends/cellprofiler/watershed.py @@ -244,8 +244,16 @@ def active_artifact_bindings( if module is None: return bindings topology = WatershedInputTopology.from_values( - cls._setting_row_value(module, cls.watershed_method_setting), - cls._setting_row_value(module, cls.declump_method_setting), + coerce_cellprofiler_enum( + WatershedMethod, + cls._setting_row_value(module, cls.watershed_method_setting) + or WatershedMethod.DISTANCE.value, + ), + coerce_cellprofiler_enum( + WatershedDeclumpMethod, + cls._setting_row_value(module, cls.declump_method_setting) + or WatershedDeclumpMethod.SHAPE.value, + ), ) return tuple( binding @@ -550,20 +558,12 @@ class WatershedInputTopology: @classmethod def from_values( cls, - watershed_method: object, - declump_method: object, + watershed_method: WatershedMethod = WatershedMethod.DISTANCE, + declump_method: WatershedDeclumpMethod = WatershedDeclumpMethod.SHAPE, ) -> "WatershedInputTopology": return cls( - watershed_method=( - WatershedMethod.DISTANCE - if watershed_method is None - else coerce_cellprofiler_enum(WatershedMethod, watershed_method) - ), - declump_method=( - WatershedDeclumpMethod.SHAPE - if declump_method is None - else coerce_cellprofiler_enum(WatershedDeclumpMethod, declump_method) - ), + watershed_method=watershed_method, + declump_method=declump_method, ) @property @@ -766,24 +766,6 @@ def watershed_image_array(value: object, *, parameter_name: str) -> np.ndarray: return array -def coerce_watershed_method(value: WatershedMethod | str | None) -> WatershedMethod: - if value is None: - return WatershedMethod.DISTANCE - return coerce_cellprofiler_enum(WatershedMethod, value) - - -def coerce_watershed_declump_method( - value: WatershedDeclumpMethod | str, -) -> WatershedDeclumpMethod: - return coerce_cellprofiler_enum(WatershedDeclumpMethod, value) - - -def coerce_watershed_seed_method( - value: WatershedSeedMethod | str, -) -> WatershedSeedMethod: - return coerce_cellprofiler_enum(WatershedSeedMethod, value) - - @dataclass(frozen=True, slots=True) class WatershedInputs: image: np.ndarray @@ -863,9 +845,9 @@ def from_settings( cls, *, image_ndim: int, - watershed_method: WatershedMethod | str | None, - declump_method: WatershedDeclumpMethod | str, - seed_method: WatershedSeedMethod | str, + watershed_method: WatershedMethod, + declump_method: WatershedDeclumpMethod, + seed_method: WatershedSeedMethod, use_advanced_settings: bool, max_seeds: int, downsample: int, @@ -877,13 +859,13 @@ def from_settings( exclude_border: bool, watershed_line: bool, gaussian_sigma: float, - structuring_element: StructuringElement | str, + structuring_element: StructuringElement, structuring_element_size: int, ) -> "WatershedParameters": """Return the single normalized parameter contract for watershed execution.""" structuring_element_array = adapt_structuring_element_rank( build_structuring_element( - coerce_cellprofiler_enum(StructuringElement, structuring_element), + structuring_element, structuring_element_size, ), image_ndim, @@ -894,8 +876,8 @@ def from_settings( ) if not use_advanced_settings: return cls( - method=coerce_watershed_method(watershed_method), - declump_method=coerce_watershed_declump_method(declump_method), + method=watershed_method, + declump_method=declump_method, seed_method=cls.BASIC_SEED_METHOD, use_advanced_settings=False, max_seeds=cls.BASIC_MAX_SEEDS, @@ -911,9 +893,9 @@ def from_settings( structuring_element=structuring_element_array, ) return cls( - method=coerce_watershed_method(watershed_method), - declump_method=coerce_watershed_declump_method(declump_method), - seed_method=coerce_watershed_seed_method(seed_method), + method=watershed_method, + declump_method=declump_method, + seed_method=seed_method, use_advanced_settings=True, max_seeds=max_seeds, downsample=downsample, diff --git a/openhcs/processing/backends/cellprofiler/worms.py b/openhcs/processing/backends/cellprofiler/worms.py index a2397cd1c..ce47e079d 100644 --- a/openhcs/processing/backends/cellprofiler/worms.py +++ b/openhcs/processing/backends/cellprofiler/worms.py @@ -2134,7 +2134,6 @@ def straighten_worms( orientation for every input channel. """ del number_of_segments, number_of_stripes - flip_mode = coerce_cellprofiler_enum(FlipMode, flip_mode) if flip_mode is FlipMode.MANUAL: raise NotImplementedError("StraightenWorms manual flipping is interactive.") image_data = np.asarray(image_payload_data(image)) diff --git a/openhcs/processing/backends/cellprofiler/zernike.py b/openhcs/processing/backends/cellprofiler/zernike.py index b70f691d3..9f3a8b55f 100644 --- a/openhcs/processing/backends/cellprofiler/zernike.py +++ b/openhcs/processing/backends/cellprofiler/zernike.py @@ -93,16 +93,13 @@ class ObjectZernikeDescriptorFeature(str, Enum): @lru_cache(maxsize=None) def indexed_object_intensity_zernike_feature_name( - feature: ObjectZernikeDescriptorFeature | str, + feature: ObjectZernikeDescriptorFeature, *, source_image_name: str, degree: int, repetition: int, ) -> str: """Return CP-compatible long-form intensity Zernike feature identity.""" - feature = ObjectZernikeDescriptorFeature( - feature, - ) return "{0}_{1}_{2}_{3}".format( feature.value, source_image_name, diff --git a/openhcs/processing/backends/enhance/basic_processor_cupy.py b/openhcs/processing/backends/enhance/basic_processor_cupy.py index 5d36936f1..f280e3bd3 100644 --- a/openhcs/processing/backends/enhance/basic_processor_cupy.py +++ b/openhcs/processing/backends/enhance/basic_processor_cupy.py @@ -23,6 +23,7 @@ # Import decorator directly from core.memory to avoid circular imports from openhcs.core.memory import cupy as cupy_func from openhcs.core.utils import optional_import +from openhcs.processing.backends.enhance.flatfield import FlatfieldCorrectionMode # For type checking only if TYPE_CHECKING: @@ -256,7 +257,7 @@ def basic_flatfield_correction_cupy( lambda_lowrank: float = 0.1, rank: int = 3, tol: float = 1e-4, - correction_mode: str = "divide", + correction_mode: FlatfieldCorrectionMode = FlatfieldCorrectionMode.DIVIDE, normalize_output: bool = True, verbose: bool = False, max_memory_gb: float = 1.0 @@ -300,10 +301,6 @@ def basic_flatfield_correction_cupy( if image.ndim != 3: raise ValueError(f"Input must be a 3D array, got {image.ndim}D") - if correction_mode not in ["divide", "subtract"]: - raise ValueError(f"Invalid correction mode: {correction_mode}. " - f"Must be 'divide' or 'subtract'") - # Store original shape and dtype z, y, x = image.shape orig_dtype = image.dtype @@ -356,7 +353,7 @@ def basic_flatfield_correction_cupy( def _gpu_flatfield_correction( image: "cp.ndarray", max_iters: int, lambda_sparse: float, lambda_lowrank: float, - rank: int, tol: float, correction_mode: str, normalize_output: bool, verbose: bool, + rank: int, tol: float, correction_mode: FlatfieldCorrectionMode, normalize_output: bool, verbose: bool, max_memory_gb: float, image_size_gb: float, estimated_peak_memory: float, z: int, y: int, x: int, orig_dtype ) -> "cp.ndarray": """GPU-based flatfield correction implementation.""" @@ -437,7 +434,7 @@ def _gpu_flatfield_correction( L_stack = L.reshape(z, y, x) # Apply correction - if correction_mode == "divide": + if correction_mode is FlatfieldCorrectionMode.DIVIDE: # Add small epsilon to avoid division by zero eps = 1e-6 corrected = image_float / (L_stack + eps) @@ -505,7 +502,7 @@ def basic_flatfield_correction_batch_cupy( lambda_lowrank: float = 0.1, rank: int = 3, tol: float = 1e-4, - correction_mode: str = "divide", + correction_mode: FlatfieldCorrectionMode = FlatfieldCorrectionMode.DIVIDE, normalize_output: bool = True, verbose: bool = False, max_memory_gb: float = 1.0 @@ -583,7 +580,7 @@ def basic_flatfield_correction_batch_cupy( def _cpu_fallback_flatfield_correction( image: "cp.ndarray", max_iters: int, lambda_sparse: float, lambda_lowrank: float, - rank: int, tol: float, correction_mode: str, normalize_output: bool, verbose: bool + rank: int, tol: float, correction_mode: FlatfieldCorrectionMode, normalize_output: bool, verbose: bool ) -> "cp.ndarray": """CPU fallback for flatfield correction when GPU runs out of memory.""" diff --git a/openhcs/processing/backends/enhance/basic_processor_jax.py b/openhcs/processing/backends/enhance/basic_processor_jax.py index 8e219ebc7..6c4d86e93 100644 --- a/openhcs/processing/backends/enhance/basic_processor_jax.py +++ b/openhcs/processing/backends/enhance/basic_processor_jax.py @@ -19,6 +19,7 @@ # Import decorator directly from core.memory to avoid circular imports from openhcs.core.memory import jax as jax_func from openhcs.core.utils import optional_import +from openhcs.processing.backends.enhance.flatfield import BasicFittingMode # For type checking only if TYPE_CHECKING: @@ -76,7 +77,7 @@ def basic_flatfield_correction_jax( smoothness_darkfield: float = 1.0, sparse_cost_darkfield: float = 0.01, get_darkfield: bool = False, - fitting_mode: str = "ladmap", + fitting_mode: BasicFittingMode = BasicFittingMode.LADMAP, working_size: Optional[Union[int, list]] = 128, verbose: bool = False ) -> "jnp.ndarray": @@ -132,7 +133,7 @@ def basic_flatfield_correction_jax( smoothness_darkfield=smoothness_darkfield, sparse_cost_darkfield=sparse_cost_darkfield, get_darkfield=get_darkfield, - fitting_mode=fitting_mode, + fitting_mode=fitting_mode.value, working_size=working_size, # Optimization parameters @@ -175,7 +176,7 @@ def basic_flatfield_correction_batch_jax( smoothness_darkfield: float = 1.0, sparse_cost_darkfield: float = 0.01, get_darkfield: bool = False, - fitting_mode: str = "ladmap", + fitting_mode: BasicFittingMode = BasicFittingMode.LADMAP, working_size: Optional[Union[int, list]] = 128, verbose: bool = False ) -> "jnp.ndarray": diff --git a/openhcs/processing/backends/enhance/basic_processor_numpy.py b/openhcs/processing/backends/enhance/basic_processor_numpy.py index 832d3de72..21de21939 100644 --- a/openhcs/processing/backends/enhance/basic_processor_numpy.py +++ b/openhcs/processing/backends/enhance/basic_processor_numpy.py @@ -23,6 +23,7 @@ # Import decorator directly from core.memory.decorators to avoid circular imports from openhcs.core.memory import numpy as numpy_func +from openhcs.processing.backends.enhance.flatfield import FlatfieldCorrectionMode logger = logging.getLogger(__name__) @@ -92,7 +93,7 @@ def basic_flatfield_correction_numpy( lambda_lowrank: float = 0.1, rank: int = 3, tol: float = 1e-4, - correction_mode: str = "divide", + correction_mode: FlatfieldCorrectionMode = FlatfieldCorrectionMode.DIVIDE, normalize_output: bool = True, verbose: bool = False ) -> np.ndarray: @@ -141,10 +142,6 @@ def basic_flatfield_correction_numpy( if image.ndim != 3: raise ValueError(f"Input must be a 3D array, got {image.ndim}D") - if correction_mode not in ["divide", "subtract"]: - raise ValueError(f"Invalid correction mode: {correction_mode}. " - f"Must be 'divide' or 'subtract'") - # Store original shape and dtype z, y, x = image.shape orig_dtype = image.dtype @@ -189,7 +186,7 @@ def basic_flatfield_correction_numpy( L_stack = L.reshape(z, y, x) # Apply correction - if correction_mode == "divide": + if correction_mode is FlatfieldCorrectionMode.DIVIDE: # Add small epsilon to avoid division by zero eps = 1e-6 corrected = image_float / (L_stack + eps) @@ -223,7 +220,7 @@ def basic_flatfield_correction_batch_numpy( lambda_lowrank: float = 0.1, rank: int = 3, tol: float = 1e-4, - correction_mode: str = "divide", + correction_mode: FlatfieldCorrectionMode = FlatfieldCorrectionMode.DIVIDE, normalize_output: bool = True, verbose: bool = False ) -> np.ndarray: diff --git a/openhcs/processing/backends/enhance/deconvolution.py b/openhcs/processing/backends/enhance/deconvolution.py new file mode 100644 index 000000000..b92eea4ed --- /dev/null +++ b/openhcs/processing/backends/enhance/deconvolution.py @@ -0,0 +1,15 @@ +"""Shared nominal contracts for self-supervised deconvolution backends.""" + +from enum import Enum + + +class DeconvolutionBlurMode(Enum): + """Blur model optimized jointly with a deconvolution network.""" + + LEARNED = "learned" + FFT = "fft" + GAUSSIAN = "gaussian" + + @property + def uses_fixed_kernel(self) -> bool: + return self in {self.FFT, self.GAUSSIAN} diff --git a/openhcs/processing/backends/enhance/flatfield.py b/openhcs/processing/backends/enhance/flatfield.py new file mode 100644 index 000000000..ea53c8a07 --- /dev/null +++ b/openhcs/processing/backends/enhance/flatfield.py @@ -0,0 +1,17 @@ +"""Nominal contracts shared by flat-field correction implementations.""" + +from enum import Enum + + +class FlatfieldCorrectionMode(Enum): + """How an estimated illumination field is removed from an image.""" + + DIVIDE = "divide" + SUBTRACT = "subtract" + + +class BasicFittingMode(Enum): + """Optimization algorithm exposed by BaSiCPy.""" + + LADMAP = "ladmap" + APPROXIMATE = "approximate" diff --git a/openhcs/processing/backends/enhance/focus_torch.py b/openhcs/processing/backends/enhance/focus_torch.py index f3f936e7f..7bff4efaa 100644 --- a/openhcs/processing/backends/enhance/focus_torch.py +++ b/openhcs/processing/backends/enhance/focus_torch.py @@ -19,16 +19,6 @@ class FocusSharpnessMethod(Enum): LAPLACIAN = "laplacian" GRADIENT = "gradient" - @classmethod - def from_value(cls, value: str) -> "FocusSharpnessMethod": - try: - return cls(value) - except ValueError as error: - raise ValueError( - f"Invalid method: {value}. Use 'laplacian' or 'gradient'" - ) from error - - def _laplacian_sharpness(image_stack: "torch.Tensor") -> "torch.Tensor": return torch.abs(laplacian(image_stack.unsqueeze(1))).squeeze(1) @@ -106,7 +96,7 @@ def laplacian(image: "torch.Tensor") -> "torch.Tensor": @torch_decorator def focus_stack_max_sharpness( image_stack: "torch.Tensor", - method: str = "laplacian", + method: FocusSharpnessMethod = FocusSharpnessMethod.LAPLACIAN, patch_size: Optional[int] = None, stride: Optional[int] = None, normalize_sharpness: bool = False @@ -136,8 +126,7 @@ def focus_stack_max_sharpness( patch_size = patch_size or max(H, W) // 8 stride = stride or patch_size // 2 - sharpness_method = FocusSharpnessMethod.from_value(method) - sharpness = FOCUS_SHARPNESS_METHODS[sharpness_method](image_stack) + sharpness = FOCUS_SHARPNESS_METHODS[method](image_stack) if normalize_sharpness: sharpness = (sharpness - sharpness.mean(dim=0)) / (sharpness.std(dim=0) + 1e-6) diff --git a/openhcs/processing/backends/enhance/self_supervised_2d_deconvolution.py b/openhcs/processing/backends/enhance/self_supervised_2d_deconvolution.py index 544e3ee93..d4dc21193 100644 --- a/openhcs/processing/backends/enhance/self_supervised_2d_deconvolution.py +++ b/openhcs/processing/backends/enhance/self_supervised_2d_deconvolution.py @@ -1,13 +1,13 @@ from __future__ import annotations import logging from dataclasses import dataclass -from enum import Enum from typing import Optional, Tuple # Import torch decorator and optional_import utility from openhcs.utils.import_utils import optional_import, create_placeholder_class from openhcs.core.memory import torch as torch_func from openhcs.core.lazy_gpu_imports import torch +from openhcs.processing.backends.enhance.deconvolution import DeconvolutionBlurMode # --- PyTorch Imports as optional dependencies --- nn = optional_import("torch.nn") if torch else None @@ -21,25 +21,6 @@ logger = logging.getLogger(__name__) -class DeconvolutionBlurMode(Enum): - """Blur modes supported by self-supervised deconvolution.""" - - LEARNED = "learned" - FFT = "fft" - GAUSSIAN = "gaussian" - - @classmethod - def from_value(cls, value: str) -> "DeconvolutionBlurMode": - try: - return cls(value) - except ValueError as error: - raise ValueError(f"Unknown blur_mode: {value}") from error - - @property - def uses_fixed_kernel(self) -> bool: - return self in {self.FFT, self.GAUSSIAN} - - @dataclass(frozen=True, slots=True) class Deconvolution2DImageProjection: """Project 2D deconvolution inputs to [1, 1, H, W].""" @@ -165,7 +146,7 @@ def self_supervised_2d_deconvolution( min_val: float = 0.0, max_val: float = 1.0, learning_rate: float = 4e-4, # Paper: Adam 4e-4 - blur_mode: str = "gaussian", # 'fft', 'gaussian', 'learned' + blur_mode: DeconvolutionBlurMode = DeconvolutionBlurMode.GAUSSIAN, blur_sigma_spatial: float = 1.5, blur_kernel_size: int = 5 ) -> torch.Tensor: @@ -187,8 +168,6 @@ def self_supervised_2d_deconvolution( # --- PyTorch Backend Implementation --- device = image.device - blur_mode_value = DeconvolutionBlurMode.from_value(blur_mode) - # FAIL LOUDLY if not on CUDA - no CPU fallback allowed if device.type != "cuda": raise RuntimeError(f"@torch_func requires CUDA tensor, got device: {device}") @@ -210,12 +189,12 @@ def self_supervised_2d_deconvolution( g_model_blur: Optional[nn.Module] = None fixed_blur_kernel: Optional[torch.Tensor] = None - if blur_mode_value is DeconvolutionBlurMode.LEARNED: + if blur_mode is DeconvolutionBlurMode.LEARNED: g_model_blur = _LearnedBlur2D_torch(kernel_size=blur_kernel_size).to(device) optimizer = torch.optim.Adam(list(f_model.parameters()) + list(g_model_blur.parameters()), lr=learning_rate) else: optimizer = torch.optim.Adam(f_model.parameters(), lr=learning_rate) - if blur_mode_value.uses_fixed_kernel: + if blur_mode.uses_fixed_kernel: fixed_blur_kernel = _gaussian_kernel_2d_torch( (blur_kernel_size, blur_kernel_size), (blur_sigma_spatial, blur_sigma_spatial), device @@ -242,13 +221,13 @@ def self_supervised_2d_deconvolution( f_x_masked = f_model(current_patch_masked).clamp(min_val, max_val) # Apply blur g(f(x)) - if blur_mode_value is DeconvolutionBlurMode.LEARNED: + if blur_mode is DeconvolutionBlurMode.LEARNED: g_f_x_orig = g_model_blur(f_x_orig) g_f_x_masked = g_model_blur(f_x_masked) - elif blur_mode_value is DeconvolutionBlurMode.FFT: + elif blur_mode is DeconvolutionBlurMode.FFT: g_f_x_orig = _blur_fft_2d_torch(f_x_orig, fixed_blur_kernel, device) g_f_x_masked = _blur_fft_2d_torch(f_x_masked, fixed_blur_kernel, device) - elif blur_mode_value is DeconvolutionBlurMode.GAUSSIAN: + elif blur_mode is DeconvolutionBlurMode.GAUSSIAN: conv_kernel = fixed_blur_kernel.unsqueeze(0).unsqueeze(0).to(device) pad_size = blur_kernel_size // 2 g_f_x_orig = F.conv2d(f_x_orig, conv_kernel, padding=pad_size) diff --git a/openhcs/processing/backends/enhance/self_supervised_3d_deconvolution.py b/openhcs/processing/backends/enhance/self_supervised_3d_deconvolution.py index 82e96a65f..f979cefee 100644 --- a/openhcs/processing/backends/enhance/self_supervised_3d_deconvolution.py +++ b/openhcs/processing/backends/enhance/self_supervised_3d_deconvolution.py @@ -1,13 +1,13 @@ from __future__ import annotations import logging from dataclasses import dataclass -from enum import Enum from typing import Optional, Tuple # Import torch decorator and optional_import utility from openhcs.utils.import_utils import optional_import, create_placeholder_class from openhcs.core.memory import torch as torch_func from openhcs.core.lazy_gpu_imports import torch +from openhcs.processing.backends.enhance.deconvolution import DeconvolutionBlurMode # --- PyTorch Imports as optional dependencies --- nn = optional_import("torch.nn") if torch else None @@ -21,25 +21,6 @@ logger = logging.getLogger(__name__) -class DeconvolutionBlurMode(Enum): - """Blur modes supported by self-supervised deconvolution.""" - - LEARNED = "learned" - FFT = "fft" - GAUSSIAN = "gaussian" - - @classmethod - def from_value(cls, value: str) -> "DeconvolutionBlurMode": - try: - return cls(value) - except ValueError as error: - raise ValueError(f"Unknown blur_mode: {value}") from error - - @property - def uses_fixed_kernel(self) -> bool: - return self in {self.FFT, self.GAUSSIAN} - - @dataclass(frozen=True, slots=True) class Deconvolution3DVolumeProjection: """Project 3D deconvolution inputs to [1, 1, D, H, W].""" @@ -171,7 +152,7 @@ def self_supervised_3d_deconvolution( min_val: float = 0.0, max_val: float = 1.0, learning_rate: float = 4e-4, - blur_mode: str = "gaussian", # 'fft', 'gaussian', 'learned' + blur_mode: DeconvolutionBlurMode = DeconvolutionBlurMode.GAUSSIAN, blur_sigma_spatial: float = 1.5, blur_sigma_depth: float = 1.5, blur_kernel_size: int = 5 # For gaussian/learned conv blur @@ -188,8 +169,6 @@ def self_supervised_3d_deconvolution( # --- PyTorch Backend Implementation --- device = image_volume.device - blur_mode_value = DeconvolutionBlurMode.from_value(blur_mode) - # FAIL LOUDLY if not on CUDA - no CPU fallback allowed if device.type != "cuda": raise RuntimeError(f"@torch_func requires CUDA tensor, got device: {device}") @@ -210,12 +189,12 @@ def self_supervised_3d_deconvolution( g_model_blur: Optional[nn.Module] = None fixed_blur_kernel: Optional[torch.Tensor] = None - if blur_mode_value is DeconvolutionBlurMode.LEARNED: + if blur_mode is DeconvolutionBlurMode.LEARNED: g_model_blur = _LearnedBlur3D_torch(kernel_size=blur_kernel_size).to(device) optimizer = torch.optim.Adam(list(f_model.parameters()) + list(g_model_blur.parameters()), lr=learning_rate) else: # fft or gaussian (fixed kernel) optimizer = torch.optim.Adam(f_model.parameters(), lr=learning_rate) - if blur_mode_value.uses_fixed_kernel: + if blur_mode.uses_fixed_kernel: fixed_blur_kernel = _gaussian_kernel_3d_torch( (blur_kernel_size, blur_kernel_size, blur_kernel_size), (blur_sigma_depth, blur_sigma_spatial, blur_sigma_spatial), device @@ -241,15 +220,15 @@ def self_supervised_3d_deconvolution( f_x_masked = f_model(current_patch_masked).clamp(min_val, max_val) # Apply blur g(f(x)) - if blur_mode_value is DeconvolutionBlurMode.LEARNED: + if blur_mode is DeconvolutionBlurMode.LEARNED: assert g_model_blur is not None g_f_x_orig = g_model_blur(f_x_orig) g_f_x_masked = g_model_blur(f_x_masked) - elif blur_mode_value is DeconvolutionBlurMode.FFT: + elif blur_mode is DeconvolutionBlurMode.FFT: assert fixed_blur_kernel is not None g_f_x_orig = _blur_fft_torch(f_x_orig, fixed_blur_kernel, device) g_f_x_masked = _blur_fft_torch(f_x_masked, fixed_blur_kernel, device) - elif blur_mode_value is DeconvolutionBlurMode.GAUSSIAN: + elif blur_mode is DeconvolutionBlurMode.GAUSSIAN: assert fixed_blur_kernel is not None # Re-use kernel logic for conv # For conv3d, kernel needs to be (out_c, in_c/groups, kD, kH, kW) conv_kernel = fixed_blur_kernel.unsqueeze(0).unsqueeze(0).to(device) diff --git a/openhcs/processing/backends/pos_gen/mist/mist_main.py b/openhcs/processing/backends/pos_gen/mist/mist_main.py index ccc4e1589..c7edb8617 100644 --- a/openhcs/processing/backends/pos_gen/mist/mist_main.py +++ b/openhcs/processing/backends/pos_gen/mist/mist_main.py @@ -448,10 +448,6 @@ def mist_compute_tile_positions( image_stack: "cp.ndarray", # type: ignore grid_dimensions: Tuple[int, int], *, - # === Input Validation Parameters === - method: str = "phase_correlation", - fft_backend: str = "cupy", - # === Core Algorithm Parameters === normalize: bool = True, verbose: bool = False, @@ -506,10 +502,6 @@ def mist_compute_tile_positions( image_stack: 3D tensor (Z, Y, X) of tiles to stitch grid_dimensions: (num_cols, num_rows) grid layout of tiles - === Input Validation Parameters === - method: Correlation method - must be "phase_correlation" - fft_backend: FFT backend - must be "cupy" for GPU acceleration - === Core Algorithm Parameters (NIST Algorithms 1-3) === normalize: Normalize each tile to [0,1] range using (tile-min)/(max-min). True = better correlation accuracy, handles varying illumination. @@ -703,7 +695,7 @@ def mist_compute_tile_positions( Positions are centered around origin Raises: - ValueError: If input validation fails (wrong method, backend, or dimensions) + ValueError: If input validation fails. TypeError: If image_stack is not a CuPy array """ _validate_cupy_array(image_stack, "image_stack") @@ -711,12 +703,6 @@ def mist_compute_tile_positions( if image_stack.ndim != 3: raise ValueError(f"Input must be a 3D tensor, got {image_stack.ndim}D") - if fft_backend != "cupy": - raise ValueError(f"FFT backend must be 'cupy', got '{fft_backend}'") - - if method != "phase_correlation": - raise ValueError(f"Only 'phase_correlation' method is supported, got '{method}'") - num_cols, num_rows = grid_dimensions Z, H, W = image_stack.shape diff --git a/openhcs/processing/backends/processors/cupy_processor.py b/openhcs/processing/backends/processors/cupy_processor.py index bfdb67a49..1857975bb 100644 --- a/openhcs/processing/backends/processors/cupy_processor.py +++ b/openhcs/processing/backends/processors/cupy_processor.py @@ -18,9 +18,14 @@ from metaclass_registry import AutoRegisterMeta from openhcs.core.memory import cupy as cupy_func +from openhcs.core.registry_strategies import EnumKeyedStrategyMixin from openhcs.core.utils import optional_import from openhcs.processing.backends.processors.method_axes import ( - RegisteredProcessorMethodStrategy, + EdgeMagnitudeMethod, + ScipyBoundaryMode, + SobelKernelBoundaryMode, + SpatialBinMethod, + StackProjectionMethod, ) logger = logging.getLogger(__name__) @@ -426,58 +431,62 @@ def mean_projection(stack: "cp.ndarray") -> "cp.ndarray": class CupySpatialBinStrategy( - RegisteredProcessorMethodStrategy, metaclass=AutoRegisterMeta + EnumKeyedStrategyMixin[SpatialBinMethod], metaclass=AutoRegisterMeta ): + __enum_member_attr__ = "method" + @abstractmethod def apply(self, array: "cp.ndarray", axis: tuple[int, ...]) -> "cp.ndarray": raise NotImplementedError class CupyMeanSpatialBinStrategy(CupySpatialBinStrategy): - method = "mean" + method = SpatialBinMethod.MEAN def apply(self, array: "cp.ndarray", axis: tuple[int, ...]) -> "cp.ndarray": return cp.mean(array, axis=axis) class CupySumSpatialBinStrategy(CupySpatialBinStrategy): - method = "sum" + method = SpatialBinMethod.SUM def apply(self, array: "cp.ndarray", axis: tuple[int, ...]) -> "cp.ndarray": return cp.sum(array, axis=axis) class CupyMaxSpatialBinStrategy(CupySpatialBinStrategy): - method = "max" + method = SpatialBinMethod.MAX def apply(self, array: "cp.ndarray", axis: tuple[int, ...]) -> "cp.ndarray": return cp.max(array, axis=axis) class CupyMinSpatialBinStrategy(CupySpatialBinStrategy): - method = "min" + method = SpatialBinMethod.MIN def apply(self, array: "cp.ndarray", axis: tuple[int, ...]) -> "cp.ndarray": return cp.min(array, axis=axis) class CupyStackProjectionStrategy( - RegisteredProcessorMethodStrategy, metaclass=AutoRegisterMeta + EnumKeyedStrategyMixin[StackProjectionMethod], metaclass=AutoRegisterMeta ): + __enum_member_attr__ = "method" + @abstractmethod def apply(self, stack: "cp.ndarray") -> "cp.ndarray": raise NotImplementedError class CupyMaxStackProjectionStrategy(CupyStackProjectionStrategy): - method = "max_projection" + method = StackProjectionMethod.MAX def apply(self, stack: "cp.ndarray") -> "cp.ndarray": return max_projection(stack) class CupyMeanStackProjectionStrategy(CupyStackProjectionStrategy): - method = "mean_projection" + method = StackProjectionMethod.MEAN def apply(self, stack: "cp.ndarray") -> "cp.ndarray": return mean_projection(stack) @@ -487,7 +496,7 @@ def apply(self, stack: "cp.ndarray") -> "cp.ndarray": def spatial_bin_2d( stack: "cp.ndarray", bin_size: int = 2, - method: str = "mean" + method: SpatialBinMethod = SpatialBinMethod.MEAN, ) -> "cp.ndarray": """ Apply 2D spatial binning to each slice in the stack - GPU accelerated. @@ -525,7 +534,9 @@ def spatial_bin_2d( # Reshape for binning: (Z, new_height, bin_size, new_width, bin_size) reshaped = cropped_stack.reshape(z_slices, new_height, bin_size, new_width, bin_size) - result = CupySpatialBinStrategy.for_method(method).apply(reshaped, axis=(2, 4)) + result = CupySpatialBinStrategy.for_enum_member(method).apply( + reshaped, axis=(2, 4) + ) return result.astype(stack.dtype) @@ -533,7 +544,7 @@ def spatial_bin_2d( def spatial_bin_3d( stack: "cp.ndarray", bin_size: int = 2, - method: str = "mean" + method: SpatialBinMethod = SpatialBinMethod.MEAN, ) -> "cp.ndarray": """ Apply 3D spatial binning to the entire stack - GPU accelerated. @@ -573,7 +584,9 @@ def spatial_bin_3d( # Reshape for 3D binning: (new_depth, bin_size, new_height, bin_size, new_width, bin_size) reshaped = cropped_stack.reshape(new_depth, bin_size, new_height, bin_size, new_width, bin_size) - result = CupySpatialBinStrategy.for_method(method).apply(reshaped, axis=(1, 3, 5)) + result = CupySpatialBinStrategy.for_enum_member(method).apply( + reshaped, axis=(1, 3, 5) + ) return result.astype(stack.dtype) @@ -633,7 +646,8 @@ def stack_equalize_histogram( @cupy_func def create_projection( - stack: "cp.ndarray", method: str = "max_projection" + stack: "cp.ndarray", + method: StackProjectionMethod = StackProjectionMethod.MAX, ) -> "cp.ndarray": """ Create a projection from a stack using the specified method. @@ -647,7 +661,7 @@ def create_projection( """ _validate_3d_array(stack) - return CupyStackProjectionStrategy.for_method(method).apply(stack) + return CupyStackProjectionStrategy.for_enum_member(method).apply(stack) @cupy_func @@ -895,7 +909,11 @@ def _get_sobel_2d_kernel(): return _sobel_2d_parallel @cupy_func -def sobel_2d_vectorized(image: "cp.ndarray", mode: str = "reflect", cval: float = 0.0) -> "cp.ndarray": +def sobel_2d_vectorized( + image: "cp.ndarray", + mode: SobelKernelBoundaryMode = SobelKernelBoundaryMode.REFLECT, + cval: float = 0.0, +) -> "cp.ndarray": """ Apply 2D Sobel edge detection to all slices simultaneously - TRUE GPU PARALLELIZED. @@ -913,24 +931,22 @@ def sobel_2d_vectorized(image: "cp.ndarray", mode: str = "reflect", cval: float """ _validate_3d_array(image) - # Map mode string to integer - mode_map = {'constant': 0, 'reflect': 1, 'nearest': 2, 'wrap': 3} - if mode not in mode_map: - raise ValueError(f"Unknown mode: {mode}. Valid modes: {list(mode_map.keys())}") - - mode_int = mode_map[mode] Z, Y, X = image.shape input_float = image.astype(cp.float32) output = cp.zeros_like(input_float) # Launch parallel kernel - each thread processes one pixel sobel_kernel = _get_sobel_2d_kernel() - sobel_kernel(input_float, Y, X, mode_int, cp.float32(cval), output) + sobel_kernel(input_float, Y, X, mode.kernel_code, cp.float32(cval), output) return output.astype(image.dtype) @cupy_func -def sobel_3d_voxel(image: "cp.ndarray", mode: str = "reflect", cval: float = 0.0) -> "cp.ndarray": +def sobel_3d_voxel( + image: "cp.ndarray", + mode: ScipyBoundaryMode = ScipyBoundaryMode.REFLECT, + cval: float = 0.0, +) -> "cp.ndarray": """ Apply true 3D voxel Sobel edge detection including Z-axis gradients - GPU PARALLELIZED. @@ -946,6 +962,17 @@ def sobel_3d_voxel(image: "cp.ndarray", mode: str = "reflect", cval: float = 0.0 Returns: 3D edge magnitude as 3D CuPy array of shape (Z, Y, X) """ + return _sobel_3d_voxel(image, mode=mode.value, cval=cval) + + +def _sobel_3d_voxel( + image: "cp.ndarray", + *, + mode: str, + cval: float, +) -> "cp.ndarray": + """Apply the 3-D Sobel implementation at its library string boundary.""" + _validate_3d_array(image) # Convert to float32 for processing @@ -962,7 +989,12 @@ def sobel_3d_voxel(image: "cp.ndarray", mode: str = "reflect", cval: float = 0.0 return magnitude.astype(image.dtype) @cupy_func -def sobel_components(image: "cp.ndarray", include_z: bool = False, mode: str = "reflect", cval: float = 0.0) -> tuple: +def sobel_components( + image: "cp.ndarray", + include_z: bool = False, + mode: ScipyBoundaryMode = ScipyBoundaryMode.REFLECT, + cval: float = 0.0, +) -> tuple: """ Return individual Sobel gradient components - GPU PARALLELIZED. @@ -986,46 +1018,53 @@ def sobel_components(image: "cp.ndarray", include_z: bool = False, mode: str = " image_float = image.astype(cp.float32) # Apply Sobel filters - GPU PARALLELIZED - sobel_x = ndimage.sobel(image_float, axis=2, mode=mode, cval=cval).astype(image.dtype) # X-direction - sobel_y = ndimage.sobel(image_float, axis=1, mode=mode, cval=cval).astype(image.dtype) # Y-direction + sobel_x = ndimage.sobel(image_float, axis=2, mode=mode.value, cval=cval).astype(image.dtype) # X-direction + sobel_y = ndimage.sobel(image_float, axis=1, mode=mode.value, cval=cval).astype(image.dtype) # Y-direction if include_z: - sobel_z = ndimage.sobel(image_float, axis=0, mode=mode, cval=cval).astype(image.dtype) # Z-direction + sobel_z = ndimage.sobel(image_float, axis=0, mode=mode.value, cval=cval).astype(image.dtype) # Z-direction return sobel_x, sobel_y, sobel_z else: return sobel_x, sobel_y class CupyEdgeMagnitudeStrategy( - RegisteredProcessorMethodStrategy, metaclass=AutoRegisterMeta + EnumKeyedStrategyMixin[EdgeMagnitudeMethod], metaclass=AutoRegisterMeta ): + __enum_member_attr__ = "method" + @abstractmethod def apply( - self, image: "cp.ndarray", *, mode: str, cval: float + self, image: "cp.ndarray", *, mode: SobelKernelBoundaryMode, cval: float ) -> "cp.ndarray": raise NotImplementedError class CupySlice2DEdgeMagnitudeStrategy(CupyEdgeMagnitudeStrategy): - method = "2d" + method = EdgeMagnitudeMethod.SLICE_2D def apply( - self, image: "cp.ndarray", *, mode: str, cval: float + self, image: "cp.ndarray", *, mode: SobelKernelBoundaryMode, cval: float ) -> "cp.ndarray": return sobel_2d_vectorized(image, mode=mode, cval=cval) class CupyVolume3DEdgeMagnitudeStrategy(CupyEdgeMagnitudeStrategy): - method = "3d" + method = EdgeMagnitudeMethod.VOLUME_3D def apply( - self, image: "cp.ndarray", *, mode: str, cval: float + self, image: "cp.ndarray", *, mode: SobelKernelBoundaryMode, cval: float ) -> "cp.ndarray": - return sobel_3d_voxel(image, mode=mode, cval=cval) + return _sobel_3d_voxel(image, mode=mode.value, cval=cval) @cupy_func -def edge_magnitude(image: "cp.ndarray", method: str = "2d", mode: str = "reflect", cval: float = 0.0) -> "cp.ndarray": +def edge_magnitude( + image: "cp.ndarray", + method: EdgeMagnitudeMethod = EdgeMagnitudeMethod.SLICE_2D, + mode: SobelKernelBoundaryMode = SobelKernelBoundaryMode.REFLECT, + cval: float = 0.0, +) -> "cp.ndarray": """ Compute edge magnitude using specified method - GPU PARALLELIZED. @@ -1045,14 +1084,16 @@ def edge_magnitude(image: "cp.ndarray", method: str = "2d", mode: str = "reflect """ _validate_3d_array(image) - return CupyEdgeMagnitudeStrategy.for_method(method).apply( + return CupyEdgeMagnitudeStrategy.for_enum_member(method).apply( image, mode=mode, cval=cval ) @cupy_func def sobel(image: "cp.ndarray", mask: Optional["cp.ndarray"] = None, *, - axis: Optional[int] = None, mode: str = "reflect", cval: float = 0.0) -> "cp.ndarray": + axis: Optional[int] = None, + mode: ScipyBoundaryMode = ScipyBoundaryMode.REFLECT, + cval: float = 0.0) -> "cp.ndarray": """ Find edges in an image using the Sobel filter (CuCIM backend). @@ -1084,7 +1125,7 @@ def sobel(image: "cp.ndarray", mask: Optional["cp.ndarray"] = None, *, raise ImportError("CuCIM is required for sobel edge detection but is not available") # Import here to avoid circular dependency - from openhcs.core.memory import DtypeConversion + from arraybridge.decorators import DtypeConversion from openhcs.core.memory import SCALING_FUNCTIONS from openhcs.constants.constants import MemoryType @@ -1096,7 +1137,7 @@ def sobel(image: "cp.ndarray", mask: Optional["cp.ndarray"] = None, *, image, mask=mask, axis=axis, - mode=mode, + mode=mode.value, cval=cval ) diff --git a/openhcs/processing/backends/processors/jax_processor.py b/openhcs/processing/backends/processors/jax_processor.py index 99097576c..79154c99c 100644 --- a/openhcs/processing/backends/processors/jax_processor.py +++ b/openhcs/processing/backends/processors/jax_processor.py @@ -12,13 +12,19 @@ from __future__ import annotations import logging +from abc import abstractmethod from typing import Any, List, Optional, Tuple +from metaclass_registry import AutoRegisterMeta from openhcs.core.memory import jax as jax_func +from openhcs.core.registry_strategies import EnumKeyedStrategyMixin from openhcs.core.utils import optional_import # Import JAX as an optional dependency from openhcs.core.lazy_gpu_imports import jax +from openhcs.processing.backends.processors.method_axes import ( + StackProjectionMethod, +) jnp = optional_import("jax.numpy") if jax else None lax = jax.lax if jax else None @@ -482,6 +488,7 @@ def mean_projection(stack: "jnp.ndarray") -> "jnp.ndarray": projection_2d = jnp.mean(stack.astype(jnp.float32), axis=0).astype(stack.dtype) return jnp.expand_dims(projection_2d, axis=0) + @jax_func def stack_equalize_histogram( stack: "jnp.ndarray", @@ -558,9 +565,37 @@ def stack_equalize_histogram( else: return equalized_stack.astype(input_dtype) +class JaxStackProjectionStrategy( + EnumKeyedStrategyMixin[StackProjectionMethod], + metaclass=AutoRegisterMeta, +): + __enum_member_attr__ = "method" + + """JAX stack-projection dispatch owner.""" + + @abstractmethod + def apply(self, stack: "jnp.ndarray") -> "jnp.ndarray": + """Apply this projection method.""" + + +class JaxMaxStackProjectionStrategy(JaxStackProjectionStrategy): + method = StackProjectionMethod.MAX + + def apply(self, stack: "jnp.ndarray") -> "jnp.ndarray": + return max_projection(stack) + + +class JaxMeanStackProjectionStrategy(JaxStackProjectionStrategy): + method = StackProjectionMethod.MEAN + + def apply(self, stack: "jnp.ndarray") -> "jnp.ndarray": + return mean_projection(stack) + + @jax_func def create_projection( - stack: "jnp.ndarray", method: str = "max_projection" + stack: "jnp.ndarray", + method: StackProjectionMethod = StackProjectionMethod.MAX, ) -> "jnp.ndarray": """ Create a projection from a stack using the specified method. @@ -574,14 +609,7 @@ def create_projection( """ _validate_3d_array(stack) - if method == "max_projection": - return max_projection(stack) - - if method == "mean_projection": - return mean_projection(stack) - - # FAIL FAST: No fallback projection methods - raise ValueError(f"Unknown projection method: {method}. Valid methods: max_projection, mean_projection") + return JaxStackProjectionStrategy.for_enum_member(method).apply(stack) @jax_func def tophat( diff --git a/openhcs/processing/backends/processors/method_axes.py b/openhcs/processing/backends/processors/method_axes.py index 0b6e1b136..58d784741 100644 --- a/openhcs/processing/backends/processors/method_axes.py +++ b/openhcs/processing/backends/processors/method_axes.py @@ -2,24 +2,60 @@ from __future__ import annotations -from abc import ABC -from typing import ClassVar, Self +from enum import Enum -PROCESSOR_METHOD_REGISTRY_KEY = "method" +class SpatialBinMethod(Enum): + """Reduction applied within one spatial bin.""" + MEAN = "mean" + SUM = "sum" + MAX = "max" + MIN = "min" -class RegisteredProcessorMethodStrategy(ABC): - """Mixin for AutoRegisterMeta roots keyed by public processor method names.""" - __registry_key__ = PROCESSOR_METHOD_REGISTRY_KEY - __skip_if_no_key__ = True - method: ClassVar[str | None] = None +class StackProjectionMethod(Enum): + """Max/mean reductions shared by processor backends.""" - @classmethod - def for_method(cls, method: str) -> Self: - try: - return cls.__registry__[method]() - except KeyError as exc: - choices = ", ".join(str(key) for key in cls.__registry__) - raise ValueError(f"{cls.__name__} must be one of: {choices}") from exc + MAX = "max_projection" + MEAN = "mean_projection" + + +class EdgeMagnitudeMethod(Enum): + """Spatial domain used for Sobel edge magnitude.""" + + SLICE_2D = "2d" + VOLUME_3D = "3d" + + +class ScipyBoundaryMode(Enum): + """Boundary extension modes supported by SciPy-compatible filters.""" + + CONSTANT = "constant" + REFLECT = "reflect" + NEAREST = "nearest" + WRAP = "wrap" + MIRROR = "mirror" + + +class SobelKernelBoundaryMode(Enum): + """Boundary extension modes implemented by the custom 2-D Sobel kernel.""" + + CONSTANT = ("constant", 0) + REFLECT = ("reflect", 1) + NEAREST = ("nearest", 2) + WRAP = ("wrap", 3) + + def __new__(cls, label: str, kernel_code: int): + member = object.__new__(cls) + member._value_ = label + member.kernel_code = kernel_code + return member + + +class OrthogonalProjectionPlane(Enum): + """Plane retained by an orthogonal stack projection.""" + + XY = "xy" + XZ = "xz" + YZ = "yz" diff --git a/openhcs/processing/backends/processors/numpy_processor.py b/openhcs/processing/backends/processors/numpy_processor.py index c4c81ba35..95e74fe9c 100644 --- a/openhcs/processing/backends/processors/numpy_processor.py +++ b/openhcs/processing/backends/processors/numpy_processor.py @@ -14,6 +14,7 @@ import logging from abc import abstractmethod +from enum import Enum from typing import Annotated, Any, List, Optional, Tuple from metaclass_registry import AutoRegisterMeta @@ -24,9 +25,11 @@ # Use direct import from core memory decorators to avoid circular imports from openhcs.core.memory import numpy as numpy_func +from openhcs.core.registry_strategies import EnumKeyedStrategyMixin from openhcs.core.runtime_array_values import RuntimeArrayPayload from openhcs.processing.backends.processors.method_axes import ( - RegisteredProcessorMethodStrategy, + OrthogonalProjectionPlane, + SpatialBinMethod, ) from openhcs.processing.backends.lib_registry.unified_registry import ProcessingContract @@ -49,43 +52,39 @@ float, "Output intensity assigned to the normalized range's upper endpoint.", ] -SpatialBinMethodInput = Annotated[ - str, - 'Reduction applied within each bin: "mean", "sum", "max", or "min".', -] - - class NumpySpatialBinStrategy( - RegisteredProcessorMethodStrategy, metaclass=AutoRegisterMeta + EnumKeyedStrategyMixin[SpatialBinMethod], metaclass=AutoRegisterMeta ): + __enum_member_attr__ = "method" + @abstractmethod def apply(self, array: np.ndarray, axis: tuple[int, ...]) -> np.ndarray: raise NotImplementedError class NumpyMeanSpatialBinStrategy(NumpySpatialBinStrategy): - method = "mean" + method = SpatialBinMethod.MEAN def apply(self, array: np.ndarray, axis: tuple[int, ...]) -> np.ndarray: return np.mean(array, axis=axis) class NumpySumSpatialBinStrategy(NumpySpatialBinStrategy): - method = "sum" + method = SpatialBinMethod.SUM def apply(self, array: np.ndarray, axis: tuple[int, ...]) -> np.ndarray: return np.sum(array, axis=axis) class NumpyMaxSpatialBinStrategy(NumpySpatialBinStrategy): - method = "max" + method = SpatialBinMethod.MAX def apply(self, array: np.ndarray, axis: tuple[int, ...]) -> np.ndarray: return np.max(array, axis=axis) class NumpyMinSpatialBinStrategy(NumpySpatialBinStrategy): - method = "min" + method = SpatialBinMethod.MIN def apply(self, array: np.ndarray, axis: tuple[int, ...]) -> np.ndarray: return np.min(array, axis=axis) @@ -451,39 +450,62 @@ def mean_projection(stack: np.ndarray) -> np.ndarray: return projection_2d.reshape(1, projection_2d.shape[0], projection_2d.shape[1]) +def min_projection(stack: np.ndarray) -> np.ndarray: + """Create a minimum intensity projection from a Z-stack.""" + + _validate_3d_array(stack) + projection_2d = np.min(stack, axis=0) + return projection_2d.reshape(1, *projection_2d.shape) + + +class NumpyStackProjectionMethod(Enum): + """Stack reductions implemented by the NumPy projection dispatcher.""" + + MAX = "max_projection" + MEAN = "mean_projection" + MIN = "min_projection" + + class NumpyStackProjectionStrategy( - RegisteredProcessorMethodStrategy, metaclass=AutoRegisterMeta + EnumKeyedStrategyMixin[NumpyStackProjectionMethod], + metaclass=AutoRegisterMeta, ): + __enum_member_attr__ = "method" + @abstractmethod def apply(self, stack: np.ndarray) -> np.ndarray: raise NotImplementedError class NumpyMaxStackProjectionStrategy(NumpyStackProjectionStrategy): - method = "max_projection" + method = NumpyStackProjectionMethod.MAX def apply(self, stack: np.ndarray) -> np.ndarray: return max_projection(stack) class NumpyMeanStackProjectionStrategy(NumpyStackProjectionStrategy): - method = "mean_projection" + method = NumpyStackProjectionMethod.MEAN def apply(self, stack: np.ndarray) -> np.ndarray: return mean_projection(stack) class NumpyMinStackProjectionStrategy(NumpyStackProjectionStrategy): - method = "min_projection" + method = NumpyStackProjectionMethod.MIN def apply(self, stack: np.ndarray) -> np.ndarray: - projection_2d = np.min(stack, axis=0) - return projection_2d.reshape(1, *projection_2d.shape) + return min_projection(stack) @numpy_func def create_orthogonal_projections( - stack: np.ndarray, projections: Tuple[str, ...] = ("xy", "xz", "yz") + stack: np.ndarray, + projections: Tuple[OrthogonalProjectionPlane, ...] = ( + OrthogonalProjectionPlane.XY, + OrthogonalProjectionPlane.XZ, + OrthogonalProjectionPlane.YZ, + ), ) -> dict: """ Create orthogonal max projections from a Z-stack. @@ -506,12 +528,12 @@ def create_orthogonal_projections( _validate_3d_array(stack) result = {} - if "xy" in projections: - result["xy"] = stack.max(axis=0) - if "xz" in projections: - result["xz"] = stack.max(axis=1) - if "yz" in projections: - result["yz"] = stack.max(axis=2) + if OrthogonalProjectionPlane.XY in projections: + result[OrthogonalProjectionPlane.XY.value] = stack.max(axis=0) + if OrthogonalProjectionPlane.XZ in projections: + result[OrthogonalProjectionPlane.XZ.value] = stack.max(axis=1) + if OrthogonalProjectionPlane.YZ in projections: + result[OrthogonalProjectionPlane.YZ.value] = stack.max(axis=2) return result @@ -538,7 +560,9 @@ def gaussian_blur(stack: np.ndarray, sigma: float = 1.0) -> np.ndarray: @numpy_func def spatial_bin_2d( - stack: np.ndarray, bin_size: int = 2, method: SpatialBinMethodInput = "mean" + stack: np.ndarray, + bin_size: int = 2, + method: SpatialBinMethod = SpatialBinMethod.MEAN, ) -> np.ndarray: """ Apply 2D spatial binning to each slice in the stack. @@ -578,14 +602,18 @@ def spatial_bin_2d( z_slices, new_height, bin_size, new_width, bin_size ) - result = NumpySpatialBinStrategy.for_method(method).apply(reshaped, axis=(2, 4)) + result = NumpySpatialBinStrategy.for_enum_member(method).apply( + reshaped, axis=(2, 4) + ) return result.astype(stack.dtype) @numpy_func def spatial_bin_3d( - stack: np.ndarray, bin_size: int = 2, method: SpatialBinMethodInput = "mean" + stack: np.ndarray, + bin_size: int = 2, + method: SpatialBinMethod = SpatialBinMethod.MEAN, ) -> np.ndarray: """ Apply 3D spatial binning to the entire stack. @@ -626,7 +654,9 @@ def spatial_bin_3d( new_depth, bin_size, new_height, bin_size, new_width, bin_size ) - result = NumpySpatialBinStrategy.for_method(method).apply(reshaped, axis=(1, 3, 5)) + result = NumpySpatialBinStrategy.for_enum_member(method).apply( + reshaped, axis=(1, 3, 5) + ) return result.astype(stack.dtype) @@ -690,7 +720,10 @@ def stack_equalize_histogram( @numpy_func(contract=ProcessingContract.VOLUMETRIC_TO_SLICE) -def create_projection(stack: np.ndarray, method: str = "max_projection") -> np.ndarray: +def create_projection( + stack: np.ndarray, + method: NumpyStackProjectionMethod = NumpyStackProjectionMethod.MAX, +) -> np.ndarray: """ Create a projection from a stack using the specified method. @@ -703,7 +736,7 @@ def create_projection(stack: np.ndarray, method: str = "max_projection") -> np.n stack_array = np.asarray(stack) _validate_3d_array(stack_array) - return NumpyStackProjectionStrategy.for_method(method).apply(stack_array)[0] + return NumpyStackProjectionStrategy.for_enum_member(method).apply(stack_array)[0] @numpy_func diff --git a/openhcs/processing/backends/processors/pyclesperanto_processor.py b/openhcs/processing/backends/processors/pyclesperanto_processor.py index 2713cbe3b..077191bac 100644 --- a/openhcs/processing/backends/processors/pyclesperanto_processor.py +++ b/openhcs/processing/backends/processors/pyclesperanto_processor.py @@ -8,11 +8,17 @@ import logging import os +from abc import abstractmethod from enum import Enum from typing import List, Optional +from metaclass_registry import AutoRegisterMeta # Import OpenHCS decorator from openhcs.core.memory import pyclesperanto as pyclesperanto_func +from openhcs.core.registry_strategies import EnumKeyedStrategyMixin +from openhcs.processing.backends.processors.method_axes import ( + StackProjectionMethod, +) # Set up logging logger = logging.getLogger(__name__) @@ -130,7 +136,7 @@ def per_slice_minmax_normalize( gpu_slice = image[z] # Direct slice access # Calculate min/max range for normalization (pure GPU) - p_low, p_high = _gpu_minmax_normalize_range(gpu_slice, low_percentile, high_percentile) + p_low, p_high = _gpu_minmax_normalize_range(gpu_slice) # Avoid division by zero if p_high == p_low: @@ -189,7 +195,7 @@ def stack_minmax_normalize( raise ValueError(f"Expected 3D array, got {len(image.shape)}D array") # Calculate global min/max range from entire stack (pure GPU) - p_low, p_high = _gpu_minmax_normalize_range(image, low_percentile, high_percentile) + p_low, p_high = _gpu_minmax_normalize_range(image) # Avoid division by zero if p_high == p_low: @@ -311,9 +317,42 @@ def mean_projection(stack: "cle.Array") -> "cle.Array": return result + +class PyclesperantoStackProjectionStrategy( + EnumKeyedStrategyMixin[StackProjectionMethod], + metaclass=AutoRegisterMeta, +): + __enum_member_attr__ = "method" + + """pyclesperanto stack-projection dispatch owner.""" + + @abstractmethod + def apply(self, stack: "cle.Array") -> "cle.Array": + """Apply this projection method.""" + + +class PyclesperantoMaxStackProjectionStrategy( + PyclesperantoStackProjectionStrategy +): + method = StackProjectionMethod.MAX + + def apply(self, stack: "cle.Array") -> "cle.Array": + return max_projection(stack) + + +class PyclesperantoMeanStackProjectionStrategy( + PyclesperantoStackProjectionStrategy +): + method = StackProjectionMethod.MEAN + + def apply(self, stack: "cle.Array") -> "cle.Array": + return mean_projection(stack) + + @pyclesperanto_func def create_projection( - stack: "cle.Array", method: str = "max_projection" + stack: "cle.Array", + method: StackProjectionMethod = StackProjectionMethod.MAX, ) -> "cle.Array": """ Create a projection from a stack using the specified method - GPU accelerated. @@ -334,14 +373,7 @@ def create_projection( if len(stack.shape) != 3: raise ValueError(f"Expected 3D array, got {len(stack.shape)}D array") - if method == "max_projection": - return max_projection(stack) - - if method == "mean_projection": - return mean_projection(stack) - - # FAIL FAST: No fallback projection methods - raise ValueError(f"Unknown projection method: {method}. Valid methods: max_projection, mean_projection") + return PyclesperantoStackProjectionStrategy.for_enum_member(method).apply(stack) @pyclesperanto_func def tophat( diff --git a/openhcs/processing/backends/processors/tensorflow_processor.py b/openhcs/processing/backends/processors/tensorflow_processor.py index 1327dbe5a..73dac551d 100644 --- a/openhcs/processing/backends/processors/tensorflow_processor.py +++ b/openhcs/processing/backends/processors/tensorflow_processor.py @@ -12,12 +12,18 @@ from __future__ import annotations import logging +from abc import abstractmethod from typing import Any, List, Optional, Tuple +from metaclass_registry import AutoRegisterMeta from packaging.version import parse as parse_version from openhcs.core.memory import tensorflow as tensorflow_func from openhcs.core.lazy_gpu_imports import tf +from openhcs.core.registry_strategies import EnumKeyedStrategyMixin +from openhcs.processing.backends.processors.method_axes import ( + StackProjectionMethod, +) # Define error variable TENSORFLOW_ERROR = "" @@ -522,6 +528,7 @@ def mean_projection(stack: "tf.Tensor") -> "tf.Tensor": projection_2d = tf.cast(tf.reduce_mean(tf.cast(stack, tf.float32), axis=0), stack.dtype) return tf.expand_dims(projection_2d, axis=0) + @tensorflow_func def stack_equalize_histogram( stack: "tf.Tensor", @@ -608,9 +615,37 @@ def stack_equalize_histogram( # Convert back to input dtype return tf.cast(equalized_stack, input_dtype) +class TensorFlowStackProjectionStrategy( + EnumKeyedStrategyMixin[StackProjectionMethod], + metaclass=AutoRegisterMeta, +): + __enum_member_attr__ = "method" + + """TensorFlow stack-projection dispatch owner.""" + + @abstractmethod + def apply(self, stack: "tf.Tensor") -> "tf.Tensor": + """Apply this projection method.""" + + +class TensorFlowMaxStackProjectionStrategy(TensorFlowStackProjectionStrategy): + method = StackProjectionMethod.MAX + + def apply(self, stack: "tf.Tensor") -> "tf.Tensor": + return max_projection(stack) + + +class TensorFlowMeanStackProjectionStrategy(TensorFlowStackProjectionStrategy): + method = StackProjectionMethod.MEAN + + def apply(self, stack: "tf.Tensor") -> "tf.Tensor": + return mean_projection(stack) + + @tensorflow_func def create_projection( - stack: "tf.Tensor", method: str = "max_projection" + stack: "tf.Tensor", + method: StackProjectionMethod = StackProjectionMethod.MAX, ) -> "tf.Tensor": """ Create a projection from a stack using the specified method. @@ -624,14 +659,7 @@ def create_projection( """ _validate_3d_array(stack) - if method == "max_projection": - return max_projection(stack) - - if method == "mean_projection": - return mean_projection(stack) - - # FAIL FAST: No fallback projection methods - raise ValueError(f"Unknown projection method: {method}. Valid methods: max_projection, mean_projection") + return TensorFlowStackProjectionStrategy.for_enum_member(method).apply(stack) @tensorflow_func def tophat( diff --git a/openhcs/processing/backends/processors/torch_processor.py b/openhcs/processing/backends/processors/torch_processor.py index d41ccdf4d..5260c1096 100644 --- a/openhcs/processing/backends/processors/torch_processor.py +++ b/openhcs/processing/backends/processors/torch_processor.py @@ -13,10 +13,16 @@ import logging import os +from abc import abstractmethod from typing import Any, List, Optional, Tuple +from metaclass_registry import AutoRegisterMeta from openhcs.core.utils import optional_import from openhcs.core.memory import torch as torch_func +from openhcs.core.registry_strategies import EnumKeyedStrategyMixin +from openhcs.processing.backends.processors.method_axes import ( + StackProjectionMethod, +) logger = logging.getLogger(__name__) @@ -525,6 +531,7 @@ def mean_projection(stack: "torch.Tensor") -> "torch.Tensor": return projection_2d.reshape(1, projection_2d.shape[0], projection_2d.shape[1]) + @torch_func def stack_equalize_histogram( stack: "torch.Tensor", @@ -608,9 +615,37 @@ def stack_equalize_histogram( else: return equalized_stack.to(input_dtype) +class TorchStackProjectionStrategy( + EnumKeyedStrategyMixin[StackProjectionMethod], + metaclass=AutoRegisterMeta, +): + __enum_member_attr__ = "method" + + """PyTorch stack-projection dispatch owner.""" + + @abstractmethod + def apply(self, stack: "torch.Tensor") -> "torch.Tensor": + """Apply this projection method.""" + + +class TorchMaxStackProjectionStrategy(TorchStackProjectionStrategy): + method = StackProjectionMethod.MAX + + def apply(self, stack: "torch.Tensor") -> "torch.Tensor": + return max_projection(stack) + + +class TorchMeanStackProjectionStrategy(TorchStackProjectionStrategy): + method = StackProjectionMethod.MEAN + + def apply(self, stack: "torch.Tensor") -> "torch.Tensor": + return mean_projection(stack) + + @torch_func def create_projection( - stack: "torch.Tensor", method: str = "max_projection" + stack: "torch.Tensor", + method: StackProjectionMethod = StackProjectionMethod.MAX, ) -> "torch.Tensor": """ Create a projection from a stack using the specified method. @@ -624,14 +659,7 @@ def create_projection( """ _validate_3d_array(stack) - if method == "max_projection": - return max_projection(stack) - - if method == "mean_projection": - return mean_projection(stack) - - # FAIL FAST: No fallback projection methods - raise ValueError(f"Unknown projection method: {method}. Valid methods: max_projection, mean_projection") + return TorchStackProjectionStrategy.for_enum_member(method).apply(stack) @torch_func def tophat( diff --git a/openhcs/processing/presets/README.md b/openhcs/processing/presets/README.md index 6aea10ccb..7bc4ba7b0 100644 --- a/openhcs/processing/presets/README.md +++ b/openhcs/processing/presets/README.md @@ -167,7 +167,6 @@ min_cell_area: 40 max_cell_area: 200-300 detection_method: DetectionMethod.WATERSHED enable_preprocessing: False -return_segmentation_mask: True ``` **Large cells** (whole cells): diff --git a/openhcs/processing/presets/mfd_specs.py b/openhcs/processing/presets/mfd_specs.py index 4e932d4b0..20df113eb 100644 --- a/openhcs/processing/presets/mfd_specs.py +++ b/openhcs/processing/presets/mfd_specs.py @@ -9,17 +9,18 @@ from typing import Any from metaclass_registry import AutoRegisterMeta +from arraybridge.decorators import DtypeConversion from openhcs.constants.constants import VariableComponents from openhcs.constants.input_source import InputSource from openhcs.core.config import LazyDtypeConfig, LazyProcessingConfig -from openhcs.core.memory import DtypeConversion from openhcs.core.steps.function_step import FunctionStep from openhcs.processing.backends.analysis.cell_counting_cpu import ( DetectionMethod, count_cells_single_channel, ) from openhcs.processing.backends.analysis.multi_template_matching import ( + OpenCVTemplateMatchMethod, multi_template_crop_reference_channel, ) from openhcs.processing.backends.analysis.skan_axon_analysis import ( @@ -146,7 +147,7 @@ def _template_crop_func() -> tuple[Any, dict[str, Any]]: multi_template_crop_reference_channel, { "score_threshold": 0.1, - "method": 1, + "method": OpenCVTemplateMatchMethod.SQDIFF_NORMED, "template_path": MFD_WHOLE_DEVICE_TEMPLATE_PATH, "rotate_result": False, }, @@ -185,7 +186,6 @@ def _cell_count_func( "min_cell_area": min_cell_area, "max_cell_area": max_cell_area, "enable_preprocessing": False, - "return_segmentation_mask": True, "detection_method": DetectionMethod.WATERSHED, "dtype_config": LazyDtypeConfig( default_dtype_conversion=DtypeConversion.UINT8 diff --git a/openhcs/processing/presets/pipelines/cy5_axon_cell_body_crop_analysis.py b/openhcs/processing/presets/pipelines/cy5_axon_cell_body_crop_analysis.py index 1f329503b..6b1ce2b85 100755 --- a/openhcs/processing/presets/pipelines/cy5_axon_cell_body_crop_analysis.py +++ b/openhcs/processing/presets/pipelines/cy5_axon_cell_body_crop_analysis.py @@ -4,10 +4,10 @@ from openhcs.constants.constants import VariableComponents from openhcs.constants.input_source import InputSource from openhcs.core.config import LazyDtypeConfig, LazyStepMaterializationConfig -from openhcs.core.memory import DtypeConversion +from arraybridge.decorators import DtypeConversion from openhcs.core.steps.function_step import FunctionStep from openhcs.processing.backends.analysis.cell_counting_cpu import DetectionMethod, count_cells_single_channel -from openhcs.processing.backends.analysis.multi_template_matching import multi_template_crop_reference_channel +from openhcs.processing.backends.analysis.multi_template_matching import OpenCVTemplateMatchMethod, multi_template_crop_reference_channel from openhcs.processing.backends.analysis.skan_axon_analysis import AnalysisDimension, OutputMode, skan_axon_skeletonize_and_analyze from openhcs.processing.backends.processors.cupy_processor import crop, tophat @@ -18,7 +18,7 @@ step_1 = FunctionStep( func=(multi_template_crop_reference_channel, { 'score_threshold': 0.1, - 'method': 1, + 'method': OpenCVTemplateMatchMethod.SQDIFF_NORMED, 'template_path': '/home/ts/nvme_usb/configs/templates/mfd_96_sobel_10x_whole_device.tif', 'rotate_result': False }), @@ -49,7 +49,6 @@ 'min_cell_area': 40, 'max_cell_area': 300, 'enable_preprocessing': False, - 'return_segmentation_mask': True, 'detection_method': DetectionMethod.WATERSHED, 'dtype_config': LazyDtypeConfig( default_dtype_conversion=DtypeConversion.UINT8 @@ -66,7 +65,6 @@ 'min_cell_area': 40, 'max_cell_area': 200, 'enable_preprocessing': False, - 'return_segmentation_mask': True, 'detection_method': DetectionMethod.WATERSHED, 'dtype_config': LazyDtypeConfig( default_dtype_conversion=DtypeConversion.UINT8 @@ -81,7 +79,7 @@ step_5 = FunctionStep( func=(multi_template_crop_reference_channel, { 'score_threshold': 0.1, - 'method': 1, + 'method': OpenCVTemplateMatchMethod.SQDIFF_NORMED, 'template_path': '/home/ts/nvme_usb/configs/templates/mfd_96_sobel_10x_whole_device.tif', 'rotate_result': False }), diff --git a/openhcs/processing/presets/pipelines/czi_brain_axon_cellbody.py b/openhcs/processing/presets/pipelines/czi_brain_axon_cellbody.py index 4385ed04b..547953e60 100644 --- a/openhcs/processing/presets/pipelines/czi_brain_axon_cellbody.py +++ b/openhcs/processing/presets/pipelines/czi_brain_axon_cellbody.py @@ -38,11 +38,10 @@ LazyNapariStreamingConfig, LazyPathPlanningConfig, LazyProcessingConfig, + LazySourceBindingsConfig, LazyStepSourceBindingsConfig, LazyWellFilterConfig, - NapariColormap, PipelineConfig, - SourceBindingsConfig, ) from openhcs.core.function_patterns import get_core_callable from openhcs.core.image_file_serialization import ImageFileFormat @@ -110,7 +109,7 @@ class CziBrainAxonCellBodyInputs: def _analysis_stream( inputs: CziBrainAxonCellBodyInputs, - colormap: NapariColormap, + colormap: str, ) -> LazyNapariStreamingConfig: return LazyNapariStreamingConfig( enabled=True, @@ -128,7 +127,7 @@ def build_czi_brain_axon_cellbody_demo( output_root = inputs.output_root.expanduser().resolve() pipeline_config = PipelineConfig( microscope=Microscope.BIOFORMATS, - source_bindings_config=SourceBindingsConfig( + source_bindings_config=LazySourceBindingsConfig( bindings=( NamedSourceBinding( alias="Axon", @@ -203,7 +202,7 @@ def build_czi_brain_axon_cellbody_demo( ), napari_streaming_config=_analysis_stream( inputs, - NapariColormap.MAGMA, + "magma", ), ), FunctionStep( @@ -252,7 +251,7 @@ def build_czi_brain_axon_cellbody_demo( ), napari_streaming_config=_analysis_stream( inputs, - NapariColormap.GRAY, + "gray", ), ), ] diff --git a/openhcs/processing/presets/pipelines/loose_operaphenix_neurite_outgrowth.py b/openhcs/processing/presets/pipelines/loose_operaphenix_neurite_outgrowth.py index 8ef8c8e52..fa8ddeae1 100644 --- a/openhcs/processing/presets/pipelines/loose_operaphenix_neurite_outgrowth.py +++ b/openhcs/processing/presets/pipelines/loose_operaphenix_neurite_outgrowth.py @@ -29,7 +29,6 @@ from openhcs.core.artifacts import ImageArtifactType, ObjectLabelsArtifactType from openhcs.core.config import ( LazyNapariStreamingConfig, - NapariColormap, LazyPathPlanningConfig, LazyProcessingConfig, LazyStepMaterializationConfig, @@ -40,9 +39,9 @@ from openhcs.core.invocation_artifacts import ArtifactDeclarationStepContext from openhcs.core.source_bindings import ( ComponentSelector, + LazySourceBindingsConfig, LazyStepSourceBindingsConfig, NamedSourceBinding, - SourceBindingsConfig, SourceFilterClause, SourceFilterMatchType, SourceFilterSubject, @@ -161,7 +160,7 @@ def _named_source(alias: str) -> LazyStepSourceBindingsConfig: def _qc_stream( inputs: LooseOperaPhenixNeuriteInputs, - colormap: NapariColormap, + colormap: str, ) -> LazyNapariStreamingConfig: return LazyNapariStreamingConfig( enabled=True, @@ -200,7 +199,7 @@ def build_loose_operaphenix_neurite_config( ), materialization_results_path=output_root / "results", materialize_runtime_artifacts=True, - source_bindings_config=SourceBindingsConfig(bindings=source_bindings), + source_bindings_config=LazySourceBindingsConfig(bindings=source_bindings), ) @@ -224,7 +223,7 @@ def build_loose_operaphenix_neurite_pipeline( input_source=InputSource.PIPELINE_START, ), source_bindings=_named_source(inputs.cell_body_source.alias), - napari_streaming_config=_qc_stream(inputs, NapariColormap.VIRIDIS), + napari_streaming_config=_qc_stream(inputs, "viridis"), ), FunctionStep( name="SMI312SourceSignal", @@ -243,7 +242,7 @@ def build_loose_operaphenix_neurite_pipeline( output_root, "qc_smi312_signal", ), - napari_streaming_config=_qc_stream(inputs, NapariColormap.MAGMA), + napari_streaming_config=_qc_stream(inputs, "magma"), ), FunctionStep( name="EnhancedNeurites", @@ -262,7 +261,7 @@ def build_loose_operaphenix_neurite_pipeline( output_root, "qc_neurite_mask", ), - napari_streaming_config=_qc_stream(inputs, NapariColormap.GRAY), + napari_streaming_config=_qc_stream(inputs, "gray"), ), FunctionStep( name="NeuriteSkeleton", @@ -271,7 +270,7 @@ def build_loose_operaphenix_neurite_pipeline( output_root, "qc_neurite_skeleton", ), - napari_streaming_config=_qc_stream(inputs, NapariColormap.GRAY), + napari_streaming_config=_qc_stream(inputs, "gray"), ), FunctionStep( name="PerNeuronNeuriteTopology", @@ -283,7 +282,7 @@ def build_loose_operaphenix_neurite_pipeline( "branchpoint_image_name": NEURITE_BRANCHPOINT_IMAGE_NAME, }, ), - napari_streaming_config=_qc_stream(inputs, NapariColormap.GRAY), + napari_streaming_config=_qc_stream(inputs, "gray"), ), FunctionStep( name="UnifiedNeurons", @@ -295,7 +294,7 @@ def build_loose_operaphenix_neurite_pipeline( input_source=InputSource.PIPELINE_START, ), source_bindings=_named_source(inputs.smi312.alias), - napari_streaming_config=_qc_stream(inputs, NapariColormap.MAGMA), + napari_streaming_config=_qc_stream(inputs, "magma"), ), FunctionStep( name="NeuriteSpreadsheetExport", diff --git a/openhcs/processing/presets/pipelines/loose_operaphenix_neurite_outgrowth_metaxpress.py b/openhcs/processing/presets/pipelines/loose_operaphenix_neurite_outgrowth_metaxpress.py index d9dfbe374..fb427fa3a 100644 --- a/openhcs/processing/presets/pipelines/loose_operaphenix_neurite_outgrowth_metaxpress.py +++ b/openhcs/processing/presets/pipelines/loose_operaphenix_neurite_outgrowth_metaxpress.py @@ -18,7 +18,6 @@ from openhcs.core.config import ( LazyNapariStreamingConfig, LazyProcessingConfig, - NapariColormap, PipelineConfig, ) from openhcs.core.steps.function_step import FunctionStep @@ -79,7 +78,7 @@ def build_loose_operaphenix_neurite_metaxpress_pipeline( enabled=True, persistent=True, port=inputs.viewer_port, - colormap=NapariColormap.MAGMA, + colormap="magma", ), ) return pipeline_config, [step] diff --git a/openhcs/processing/presets/pipelines/neuroncyto_ii_crossover_neurite_outgrowth.py b/openhcs/processing/presets/pipelines/neuroncyto_ii_crossover_neurite_outgrowth.py index 891472321..51e3020b8 100644 --- a/openhcs/processing/presets/pipelines/neuroncyto_ii_crossover_neurite_outgrowth.py +++ b/openhcs/processing/presets/pipelines/neuroncyto_ii_crossover_neurite_outgrowth.py @@ -35,14 +35,13 @@ LazyPathPlanningConfig, LazyProcessingConfig, LazyWellFilterConfig, - NapariColormap, PipelineConfig, ) from openhcs.core.memory import numpy as numpy_func from openhcs.core.source_bindings import ( ComponentSelector, + LazySourceBindingsConfig, NamedSourceBinding, - SourceBindingsConfig, SourceFilterClause, SourceFilterMatchType, SourceFilterSubject, @@ -160,7 +159,7 @@ def build_neuroncyto_ii_crossover_demo( ), materialization_results_path=output_root / "results", materialize_runtime_artifacts=True, - source_bindings_config=SourceBindingsConfig(bindings=source_bindings), + source_bindings_config=LazySourceBindingsConfig(bindings=source_bindings), ) step = FunctionStep( name="NeuronCyto II Crossover Neurite Outgrowth", @@ -198,7 +197,7 @@ def build_neuroncyto_ii_crossover_demo( enabled=True, persistent=True, port=inputs.viewer_port, - colormap=NapariColormap.MAGMA, + colormap="magma", ), ) return pipeline_config, [step] @@ -296,7 +295,7 @@ def prepare() -> None: enabled=True, persistent=True, port=inputs.viewer_port, - colormap=NapariColormap.MAGMA, + colormap="magma", ), ) enhancement_step = FunctionStep( @@ -314,7 +313,7 @@ def prepare() -> None: enabled=True, persistent=True, port=inputs.viewer_port, - colormap=NapariColormap.MAGMA, + colormap="magma", ), ) compact_step = compact_steps[0] diff --git a/openhcs/pyqt_gui/services/llm_pipeline_service.py b/openhcs/pyqt_gui/services/llm_pipeline_service.py index 3184ac27d..cfb2d66fa 100644 --- a/openhcs/pyqt_gui/services/llm_pipeline_service.py +++ b/openhcs/pyqt_gui/services/llm_pipeline_service.py @@ -81,7 +81,8 @@ def build_pipeline_system_prompt(self) -> str: LazyProcessingConfig, LazyStepWellFilterConfig, LazyStepMaterializationConfig, LazyNapariStreamingConfig, LazyFijiStreamingConfig ) -from openhcs.constants.constants import VariableComponents, GroupBy, DtypeConversion +from openhcs.constants.constants import VariableComponents, GroupBy +from arraybridge.decorators import DtypeConversion from openhcs.constants.input_source import InputSource ``` diff --git a/openhcs/runtime/fiji_viewer_server.py b/openhcs/runtime/fiji_viewer_server.py index 9e83b46aa..0a9957e05 100644 --- a/openhcs/runtime/fiji_viewer_server.py +++ b/openhcs/runtime/fiji_viewer_server.py @@ -26,7 +26,6 @@ from openhcs.core.config import ( FijiDimensionMode, FijiDisplayConfig, - FijiLUT, ) from openhcs.core.streaming_config_declarations import ViewerType from openhcs.runtime.viewer_protocol import ( @@ -1315,87 +1314,6 @@ def response_for( ) -@dataclass(frozen=True, slots=True) -class FijiDisplayConfigWireAdapter: - """Rehydrate OpenHCS FijiDisplayConfig from a serialized stream payload.""" - - payload: Mapping[str, FijiWireValue] - - @classmethod - def from_payload(cls, payload: FijiWireValue) -> "FijiDisplayConfigWireAdapter": - if not isinstance(payload, Mapping): - raise TypeError("Fiji batch message 'display_config' must be a mapping.") - return cls(payload) - - def to_config(self) -> FijiDisplayConfig: - component_modes = self._required_mapping("component_modes") - return FijiDisplayConfig( - lut=self._lut(self._required_value("lut")), - auto_contrast=self._auto_contrast(self._required_value("auto_contrast")), - **self._component_mode_fields(component_modes), - ) - - def _required_value(self, field_name: str) -> FijiWireValue: - if field_name not in self.payload: - raise ValueError( - f"Fiji batch message 'display_config' missing {field_name!r}." - ) - return self.payload[field_name] - - def _required_mapping(self, field_name: str) -> Mapping[str, FijiWireValue]: - return self._required_typed_value(field_name, Mapping, "mapping") - - def _required_typed_value( - self, - field_name: str, - expected_type: type | tuple[type, ...], - expected_name: str, - ) -> FijiWireValue: - value = self._required_value(field_name) - if not isinstance(value, expected_type): - raise TypeError( - f"Fiji batch message display_config[{field_name!r}] must be " - f"a {expected_name}." - ) - return value - - @staticmethod - def _lut(value: FijiWireValue) -> FijiLUT: - if isinstance(value, FijiLUT): - return value - try: - return FijiLUT(str(value)) - except ValueError as error: - raise ValueError(f"Unknown Fiji LUT value {value!r}.") from error - - @staticmethod - def _auto_contrast(value: FijiWireValue) -> bool: - if isinstance(value, bool): - return value - raise TypeError(f"Fiji auto_contrast must be bool, got {value!r}.") - - @staticmethod - def _component_mode_fields( - component_modes: Mapping[str, FijiWireValue], - ) -> dict[str, FijiDimensionMode]: - fields = FijiDisplayConfig.__dataclass_fields__ - mode_fields = {} - for component, mode_value in component_modes.items(): - field_name = f"{component}_mode" - if field_name not in fields: - raise ValueError( - f"Fiji display config contains unknown component {component!r}." - ) - try: - mode_fields[field_name] = FijiDimensionMode(str(mode_value)) - except ValueError as error: - raise ValueError( - f"Unknown Fiji dimension mode {mode_value!r} for " - f"component {component!r}." - ) from error - return mode_fields - - @dataclass(frozen=True, slots=True) class FijiPayloadHandlerRequest(FijiDisplayItemContext): """Typed execution request for one Fiji payload handler.""" @@ -1439,9 +1357,9 @@ def batch_message(self) -> "FijiBatchMessage": display_config_payload = fields.required_mapping( ViewerBatchWireField.DISPLAY_CONFIG ) - display_config = FijiDisplayConfigWireAdapter.from_payload( + display_config = FijiDisplayConfig.from_display_payload( display_config_payload - ).to_config() + ) component_axis_semantics = fields.component_axis_semantics( ViewerMappingDisplayConfigInput(display_config_payload), context="Fiji component value domain", @@ -2825,7 +2743,7 @@ def _build_new_hyperstack( self._apply_display_settings( imp, window_key, - display_config.get_lut_name(), + display_config.lut, display_auto_contrast, nChannels, preserved_ranges=display_ranges, diff --git a/openhcs/runtime/napari_streaming_handlers.py b/openhcs/runtime/napari_streaming_handlers.py index 9e840da03..8e7eadca1 100644 --- a/openhcs/runtime/napari_streaming_handlers.py +++ b/openhcs/runtime/napari_streaming_handlers.py @@ -9,15 +9,17 @@ from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field from enum import Enum -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING, ClassVar, TypeAlias import numpy as np +from napari.layers.shapes._shapes_constants import ShapeType from polystore.streaming_constants import StreamingDataType from polystore.streaming.identity import StreamProducerIdentity from zmqruntime.viewer_protocol import ViewerComponentMode, ViewerWireField from openhcs.core.artifacts import ObjectArtifactSubjectBinding +from openhcs.core.config import NapariDisplayConfig from openhcs.core.runtime_image_values import ( ImagePayloadMetadata, ) @@ -183,6 +185,7 @@ class NapariPendingLayerUpdate(ViewerComponentAxisSemantics): timer: NapariTimerHandle data_type: StreamingDataType + display_config: NapariDisplayConfig @classmethod def from_semantics( @@ -191,12 +194,14 @@ def from_semantics( timer: NapariTimerHandle, data_type: StreamingDataType, semantics: ViewerComponentAxisSemantics, + display_config: NapariDisplayConfig, ) -> "NapariPendingLayerUpdate": return cls( entries=semantics.entries, layout=semantics.layout, timer=timer, data_type=data_type, + display_config=display_config, ) def stop_timer(self) -> None: @@ -1351,7 +1356,6 @@ class NapariLayerBatchDebouncePolicy: """Shared debounce policy for Napari layer updates and batch processors.""" delay_ms: int = 1000 - max_wait_ms: int = 5000 def start_timer(self, timer: NapariTimerHandle) -> None: timer.start(self.delay_ms) @@ -1360,15 +1364,11 @@ def create_processor( self, *, napari_server: "NapariViewerServer", - batch_size: int | None, ) -> "NapariBatchProcessor": from polystore.streaming.receivers.napari import NapariBatchProcessor return NapariBatchProcessor( napari_server=napari_server, - batch_size=batch_size, - debounce_delay_ms=self.delay_ms, - max_debounce_wait_ms=self.max_wait_ms, ) @@ -1387,18 +1387,15 @@ def get_or_create( *, layer_key: str, napari_server: "NapariViewerServer", - batch_size: int | None = None, ) -> "NapariBatchProcessor": with self.lock: if layer_key not in self.processors: self.processors[layer_key] = self.debounce_policy.create_processor( napari_server=napari_server, - batch_size=batch_size, ) logger.info( - "NapariViewerServer: Created batch processor for layer '%s' with batch_size=%s", + "NapariViewerServer: Created batch processor for layer '%s'", layer_key, - batch_size, ) return self.processors[layer_key] @@ -1406,6 +1403,8 @@ def get_or_create( class NapariShapeLabelAuthority: """Own validation and allocation of streamed ROI object identities.""" + MAX_LABEL: ClassVar[int] = int(np.iinfo(np.uint32).max) + @staticmethod def declared_label(shape_dict: ShapePayloadMap) -> int | None: metadata = shape_dict.get(ViewerWireField.METADATA.value) @@ -1421,7 +1420,7 @@ def declared_label(shape_dict: ShapePayloadMap) -> int | None: "Napari ROI shape metadata label must be a positive integer, " f"got {label!r}." ) - if label > np.iinfo(np.uint32).max: + if label > NapariShapeLabelAuthority.MAX_LABEL: raise ValueError( "Napari ROI shape metadata label exceeds the uint32 ROI-label " f"domain: {label!r}." @@ -1466,25 +1465,59 @@ def label_for(self, shape_dict: ShapePayloadMap) -> int: return label +@dataclass(slots=True) +class NapariShapeFeatureColumns: + """Accumulate native feature columns without materializing row mirrors.""" + + values: dict[str, list[object]] = field(default_factory=dict) + row_count: int = 0 + + def append( + self, + metadata: Mapping[str, ShapePayloadValue], + *, + label: int, + path: str, + ) -> None: + """Append one metadata row while preserving first-seen column order.""" + + for column_values in self.values.values(): + column_values.append(None) + for name, value in metadata.items(): + if name in ( + ObjectArtifactSubjectBinding.SUBJECT_FEATURE, + ObjectArtifactSubjectBinding.SUBJECT_ID_FEATURE, + ): + continue + self._set_last(str(name), NapariShapeLayerPayload._feature_value(value)) + self._set_last(VisualMetadataField.LABEL.value, label) + self._set_last(ViewerWireField.PATH.value, path) + self.row_count += 1 + + def _set_last(self, name: str, value: object) -> None: + if name not in self.values: + self.values[name] = [None] * self.row_count + [value] + return + self.values[name][-1] = value + + @dataclass(frozen=True, slots=True) -class NapariShapeLayerPayload: - """Native N-D Napari Shapes data assembled from streamed ROI payloads.""" +class NapariShapeColorProjection: + """One authoritative label-to-color projection for a Shapes payload.""" - data: list[np.ndarray] - shape_types: list[str] - features: dict[str, list[object]] - ndim: int - result_metadata: dict[str, object] = field(default_factory=dict) + cycle: list[tuple[float, float, float, float]] + member_colors: list[tuple[float, float, float, float]] - @property - def label_color_cycle(self) -> list[tuple[float, float, float, float]]: - """Return stable, distinct colors in first-observed label order.""" + @classmethod + def from_labels( + cls, + labels: Sequence[object], + ) -> "NapariShapeColorProjection": + """Build the cycle and member projection in one label-domain pass.""" - labels = tuple( - dict.fromkeys(self.features.get(VisualMetadataField.LABEL.value, ())) - ) + label_order = tuple(dict.fromkeys(labels)) golden_ratio_conjugate = 0.618033988749895 - return [ + cycle = [ ( *colorsys.hsv_to_rgb( (int(label) * golden_ratio_conjugate) % 1.0, @@ -1493,26 +1526,31 @@ def label_color_cycle(self) -> list[tuple[float, float, float, float]]: ), 1.0, ) - for label in labels + for label in label_order ] + colors_by_label = dict(zip(label_order, cycle)) + return cls( + cycle=cycle, + member_colors=[colors_by_label[label] for label in labels], + ) - @property - def label_colors(self) -> list[tuple[float, float, float, float]]: - """Return the stable declared-label color for every shape member.""" - labels = self.features.get(VisualMetadataField.LABEL.value, ()) - return self.colors_for_labels(labels) +@dataclass(frozen=True, slots=True) +class NapariShapeLayerPayload: + """Native N-D Napari Shapes data assembled from streamed ROI payloads.""" - def colors_for_labels( - self, - labels: Sequence[object], - ) -> list[tuple[float, float, float, float]]: - """Project arbitrary members through this full payload's label palette.""" + data: list[np.ndarray] + shape_types: list[ShapeType] + features: dict[str, list[object]] + ndim: int + result_metadata: dict[str, object] = field(default_factory=dict) + + @property + def color_projection(self) -> NapariShapeColorProjection: + """Project the payload's authoritative labels to one color domain.""" - payload_labels = self.features.get(VisualMetadataField.LABEL.value, ()) - label_order = tuple(dict.fromkeys(payload_labels)) - colors_by_label = dict(zip(label_order, self.label_color_cycle)) - return [colors_by_label[label] for label in labels] + labels = self.features.get(VisualMetadataField.LABEL.value, ()) + return NapariShapeColorProjection.from_labels(labels) def chunks( self, @@ -1575,8 +1613,8 @@ def build( aggregate_axis_bindings = NapariAggregateAxisBindingSet() shape_data: list[np.ndarray] = [] - shape_types: list[str] = [] - feature_records: list[dict[str, object]] = [] + shape_types: list[ShapeType] = [] + feature_columns = NapariShapeFeatureColumns() object_subject_tokens: list[object] = [] object_subject_ids: list[object] = [] subject_metadata_member_count = 0 @@ -1595,11 +1633,12 @@ def build( raise TypeError( "Napari SHAPES payload entries must be shape mappings." ) - shape_type = shape_dict.get(ViewerWireField.TYPE.value) - if not isinstance(shape_type, str) or not shape_type: + shape_type_value = shape_dict.get(ViewerWireField.TYPE.value) + if not isinstance(shape_type_value, str) or not shape_type_value: raise TypeError( "Napari SHAPES payload type must be a non-empty string." ) + shape_type = ShapeType(shape_type_value) coordinates = np.asarray(shape_dict["coordinates"], dtype=float) if coordinates.ndim != 2 or coordinates.shape[1] != 2: raise ValueError( @@ -1638,31 +1677,14 @@ def build( subject_metadata_member_count += 1 object_subject_tokens.append(cls._feature_value(subject_token)) object_subject_ids.append(cls._feature_value(subject_id)) - feature_record = { - str(name): cls._feature_value(value) - for name, value in metadata.items() - if name - not in ( - ObjectArtifactSubjectBinding.SUBJECT_FEATURE, - ObjectArtifactSubjectBinding.SUBJECT_ID_FEATURE, - ) - } - feature_record[VisualMetadataField.LABEL.value] = ( - label_allocator.label_for(shape_dict) + feature_columns.append( + metadata, + label=label_allocator.label_for(shape_dict), + path=item.address.path, ) - feature_record[ViewerWireField.PATH.value] = item.address.path shape_data.append(coordinates) shape_types.append(shape_type) - feature_records.append(feature_record) - - feature_names = tuple( - dict.fromkeys(name for record in feature_records for name in record) - ) - features = { - name: [record.get(name) for record in feature_records] - for name in feature_names - } result_metadata: dict[str, object] = {} if subject_metadata_member_count: if subject_metadata_member_count != len(shape_data): @@ -1683,7 +1705,7 @@ def build( return cls( data=shape_data, shape_types=shape_types, - features=features, + features=feature_columns.values, ndim=len(axis_projection.projected_axis_components) + 2, result_metadata=result_metadata, ) diff --git a/openhcs/runtime/napari_viewer_server.py b/openhcs/runtime/napari_viewer_server.py index fbda8f50a..3dc4c3078 100644 --- a/openhcs/runtime/napari_viewer_server.py +++ b/openhcs/runtime/napari_viewer_server.py @@ -27,11 +27,17 @@ from qtpy.QtCore import Qt, QTimer from openhcs.core.artifacts import ObjectArtifactSubjectBinding +from openhcs.core.config import ( + NapariDisplayConfig, + NapariVariableSizeHandling, +) +from openhcs.core.registry_strategies import EnumKeyedStrategyMixin from openhcs.core.runtime_image_values import ( ImagePayloadMetadata, ) from openhcs.core.source_spatial_domain import SourceSpatialDomain from metaclass_registry import AutoRegisterMeta +from openhcs.constants import AllComponents from polystore.backend_registry import register_cleanup_callback from polystore.streaming import StreamingSharedMemoryAuthority from zmqruntime.config import TransportMode @@ -47,7 +53,6 @@ from polystore.streaming.receivers.napari import build_route_key from openhcs.core.streaming_config_declarations import ViewerType from openhcs.runtime.viewer_protocol import ( - ChannelColormapPolicy, NapariLayerKind, NapariViewerServerRequest, ViewerBatchMessageType, @@ -90,6 +95,7 @@ NapariLayerSettlementState, NapariPendingLayerUpdate, NapariLayerRouteStateStore, + NapariShapeColorProjection, NapariShapeLayerPayload, NapariStreamLayerAddress, NapariStreamLayerItem, @@ -108,7 +114,6 @@ ViewerComponentMetadataNormalizer, ViewerComponentNameMetadata, ViewerComponentValueDomainPayload, - ViewerComponentSemanticRole, ViewerBatchPayloadFields, ViewerDisplayBatchContext, ViewerDisplayAxisDomain, @@ -394,7 +399,7 @@ def value( @dataclass(frozen=True) -class NapariBatchPayload(ViewerDisplayBatchContext[Mapping[str, NapariWireValue]]): +class NapariBatchPayload(ViewerDisplayBatchContext[NapariDisplayConfig]): """Typed view of an incoming batch message.""" images: NapariBatchImagePayloads @@ -406,9 +411,11 @@ def from_json_payload( data: Mapping[str, NapariWireValue], ) -> "NapariBatchPayload": fields = ViewerBatchPayloadFields(data, "Napari batch message") - display_config = fields.required_mapping(ViewerBatchWireField.DISPLAY_CONFIG) + display_config_payload = fields.required_mapping( + ViewerBatchWireField.DISPLAY_CONFIG + ) component_axis_semantics = fields.component_axis_semantics( - ViewerMappingDisplayConfigInput(display_config), + ViewerMappingDisplayConfigInput(display_config_payload), context="Napari component value domain", ) component_names_metadata = fields.optional_component_names_metadata( @@ -417,7 +424,9 @@ def from_json_payload( return cls( msg_type=fields.message_type(), images=fields.required_mapping_items(ViewerBatchWireField.IMAGES), - viewer_display_config=display_config, + viewer_display_config=NapariDisplayConfig.from_display_payload( + display_config_payload + ), store=component_names_metadata.store, entries=component_axis_semantics.entries, layout=component_axis_semantics.layout, @@ -504,12 +513,14 @@ class NapariStreamLayerContext(ViewerComponentAxisSemantics): address: NapariStreamLayerAddress image_metadata: ImagePayloadMetadata plane_component_domain: ViewerComponentValueDomainPayload + display_config: NapariDisplayConfig @classmethod def from_payload_map( cls, payload: PayloadMap, layer_axis_projection_semantics: ViewerComponentAxisSemantics, + display_config: NapariDisplayConfig, ) -> "NapariStreamLayerContext": data_type_value = payload.optional(ViewerWireField.DATA_TYPE) if data_type_value is None: @@ -543,6 +554,7 @@ def from_payload_map( payload.optional_mapping(ViewerWireField.PLANE_COMPONENT_VALUES), context="Napari image plane component values", ), + display_config=display_config, ) def layer_route( @@ -589,6 +601,7 @@ def layer_route( payload_layout_role=payload_layout_role, image_metadata=self.image_metadata, plane_component_domain=self.plane_component_domain, + display_config=self.display_config, ) @@ -604,11 +617,13 @@ def from_payload( cls, image_info: Mapping[str, NapariWireValue], layer_axis_projection_semantics: ViewerComponentAxisSemantics, + display_config: NapariDisplayConfig, ) -> "NapariImagePayload": payload = PayloadMap(image_info, "Napari image message") stream_layer_context = NapariStreamLayerContext.from_payload_map( payload, layer_axis_projection_semantics, + display_config, ) image_id = payload.optional(ViewerWireField.IMAGE_ID) return cls( @@ -620,6 +635,7 @@ def from_payload( address=stream_layer_context.address, image_metadata=stream_layer_context.image_metadata, plane_component_domain=stream_layer_context.plane_component_domain, + display_config=stream_layer_context.display_config, ) @property @@ -767,6 +783,7 @@ class NapariLayerRoute(ViewerComponentAxisSemantics): layer_title: str component_info: ComponentMap item_address: NapariStreamLayerAddress + display_config: NapariDisplayConfig payload_layout_role: NapariImagePayloadLayoutRole | None = None image_metadata: ImagePayloadMetadata = field(default_factory=ImagePayloadMetadata) plane_component_domain: ViewerComponentValueDomainPayload = field( @@ -774,6 +791,137 @@ class NapariLayerRoute(ViewerComponentAxisSemantics): ) +class NapariVariableSizeDisplayStrategy( + EnumKeyedStrategyMixin[NapariVariableSizeHandling], + ABC, + metaclass=AutoRegisterMeta, +): + """Apply one declared variable-size policy at routing and display time.""" + + __enum_member_attr__ = "variable_size_handling" + + variable_size_handling: ClassVar[NapariVariableSizeHandling] + + def routing_context( + self, + context: NapariStreamLayerContext, + ) -> NapariStreamLayerContext: + """Return the effective route context for one streamed payload.""" + + return context + + @abstractmethod + def materialize_layer_items( + self, + layer_items: list[NapariStreamLayerItem], + *, + route_key: str, + ) -> list[NapariStreamLayerItem]: + """Return image items that can share one native Napari layer.""" + + +class NapariPadToMaxDisplayStrategy(NapariVariableSizeDisplayStrategy): + """Keep one component route and pad its images to a common shape.""" + + variable_size_handling = NapariVariableSizeHandling.PAD_TO_MAX + + def materialize_layer_items( + self, + layer_items: list[NapariStreamLayerItem], + *, + route_key: str, + ) -> list[NapariStreamLayerItem]: + shapes = {item.data.shape for item in layer_items} + if len(shapes) > 1: + logger.info( + "🔬 NAPARI PROCESS: Images in layer %s have different shapes - " + "padding to max size", + route_key, + ) + max_shape = tuple( + max(shape[axis] for shape in shapes) + for axis in range(len(layer_items[0].data.shape)) + ) + for item_index, item in enumerate(layer_items): + if item.data.shape == max_shape: + continue + pad_width = tuple( + (0, maximum - current) + for current, maximum in zip( + item.data.shape, + max_shape, + strict=True, + ) + ) + layer_items[item_index] = replace( + item, + data=np.pad( + item.data, + pad_width, + mode="constant", + constant_values=0, + ), + ) + logger.debug( + "🔬 NAPARI PROCESS: Padded image from %s to %s", + item.data.shape, + max_shape, + ) + return layer_items + + +class NapariSeparateLayersDisplayStrategy(NapariVariableSizeDisplayStrategy): + """Project the well axis into distinct routes and preserve native shapes.""" + + variable_size_handling = NapariVariableSizeHandling.SEPARATE_LAYERS + + def routing_context( + self, + context: NapariStreamLayerContext, + ) -> NapariStreamLayerContext: + if ( + context.address.stream_layer_data_type + is not StreamingDataType.IMAGE + ): + return context + + well_component = AllComponents.WELL.value + ViewerComponentCoordinateAuthority.required_value( + context.address.components, + well_component, + context="Napari separate-layer routing", + ) + if well_component in context.layout.components_for_mode( + ViewerComponentMode.SLICE + ): + return context + + component_modes = dict(context.layout.component_modes) + component_modes[well_component] = ViewerComponentMode.SLICE.value + return replace( + context, + layout=ViewerComponentLayout.from_parts( + component_modes=component_modes, + component_order=context.layout.component_order, + ), + ) + + def materialize_layer_items( + self, + layer_items: list[NapariStreamLayerItem], + *, + route_key: str, + ) -> list[NapariStreamLayerItem]: + shapes = {item.data.shape for item in layer_items} + if len(shapes) > 1: + raise ValueError( + "Napari separate-layer route " + f"{route_key!r} still contains mixed image shapes: " + f"{sorted(shapes)!r}." + ) + return layer_items + + class NapariComponentAwareDisplayCoordinator: """Route loaded payload data into debounced Napari layer updates.""" @@ -822,10 +970,15 @@ def _route( stream_layer_context: NapariStreamLayerContext, server: "NapariViewerServer", ) -> NapariLayerRoute: + effective_context = ( + NapariVariableSizeDisplayStrategy.for_enum_member( + stream_layer_context.display_config.variable_size_handling + ).routing_context(stream_layer_context) + ) payload_layout_role = NapariImagePayloadLayoutRole.for_stream_layer_context( - stream_layer_context, + effective_context, ) - route = stream_layer_context.layer_route( + route = effective_context.layer_route( payload_layout_role=payload_layout_role, layer_route_state=server.layer_route_state, ) @@ -1523,6 +1676,7 @@ class NapariLayerDisplayRequest: pipeline: "NapariLayerDisplayPipeline" items: list[NapariStreamLayerItem] presentation: NapariAxisPresentation + display_config: NapariDisplayConfig def create_or_update_layer( self, @@ -1600,14 +1754,14 @@ def handle(self, request: NapariLayerDisplayRequest) -> None: f"Layer {presentation.route_key} contains mixed-rank image payloads: " f"{sorted(set(shapes))}" ) - if len(set(shapes)) > 1: - logger.info( - "🔬 NAPARI PROCESS: Images in layer %s have different shapes - " - "padding to max size", - presentation.route_key, + layer_items = ( + NapariVariableSizeDisplayStrategy.for_enum_member( + request.display_config.variable_size_handling + ).materialize_layer_items( + layer_items, + route_key=presentation.route_key, ) - self._pad_to_max_shape(layer_items) - + ) logger.info( "🔬 NAPARI PROCESS: Building nD data for %s from %d items", presentation.route_key, @@ -1626,20 +1780,6 @@ def handle(self, request: NapariLayerDisplayRequest) -> None: ) translate = presentation.projection.translate(payload_axis_labels) - color_component = presentation.role_component_for_mode( - role=ViewerComponentSemanticRole.COLOR, - mode=ViewerComponentMode.SLICE, - ) - colormap = None - if color_component is not None: - first_item = layer_items[0] - color_value = ViewerComponentCoordinateAuthority.required_value( - first_item.address.components, - color_component, - context="Napari image colormap", - ) - colormap = ChannelColormapPolicy().colormap(color_value) - axis_labels = pipeline.dimension_label_store.apply( replace(presentation, payload_axis_labels=payload_axis_labels) ) @@ -1647,7 +1787,7 @@ def handle(self, request: NapariLayerDisplayRequest) -> None: layer_kwargs = NapariImageLayerPresentationPolicy.layer_kwargs( layer_items[0].data, layer_items[0].image_metadata, - colormap, + request.display_config.colormap, ) if axis_labels is not None: layer_kwargs["axis_labels"] = axis_labels @@ -1691,43 +1831,6 @@ def _materialize_source_domains( ) return materialized_items - @staticmethod - def _pad_to_max_shape(layer_items: list[NapariStreamLayerItem]) -> None: - shapes = [item.data.shape for item in layer_items] - max_shape = list(shapes[0]) - for img_shape in shapes: - for index, dimension in enumerate(img_shape): - max_shape[index] = max(max_shape[index], dimension) - resolved_max_shape = tuple(max_shape) - - for item_index, img_info in enumerate(layer_items): - img_data = img_info.data - if img_data.shape == resolved_max_shape: - continue - pad_width = [] - for current_dim, max_dim in zip(img_data.shape, resolved_max_shape): - pad_width.append((0, max_dim - current_dim)) - - padded_data = np.pad( - img_data, - pad_width, - mode="constant", - constant_values=0, - ) - layer_items[item_index] = NapariStreamLayerItem( - data=padded_data, - producer=img_info.producer, - address=img_info.address, - image_metadata=img_info.image_metadata, - plane_component_domain=img_info.plane_component_domain, - ) - logger.debug( - "🔬 NAPARI PROCESS: Padded image from %s to %s", - img_data.shape, - padded_data.shape, - ) - - @dataclass(slots=True) class NapariShapesLayerDisplayWork(NapariLayerDisplayWork): """Incrementally materialize one native Shapes layer without starving Qt.""" @@ -1735,8 +1838,7 @@ class NapariShapesLayerDisplayWork(NapariLayerDisplayWork): request: NapariLayerDisplayRequest payload: NapariShapeLayerPayload chunks: tuple[NapariShapeLayerPayload, ...] - member_colors: list[tuple[float, float, float, float]] - color_cycle: list[tuple[float, float, float, float]] + color_projection: NapariShapeColorProjection common_layer_kwargs: dict[str, LayerKwargValue] next_chunk_index: int = 0 next_member_index: int = 0 @@ -1748,7 +1850,9 @@ def advance(self) -> bool: chunk = self.chunks[self.next_chunk_index] next_member_index = self.next_member_index + len(chunk.data) - member_colors = self.member_colors[self.next_member_index : next_member_index] + member_colors = self.color_projection.member_colors[ + self.next_member_index : next_member_index + ] if self.layer is None: layer_kwargs = dict(self.common_layer_kwargs) layer_kwargs.update( @@ -1789,8 +1893,8 @@ def advance(self) -> bool: if self.layer is None: raise RuntimeError("Napari Shapes display completed without a layer.") self.layer.features = self.payload.features - self.layer.edge_color_cycle = self.color_cycle - self.layer.face_color_cycle = self.color_cycle + self.layer.edge_color_cycle = self.color_projection.cycle + self.layer.face_color_cycle = self.color_projection.cycle if self.layer.edge_color_mode != "cycle": self.layer.edge_color_mode = "cycle" if self.layer.face_color_mode != "cycle": @@ -1838,8 +1942,7 @@ def display_work( axis_projection=presentation.projection, aggregate_axis_bindings=presentation.aggregate_axis_bindings, ) - member_colors = shape_payload.label_colors - color_cycle = shape_payload.label_color_cycle + color_projection = shape_payload.color_projection chunks = shape_payload.chunks( max_shape_count=self.MAX_SHAPES_PER_WORK_UNIT, max_vertex_count=self.MAX_VERTICES_PER_WORK_UNIT, @@ -1854,8 +1957,8 @@ def display_work( ) layer_kwargs: dict[str, LayerKwargValue] = { - "edge_color_cycle": color_cycle, - "face_color_cycle": color_cycle, + "edge_color_cycle": color_projection.cycle, + "face_color_cycle": color_projection.cycle, "opacity": 0.7, "ndim": shape_payload.ndim, "translate": presentation.projection.translate(), @@ -1869,8 +1972,7 @@ def display_work( request=request, payload=shape_payload, chunks=chunks, - member_colors=member_colors, - color_cycle=color_cycle, + color_projection=color_projection, common_layer_kwargs=layer_kwargs, ) @@ -2004,7 +2106,7 @@ def schedule_layer_update( self, layer_key: str, data_type: StreamingDataType, - layer_axis_projection_semantics: ViewerComponentAxisSemantics, + layer_route: NapariLayerRoute, ) -> None: if self.server.layer_route_state.cancel_pending_update(layer_key): logger.debug(f"🔬 NAPARI PROCESS: Cancelled pending update for {layer_key}") @@ -2015,7 +2117,8 @@ def schedule_layer_update( update = NapariPendingLayerUpdate.from_semantics( timer=timer, data_type=data_type, - semantics=layer_axis_projection_semantics, + semantics=layer_route, + display_config=layer_route.display_config, ) timer.timeout.connect( lambda: self.execute_scheduled_layer_update(layer_key, update) @@ -2095,7 +2198,7 @@ def execute_layer_update( self, layer_key: str, data_type: StreamingDataType, - layer_axis_projection_semantics: ViewerComponentAxisSemantics, + layer_axis_projection_semantics: NapariPendingLayerUpdate, ) -> NapariLayerDisplayWork: try: layer_items = self.server.component_groups.existing_items_for(layer_key) @@ -2230,7 +2333,7 @@ def display_layer_batch( *, layer_key: str, items: list[NapariStreamLayerItem], - display_payload: ViewerComponentAxisSemantics, + display_payload: NapariPendingLayerUpdate, component_names_metadata: ViewerComponentNameMetadata, ) -> NapariLayerDisplayWork: if component_names_metadata: @@ -2269,6 +2372,7 @@ def display_layer_batch( projection=axis_projection, aggregate_axis_bindings=aggregate_axis_bindings, ), + display_config=display_payload.display_config, ) ) logger.info( @@ -4454,7 +4558,11 @@ def accept( return NapariAcceptedStreamBatch( payload=batch_payload, items=tuple( - server._accept_single_image(image_info, batch_payload) + server._accept_single_image( + image_info, + batch_payload, + batch_payload.viewer_display_config, + ) for image_info in batch_payload.images ), ) @@ -4815,7 +4923,7 @@ def display_layer_batch( *, layer_key: str, items: list[NapariStreamLayerItem], - display_payload: ViewerComponentAxisSemantics, + display_payload: NapariPendingLayerUpdate, component_names_metadata: ViewerComponentNameMetadata, ) -> NapariLayerDisplayWork: """Display one debounced batch through the composed display pipeline.""" @@ -4882,6 +4990,7 @@ def display_image(self, image_data: np.ndarray, metadata: ComponentMap) -> None: self._process_single_image( image_info, ViewerComponentAxisSemanticsAuthority.empty(), + NapariDisplayConfig(), ) def accept_stream_message(self, message: bytes) -> NapariStreamMessageReply: @@ -4938,12 +5047,14 @@ def _accept_single_image( self, image_info: Mapping[str, NapariWireValue], layer_axis_projection_semantics: ViewerComponentAxisSemantics, + display_config: NapariDisplayConfig, ) -> NapariAcceptedStreamItem: """Materialize one payload without reading or mutating Qt state.""" payload = NapariImagePayload.from_payload( image_info, layer_axis_projection_semantics, + display_config, ) loaded_data = _NAPARI_PAYLOAD_DATA_LOADER.load(payload) return NapariAcceptedStreamItem(payload=payload, data=loaded_data) @@ -4952,6 +5063,7 @@ def _process_single_image( self, image_info: Mapping[str, NapariWireValue], layer_axis_projection_semantics: ViewerComponentAxisSemantics, + display_config: NapariDisplayConfig, ) -> None: """Materialize and display one direct payload on the Qt thread.""" @@ -4959,6 +5071,7 @@ def _process_single_image( self._accept_single_image( image_info, layer_axis_projection_semantics, + display_config, ) ) diff --git a/openhcs/runtime/viewer_component_system.py b/openhcs/runtime/viewer_component_system.py index e555346fb..c5056c895 100644 --- a/openhcs/runtime/viewer_component_system.py +++ b/openhcs/runtime/viewer_component_system.py @@ -22,10 +22,10 @@ ViewerWireValue, viewer_component_mode_value, ) +from polystore.streaming.viewer_transport import ViewerDisplayConfigABC from openhcs.constants.constants import AllComponents from openhcs.runtime.viewer_protocol import ViewerComponentValueOrdering -from openhcs.utils.display_config_factory import ViewerDisplayConfigObject ComponentValue: TypeAlias = str | int | float | bool | tuple | None @@ -151,7 +151,7 @@ class ViewerObjectDisplayConfigInput(ViewerDisplayConfigInput): """Object-backed viewer display config input.""" DISPLAY_CONFIG_INPUT_KIND = "object" - object_display_config: ViewerDisplayConfigObject + object_display_config: ViewerDisplayConfigABC def layout(self) -> "ViewerComponentLayout": return ViewerComponentLayout.from_parts( diff --git a/openhcs/tests/basic_pipeline.py b/openhcs/tests/basic_pipeline.py index ea09f1def..7c2a30829 100644 --- a/openhcs/tests/basic_pipeline.py +++ b/openhcs/tests/basic_pipeline.py @@ -10,7 +10,6 @@ from openhcs.constants.constants import GroupBy, VariableComponents from openhcs.constants.input_source import InputSource from openhcs.core.config import LazyFijiStreamingConfig, LazyNapariStreamingConfig, LazyStepMaterializationConfig, LazyStepWellFilterConfig, LazyProcessingConfig, NapariVariableSizeHandling, PipelineConfig -from openhcs.core.memory import DtypeConversion from openhcs.core.steps.function_step import FunctionStep from openhcs.processing.backends.analysis.cell_counting_cpu import DetectionMethod, count_cells_single_channel from openhcs.processing.backends.assemblers.assemble_stack_cpu import assemble_stack_cpu @@ -93,7 +92,6 @@ # 'enable_preprocessing': False, # 'detection_method': DetectionMethod.WATERSHED, # 'dtype_config': DtypeConfig(default_dtype_conversion=DtypeConversion.UINT8), -# 'return_segmentation_mask': True # }), # '2': (count_cells_single_channel, { # 'min_cell_area': 40, @@ -101,7 +99,6 @@ # 'enable_preprocessing': False, # 'detection_method': DetectionMethod.WATERSHED, # 'dtype_config': DtypeConfig(default_dtype_conversion=DtypeConversion.UINT8), -# 'return_segmentation_mask': True # }) # }, # name="Cell Counting", diff --git a/openhcs/tests/test_pipeline.py b/openhcs/tests/test_pipeline.py index 4c2434ca0..8f658f03b 100644 --- a/openhcs/tests/test_pipeline.py +++ b/openhcs/tests/test_pipeline.py @@ -92,8 +92,7 @@ 'min_cell_area': 40, 'max_cell_area': 200, 'enable_preprocessing': False, - 'detection_method': DetectionMethod.WATERSHED, - 'return_segmentation_mask': True + 'detection_method': DetectionMethod.WATERSHED }), name="Cell Counting", napari_streaming_config=LazyNapariStreamingConfig(variable_size_handling=NapariVariableSizeHandling.PAD_TO_MAX), diff --git a/openhcs/tests/test_pipeline_old.py b/openhcs/tests/test_pipeline_old.py index fc6702885..f58a18411 100644 --- a/openhcs/tests/test_pipeline_old.py +++ b/openhcs/tests/test_pipeline_old.py @@ -10,7 +10,7 @@ from openhcs.constants.constants import GroupBy, VariableComponents from openhcs.constants.input_source import InputSource from openhcs.core.config import LazyDtypeConfig, LazyFijiStreamingConfig, LazyNapariStreamingConfig, LazyStepMaterializationConfig, LazyStepWellFilterConfig, LazyProcessingConfig, NapariVariableSizeHandling -from openhcs.core.memory import DtypeConversion +from arraybridge.decorators import DtypeConversion from openhcs.core.steps.function_step import FunctionStep from openhcs.processing.backends.analysis.cell_counting_cpu import DetectionMethod, count_cells_single_channel from openhcs.processing.backends.assemblers.assemble_stack_cpu import assemble_stack_cpu @@ -95,8 +95,7 @@ 'detection_method': DetectionMethod.WATERSHED, 'dtype_config': LazyDtypeConfig( default_dtype_conversion=DtypeConversion.UINT8 - ), - 'return_segmentation_mask': True + ) }), '2': (count_cells_single_channel, { 'min_cell_area': 40, @@ -105,8 +104,7 @@ 'detection_method': DetectionMethod.WATERSHED, 'dtype_config': LazyDtypeConfig( default_dtype_conversion=DtypeConversion.UINT8 - ), - 'return_segmentation_mask': True + ) }) }, name="Cell Counting", diff --git a/openhcs/utils/display_config_factory.py b/openhcs/utils/display_config_factory.py deleted file mode 100644 index 117508898..000000000 --- a/openhcs/utils/display_config_factory.py +++ /dev/null @@ -1,364 +0,0 @@ -""" -Display configuration factory for creating viewer-specific config dataclasses. - -Provides generic infrastructure for creating display configuration dataclasses -with component-specific dimension modes, supporting both Napari and Fiji viewers. -""" -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from enum import Enum -from typing import Callable, Optional, Sequence, TypeAlias - -from polystore.streaming.viewer_transport import ViewerDisplayConfigABC - - -DisplayComponentName: TypeAlias = str | Enum -DisplayFieldDefault: TypeAlias = str | int | float | bool | Enum | None -DisplayBaseFields: TypeAlias = dict[ - str, - tuple[type, DisplayFieldDefault, str], -] -DisplayModeDefaults: TypeAlias = dict[str, Enum] -DisplayMethods: TypeAlias = dict[str, Callable] - - -class ViewerDisplayConfigObject(ViewerDisplayConfigABC, ABC): - """Object display config contract emitted by OpenHCS config factories.""" - - COMPONENT_ORDER: tuple[str, ...] - - @abstractmethod - def component_modes(self) -> dict[str, str]: - """Return component-name to mode-name mapping.""" - - -def component_value(component: DisplayComponentName) -> str: - if isinstance(component, Enum): - return str(component.value) - return str(component).lower() - - -def display_component_default( - component: str, - component_defaults: DisplayModeDefaults, - default_mode: Enum, -) -> Enum: - if component in component_defaults: - return component_defaults[component] - return default_mode - - -def create_display_config( - name: str, - base_fields: DisplayBaseFields, - component_mode_enum: type[Enum], - component_defaults: DisplayModeDefaults, - virtual_components: Sequence[Enum], - component_order: Sequence[str], - default_mode: Enum, - methods: DisplayMethods, - docstring: str, -) -> type: - """ - Generic factory for creating display configuration dataclasses. - - Creates a frozen dataclass with: - - Base fields (e.g., colormap, variable_size_handling) - - Component-specific mode fields (e.g., channel_mode, z_index_mode, well_mode) - - Custom methods (e.g., get_dimension_mode, get_colormap_name) - - COMPONENT_ORDER class attribute for canonical layer naming order - - Args: - name: Name of the dataclass to create - base_fields: Dict mapping field names to (type, default_value) tuples - component_mode_enum: Enum class for component dimension modes - component_defaults: Dict mapping component names to default modes - virtual_components: Extra non-filename components. - component_order: Canonical order for layer naming (e.g., ['well', 'channel']) - default_mode: Default mode for components not specified in component_defaults - methods: Dict mapping method names to method implementations - docstring: Docstring for the created class - - Returns: - Dynamically created frozen dataclass - - Example: - >>> NapariDisplayConfig = create_display_config( - ... name='NapariDisplayConfig', - ... base_fields={ - ... 'colormap': (NapariColormap, NapariColormap.GRAY), - ... 'variable_size_handling': (NapariVariableSizeHandling, NapariVariableSizeHandling.SEPARATE_LAYERS) - ... }, - ... component_mode_enum=NapariDimensionMode, - ... component_defaults={'channel': NapariDimensionMode.SLICE}, - ... component_order=['well', 'channel'], - ... methods={'get_colormap_name': lambda self: self.colormap.value} - ... ) - """ - from openhcs.constants import AllComponents - - component_order = tuple(component_order) - annotations: dict[str, type] = {} - defaults: dict[str, DisplayFieldDefault] = {} - - for field_name, (field_type, default_value, description) in base_fields.items(): - annotations[field_name] = field_type - defaults[field_name] = field( - default=default_value, - metadata={"description": description}, - ) - - for component in (*AllComponents, *virtual_components): - component_name = component_value(component) - annotations[f"{component_name}_mode"] = component_mode_enum - defaults[f"{component_name}_mode"] = field( - default=display_component_default( - component_name, - component_defaults, - default_mode, - ), - metadata={ - "description": ( - f"Viewer layout mode for the {component_name} axis; choose " - f"one of {', '.join(member.name for member in component_mode_enum)}." - ) - }, - ) - - def component_modes(self) -> dict[str, str]: - modes = {} - storage = vars(self) - for component in component_order: - mode = storage[f"{component}_mode"] - if mode is None: - mode = display_component_default( - component, - component_defaults, - default_mode, - ) - modes[component] = mode.value if isinstance(mode, Enum) else str(mode) - return modes - - class_attrs = { - "__annotations__": annotations, - "component_modes": component_modes, - "__doc__": docstring, - "COMPONENT_ORDER": component_order, - } - class_attrs.update(defaults) - class_attrs.update(methods) - - display_config = type(name, (ViewerDisplayConfigObject,), class_attrs) - display_config = dataclass(frozen=True)(display_config) - display_config.__module__ = "openhcs.core.config" - display_config.__qualname__ = name - return display_config - - -def create_napari_display_config( - colormap_enum: type[Enum], - dimension_mode_enum: type[Enum], - variable_size_handling_enum: type[Enum], - visualization_dtype_enum: type[Enum], - component_order: Sequence[str], - virtual_components: Optional[type[Enum]] = None, - virtual_component_defaults: Optional[DisplayModeDefaults] = None, - default_visualization_dtype: Optional[Enum] = None -) -> type: - """ - Create NapariDisplayConfig with component-specific fields. - - Args: - colormap_enum: Enum for colormap options - dimension_mode_enum: Enum for dimension modes (SLICE/STACK) - variable_size_handling_enum: Enum for variable size handling - visualization_dtype_enum: Enum for dtype normalization (UINT8/UINT16/FLOAT32) - virtual_components: Optional enum for non-filename display components. - component_order: Canonical order for layer naming - virtual_component_defaults: Optional dict mapping virtual component names to default modes - default_visualization_dtype: Default dtype for visualization normalization (defaults to UINT16) - - Returns: - NapariDisplayConfig dataclass - """ - def get_dimension_mode(self, component): - field_name = f"{component_value(component)}_mode" - mode = vars(self)[field_name] - - if mode is None: - # Default: all components are STACK (well, channel, site, z_index, timepoint) - return dimension_mode_enum.STACK - - return mode - - def get_colormap_name(self): - return self.colormap.value - - from openhcs.constants import AllComponents - - component_defaults = { - component.value: dimension_mode_enum.STACK - for component in AllComponents - } - if virtual_component_defaults is not None: - component_defaults.update(virtual_component_defaults) - - # Default visualization dtype to UINT16 (microscopy standard) if not specified - if default_visualization_dtype is None: - default_visualization_dtype = visualization_dtype_enum.UINT16 - - virtual_component_members: tuple[Enum, ...] = () - if virtual_components is not None: - virtual_component_members = tuple(virtual_components) - - return create_display_config( - name='NapariDisplayConfig', - base_fields={ - 'colormap': ( - colormap_enum, - colormap_enum.GRAY, - "Colormap applied to grayscale image layers in napari.", - ), - 'variable_size_handling': ( - variable_size_handling_enum, - variable_size_handling_enum.PAD_TO_MAX, - "How napari combines images with different spatial dimensions into layers.", - ), - 'visualization_dtype': ( - visualization_dtype_enum, - default_visualization_dtype, - "Target dtype used for contrast-preserving visualization scaling.", - ), - }, - component_mode_enum=dimension_mode_enum, - component_defaults=component_defaults, - virtual_components=virtual_component_members, - component_order=component_order, - default_mode=dimension_mode_enum.STACK, - methods={ - 'get_dimension_mode': get_dimension_mode, - 'get_colormap_name': get_colormap_name, - }, - docstring="""Configuration for napari display behavior for all OpenHCS components. - - This class is dynamically generated with individual fields for each component dimension. - Each component has a corresponding {component}_mode field that controls whether - it's displayed as a slice or stack in napari. - - Includes ALL dimensions (site, channel, z_index, timepoint, well) regardless of - which dimension is used as the multiprocessing axis. - - visualization_dtype controls dtype normalization for stacking - all images in a stack - are normalized to this dtype using contrast-preserving scaling (not simple casting). - Defaults to UINT16 (microscopy standard) for optimal precision preservation. - """ - ) - - -def create_fiji_display_config( - lut_enum: type[Enum], - dimension_mode_enum: type[Enum], - component_order: Sequence[str], - virtual_components: Optional[type[Enum]] = None, - virtual_component_defaults: Optional[DisplayModeDefaults] = None -) -> type: - """ - Create FijiDisplayConfig with component-specific fields. - - Maps OpenHCS dimensions to ImageJ hyperstack dimensions (C, Z, T). - Default mapping: - - well → FRAME (wells become frames) - - site → FRAME (sites become frames) - - channel → CHANNEL (channels become channels) - - z_index → SLICE (z-planes become slices) - - timepoint → FRAME (timepoints become frames) - - Args: - lut_enum: Enum for Fiji LUT options - dimension_mode_enum: Enum for dimension modes (WINDOW/CHANNEL/SLICE/FRAME) - virtual_components: Optional enum for non-filename display components. - component_order: Canonical order for layer naming - virtual_component_defaults: Optional dict mapping virtual component names to default modes - - Returns: - FijiDisplayConfig dataclass - """ - def get_dimension_mode(self, component): - key = component_value(component) - field_name = f"{key}_mode" - mode = vars(self)[field_name] - - if mode is None: - return display_component_default( - key, - fiji_component_defaults, - dimension_mode_enum.CHANNEL, - ) - - return mode - - def get_lut_name(self): - return self.lut.value - - from openhcs.constants import AllComponents - - component_defaults = { - component.value: dimension_mode_enum.CHANNEL - for component in AllComponents - } - component_defaults.update( - { - AllComponents.WELL.value: dimension_mode_enum.FRAME, - AllComponents.SITE.value: dimension_mode_enum.FRAME, - AllComponents.Z_INDEX.value: dimension_mode_enum.SLICE, - AllComponents.TIMEPOINT.value: dimension_mode_enum.FRAME, - } - ) - fiji_component_defaults = dict(component_defaults) - if virtual_component_defaults is not None: - component_defaults.update(virtual_component_defaults) - - virtual_component_members: tuple[Enum, ...] = () - if virtual_components is not None: - virtual_component_members = tuple(virtual_components) - - return create_display_config( - name='FijiDisplayConfig', - base_fields={ - 'lut': ( - lut_enum, - lut_enum.GRAYS, - "Fiji lookup table applied when image data is displayed.", - ), - 'auto_contrast': ( - bool, - True, - "Automatically set Fiji display limits from the streamed image data.", - ), - }, - component_mode_enum=dimension_mode_enum, - component_defaults=component_defaults, - virtual_components=virtual_component_members, - component_order=component_order, - default_mode=dimension_mode_enum.CHANNEL, - methods={ - 'get_dimension_mode': get_dimension_mode, - 'get_lut_name': get_lut_name, - }, - docstring="""Configuration for Fiji display behavior for all OpenHCS components. - - This class is dynamically generated with individual fields for each component dimension. - Each component has a corresponding {component}_mode field that controls how it maps - to ImageJ hyperstack dimensions (WINDOW/CHANNEL/SLICE/FRAME). - - Includes ALL dimensions (site, channel, z_index, timepoint, well) regardless of - which dimension is used as the multiprocessing axis. - - ImageJ hyperstacks have 3 dimensions: - - Channels (C): Color channels or sites - - Slices (Z): Z-planes or depth - - Frames (T): Time points or temporal dimension - - WINDOW mode creates separate windows instead of combining into hyperstack. - """ - ) diff --git a/openhcs/utils/enum_factory.py b/openhcs/utils/enum_factory.py deleted file mode 100644 index e2e0365b2..000000000 --- a/openhcs/utils/enum_factory.py +++ /dev/null @@ -1,211 +0,0 @@ -""" -Dynamic enum creation utilities for OpenHCS. - -Provides functions for creating enums dynamically from introspection, -particularly for visualization colormaps and other runtime-discovered options. - -Caching: -- Colormap enums are cached to avoid expensive napari/matplotlib imports -- Cache invalidated on OpenHCS version change or after 30 days -- Provides ~20x speedup on subsequent runs -""" -from enum import Enum -from typing import List, Callable, Optional, Dict, Any -import logging -from openhcs.utils.environment import is_headless_mode - -logger = logging.getLogger(__name__) - - -# Lazy import cache manager to avoid circular dependencies -_cache_manager = None - - -def _get_colormap_cache_manager(): - """Lazy import of cache manager for colormap enums.""" - global _cache_manager - if _cache_manager is None: - try: - from metaclass_registry.cache import RegistryCacheManager, CacheConfig - - def get_version(): - try: - import openhcs - return openhcs.__version__ - except: - return "unknown" - - # Serializer for enum members (just store the dict) - def serialize_enum_members(members: Dict[str, str]) -> Dict[str, Any]: - return {'members': members} - - # Deserializer for enum members - def deserialize_enum_members(data: Dict[str, Any]) -> Dict[str, str]: - return data.get('members', {}) - - _cache_manager = RegistryCacheManager( - cache_name="colormap_enum", - version_getter=get_version, - serializer=serialize_enum_members, - deserializer=deserialize_enum_members, - config=CacheConfig( - max_age_days=30, # Longer cache for stable enums - check_mtimes=False # No file tracking needed for external libs - ) - ) - except Exception as e: - logger.debug(f"Failed to initialize colormap cache manager: {e}") - _cache_manager = False # Mark as failed to avoid retrying - - return _cache_manager if _cache_manager is not False else None - - -def get_available_colormaps() -> List[str]: - """ - Get available colormaps using introspection - napari first, then matplotlib. - - In headless/CI contexts, avoid importing viz libs; return minimal stable set. - - Returns: - List of available colormap names - """ - if is_headless_mode(): - return ['gray', 'viridis'] - - try: - from napari.utils.colormaps import AVAILABLE_COLORMAPS - return list(AVAILABLE_COLORMAPS.keys()) - except ImportError: - pass - - try: - import matplotlib.pyplot as plt - return list(plt.colormaps()) - except ImportError: - pass - - raise ImportError("Neither napari nor matplotlib colormaps are available. Install napari or matplotlib.") - - -def create_colormap_enum(lazy: bool = False, enable_cache: bool = True) -> Enum: - """ - Create a dynamic enum for available colormaps using pure introspection. - - Caching is enabled by default to avoid expensive napari/matplotlib imports - on subsequent runs (~20x speedup). - - Args: - lazy: If True, use minimal colormap set without importing napari/matplotlib. - This avoids blocking imports (napari → dask → GPU libs). - enable_cache: If True, use persistent cache for enum members (default: True) - - Returns: - Enum class with colormap names as members - - Raises: - ValueError: If no colormaps are available or no valid identifiers could be created - """ - # Try to load from cache first (if not lazy mode) - cache_manager = _get_colormap_cache_manager() if enable_cache and not lazy else None - - if cache_manager: - try: - cached_data = cache_manager.load_cache() - if cached_data is not None: - # Cache hit - reconstruct enum from cached members - members = cached_data - logger.debug(f"✅ Loaded {len(members)} colormap enum members from cache") - - NapariColormap = Enum('NapariColormap', members) - NapariColormap.__module__ = 'openhcs.core.config' - NapariColormap.__qualname__ = 'NapariColormap' - return NapariColormap - except Exception as e: - logger.debug(f"Cache load failed for colormap enum: {e}") - - # Cache miss or disabled - discover colormaps - if lazy: - # Use minimal set without importing visualization libraries - available_cmaps = ['gray', 'viridis', 'magma', 'inferno', 'plasma', 'cividis'] - else: - available_cmaps = get_available_colormaps() - - if not available_cmaps: - raise ValueError("No colormaps available for enum creation") - - members = {} - for cmap_name in available_cmaps: - enum_name = cmap_name.replace(' ', '_').replace('-', '_').replace('.', '_').upper() - if enum_name and enum_name[0].isdigit(): - enum_name = f"CMAP_{enum_name}" - if enum_name and enum_name.replace('_', '').replace('CMAP', '').isalnum(): - members[enum_name] = cmap_name - - if not members: - raise ValueError("No valid colormap identifiers could be created") - - # Save to cache if enabled - if cache_manager: - try: - cache_manager.save_cache(members) - logger.debug(f"💾 Saved {len(members)} colormap enum members to cache") - except Exception as e: - logger.debug(f"Failed to save colormap enum cache: {e}") - - NapariColormap = Enum('NapariColormap', members) - - # Set proper module and qualname for pickling support - NapariColormap.__module__ = 'openhcs.core.config' - NapariColormap.__qualname__ = 'NapariColormap' - - return NapariColormap - - -def create_enum_from_source( - enum_name: str, - source_func: Callable[[], List[str]], - name_transform: Optional[Callable[[str], str]] = None -) -> Enum: - """ - Generic factory for creating enums from introspection source functions. - - Args: - enum_name: Name for the created enum class - source_func: Function that returns list of string values for enum members - name_transform: Optional function to transform value strings to enum member names - - Returns: - Dynamically created Enum class - - Example: - >>> def get_luts(): - ... return ['Grays', 'Fire', 'Ice'] - >>> FijiLUT = create_enum_from_source('FijiLUT', get_luts) - """ - values = source_func() - if not values: - raise ValueError(f"No values available for {enum_name} creation") - - members = {} - for value in values: - if name_transform: - member_name = name_transform(value) - else: - member_name = value.replace(' ', '_').replace('-', '_').replace('.', '_').upper() - if member_name and member_name[0].isdigit(): - member_name = f"VAL_{member_name}" - - if member_name and member_name.replace('_', '').replace('VAL', '').isalnum(): - members[member_name] = value - - if not members: - raise ValueError(f"No valid identifiers could be created for {enum_name}") - - EnumClass = Enum(enum_name, members) - - # Set proper module and qualname for pickling support - EnumClass.__module__ = 'openhcs.core.config' - EnumClass.__qualname__ = enum_name - - return EnumClass - diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index c79cf31a5..b472cd5f7 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -55,6 +55,7 @@ ashlar_compute_tile_positions_cpu, ) from openhcs.processing.backends.processors.numpy_processor import ( + NumpyStackProjectionMethod, create_composite, create_projection, stack_percentile_normalize, @@ -63,7 +64,7 @@ count_cells_single_channel, DetectionMethod, ) -from openhcs.core.memory import DtypeConversion +from arraybridge.decorators import DtypeConversion # Test utilities and fixtures from tests.integration.helpers.fixture_utils import ( @@ -287,7 +288,7 @@ def create_test_pipeline( ), Step( name="Z-Stack Flattening", - func=(create_projection, {"method": "max_projection"}), + func=(create_projection, {"method": NumpyStackProjectionMethod.MAX}), processing_config=LazyProcessingConfig( variable_components=[VariableComponents.Z_INDEX] ), @@ -309,7 +310,7 @@ def create_test_pipeline( Step(name="CPU Assembly", func=assemble_stack_cpu), Step( name="Z-Stack Flattening", - func=(create_projection, {"method": "max_projection"}), + func=(create_projection, {"method": NumpyStackProjectionMethod.MAX}), processing_config=LazyProcessingConfig( variable_components=[VariableComponents.Z_INDEX] ), @@ -328,7 +329,6 @@ def create_test_pipeline( "dtype_config": LazyDtypeConfig( default_dtype_conversion=DtypeConversion.UINT8 ), - "return_segmentation_mask": True, }, ), "2": ( @@ -341,7 +341,6 @@ def create_test_pipeline( "dtype_config": LazyDtypeConfig( default_dtype_conversion=DtypeConversion.UINT8 ), - "return_segmentation_mask": True, }, ), } diff --git a/tests/pyqt_gui/integration/test_end_to_end_workflow_foundation.py b/tests/pyqt_gui/integration/test_end_to_end_workflow_foundation.py index 6024a4396..cc528e792 100644 --- a/tests/pyqt_gui/integration/test_end_to_end_workflow_foundation.py +++ b/tests/pyqt_gui/integration/test_end_to_end_workflow_foundation.py @@ -92,7 +92,7 @@ class TestScenario: LEGITIMATE_NONE_FIELDS = frozenset({ 'barcode', 'plate_name', 'plate_id', 'description', 'default_format', # Fields that legitimately resolve to None through dual-axis resolver - 'global_output_folder', 'fiji_executable_path' + 'global_output_folder' }) # ============================================================================ diff --git a/tests/unit/agent/test_mcp_server.py b/tests/unit/agent/test_mcp_server.py index 41c0026f2..389ab686d 100644 --- a/tests/unit/agent/test_mcp_server.py +++ b/tests/unit/agent/test_mcp_server.py @@ -2266,20 +2266,20 @@ def list_object_state_scopes(self, request, connection): object_state_path_type=( "openhcs.core.config.NapariDisplayConfig" ), - raw_value_type="NapariColormap", - resolved_value_type="NapariColormap", + raw_value_type="str", + resolved_value_type="str", dirty=False, signature_diff=False, last_changed=False, raw_value_preview=UiObjectStateValuePreview( - type_name="NapariColormap", + type_name="str", is_none=False, - text="NapariColormap.GRAY", + text="'gray'", ), resolved_value_preview=UiObjectStateValuePreview( - type_name="NapariColormap", + type_name="str", is_none=False, - text="NapariColormap.GRAY", + text="'gray'", ), ), ), @@ -2319,7 +2319,7 @@ async def call_describe_tool(): assert request.field_limit == 1 assert request.field_paths == ("napari_display_config.colormap",) assert payload["help_target_type"] == "openhcs.core.config.NapariDisplayConfig" - assert payload["summary"] == "• colormap (NapariColormap)" + assert payload["summary"] == "• colormap (str)" def test_mcp_describe_object_state_field_tool_infers_unique_scope(monkeypatch): @@ -9694,7 +9694,7 @@ def test_mcp_dev_client_call_renders_object_state_field_help_compactly(): "object_type": "openhcs.core.config.GlobalPipelineConfig", "help_target_type": "openhcs.core.config.NapariDisplayConfig", "parameter_name": "colormap", - "summary": "• colormap (NapariColormap)", + "summary": "• colormap (str)", "description": "No description available", } ], @@ -9712,7 +9712,7 @@ def test_mcp_dev_client_call_renders_object_state_field_help_compactly(): "field=napari_display_config.colormap" ) in rendered assert "help_target=NapariDisplayConfig parameter=colormap" in rendered - assert "Summary: • colormap (NapariColormap)" in rendered + assert "Summary: • colormap (str)" in rendered assert "Description:\nNo description available" in rendered assert '"payloads"' not in rendered diff --git a/tests/unit/agent/test_object_state_field_help_service.py b/tests/unit/agent/test_object_state_field_help_service.py index 534f9ff28..a54ab3fbc 100644 --- a/tests/unit/agent/test_object_state_field_help_service.py +++ b/tests/unit/agent/test_object_state_field_help_service.py @@ -80,12 +80,6 @@ def get_by_import_path( default_repr="10", required=False, ), - FunctionParameterSpec( - name="return_segmentation_mask", - annotation="bool", - default_repr="False", - required=False, - ), ), runtime_contract=FunctionRuntimeContractSummary( callable_kind="openhcs_function", @@ -104,8 +98,7 @@ def get_by_import_path( "Count cells in single-channel image stack using watershed.\n" "\n" "Args:\n" - " min_cell_area: Minimum area for valid cells.\n" - " return_segmentation_mask: Return ROI masks." + " min_cell_area: Minimum area for valid cells." ), ) @@ -163,7 +156,6 @@ def list_object_state_scopes(self, request, connection): }, { "min_cell_area": 40, - "return_segmentation_mask": True, }, ], resolved_value=[ @@ -183,7 +175,6 @@ def list_object_state_scopes(self, request, connection): }, { "min_cell_area": 40, - "return_segmentation_mask": True, }, ], ), @@ -209,7 +200,6 @@ def list_object_state_scopes(self, request, connection): assert "Callable value:" in (result.description or "") assert "count_cells_single_channel" in (result.description or "") assert "min_cell_area=40" in (result.description or "") - assert "return_segmentation_mask=True" in (result.description or "") assert function_catalog_service.import_paths == [ ( "openhcs.processing.backends.analysis.cell_counting_cpu." diff --git a/tests/unit/pyqt_gui/test_main_config_propagation.py b/tests/unit/pyqt_gui/test_main_config_propagation.py index ce25b4082..947f08096 100644 --- a/tests/unit/pyqt_gui/test_main_config_propagation.py +++ b/tests/unit/pyqt_gui/test_main_config_propagation.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass, replace +from dataclasses import dataclass, fields, is_dataclass, replace from types import MethodType, SimpleNamespace import openhcs.pyqt_gui.main as main_module @@ -19,6 +19,28 @@ MainWindowLifecycleWorkflow, MainWindowUiBridgeLifecycle, ) +from openhcs.pyqt_gui.widgets.plate_manager import PlateManagerWidget + + +def _visible_leaf_paths(value: object, prefix: str = "") -> tuple[str, ...]: + """Derive the actual Configure OpenHCS leaf inventory from dataclasses.""" + + if not is_dataclass(value): + return (prefix,) + paths: list[str] = [] + for declaration in fields(value): + if declaration.metadata.get("ui_hidden"): + continue + child_prefix = ( + f"{prefix}.{declaration.name}" if prefix else declaration.name + ) + paths.extend( + _visible_leaf_paths( + getattr(value, declaration.name), + child_prefix, + ) + ) + return tuple(paths) @dataclass @@ -178,6 +200,106 @@ def emit(self, value) -> None: assert main_like.ui_config_changed.value is updated +def test_configure_openhcs_roots_reach_live_application_owners() -> None: + """The exact saved roots reach their live and next-execution owners.""" + + class Recorder: + def __init__(self) -> None: + self.values: list[object] = [] + + def record(self, value=None, *additional_values, **named_values) -> None: + if value is not None: + self.values.append(value) + self.values.extend(additional_values) + self.values.extend(named_values.values()) + + ui_config = get_default_ui_config() + monitor = Recorder() + progress = Recorder() + plate_zmq = Recorder() + shortcuts = Recorder() + bridge = Recorder() + manager_zmq = Recorder() + + plate_manager = SimpleNamespace( + _zmq_client_service=SimpleNamespace(set_config=plate_zmq.record), + _batch_workflow_service=SimpleNamespace( + update_progress_config=progress.record + ), + ) + plate_manager.set_ui_config = MethodType( + PlateManagerWidget.set_ui_config, + plate_manager, + ) + main_like = SimpleNamespace( + system_monitor=SimpleNamespace(update_config=monitor.record), + plate_manager_widget=plate_manager, + shortcut_lifecycle=SimpleNamespace(apply=shortcuts.record), + ui_bridge_lifecycle=SimpleNamespace(reconcile=bridge.record), + zmq_manager_widget=SimpleNamespace(set_zmq_config=manager_zmq.record), + _create_ui_bridge_server=lambda *_args: None, + zmq_server_manager_ports_to_scan=lambda _config: (), + ) + main_like._reconcile_ui_bridge = MethodType( + OpenHCSMainWindow._reconcile_ui_bridge, + main_like, + ) + + OpenHCSMainWindow._apply_ui_config_consumers(main_like, ui_config) + + live_owner_values = { + id(value) + for value in ( + *monitor.values, + *progress.values, + *plate_zmq.values, + *shortcuts.values, + *bridge.values, + *manager_zmq.values, + ) + } + visible_top_level_owners = { + declaration.name: getattr(ui_config, declaration.name) + for declaration in fields(ui_config) + if not declaration.metadata.get("ui_hidden") + } + ui_leaf_lifecycle = { + path: ( + "live" + if id(visible_top_level_owners[path.partition(".")[0]]) + in live_owner_values + else "unconsumed" + ) + for path in _visible_leaf_paths(ui_config) + } + assert ui_leaf_lifecycle + assert set(ui_leaf_lifecycle.values()) == {"live"} + + global_config = GlobalPipelineConfig() + global_publication = Recorder() + global_propagation = Recorder() + global_like = SimpleNamespace( + runtime_context=PyQtGuiRuntimeContext(ui_config), + config_services=SimpleNamespace( + set_global_config=global_publication.record + ), + lifecycle_workflow=SimpleNamespace( + propagate_config=global_propagation.record + ), + ) + global_like.set_pipeline_runtime_config = MethodType( + OpenHCSMainWindow.set_pipeline_runtime_config, + global_like, + ) + + OpenHCSMainWindow.on_config_changed(global_like, global_config) + + assert global_like.runtime_context.pipeline_runtime is global_config + assert global_publication.values == [global_config] + assert global_propagation.values == [global_config] + assert _visible_leaf_paths(global_config) + + def test_set_ui_config_restores_previous_consumers_before_rejecting_update() -> None: class Signal: def __init__(self) -> None: @@ -213,7 +335,7 @@ def apply_consumers(config) -> None: assert main_like.ui_config_changed.values == [] -def test_lifecycle_workflow_propagates_config_to_embedded_widgets() -> None: +def test_lifecycle_workflow_propagates_config_to_embedded_widgets(qapp) -> None: plate_manager = _ConfigAwareStub() pipeline_editor = _ConfigAwareStub() progress_bar = type("ProgressBar", (), {})() diff --git a/tests/unit/pyqt_gui/test_ui_agent_bridge.py b/tests/unit/pyqt_gui/test_ui_agent_bridge.py index d33419a8d..7586b539f 100644 --- a/tests/unit/pyqt_gui/test_ui_agent_bridge.py +++ b/tests/unit/pyqt_gui/test_ui_agent_bridge.py @@ -2525,8 +2525,8 @@ def test_object_state_field_help_uses_object_state_path_types() -> None: assert child.errors == () assert child.help_target_type == "openhcs.core.config.NapariDisplayConfig" assert child.parameter_name == "colormap" - assert child.summary == "• colormap (NapariColormap)" - assert child.description == "Colormap applied to grayscale image layers in napari." + assert child.summary == "• colormap (str)" + assert "colormap registered in the installed Napari viewer" in child.description def test_object_state_field_help_uses_source_binding_field_docstrings() -> None: diff --git a/tests/unit/test_cell_counting_cpu_artifact_contract.py b/tests/unit/test_cell_counting_cpu_artifact_contract.py index f1f448f47..ff055b0cf 100644 --- a/tests/unit/test_cell_counting_cpu_artifact_contract.py +++ b/tests/unit/test_cell_counting_cpu_artifact_contract.py @@ -36,7 +36,6 @@ def test_cpu_cell_counter_always_returns_columnar_rows_and_aligned_labels() -> N min_cell_area=1, max_cell_area=100, remove_border_cells=False, - return_segmentation_mask=False, ) assert output.shape == image.shape diff --git a/tests/unit/test_cellprofiler_analyst_export.py b/tests/unit/test_cellprofiler_analyst_export.py index 6f8097283..60dd93330 100644 --- a/tests/unit/test_cellprofiler_analyst_export.py +++ b/tests/unit/test_cellprofiler_analyst_export.py @@ -769,7 +769,6 @@ def test_sqlite_declares_empty_image_object_and_experiment_properties_tables() - source_image_set_identity_policy=SourceImageSetIdentityPolicy(), ) settings = CellProfilerDatabaseExportSettings( - database_type="sqlite", sqlite_file="QC.db", experiment_name="QC", table_prefix="QC_", @@ -1271,7 +1270,6 @@ def test_raw_callable_uses_batch_source_plan_with_sibling_plate_step() -> None: def _settings() -> CellProfilerDatabaseExportSettings: return CellProfilerDatabaseExportSettings( - database_type="sqlite", sqlite_file="DefaultDB.db", experiment_name="Experiment", table_prefix="CPA_", diff --git a/tests/unit/test_cellprofiler_closing_hotpath.py b/tests/unit/test_cellprofiler_closing_hotpath.py index 0c7550f7b..ee8ff2249 100644 --- a/tests/unit/test_cellprofiler_closing_hotpath.py +++ b/tests/unit/test_cellprofiler_closing_hotpath.py @@ -20,6 +20,9 @@ erode_image, opening, ) +from openhcs.processing.backends.cellprofiler.structuring_elements import ( + StructuringElement, +) def _raw_function(function: Callable[..., np.ndarray]) -> Callable[..., np.ndarray]: @@ -55,7 +58,11 @@ def record_native_call( record_native_call, ) - observed = _raw_function(closing)(image, structuring_element="disk", size=7) + observed = _raw_function(closing)( + image, + structuring_element=StructuringElement.DISK, + size=7, + ) np.testing.assert_array_equal(observed, expected) assert observed.dtype == image.dtype @@ -79,7 +86,7 @@ def test_closing_stack_preserves_explicit_provider_semantics( observed = _raw_function(closing)( image, - structuring_element="disk", + structuring_element=StructuringElement.DISK, size=3, morphology_backend_provider=provider, ) @@ -110,7 +117,7 @@ def test_image_morphology_stack_matches_planewise_reference( observed = _raw_function(function)( image, - structuring_element="disk", + structuring_element=StructuringElement.DISK, size=3, ) diff --git a/tests/unit/test_cellprofiler_conditional_analysis_images.py b/tests/unit/test_cellprofiler_conditional_analysis_images.py index 019cddbf7..a41753129 100644 --- a/tests/unit/test_cellprofiler_conditional_analysis_images.py +++ b/tests/unit/test_cellprofiler_conditional_analysis_images.py @@ -37,7 +37,9 @@ from openhcs.interop.cellprofiler.parser import ModuleBlock, ModuleSetting from openhcs.processing.backends.cellprofiler.classification import ( ClassifiedImageSourceRelation, + ClassificationBinChoice, ClassifyObjectsSingleMeasurementModule, + SingleMeasurementClassificationRule, classification_rgb_image, classify_objects_single_measurement, ) @@ -168,20 +170,22 @@ def _classification_context() -> ArtifactDeclarationStepContext: ) -def _classification_rules(active_count: int) -> tuple[dict[str, object], ...]: +def _classification_rules( + active_count: int, +) -> tuple[SingleMeasurementClassificationRule, ...]: active_indices = {1} if active_count == 1 else set(range(active_count)) return tuple( - { - "measurement_feature": "AreaShape_Area", - "bin_choice": "custom", - "custom_thresholds": "0,0.5,1", - "bin_names": "Low,High", - "retained_image_name": ( + SingleMeasurementClassificationRule( + measurement_feature="AreaShape_Area", + bin_choice=ClassificationBinChoice.CUSTOM, + custom_thresholds=(0.0, 0.5, 1.0), + bin_names=("Low", "High"), + retained_image_name=( ("FirstClassified", "SecondClassified")[rule_index] if rule_index in active_indices else None ), - } + ) for rule_index in range(2) ) @@ -196,7 +200,7 @@ def _classification_records(active_count: int) -> tuple[tuple[str, str], ...]: ("Select the object to be classified", "Cells"), ] for rule in _classification_rules(active_count): - output_name = rule["retained_image_name"] + output_name = rule.retained_image_name records.extend( ( ("Select the measurement to classify by", "AreaShape_Area"), diff --git a/tests/unit/test_cellprofiler_conditional_named_images.py b/tests/unit/test_cellprofiler_conditional_named_images.py index 355175448..a860bfe6d 100644 --- a/tests/unit/test_cellprofiler_conditional_named_images.py +++ b/tests/unit/test_cellprofiler_conditional_named_images.py @@ -32,6 +32,7 @@ from openhcs.interop.cellprofiler.settings_binder import SettingsBinder from openhcs.processing.backends.cellprofiler.color import ( InvertForPrintingModule, + OutputMode, invert_for_printing, invert_for_printing_grayscale, invert_for_printing_without_output, @@ -291,7 +292,10 @@ def test_invert_for_printing_runtime_declares_color_channel_axis() -> None: image[..., 1] = 0.4 image[..., 2] = 0.6 - output = inspect.unwrap(invert_for_printing)(image, output_mode="color") + output = inspect.unwrap(invert_for_printing)( + image, + output_mode=OutputMode.COLOR, + ) assert image_payload_data(output).shape == (2, 3, 3) assert image_payload_metadata(output).source_channel_axis == -1 diff --git a/tests/unit/test_cellprofiler_export_measurement_outputs.py b/tests/unit/test_cellprofiler_export_measurement_outputs.py index b1b53990e..951648965 100644 --- a/tests/unit/test_cellprofiler_export_measurement_outputs.py +++ b/tests/unit/test_cellprofiler_export_measurement_outputs.py @@ -49,6 +49,7 @@ export_to_database, ) from openhcs.processing.backends.cellprofiler.save_images import ( + SaveImagesFileFormat, SaveImagesFilenameMethod, SaveImagesModule, SaveImagesRecordedMeasurementSourceRelation, @@ -241,7 +242,7 @@ def test_export_to_database_writes_selected_thumbnails_into_sqlite() -> None: sqlite_file="analysis.sqlite", wants_properties_file=False, write_image_thumbnails=True, - thumbnail_image_names="DNA, RNA", + thumbnail_image_names=("DNA", "RNA"), ) assert tuple(bundle) == ("analysis.sqlite",) @@ -322,7 +323,7 @@ def test_save_images_file_measurement_output_and_rows_are_conditional() -> None: saved_image_name="DNA", filename_method=SaveImagesFilenameMethod.SINGLE_NAME, single_file_name="SavedDNA", - file_format="png", + file_format=SaveImagesFileFormat.PNG, output_location="exports", slice_index=3, ) @@ -395,7 +396,7 @@ def test_public_callables_reconstruct_active_export_contracts() -> None: { "include_all_images": False, "write_image_thumbnails": True, - "thumbnail_image_names": "DNA, RNA", + "thumbnail_image_names": ("DNA", "RNA"), "wants_properties_file": False, }, ) diff --git a/tests/unit/test_cellprofiler_export_to_database.py b/tests/unit/test_cellprofiler_export_to_database.py index 447ab5afe..c03ef936a 100644 --- a/tests/unit/test_cellprofiler_export_to_database.py +++ b/tests/unit/test_cellprofiler_export_to_database.py @@ -1,4 +1,5 @@ from contextlib import contextmanager +import inspect from pathlib import Path import sqlite3 from collections.abc import Iterator @@ -134,6 +135,16 @@ ), ), } +REMOVED_EXPORT_FALSE_OPTIONS = { + "calculate_per_well_mean", + "calculate_per_well_median", + "calculate_per_well_standard_deviation", + "wants_filter_fields", + "create_plate_filters", + "overwrite_mode", + "wants_workspace_file", + "workspace_measurements", +} def _projection_builder() -> CellProfilerAnalystProjectionBuilder: @@ -368,6 +379,15 @@ def test_cppipe_import_codegen_and_transport_preserve_database_semantics( block.get_setting_values(ExportToDatabaseModule.group_columns_setting.canonical) == DATABASE_SEMANTIC_KWARGS["group_fields"][0][1:] ) + assert block.get_setting_values( + ExportToDatabaseModule.aggregate_well_mean_setting.canonical + ) == ("No",) + assert block.get_setting_values( + ExportToDatabaseModule.overwrite_mode_setting.canonical + ) == ("Never",) + assert block.get_setting_values( + ExportToDatabaseModule.wants_workspace_file_setting.canonical + ) == ("No",) pipeline_source = FunctionStepTransportAuthority.source_from_pipeline(steps) config_source = generate_python_source( @@ -389,6 +409,34 @@ def test_cppipe_import_codegen_and_transport_preserve_database_semantics( assert restored_invocation.kwargs_dict == imported_kwargs +def test_database_export_public_contract_omits_false_options() -> None: + parameter_names = set( + inspect.signature(ExportToDatabaseModule.require_callable()).parameters + ) + + assert REMOVED_EXPORT_FALSE_OPTIONS.isdisjoint(parameter_names) + + +def test_database_import_rejects_enabled_unsupported_well_aggregation( + tmp_path: Path, +) -> None: + cppipe_path = tmp_path / "unsupported-per-well.cppipe" + cppipe_path.write_text( + _database_cppipe_text().replace( + " Experiment name:Experiment", + " Experiment name:Experiment\n" + " Calculate the per-well mean values of object measurements?:Yes", + ), + encoding="utf-8", + ) + + with pytest.raises( + ValueError, + match="does not support enabled.*per-well mean", + ): + import_cellprofiler_pipeline(cppipe_path) + + @pytest.mark.parametrize("pipeline_path", OFFICIAL30_DATABASE_PIPELINES) def test_official30_database_pipelines_import_and_round_trip_public_steps( pipeline_path: Path, @@ -582,6 +630,7 @@ def test_official30_database_modules_bind_and_build_exact_contracts( bound = ExportToDatabaseModule.bind_settings(module, binder=SettingsBinder()) assert bound.unmapped_kwargs == {} + assert REMOVED_EXPORT_FALSE_OPTIONS.isdisjoint(bound.kwargs) assert len(bound.setting_coverage) == len(module.iter_settings()) thumbnail_value = optional_setting_value( @@ -1226,7 +1275,6 @@ def _field_rows( def _cpa_settings(**overrides: object) -> CellProfilerDatabaseExportSettings: values: dict[str, object] = { - "database_type": "sqlite", "sqlite_file": "analysis.db", "experiment_name": "Experiment", "table_prefix": "CPA_", diff --git a/tests/unit/test_cellprofiler_extended_relationship_topology.py b/tests/unit/test_cellprofiler_extended_relationship_topology.py index 9239875ae..11180f23a 100644 --- a/tests/unit/test_cellprofiler_extended_relationship_topology.py +++ b/tests/unit/test_cellprofiler_extended_relationship_topology.py @@ -48,6 +48,7 @@ from openhcs.processing.backends.cellprofiler.tracking import ( TrackObjectsModule, TrackObjectsResult, + TrackingMethod, track_objects, ) @@ -448,9 +449,12 @@ def _timepoint_labels(labels: np.ndarray) -> ObjectLabelPayload: ) -@pytest.mark.parametrize("method", ("overlap", "distance")) +@pytest.mark.parametrize( + "method", + (TrackingMethod.OVERLAP, TrackingMethod.DISTANCE), +) def test_track_objects_runtime_payload_uses_previous_and_current_object_endpoints( - method: str, + method: TrackingMethod, ) -> None: labels = np.zeros((2, 5, 5), dtype=np.int32) labels[0, 1:3, 1:3] = 1 @@ -482,7 +486,7 @@ def test_track_objects_overlap_relationship_keeps_all_merged_parents() -> None: result = unwrap(track_objects)( np.zeros(labels.shape, dtype=np.float32), labels=_timepoint_labels(labels), - tracking_method="overlap", + tracking_method=TrackingMethod.OVERLAP, ) _output, relationship, _rows = result.as_runtime_tuple() diff --git a/tests/unit/test_cellprofiler_image_math_hotpath.py b/tests/unit/test_cellprofiler_image_math_hotpath.py index 33e8b2e41..ea4702dd2 100644 --- a/tests/unit/test_cellprofiler_image_math_hotpath.py +++ b/tests/unit/test_cellprofiler_image_math_hotpath.py @@ -112,7 +112,7 @@ def test_image_math_reduction_reuses_the_detached_first_operand( operation: ImageMathOperation, ) -> None: operands = [operand.astype(np.float64) for operand in _source_binding_operands()] - strategy = ImageMathOperationStrategy.coerce(operation) + strategy = ImageMathOperationStrategy.for_operation(operation) output = strategy.prepare_initial_output( operands[0], operands, diff --git a/tests/unit/test_cellprofiler_library_loading.py b/tests/unit/test_cellprofiler_library_loading.py index 53309bb85..13d9f6ed9 100644 --- a/tests/unit/test_cellprofiler_library_loading.py +++ b/tests/unit/test_cellprofiler_library_loading.py @@ -23,9 +23,13 @@ mask_objects, ) from openhcs.processing.backends.cellprofiler.illumination import ( + CalculationScope as IlluminationCalculationScope, + FilterSizeMethod as IlluminationFilterSizeMethod, + IlluminationCorrectionMethod, + IntensityChoice as IlluminationIntensityChoice, + RescaleOption as IlluminationRescaleOption, + SmoothingMethod as IlluminationSmoothingMethod, correct_illumination_apply, -) -from openhcs.processing.backends.cellprofiler.illumination import ( correct_illumination_calculate, ) from openhcs.processing.backends.cellprofiler.crop import crop @@ -37,10 +41,16 @@ from openhcs.processing.backends.cellprofiler.texture import ( ObjectTextureCropBackendStrategy, ) -from openhcs.processing.backends.cellprofiler.image_math import image_math -from openhcs.processing.backends.cellprofiler.image_geometry import mask_image +from openhcs.processing.backends.cellprofiler.image_math import ( + ImageMathOperation, + image_math, +) +from openhcs.processing.backends.cellprofiler.image_geometry import MaskSource, mask_image from openhcs.processing.backends.cellprofiler.primary_objects import ( + ExcessObjectHandling, FillHolesOption, + UnclumpMethod, + WatershedMethod, identify_primary_objects, ) from openhcs.processing.backends.cellprofiler.morphology import ( @@ -72,7 +82,9 @@ ) from openhcs.processing.backends.cellprofiler.morphology import opening from openhcs.processing.backends.cellprofiler.outlines import ( + LineMode, OverlayObjectsModule, + OutlineSourceKind, overlay_objects, ) from openhcs.processing.backends.cellprofiler.outlines import overlay_outlines @@ -90,8 +102,15 @@ flip_and_rotate, mask_image_with_binary, ) -from openhcs.processing.backends.cellprofiler.edge import enhance_edges -from openhcs.processing.backends.cellprofiler.smoothing import smooth +from openhcs.processing.backends.cellprofiler.edge import ( + EdgeDirection, + EdgeMethod, + enhance_edges, +) +from openhcs.processing.backends.cellprofiler.structuring_elements import ( + StructuringElement, +) +from openhcs.processing.backends.cellprofiler.smoothing import SmoothingMethod, smooth from openhcs.processing.backends.cellprofiler.thresholding import ( CELLPROFILER_BASIC_THRESHOLD_SMOOTHING_SCALE, CellProfilerAveragingMethod, @@ -110,7 +129,7 @@ threshold_multiotsu, ) from openhcs.processing.backends.cellprofiler.thresholding import threshold -from openhcs.processing.backends.cellprofiler.color import unmix_colors +from openhcs.processing.backends.cellprofiler.color import StainType, unmix_colors from openhcs.processing.backends.cellprofiler.crop import CropModule from openhcs.core.config import DtypeConfig from openhcs.core.runtime_batch_contracts import RuntimeBatchInvocationRequest @@ -332,6 +351,8 @@ def test_absorbed_watershed_accepts_grayscale_volumes() -> None: def test_watershed_marker_mode_preserves_marker_label_identity() -> None: from openhcs.processing.backends.cellprofiler.watershed import ( + WatershedDeclumpMethod, + WatershedMethod, watershed_library, ) @@ -344,8 +365,8 @@ def test_watershed_marker_mode_preserves_marker_label_identity() -> None: _output, _stats, labels = watershed_library( image, topology_inputs=(markers,), - watershed_method="markers", - declump_method="shape", + watershed_method=WatershedMethod.MARKERS, + declump_method=WatershedDeclumpMethod.SHAPE, use_advanced_settings=False, ) assert set(np.unique(object_label_dense_array(labels))) == {0, 1, 2} @@ -512,7 +533,7 @@ def test_erode_objects_preserves_leading_axes_for_volume_stacks() -> None: domain=ObjectLabelDomain(declared_object_ids=(1,)), ) _output, stats, eroded, relationship = raw_erode_objects( - image, label_payload, structuring_element="ball", size=1 + image, label_payload, structuring_element=StructuringElement.BALL, size=1 ) assert object_label_dense_array(eroded).shape == labels.shape assert relationship.source_ids == (1,) @@ -529,13 +550,18 @@ def test_erode_image_preserves_leading_axes_for_volume_stacks() -> None: raw_erode_image = erode_image while hasattr(raw_erode_image, "__wrapped__"): raw_erode_image = raw_erode_image.__wrapped__ - eroded = raw_erode_image(image, structuring_element="ball", size=1) + eroded = raw_erode_image( + image, + structuring_element=StructuringElement.BALL, + size=1, + ) assert eroded.shape == image.shape assert np.count_nonzero(eroded) < np.count_nonzero(image) def test_convert_objects_to_image_accepts_volume_label_stacks() -> None: from openhcs.processing.backends.cellprofiler.object_images import ( + ImageMode, convert_objects_to_image, ) @@ -545,7 +571,9 @@ def test_convert_objects_to_image_accepts_volume_label_stacks() -> None: while hasattr(raw_convert_objects_to_image, "__wrapped__"): raw_convert_objects_to_image = raw_convert_objects_to_image.__wrapped__ converted = raw_convert_objects_to_image( - np.zeros_like(labels, dtype=np.float32), labels, image_mode="color" + np.zeros_like(labels, dtype=np.float32), + labels, + image_mode=ImageMode.COLOR, ) assert converted.shape == labels.shape assert converted.dtype == np.float32 @@ -555,6 +583,7 @@ def test_convert_objects_to_image_accepts_volume_label_stacks() -> None: def test_convert_objects_to_image_uint16_preserves_integer_object_ids() -> None: from openhcs.processing.backends.cellprofiler.object_images import ( + ImageMode, convert_objects_to_image, ) @@ -563,7 +592,9 @@ def test_convert_objects_to_image_uint16_preserves_integer_object_ids() -> None: while hasattr(raw_convert_objects_to_image, "__wrapped__"): raw_convert_objects_to_image = raw_convert_objects_to_image.__wrapped__ converted = raw_convert_objects_to_image( - np.zeros_like(labels, dtype=np.float32), labels, image_mode="uint16" + np.zeros_like(labels, dtype=np.float32), + labels, + image_mode=ImageMode.UINT16, ) assert converted.dtype == np.int32 np.testing.assert_array_equal(converted, labels) @@ -652,7 +683,11 @@ def test_opening_default_backend_matches_skimage_grayscale_opening() -> None: raw_opening = opening while hasattr(raw_opening, "__wrapped__"): raw_opening = raw_opening.__wrapped__ - observed = raw_opening(image, structuring_element="disk", size=3) + observed = raw_opening( + image, + structuring_element=StructuringElement.DISK, + size=3, + ) np.testing.assert_array_equal(observed, expected) @@ -664,7 +699,11 @@ def test_closing_default_backend_matches_skimage_grayscale_closing() -> None: raw_closing = closing while hasattr(raw_closing, "__wrapped__"): raw_closing = raw_closing.__wrapped__ - observed = raw_closing(image, structuring_element="disk", size=3) + observed = raw_closing( + image, + structuring_element=StructuringElement.DISK, + size=3, + ) np.testing.assert_array_equal(observed, expected) @@ -712,12 +751,12 @@ def test_threshold_rejects_unprojected_explicit_mask() -> None: ) -def test_smooth_accepts_cellprofiler_display_setting_literals(): +def test_smooth_accepts_nominal_smoothing_method(): image = np.zeros((9, 9), dtype=np.float32) image[4, 4] = 1.0 result = smooth( image, - smoothing_method="Gaussian Filter", + smoothing_method=SmoothingMethod.GAUSSIAN_FILTER, auto_object_size=False, object_size=3.0, dtype_config=DtypeConfig(), @@ -739,7 +778,7 @@ def test_smooth_matches_cellprofiler_masked_gaussian(): object_size = 3.0 result = smooth( payload, - smoothing_method="Gaussian Filter", + smoothing_method=SmoothingMethod.GAUSSIAN_FILTER, auto_object_size=False, object_size=object_size, dtype_config=DtypeConfig(), @@ -762,7 +801,7 @@ def test_smooth_matches_cellprofiler_unmasked_gaussian_edge_normalization(): object_size = 3.0 result = smooth( image, - smoothing_method="Gaussian Filter", + smoothing_method=SmoothingMethod.GAUSSIAN_FILTER, auto_object_size=False, object_size=object_size, dtype_config=DtypeConfig(), @@ -776,11 +815,14 @@ def test_smooth_matches_cellprofiler_unmasked_gaussian_edge_normalization(): assert np.allclose(image_payload_data(result), expected.astype(np.float32)) -def test_enhance_edges_accepts_cellprofiler_display_setting_literals(): +def test_enhance_edges_accepts_nominal_method_and_direction(): image = np.zeros((9, 9), dtype=np.float32) image[:, 5:] = 1.0 result = enhance_edges( - image, method="Sobel", direction="All", dtype_config=DtypeConfig() + image, + method=EdgeMethod.SOBEL, + direction=EdgeDirection.ALL, + dtype_config=DtypeConfig(), ) assert result.shape == image.shape assert result.dtype == np.float32 @@ -796,7 +838,10 @@ def test_enhance_edges_uses_and_preserves_runtime_mask(): mask[:, :4] = False payload = ImagePayloadMetadata().payload_with(image, mask) result = enhance_edges( - payload, method="Sobel", direction="All", dtype_config=DtypeConfig() + payload, + method=EdgeMethod.SOBEL, + direction=EdgeDirection.ALL, + dtype_config=DtypeConfig(), ) assert np.allclose( image_payload_data(result), @@ -815,7 +860,10 @@ def test_closing_preserves_runtime_mask_context(): mask[:2, :] = False payload = ImagePayloadMetadata().payload_with(image, mask) result = closing( - payload, structuring_element="disk", size=1, dtype_config=DtypeConfig() + payload, + structuring_element=StructuringElement.DISK, + size=1, + dtype_config=DtypeConfig(), ) assert np.array_equal(image_payload_data(result), skimage_closing(image, disk(1))) assert np.array_equal(image_payload_mask(result), mask) @@ -865,7 +913,7 @@ def test_dilate_objects_rejects_volumetric_structuring_element_for_2d_labels(): dilate_objects.__wrapped__( image, labels=labels, - structuring_element_shape="ball", + structuring_element_shape=StructuringElement.BALL, structuring_element_size=1, ) @@ -1672,7 +1720,7 @@ def fake_threshold_tuple(self, **_kwargs): assert calls["smooth_threshold_application"] is True -def test_identify_primary_objects_coerces_cellprofiler_literal_enums_directly(): +def test_identify_primary_objects_accepts_nominal_options_directly(): image = np.zeros((8, 8), dtype=np.float32) image[2:6, 2:6] = 1.0 _image, _measurements, labels = identify_primary_objects( @@ -1681,11 +1729,11 @@ def test_identify_primary_objects_coerces_cellprofiler_literal_enums_directly(): max_diameter=8, exclude_size=False, exclude_border_objects=False, - unclump_method="None", - watershed_method="None", - fill_holes="After both thresholding and declumping", - limit_erase="Continue", - threshold_method="Manual", + unclump_method=UnclumpMethod.NONE, + watershed_method=WatershedMethod.NONE, + fill_holes=FillHolesOption.AFTER_BOTH, + limit_erase=ExcessObjectHandling.CONTINUE, + threshold_method=CellProfilerThresholdMethod.MANUAL, manual_threshold=0.5, dtype_config=DtypeConfig(), ) @@ -1723,10 +1771,10 @@ def fake_diagnostics(image, binary, **kwargs): max_diameter=10, exclude_size=False, exclude_border_objects=False, - unclump_method="None", - watershed_method="None", - fill_holes="After both thresholding and declumping", - threshold_method="Manual", + unclump_method=UnclumpMethod.NONE, + watershed_method=WatershedMethod.NONE, + fill_holes=FillHolesOption.AFTER_BOTH, + threshold_method=CellProfilerThresholdMethod.MANUAL, dtype_config=DtypeConfig(), ) np.testing.assert_array_equal(captured["binary"], threshold_binary) @@ -1744,10 +1792,10 @@ def test_identify_primary_objects_does_not_size_filter_after_hole_fill() -> None max_diameter=5, exclude_size=True, exclude_border_objects=False, - unclump_method="None", - watershed_method="None", - fill_holes="After declumping only", - threshold_method="Manual", + unclump_method=UnclumpMethod.NONE, + watershed_method=WatershedMethod.NONE, + fill_holes=FillHolesOption.AFTER_DECLUMP, + threshold_method=CellProfilerThresholdMethod.MANUAL, threshold_smoothing_scale=0.0, manual_threshold=0.5, dtype_config=DtypeConfig(), @@ -2122,6 +2170,8 @@ def test_cellprofiler_legacy_watershed_keeps_descending_pixel_priority(): def test_cellprofiler4_marker_watershed_matches_cellprofiler_source_path(): from openhcs.processing.backends.cellprofiler.watershed import ( + WatershedDeclumpMethod, + WatershedMethod, watershed_cellprofiler4, ) @@ -2130,8 +2180,8 @@ def test_cellprofiler4_marker_watershed_matches_cellprofiler_source_path(): _image, _stats, labels = watershed_cellprofiler4( image, topology_inputs=(markers, np.ones_like(image, dtype=bool)), - watershed_method="markers", - declump_method="shape", + watershed_method=WatershedMethod.MARKERS, + declump_method=WatershedDeclumpMethod.SHAPE, use_advanced_settings=False, ) np.testing.assert_array_equal( @@ -3092,12 +3142,16 @@ def test_medianfilter_matches_cellprofiler_constant_default(): def test_medianfilter_honors_explicit_reflect_mode(): from scipy.ndimage import median_filter as scipy_median_filter from openhcs.processing.backends.cellprofiler.median_filter import medianfilter + from openhcs.processing.backends.processors.method_axes import ScipyBoundaryMode image = np.arange(35, dtype=np.float32).reshape(5, 7) image[1, 2] = 100.0 image[3, 5] = -20.0 observed = medianfilter( - image, window_size=3, mode="reflect", dtype_config=DtypeConfig() + image, + window_size=3, + mode=ScipyBoundaryMode.REFLECT, + dtype_config=DtypeConfig(), ) expected = scipy_median_filter(image, size=3, mode="reflect").astype(image.dtype) np.testing.assert_array_equal(observed, expected) @@ -3108,13 +3162,16 @@ def test_medianfilter_vectorized_volume_path_matches_scipy_constant(): from openhcs.processing.backends.cellprofiler.median_filter import ( median_filter_backend, ) + from openhcs.processing.backends.processors.method_axes import ScipyBoundaryMode rng = np.random.default_rng(123) image = rng.random((5, 9, 7), dtype=np.float32) image[0, 0, 0] = 0.0 image[-1, -1, -1] = 1.0 observed = median_filter_backend().vectorized_window_filter( - image, window_size=5, mode="constant" + image, + window_size=5, + mode=ScipyBoundaryMode.CONSTANT, ) expected = scipy_median_filter(image, size=5, mode="constant").astype(image.dtype) assert observed is not None @@ -3126,6 +3183,7 @@ def test_medianfilter_high_cardinality_volume_uses_exact_vector_path(monkeypatch from openhcs.processing.backends.cellprofiler.median_filter import ( median_filter_backend, ) + from openhcs.processing.backends.processors.method_axes import ScipyBoundaryMode image = np.linspace(0.0, 1.0, 17 * 64 * 64, dtype=np.float32).reshape( (17, 64, 64) @@ -3139,7 +3197,11 @@ def reject_scipy_fallback(*_args, **_kwargs): monkeypatch.setattr(backend, "scipy_filter", reject_scipy_fallback) assert np.unique(image).size > np.iinfo(np.uint16).max - observed = backend.filter(image, window_size=3, mode="constant") + observed = backend.filter( + image, + window_size=3, + mode=ScipyBoundaryMode.CONSTANT, + ) np.testing.assert_array_equal(observed, expected) @@ -3168,9 +3230,13 @@ def test_medianfilter_declares_flexible_slice_by_slice_semantics(): assert not np.array_equal(volumetric, planar) -def test_image_math_coerces_cellprofiler_operation_strings(): +def test_image_math_accepts_nominal_operation(): image = np.array([[0.0, 0.25], [0.5, 1.0]], dtype=np.float32) - result = image_math(image, operation="Invert", dtype_config=DtypeConfig()) + result = image_math( + image, + operation=ImageMathOperation.INVERT, + dtype_config=DtypeConfig(), + ) np.testing.assert_allclose(result, 1 - image) @@ -3178,9 +3244,16 @@ def test_image_math_preserves_or_ignores_masked_image_payload(): image = np.array([[0.0, 0.25], [0.5, 1.0]], dtype=np.float32) mask = np.array([[True, False], [True, True]]) payload = MaskedImagePayload(data=image, mask=mask) - preserved = image_math(payload, operation="Invert", dtype_config=DtypeConfig()) + preserved = image_math( + payload, + operation=ImageMathOperation.INVERT, + dtype_config=DtypeConfig(), + ) ignored = image_math( - payload, operation="Invert", ignore_masks=True, dtype_config=DtypeConfig() + payload, + operation=ImageMathOperation.INVERT, + ignore_masks=True, + dtype_config=DtypeConfig(), ) assert isinstance(preserved, MaskedImagePayload) np.testing.assert_array_equal(preserved.mask, mask) @@ -3217,7 +3290,7 @@ def test_image_math_combines_operand_masks_without_reexpanding_single_output(): ).compose() result = image_math( payload, - operation="Add", + operation=ImageMathOperation.ADD, factors=(1.0, 1.0, 1.0), dtype_config=DtypeConfig(), ) @@ -3236,7 +3309,11 @@ def test_image_math_preserves_source_plane_stack_as_single_operand(): ) ).payload_with(image, None) - result = image_math(payload, operation="Invert", dtype_config=DtypeConfig()) + result = image_math( + payload, + operation=ImageMathOperation.INVERT, + dtype_config=DtypeConfig(), + ) assert isinstance(result, ImageMetadataPayload) assert result.data.shape == image.shape @@ -3269,7 +3346,7 @@ def test_image_math_reduces_multi_volume_bundle_across_source_axis(): result = image_math( payload, - operation="Add", + operation=ImageMathOperation.ADD, factors=(1.0, 1.0, 1.0), truncate_high=False, dtype_config=DtypeConfig(), @@ -3298,7 +3375,7 @@ def test_image_math_uses_only_the_declared_source_binding_axis(): result = image_math( payload, - operation="Add", + operation=ImageMathOperation.ADD, factors=(1.0, 1.0, 1.0), truncate_high=False, dtype_config=DtypeConfig(), @@ -3365,7 +3442,7 @@ def test_correct_illumination_apply_projects_runtime_slice_artifact_stack() -> N result = correct_illumination_apply( image, illumination_function=illumination, - method="Subtract", + method=IlluminationCorrectionMethod.SUBTRACT, truncate_low=False, truncate_high=True, dtype_config=DtypeConfig(), @@ -3451,8 +3528,8 @@ def test_correct_illumination_returns_retained_images_in_declared_port_order(): retained_images = correct_illumination_calculate( image, - smoothing_method="No smoothing", - rescale_option="No", + smoothing_method=IlluminationSmoothingMethod.NONE, + rescale_option=IlluminationRescaleOption.NO, retain_average=True, retain_dilated=True, dtype_config=DtypeConfig(), @@ -3465,19 +3542,19 @@ def test_correct_illumination_returns_retained_images_in_declared_port_order(): np.testing.assert_array_equal(dilated, image) -def test_illumination_functions_accept_cellprofiler_enum_literals(): +def test_illumination_functions_accept_nominal_enums(): image = np.ones((8, 8), dtype=np.float32) illumination = correct_illumination_calculate( image, - intensity_choice="Regular", - rescale_option="No", - smoothing_method="No smoothing", + intensity_choice=IlluminationIntensityChoice.REGULAR, + rescale_option=IlluminationRescaleOption.NO, + smoothing_method=IlluminationSmoothingMethod.NONE, dtype_config=DtypeConfig(), ) corrected = correct_illumination_apply( image, illumination_function=np.full_like(image, 0.25), - method="Subtract", + method=IlluminationCorrectionMethod.SUBTRACT, truncate_low=False, truncate_high=False, dtype_config=DtypeConfig(), @@ -3530,10 +3607,10 @@ def test_correct_illumination_background_uses_blockwise_minima(): image = (np.arange(16, dtype=np.float32).reshape(4, 4) + 1) / 100 illumination = correct_illumination_calculate( image, - intensity_choice="Background", + intensity_choice=IlluminationIntensityChoice.BACKGROUND, block_size=2, - smoothing_method="No smoothing", - rescale_option="No", + smoothing_method=IlluminationSmoothingMethod.NONE, + rescale_option=IlluminationRescaleOption.NO, dtype_config=DtypeConfig(), ) expected = np.array( @@ -3558,10 +3635,10 @@ def test_correct_illumination_gaussian_normalizes_implicit_mask_at_borders(): illumination = correct_illumination_calculate( image, - smoothing_method="Gaussian Filter", - filter_size_method="Manually", + smoothing_method=IlluminationSmoothingMethod.GAUSSIAN_FILTER, + filter_size_method=IlluminationFilterSizeMethod.MANUALLY, manual_filter_size=filter_size, - rescale_option="No", + rescale_option=IlluminationRescaleOption.NO, dtype_config=DtypeConfig(), ) @@ -3625,10 +3702,10 @@ def test_correct_illumination_background_respects_image_mask(): mask = np.array([[True, False], [True, True]], dtype=bool) illumination = correct_illumination_calculate( MaskedImagePayload(data=image, mask=mask), - intensity_choice="Background", + intensity_choice=IlluminationIntensityChoice.BACKGROUND, block_size=2, - smoothing_method="No smoothing", - rescale_option="No", + smoothing_method=IlluminationSmoothingMethod.NONE, + rescale_option=IlluminationRescaleOption.NO, dtype_config=DtypeConfig(), ) np.testing.assert_array_equal(image_payload_mask(illumination), mask) @@ -3647,9 +3724,9 @@ def test_correct_illumination_all_scope_averages_stack_before_smoothing(): ) illumination = correct_illumination_calculate( stack, - calculation_scope="All: First cycle", - smoothing_method="No smoothing", - rescale_option="No", + calculation_scope=IlluminationCalculationScope.ALL_FIRST_CYCLE, + smoothing_method=IlluminationSmoothingMethod.NONE, + rescale_option=IlluminationRescaleOption.NO, dtype_config=DtypeConfig(), ) assert illumination.shape == (4, 4) @@ -3678,10 +3755,10 @@ def median(image, positional_footprint=None, **kwargs): image = np.linspace(0.0, 1.0, 20 * 20, dtype=np.float32).reshape((20, 20)) illumination = correct_illumination_calculate( image, - smoothing_method="Median Filter", - filter_size_method="Manually", + smoothing_method=IlluminationSmoothingMethod.MEDIAN_FILTER, + filter_size_method=IlluminationFilterSizeMethod.MANUALLY, manual_filter_size=2.35, - rescale_option="No", + rescale_option=IlluminationRescaleOption.NO, dtype_config=DtypeConfig(), rank_median_backend_provider=CellProfilerBackendProvider.NATIVE, ) @@ -3717,12 +3794,12 @@ def test_correct_illumination_median_smoothing_fast_minimum_majority_path(): image[1::4, 1::4] = 0.25 illumination = correct_illumination_calculate( image, - intensity_choice="Background", + intensity_choice=IlluminationIntensityChoice.BACKGROUND, block_size=4, - smoothing_method="Median Filter", - filter_size_method="Manually", + smoothing_method=IlluminationSmoothingMethod.MEDIAN_FILTER, + filter_size_method=IlluminationFilterSizeMethod.MANUALLY, manual_filter_size=16, - rescale_option="No", + rescale_option=IlluminationRescaleOption.NO, dtype_config=DtypeConfig(), ) expected = np.full((16, 16), np.uint16(0.25 * 65535) / 65535, dtype=np.float32) @@ -3745,10 +3822,10 @@ def test_correct_illumination_median_smoothing_hybrid_matches_rank_reference(): ) illumination = correct_illumination_calculate( image, - smoothing_method="Median Filter", - filter_size_method="Manually", + smoothing_method=IlluminationSmoothingMethod.MEDIAN_FILTER, + filter_size_method=IlluminationFilterSizeMethod.MANUALLY, manual_filter_size=4.7, - rescale_option="No", + rescale_option=IlluminationRescaleOption.NO, dtype_config=DtypeConfig(), ) np.testing.assert_array_equal(illumination, expected) @@ -3762,18 +3839,18 @@ def test_correct_illumination_median_smoothing_falls_back_when_minimum_not_major image = np.arange(25, dtype=np.float32).reshape((5, 5)) / 24 accelerated = correct_illumination_calculate( image, - smoothing_method="Median Filter", - filter_size_method="Manually", + smoothing_method=IlluminationSmoothingMethod.MEDIAN_FILTER, + filter_size_method=IlluminationFilterSizeMethod.MANUALLY, manual_filter_size=2.35, - rescale_option="No", + rescale_option=IlluminationRescaleOption.NO, dtype_config=DtypeConfig(), ) reference = correct_illumination_calculate( image, - smoothing_method="Median Filter", - filter_size_method="Manually", + smoothing_method=IlluminationSmoothingMethod.MEDIAN_FILTER, + filter_size_method=IlluminationFilterSizeMethod.MANUALLY, manual_filter_size=2.35, - rescale_option="No", + rescale_option=IlluminationRescaleOption.NO, dtype_config=DtypeConfig(), rank_median_backend_provider=CellProfilerBackendProvider.NATIVE, ) @@ -3791,8 +3868,8 @@ def test_correct_illumination_convex_hull_smoothing_suppresses_sparse_spikes(): image[5, 1] = 1.0 illumination = correct_illumination_calculate( image, - smoothing_method="Convex Hull", - rescale_option="No", + smoothing_method=IlluminationSmoothingMethod.CONVEX_HULL, + rescale_option=IlluminationRescaleOption.NO, convex_hull_backend_provider=CellProfilerBackendProvider.NUMBA, dtype_config=DtypeConfig(), ) @@ -3809,8 +3886,8 @@ def test_correct_illumination_centrosome_convex_hull_preserves_input_dtype(): illumination = correct_illumination_calculate( image, - smoothing_method="Convex Hull", - rescale_option="No", + smoothing_method=IlluminationSmoothingMethod.CONVEX_HULL, + rescale_option=IlluminationRescaleOption.NO, convex_hull_backend_provider=CellProfilerBackendProvider.CENTROSOME, dtype_config=DtypeConfig(), ) @@ -3831,15 +3908,15 @@ def test_correct_illumination_exact_convex_hull_matches_native_reference(): image[rows, columns] = values accelerated = correct_illumination_calculate( image, - smoothing_method="Convex Hull", - rescale_option="No", + smoothing_method=IlluminationSmoothingMethod.CONVEX_HULL, + rescale_option=IlluminationRescaleOption.NO, convex_hull_backend_provider=CellProfilerBackendProvider.NUMBA, dtype_config=DtypeConfig(), ) reference = correct_illumination_calculate( image, - smoothing_method="Convex Hull", - rescale_option="No", + smoothing_method=IlluminationSmoothingMethod.CONVEX_HULL, + rescale_option=IlluminationRescaleOption.NO, convex_hull_backend_provider=CellProfilerBackendProvider.NATIVE, dtype_config=DtypeConfig(), ) @@ -3854,18 +3931,18 @@ def test_correct_illumination_convex_hull_default_uses_cellprofiler_reference_ba image = np.arange(49, dtype=np.float32).reshape(7, 7) / 100 illumination = correct_illumination_calculate( image, - smoothing_method="Convex Hull", - filter_size_method="Manually", + smoothing_method=IlluminationSmoothingMethod.CONVEX_HULL, + filter_size_method=IlluminationFilterSizeMethod.MANUALLY, manual_filter_size=3, - rescale_option="No", + rescale_option=IlluminationRescaleOption.NO, dtype_config=DtypeConfig(), ) expected = correct_illumination_calculate( image, - smoothing_method="Convex Hull", - filter_size_method="Manually", + smoothing_method=IlluminationSmoothingMethod.CONVEX_HULL, + filter_size_method=IlluminationFilterSizeMethod.MANUALLY, manual_filter_size=3, - rescale_option="No", + rescale_option=IlluminationRescaleOption.NO, convex_hull_backend_provider=CellProfilerBackendProvider.CENTROSOME, dtype_config=DtypeConfig(), ) @@ -3881,10 +3958,10 @@ def test_correct_illumination_convex_hull_legacy_fast_backend_is_explicit(): image = np.arange(49, dtype=np.float32).reshape(7, 7) / 100 illumination = correct_illumination_calculate( image, - smoothing_method="Convex Hull", - filter_size_method="Manually", + smoothing_method=IlluminationSmoothingMethod.CONVEX_HULL, + filter_size_method=IlluminationFilterSizeMethod.MANUALLY, manual_filter_size=3, - rescale_option="No", + rescale_option=IlluminationRescaleOption.NO, convex_hull_backend_provider=CellProfilerBackendProvider.LEGACY_FAST, dtype_config=DtypeConfig(), ) @@ -3902,7 +3979,7 @@ def test_correct_illumination_convex_hull_unregistered_backend_is_explicit_error with pytest.raises(NotImplementedError, match="No CellProfiler"): correct_illumination_calculate( np.ones((4, 4), dtype=np.float32), - smoothing_method="Convex Hull", + smoothing_method=IlluminationSmoothingMethod.CONVEX_HULL, convex_hull_backend_provider=CellProfilerBackendProvider.CUCIM, dtype_config=DtypeConfig(), ) @@ -4032,7 +4109,7 @@ def test_unmix_colors_returns_one_output_per_stain_row(): image = np.full((8, 9, 3), 0.5, dtype=np.float32) outputs = unmix_colors( ImagePayloadMetadata(source_channel_axis=-1).payload_with(image, None), - stain_names=("Hematoxylin", "Eosin", "Custom"), + stain_names=(StainType.HEMATOXYLIN, StainType.EOSIN, StainType.CUSTOM), custom_absorbances=((0.5, 0.5, 0.5), (0.5, 0.5, 0.5), (0.1, 0.2, 0.3)), dtype_config=DtypeConfig(), ) @@ -4262,7 +4339,10 @@ def test_mask_image_applies_2d_object_mask_to_projected_image_plane(): labels = np.zeros((5, 6), dtype=np.int32) labels[1:4, 2:5] = 1 masked = mask_image( - image, labels, mask_source="objects", dtype_config=DtypeConfig() + image, + labels, + mask_source=MaskSource.OBJECTS, + dtype_config=DtypeConfig(), ) assert masked.shape == image.shape assert isinstance(masked, MaskedImagePayload) @@ -4281,7 +4361,7 @@ def test_mask_image_accepts_object_label_payload_mask(): variant_data=ObjectLabelVariantData(labels=labels), domain=ObjectLabelDomain(declared_object_ids=(1,)), ), - mask_source="objects", + mask_source=MaskSource.OBJECTS, dtype_config=DtypeConfig(), ) assert isinstance(masked, MaskedImagePayload) @@ -4296,7 +4376,10 @@ def test_mask_image_accepts_source_backed_projected_image_plane(): labels = np.zeros((5, 6), dtype=np.int32) labels[1:4, 2:5] = 1 masked = mask_image( - image, labels, mask_source="objects", dtype_config=DtypeConfig() + image, + labels, + mask_source=MaskSource.OBJECTS, + dtype_config=DtypeConfig(), ) assert masked.shape == (5, 6) assert np.count_nonzero(image_payload_data(masked)) == 9 @@ -4336,7 +4419,7 @@ def test_mask_image_uses_aligned_mask_stack_planes(): axis_size=2, ), ), - mask_source="image", + mask_source=MaskSource.IMAGE, dtype_config=DtypeConfig(), ) for index in range(2) @@ -4362,7 +4445,7 @@ def test_mask_image_rejects_unprojected_object_label_stack(): mask_image( image, label_stack, - mask_source="objects", + mask_source=MaskSource.OBJECTS, dtype_config=DtypeConfig(), ) @@ -4501,7 +4584,7 @@ def test_mask_image_combines_existing_image_mask_with_mask_input(): masked = mask_image( MaskedImagePayload(data=image, mask=existing_mask), mask, - mask_source="image", + mask_source=MaskSource.IMAGE, dtype_config=DtypeConfig(), ) expected_mask = existing_mask & (mask > 0) @@ -4525,7 +4608,7 @@ def test_align_returns_two_registered_images_and_shift_measurements(): ), plane_axis=RuntimePlaneAxis.SOURCE_BINDING, ).payload_with(image, None), - crop_mode="Keep size", + crop_mode=AlignModule.CropMode.KEEP_SIZE, dtype_config=DtypeConfig(), ) assert isinstance(aligned_images, AlignedImageStack) @@ -4579,8 +4662,8 @@ def test_align_applies_similar_shift_to_additional_images(): ), plane_axis=RuntimePlaneAxis.SOURCE_BINDING, ).payload_with(image, None), - crop_mode="Keep size", - additional_alignment_modes=("Similarly",), + crop_mode=AlignModule.CropMode.KEEP_SIZE, + additional_alignment_modes=(AlignModule.AdditionalMode.SIMILARLY,), dtype_config=DtypeConfig(), ) assert isinstance(aligned_images, AlignedImageStack) @@ -4602,7 +4685,7 @@ def test_overlay_outlines_runs_mixed_image_and_object_rows(): labels[3:6, 3:6] = 1 output = overlay_outlines( np.stack((base, outline_image)), - outline_source_kinds=("image", "objects"), + outline_source_kinds=(OutlineSourceKind.IMAGE, OutlineSourceKind.OBJECTS), outline_colors=("Red", "Green"), object_labels=( ObjectLabelPayload(variant_data=ObjectLabelVariantData(labels=labels)), @@ -4622,7 +4705,7 @@ def test_overlay_outlines_accepts_hex_color_literals(): labels[3:6, 3:6] = 1 output = overlay_outlines( base, - outline_source_kinds=("objects",), + outline_source_kinds=(OutlineSourceKind.OBJECTS,), outline_colors=("#0800F7",), object_labels=( ObjectLabelPayload(variant_data=ObjectLabelVariantData(labels=labels)), @@ -4640,7 +4723,7 @@ def test_overlay_outlines_accepts_css_named_color_literals(): labels[3:6, 3:6] = 1 output = overlay_outlines( base, - outline_source_kinds=("objects",), + outline_source_kinds=(OutlineSourceKind.OBJECTS,), outline_colors=("DarkOrange",), object_labels=( ObjectLabelPayload(variant_data=ObjectLabelVariantData(labels=labels)), @@ -4660,8 +4743,8 @@ def test_overlay_outlines_uses_cellprofiler_mark_boundaries_semantics(): labels[3:6, 3:6] = 1 output = overlay_outlines( base, - line_mode="Inner", - outline_source_kinds=("objects",), + line_mode=LineMode.INNER, + outline_source_kinds=(OutlineSourceKind.OBJECTS,), outline_colors=("Green",), object_labels=( ObjectLabelPayload(variant_data=ObjectLabelVariantData(labels=labels)), @@ -4694,7 +4777,7 @@ def test_overlay_outlines_rejects_unprojected_object_label_stack(): with pytest.raises(ValueError, match="runtime-projected 2-D"): overlay_outlines( image, - outline_source_kinds=("objects",), + outline_source_kinds=(OutlineSourceKind.OBJECTS,), outline_colors=("Green",), object_labels=(payload,), dtype_config=DtypeConfig(), @@ -4722,7 +4805,7 @@ def test_overlay_outlines_renders_exact_projected_empty_label_plane(): ) output = overlay_outlines( image[1], - outline_source_kinds=("objects",), + outline_source_kinds=(OutlineSourceKind.OBJECTS,), object_labels=(projected,), dtype_config=DtypeConfig(), ) diff --git a/tests/unit/test_cellprofiler_measurement_rows.py b/tests/unit/test_cellprofiler_measurement_rows.py index c65cf7435..7972a784e 100644 --- a/tests/unit/test_cellprofiler_measurement_rows.py +++ b/tests/unit/test_cellprofiler_measurement_rows.py @@ -170,7 +170,7 @@ def test_image_intensity_rows_own_exact_source_qualified_features() -> None: np.asarray((1.0, 2.0, 3.0)), percentile_spec=ImageIntensityPercentileSpec( enabled=True, - raw_percentiles="10,90", + percentiles=(10, 90), ), ) diff --git a/tests/unit/test_cellprofiler_module_execution.py b/tests/unit/test_cellprofiler_module_execution.py index 4daceff9f..05ba7eb2c 100644 --- a/tests/unit/test_cellprofiler_module_execution.py +++ b/tests/unit/test_cellprofiler_module_execution.py @@ -270,6 +270,7 @@ ObjectColocalizationMeasurements, ) from openhcs.processing.backends.cellprofiler.color import ( + GrayToColorModule, color_to_gray, gray_to_color, ) @@ -330,6 +331,7 @@ filter_objects, ) from openhcs.processing.backends.cellprofiler.object_images import ( + ImageMode, convert_objects_to_image, ) from openhcs.processing.backends.cellprofiler.primary_objects import ( @@ -3171,7 +3173,6 @@ def test_object_output_measurements_derive_count_and_locations_from_output_label source_image_set_identity_policy=SourceImageSetIdentityPolicy(), ) settings = CellProfilerDatabaseExportSettings( - database_type="sqlite", sqlite_file="analysis.db", experiment_name="Experiment", table_prefix="CPA_", @@ -7383,7 +7384,7 @@ def test_gray_to_color_consumes_channel_stack_as_single_rgb_image() -> None: result = gray_to_color( image, - color_scheme="RGB", + color_scheme=GrayToColorModule.Scheme.RGB, red_channel=-1, green_channel=0, blue_channel=1, @@ -7424,7 +7425,7 @@ def test_gray_to_color_inherits_first_declared_input_mask() -> None: ).resolve_canonical_raw_callable() result = raw_gray_to_color( image, - color_scheme="RGB", + color_scheme=GrayToColorModule.Scheme.RGB, red_channel=0, green_channel=1, blue_channel=-1, @@ -17417,7 +17418,7 @@ def test_convert_objects_to_image_contract_preserves_volume_label_payload() -> N np.zeros((5, 7), dtype=np.float32), { "labels": label_payload, - "image_mode": "uint16", + "image_mode": ImageMode.UINT16, }, execution_mode=ImagePayloadExecutionMode.NATURAL, ) @@ -17473,7 +17474,7 @@ def test_convert_objects_to_image_uses_declared_label_source_for_runtime_plane_d raw_result = convert_objects_to_image( primary_image, label_payload, - image_mode="uint16", + image_mode=ImageMode.UINT16, ) source_spec = ArtifactSpec.input("Nuclei", ObjectLabelsArtifactType) output_plan = ArtifactOutputPlan( @@ -17545,7 +17546,7 @@ def test_object_label_singleton_volume_output_declares_runtime_plane_axis() -> N raw_result = convert_objects_to_image( np.zeros((5, 7), dtype=np.float32), label_payload, - image_mode="uint16", + image_mode=ImageMode.UINT16, ) source_spec = ArtifactSpec.input("Nuclei", ObjectLabelsArtifactType) output_plan = ArtifactOutputPlan( @@ -17579,7 +17580,7 @@ def test_object_label_scalar_image_output_does_not_invent_runtime_plane_axis() - raw_result = convert_objects_to_image( np.zeros((5, 7), dtype=np.float32), label_payload, - image_mode="uint16", + image_mode=ImageMode.UINT16, ) source_spec = ArtifactSpec.input("Nuclei", ObjectLabelsArtifactType) output_plan = ArtifactOutputPlan( diff --git a/tests/unit/test_cellprofiler_morphology.py b/tests/unit/test_cellprofiler_morphology.py index b929f6d47..c07940369 100644 --- a/tests/unit/test_cellprofiler_morphology.py +++ b/tests/unit/test_cellprofiler_morphology.py @@ -19,6 +19,7 @@ from openhcs.processing.backends.cellprofiler.morphology import ( CentrosomeNumpyMorphologyBackendStrategy, CellProfilerDeclumpMethod, + CombineObjectsMethod, CombineObjectsStrategy, MorphologyBackendStrategy, NumbaNumpyMorphologyBackendStrategy, @@ -70,10 +71,10 @@ def test_mask_objects_aligns_nominal_label_domains_before_array_conversion() -> def test_combine_objects_strategies_are_registered_by_enum_value() -> None: expected = { - "merge": "MergeCombineObjectsStrategy", - "preserve": "PreserveCombineObjectsStrategy", - "discard": "DiscardCombineObjectsStrategy", - "segment": "SegmentCombineObjectsStrategy", + CombineObjectsMethod.MERGE: "MergeCombineObjectsStrategy", + CombineObjectsMethod.PRESERVE: "PreserveCombineObjectsStrategy", + CombineObjectsMethod.DISCARD: "DiscardCombineObjectsStrategy", + CombineObjectsMethod.SEGMENT: "SegmentCombineObjectsStrategy", } assert { diff --git a/tests/unit/test_cellprofiler_nominal_policy_migration.py b/tests/unit/test_cellprofiler_nominal_policy_migration.py index bf8cf380e..24baeb834 100644 --- a/tests/unit/test_cellprofiler_nominal_policy_migration.py +++ b/tests/unit/test_cellprofiler_nominal_policy_migration.py @@ -17,11 +17,13 @@ def _class_name(node: ast.expr) -> str: def _class_definitions() -> dict[str, list[tuple[Path, ast.ClassDef]]]: definitions: dict[str, list[tuple[Path, ast.ClassDef]]] = {} - for path in ( - *RUNTIME_ROOT.glob("*.py"), + source_paths = { + *( + REPO_ROOT / "openhcs/interop/cellprofiler" + ).rglob("*.py"), *BACKEND_ROOT.glob("*.py"), - REPO_ROOT / "openhcs/interop/cellprofiler/module_declarations.py", - ): + } + for path in sorted(source_paths): tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) for node in ast.walk(tree): if isinstance(node, ast.ClassDef): @@ -131,3 +133,96 @@ def test_object_measurement_columnar_rows_own_complete_domain_semantics() -> Non ): assert issubclass(row_type, ObjectMeasurementColumnarRows) assert "covers_declared_object_measurement_domain" not in row_type.__dict__ + + +def test_registered_runtime_bodies_do_not_parse_cellprofiler_enum_text() -> None: + """Legacy CellProfiler text is normalized before registered runtime calls.""" + + occurrences: list[str] = [] + for path in BACKEND_ROOT.glob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in tree.body: + if not isinstance(node, ast.FunctionDef): + continue + if not node.decorator_list: + continue + for descendant in ast.walk(node): + if ( + isinstance(descendant, ast.Call) + and isinstance(descendant.func, (ast.Name, ast.Attribute)) + and ( + descendant.func.id + if isinstance(descendant.func, ast.Name) + else descendant.func.attr + ) + == "coerce_cellprofiler_enum" + ): + occurrences.append( + f"{path.relative_to(REPO_ROOT)}:{descendant.lineno}" + ) + + assert occurrences == [] + + +def test_enum_text_coercion_remains_at_cellprofiler_source_boundaries() -> None: + """Runtime computation receives enums; only source parsers accept CP text.""" + + module_class_names = _module_mro_class_names(_class_definitions()) + runtime_occurrences: list[str] = [] + parser_name_markers = ( + "cellprofiler", + "literal", + "parse", + "setting", + "from_module", + ) + + for path in BACKEND_ROOT.glob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + parents: dict[ast.AST, ast.AST] = {} + for parent in ast.walk(tree): + for child in ast.iter_child_nodes(parent): + parents[child] = parent + + for node in ast.walk(tree): + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, (ast.Name, ast.Attribute)) + and ( + node.func.id + if isinstance(node.func, ast.Name) + else node.func.attr + ) + == "coerce_cellprofiler_enum" + ): + continue + + enclosing_function: ast.FunctionDef | None = None + enclosing_class: ast.ClassDef | None = None + ancestor = parents.get(node) + while ancestor is not None: + if ( + enclosing_function is None + and isinstance(ancestor, ast.FunctionDef) + ): + enclosing_function = ancestor + if isinstance(ancestor, ast.ClassDef): + enclosing_class = ancestor + break + ancestor = parents.get(ancestor) + + if ( + enclosing_class is not None + and enclosing_class.name in module_class_names + ): + continue + function_name = ( + enclosing_function.name if enclosing_function is not None else "" + ) + if any(marker in function_name for marker in parser_name_markers): + continue + runtime_occurrences.append( + f"{path.relative_to(REPO_ROOT)}:{node.lineno}:{function_name}" + ) + + assert runtime_occurrences == [] diff --git a/tests/unit/test_cellprofiler_object_morphology_hotpath.py b/tests/unit/test_cellprofiler_object_morphology_hotpath.py index 467130524..1c2406778 100644 --- a/tests/unit/test_cellprofiler_object_morphology_hotpath.py +++ b/tests/unit/test_cellprofiler_object_morphology_hotpath.py @@ -148,7 +148,7 @@ def test_erode_image_returns_exact_nonempty_stack_without_same_dtype_copy( def preserve_pixels( pixels: np.ndarray, - _structuring_element: StructuringElement | str, + _structuring_element: StructuringElement, _size: int, _operation: Callable[[np.ndarray, np.ndarray], np.ndarray], ) -> np.ndarray: diff --git a/tests/unit/test_cellprofiler_processing_backend.py b/tests/unit/test_cellprofiler_processing_backend.py index 39f140cc5..3d7f0f606 100644 --- a/tests/unit/test_cellprofiler_processing_backend.py +++ b/tests/unit/test_cellprofiler_processing_backend.py @@ -130,6 +130,7 @@ def test_classify_objects_aligns_dense_label_indexed_measurements_to_sparse_labe ObjectLabelPayload, ) from openhcs.processing.backends.cellprofiler.classification import ( + ClassificationBinChoice, classify_objects_single_measurement, ) @@ -151,11 +152,11 @@ def test_classify_objects_aligns_dense_label_indexed_measurements_to_sparse_labe image, ObjectLabelPayload(variant_data=ObjectLabelVariantData(labels=labels)), measurement_values=measurement_values, - bin_choice="even", + bin_choice=ClassificationBinChoice.EVEN, bin_count=3, low_threshold=0.0, high_threshold=900.0, - bin_names="Small,Medium,Large", + bin_names=("Small", "Medium", "Large"), ) result_row = result.row_mappings()[0] @@ -170,6 +171,7 @@ def test_classify_objects_measurement_vector_does_not_consume_rgb_main_flow() -> ObjectLabelPayload, ) from openhcs.processing.backends.cellprofiler.classification import ( + ClassificationBinChoice, classify_objects_single_measurement, ) @@ -187,9 +189,9 @@ def test_classify_objects_measurement_vector_does_not_consume_rgb_main_flow() -> image, ObjectLabelPayload(variant_data=ObjectLabelVariantData(labels=labels)), measurement_values=np.array([0.25, 0.75], dtype=np.float64), - bin_choice="custom", - custom_thresholds="0,0.5,1", - bin_names="Low,High", + bin_choice=ClassificationBinChoice.CUSTOM, + custom_thresholds=(0.0, 0.5, 1.0), + bin_names=("Low", "High"), ) np.testing.assert_array_equal(classified, labels) @@ -1296,7 +1298,12 @@ def test_rescale_intensity_identity_preserves_unit_interval_scale_metadata() -> image_payload_metadata, normalize_image_payload_intensity, ) - from openhcs.processing.backends.cellprofiler.intensity import rescale_intensity + from openhcs.processing.backends.cellprofiler.intensity import ( + AutomaticHigh, + AutomaticLow, + RescaleMethod, + rescale_intensity, + ) raw = np.array([[0, 65535], [32768, 1]], dtype=np.uint16) payload = ImagePayloadMetadata.for_array(raw).payload_with(raw, None) @@ -1304,9 +1311,9 @@ def test_rescale_intensity_identity_preserves_unit_interval_scale_metadata() -> rescaled = rescale_intensity.__wrapped__( normalized, - rescale_method="stretch", - automatic_low="custom", - automatic_high="custom", + rescale_method=RescaleMethod.STRETCH, + automatic_low=AutomaticLow.CUSTOM, + automatic_high=AutomaticHigh.CUSTOM, source_low=0.0, source_high=1.0, dest_low=0.0, @@ -1322,7 +1329,12 @@ def test_rescale_intensity_nonidentity_clears_unit_interval_scale_metadata() -> image_payload_metadata, normalize_image_payload_intensity, ) - from openhcs.processing.backends.cellprofiler.intensity import rescale_intensity + from openhcs.processing.backends.cellprofiler.intensity import ( + AutomaticHigh, + AutomaticLow, + RescaleMethod, + rescale_intensity, + ) raw = np.array([[0, 65535], [32768, 1]], dtype=np.uint16) payload = ImagePayloadMetadata.for_array(raw).payload_with(raw, None) @@ -1330,9 +1342,9 @@ def test_rescale_intensity_nonidentity_clears_unit_interval_scale_metadata() -> rescaled = rescale_intensity.__wrapped__( normalized, - rescale_method="manual_io_range", - automatic_low="custom", - automatic_high="custom", + rescale_method=RescaleMethod.MANUAL_IO_RANGE, + automatic_low=AutomaticLow.CUSTOM, + automatic_high=AutomaticHigh.CUSTOM, source_low=0.0, source_high=1.0, dest_low=0.0, @@ -1349,6 +1361,8 @@ def test_resize_nearest_preserves_unit_interval_scale_metadata() -> None: normalize_image_payload_intensity, ) from openhcs.processing.backends.cellprofiler.image_geometry import ( + InterpolationMethod, + ResizeMethod, resize_volumetric, ) @@ -1358,11 +1372,11 @@ def test_resize_nearest_preserves_unit_interval_scale_metadata() -> None: resized = resize_volumetric.__wrapped__( normalized, - resize_method="by_factor", + resize_method=ResizeMethod.BY_FACTOR, resizing_factor_x=1.0, resizing_factor_y=1.0, resizing_factor_z=1.0, - interpolation="nearest_neighbor", + interpolation=InterpolationMethod.NEAREST_NEIGHBOR, ) assert image_payload_metadata(resized).unit_interval_intensity_scale == 65535 @@ -1375,6 +1389,8 @@ def test_resize_interpolation_clears_unit_interval_scale_metadata() -> None: normalize_image_payload_intensity, ) from openhcs.processing.backends.cellprofiler.image_geometry import ( + InterpolationMethod, + ResizeMethod, resize_volumetric, ) @@ -1384,11 +1400,11 @@ def test_resize_interpolation_clears_unit_interval_scale_metadata() -> None: resized = resize_volumetric.__wrapped__( normalized, - resize_method="by_factor", + resize_method=ResizeMethod.BY_FACTOR, resizing_factor_x=1.0, resizing_factor_y=1.0, resizing_factor_z=1.0, - interpolation="bilinear", + interpolation=InterpolationMethod.BILINEAR, ) assert image_payload_metadata(resized).unit_interval_intensity_scale is None diff --git a/tests/unit/test_cellprofiler_runtime_adapter.py b/tests/unit/test_cellprofiler_runtime_adapter.py index ac7f2bbb2..399e7c076 100644 --- a/tests/unit/test_cellprofiler_runtime_adapter.py +++ b/tests/unit/test_cellprofiler_runtime_adapter.py @@ -78,6 +78,10 @@ ObjectLabelVariantData, object_label_dense_array, ) +from openhcs.processing.backends.cellprofiler.classification import ( + ClassificationBinChoice, + SingleMeasurementClassificationRule, +) from openhcs.core.runtime_sparse_labels import SparseIJVLabelRows from openhcs.core.runtime_relationships import ( ObjectRelationship, @@ -8079,7 +8083,7 @@ def test_classify_objects_binds_runtime_measurement_values(): ).payload_with(np.zeros((1, 2, 2), dtype=np.float32), None), cellprofiler_runtime=adapter, measurement_feature="Math_Ratio", - bin_choice="even", + bin_choice=ClassificationBinChoice.EVEN, bin_count=2, low_threshold=0.0, high_threshold=1.0, @@ -8187,7 +8191,7 @@ def test_classify_objects_uses_declared_area_shape_measurements(): ).payload_with(np.zeros((1, 2, 2), dtype=np.float32), None), cellprofiler_runtime=adapter, measurement_feature="AreaShape_Area", - bin_choice="even", + bin_choice=ClassificationBinChoice.EVEN, bin_count=2, low_threshold=0.0, high_threshold=2.0, @@ -8289,14 +8293,14 @@ def test_classify_objects_binds_custom_threshold_and_named_low_high_bins(): ).payload_with(np.zeros((1, 2, 2), dtype=np.float32), None), cellprofiler_runtime=adapter, measurement_feature="Intensity_MaxIntensity_OrigGreen", - bin_choice="custom", + bin_choice=ClassificationBinChoice.CUSTOM, bin_count=3, low_threshold=0.0, high_threshold=1.0, wants_low_bin=True, wants_high_bin=True, - custom_thresholds="0.2", - bin_names="PH3Neg,PH3Pos", + custom_thresholds=(0.2,), + bin_names=("PH3Neg", "PH3Pos"), dtype_config=DtypeConfig(), ) rows = _output_measurements(adapter, MEASUREMENTS).rows @@ -8416,20 +8420,20 @@ def test_classify_objects_binds_repeated_single_measurement_rules(): ).payload_with(np.zeros((1, 2, 2), dtype=np.float32), None), cellprofiler_runtime=adapter, classification_rules=( - { - "measurement_feature": "AreaShape_Area", - "bin_choice": "custom", - "custom_thresholds": "0,5,20", - "bin_names": "Small,Large", - }, - { - "measurement_feature": "Intensity_MeanIntensity_DNA", - "bin_choice": "custom", - "custom_thresholds": "0.05", - "wants_low_bin": True, - "wants_high_bin": True, - "bin_names": "White,Red", - }, + SingleMeasurementClassificationRule( + measurement_feature="AreaShape_Area", + bin_choice=ClassificationBinChoice.CUSTOM, + custom_thresholds=(0.0, 5.0, 20.0), + bin_names=("Small", "Large"), + ), + SingleMeasurementClassificationRule( + measurement_feature="Intensity_MeanIntensity_DNA", + bin_choice=ClassificationBinChoice.CUSTOM, + custom_thresholds=(0.05,), + wants_low_bin=True, + wants_high_bin=True, + bin_names=("White", "Red"), + ), ), dtype_config=DtypeConfig(), ) @@ -8545,7 +8549,7 @@ def test_classify_objects_slices_runtime_measurements_with_label_stack(): ).payload_with(np.zeros((2, 2, 2), dtype=np.float32), None), cellprofiler_runtime=adapter, measurement_feature="AreaShape_Area", - bin_choice="even", + bin_choice=ClassificationBinChoice.EVEN, bin_count=2, low_threshold=0.0, high_threshold=4.0, diff --git a/tests/unit/test_cellprofiler_trackobjects.py b/tests/unit/test_cellprofiler_trackobjects.py index a530021db..20f284e53 100644 --- a/tests/unit/test_cellprofiler_trackobjects.py +++ b/tests/unit/test_cellprofiler_trackobjects.py @@ -30,6 +30,7 @@ NumbaNumpyObjectTrackingBackendStrategy, ObjectTrackingBackendStrategy, TrackObjectsModule, + TrackingMethod, TrackingImageMeasurement, TrackingObjectMeasurement, ) @@ -271,7 +272,7 @@ def test_track_objects_emits_stack_tracking_measurements(): result = unwrap(track_objects)( image, labels=_timepoint_labels(labels, axis_size=2), - tracking_method="overlap", + tracking_method=TrackingMethod.OVERLAP, pixel_radius=50, ) output = result.output_image @@ -334,7 +335,7 @@ def test_track_objects_parent_image_number_is_axis_local(): result = unwrap(track_objects)( image, labels=_timepoint_labels(labels, axis_size=2), - tracking_method="overlap", + tracking_method=TrackingMethod.OVERLAP, pixel_radius=50, ) rows = _projected_measurement_rows(result) @@ -382,7 +383,7 @@ def test_track_objects_preserves_fractional_trajectory_measurements(): result = unwrap(track_objects)( image, labels=_timepoint_labels(labels, axis_size=2), - tracking_method="overlap", + tracking_method=TrackingMethod.OVERLAP, pixel_radius=50, ) rows = _projected_measurement_rows(result) @@ -427,7 +428,7 @@ def test_track_objects_overlap_allows_split_children_to_inherit_parent_label(): result = unwrap(track_objects)( image, labels=_timepoint_labels(labels, axis_size=3), - tracking_method="overlap", + tracking_method=TrackingMethod.OVERLAP, pixel_radius=50, ) rows = _projected_measurement_rows(result) @@ -503,7 +504,7 @@ def test_track_objects_overlap_counts_distinct_parent_merge_not_loss(): result = unwrap(track_objects)( image, labels=_timepoint_labels(labels, axis_size=2), - tracking_method="overlap", + tracking_method=TrackingMethod.OVERLAP, pixel_radius=50, ) rows = _projected_measurement_rows(result) @@ -537,7 +538,7 @@ def test_track_objects_motion_state_follows_split_parent_object(): result = unwrap(track_objects)( image, labels=_timepoint_labels(labels, axis_size=3), - tracking_method="overlap", + tracking_method=TrackingMethod.OVERLAP, pixel_radius=50, ) rows = _projected_measurement_rows(result) @@ -582,7 +583,7 @@ def test_track_objects_final_age_marks_terminal_track_labels(): result = unwrap(track_objects)( image, labels=_timepoint_labels(labels, axis_size=3), - tracking_method="overlap", + tracking_method=TrackingMethod.OVERLAP, pixel_radius=50, ) rows = _projected_measurement_rows(result) diff --git a/tests/unit/test_configuration_consumer_coverage.py b/tests/unit/test_configuration_consumer_coverage.py new file mode 100644 index 000000000..5b13b480f --- /dev/null +++ b/tests/unit/test_configuration_consumer_coverage.py @@ -0,0 +1,1044 @@ +"""Generated receiver-specific coverage for Configure OpenHCS fields.""" + +from __future__ import annotations + +import ast +import inspect +import re +from dataclasses import dataclass, fields, is_dataclass +from pathlib import Path +from typing import get_args, get_type_hints + +from openhcs.core.config import GlobalPipelineConfig +from openhcs.pyqt_gui.config import ShortcutConfig, get_default_ui_config +from openhcs.pyqt_gui.main import OpenHCSMainWindow +from openhcs.pyqt_gui.services.main_window_workflows import ( + MainWindowShortcutLifecycle, +) + + +@dataclass(frozen=True, slots=True) +class _VisibleConfigLeaf: + path: str + owner: type[object] + field_name: str + + +@dataclass(frozen=True, slots=True) +class _SyntaxUnit: + path: Path + module_name: str + tree: ast.Module + + +@dataclass(frozen=True, slots=True) +class _ClassScope: + source_path: Path + qualname: str + + +_TypeReference = type[object] | _ClassScope +_TypeDomain = frozenset[_TypeReference] +_OwnerIdentity = tuple[Path, str] +_ConsumedField = tuple[_OwnerIdentity, str] + + +def _owner_identity(owner: type[object]) -> _OwnerIdentity: + source_path = inspect.getsourcefile(owner) + if source_path is None: + raise AssertionError(f"{owner!r} has no source identity.") + return Path(source_path).resolve(), owner.__qualname__ + + +def _declaring_owner(runtime_type: type[object], field_name: str) -> type[object]: + """Return the oldest MRO declaration that owns ``field_name``.""" + + for candidate in reversed(runtime_type.__mro__): + if field_name in getattr(candidate, "__annotations__", {}): + return candidate + raise AssertionError( + f"{runtime_type.__qualname__}.{field_name} has no nominal declaration." + ) + + +def _config_graph() -> tuple[ + tuple[_VisibleConfigLeaf, ...], + frozenset[type[object]], + dict[tuple[type[object], str], type[object]], +]: + """Derive leaves and nested nominal edges from the actual config roots.""" + + leaves: list[_VisibleConfigLeaf] = [] + config_types: set[type[object]] = set() + nested_fields: dict[tuple[type[object], str], type[object]] = {} + + def visit(value: object, prefix: str = "") -> None: + runtime_type = type(value) + config_types.add(runtime_type) + for declaration in fields(value): + if declaration.metadata.get("ui_hidden"): + continue + field_value = getattr(value, declaration.name) + path = f"{prefix}.{declaration.name}" if prefix else declaration.name + if is_dataclass(field_value) and not isinstance(field_value, type): + nested_fields[(runtime_type, declaration.name)] = type(field_value) + visit(field_value, path) + continue + leaves.append( + _VisibleConfigLeaf( + path=path, + owner=_declaring_owner(runtime_type, declaration.name), + field_name=declaration.name, + ) + ) + + visit(get_default_ui_config()) + visit(GlobalPipelineConfig()) + config_types.update(leaf.owner for leaf in leaves) + while True: + discovered_subclasses = { + subclass + for config_type in config_types + for subclass in config_type.__subclasses__() + if is_dataclass(subclass) + } + if discovered_subclasses <= config_types: + break + config_types.update(discovered_subclasses) + for config_type in tuple(config_types): + config_types.update( + candidate + for candidate in config_type.__mro__ + if candidate is not object + ) + + for config_type in tuple(config_types): + try: + hints = get_type_hints(config_type) + except (NameError, TypeError): + continue + for field_name, hint in hints.items(): + hinted_types = { + candidate + for candidate in (hint, *get_args(hint)) + if isinstance(candidate, type) and candidate in config_types + } + if len(hinted_types) == 1: + nested_fields.setdefault( + (config_type, field_name), + hinted_types.pop(), + ) + + return tuple(leaves), frozenset(config_types), nested_fields + + +def _production_syntax_units() -> tuple[_SyntaxUnit, ...]: + repository_root = Path(__file__).resolve().parents[2] + roots = ( + repository_root / "openhcs", + repository_root / "external" / "pyqt-reactive" / "src", + repository_root / "external" / "zmqruntime" / "src", + repository_root / "external" / "arraybridge" / "src", + repository_root / "external" / "PolyStore" / "src", + ) + units: list[_SyntaxUnit] = [] + for root in roots: + for source_path in root.rglob("*.py"): + relative_module_path = source_path.relative_to(root).with_suffix("") + module_parts = list(relative_module_path.parts) + if root.name == "openhcs": + module_parts.insert(0, "openhcs") + if module_parts[-1] == "__init__": + module_parts.pop() + units.append( + _SyntaxUnit( + path=source_path, + module_name=".".join(module_parts), + tree=ast.parse( + source_path.read_text(encoding="utf-8"), + filename=str(source_path), + ), + ) + ) + return tuple(units) + + +def _functions_with_owner( + unit: _SyntaxUnit, +) -> tuple[ + tuple[ast.FunctionDef | ast.AsyncFunctionDef, _ClassScope | None], + ..., +]: + functions: list[ + tuple[ast.FunctionDef | ast.AsyncFunctionDef, _ClassScope | None] + ] = [] + + class _NestedFunctionVisitor(ast.NodeVisitor): + """Collect nested callables without assigning enclosing class ownership.""" + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + functions.append((node, None)) + for statement in node.body: + self.visit(statement) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + functions.append((node, None)) + for statement in node.body: + self.visit(statement) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + return + + def visit( + statements: list[ast.stmt], + owner_qualname: str | None = None, + ) -> None: + for statement in statements: + if isinstance(statement, ast.ClassDef): + class_qualname = ( + f"{owner_qualname}.{statement.name}" + if owner_qualname + else statement.name + ) + visit(statement.body, class_qualname) + elif isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + owner = ( + _ClassScope(unit.path, owner_qualname) + if owner_qualname + else None + ) + functions.append((statement, owner)) + nested_visitor = _NestedFunctionVisitor() + for nested_statement in statement.body: + nested_visitor.visit(nested_statement) + + visit(unit.tree.body) + return tuple(functions) + + +def _module_imports(unit: _SyntaxUnit) -> tuple[ast.ImportFrom, ...]: + """Return module imports plus direct ``TYPE_CHECKING`` imports only.""" + + imports: list[ast.ImportFrom] = [] + for statement in unit.tree.body: + if isinstance(statement, ast.ImportFrom): + imports.append(statement) + continue + if not ( + isinstance(statement, ast.If) + and ( + ( + isinstance(statement.test, ast.Name) + and statement.test.id == "TYPE_CHECKING" + ) + or ( + isinstance(statement.test, ast.Attribute) + and isinstance(statement.test.value, ast.Name) + and statement.test.value.id == "typing" + and statement.test.attr == "TYPE_CHECKING" + ) + ) + ): + continue + imports.extend( + nested + for nested in statement.body + if isinstance(nested, ast.ImportFrom) + ) + return tuple(imports) + + +def _scope_nodes( + function: ast.FunctionDef | ast.AsyncFunctionDef, +) -> tuple[ast.AST, ...]: + """Return nodes owned by one function without nested-scope leakage.""" + + nodes: list[ast.AST] = [] + + def visit(node: ast.AST) -> None: + for child in ast.iter_child_nodes(node): + if isinstance( + child, + (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda), + ): + continue + nodes.append(child) + visit(child) + + visit(function) + return tuple(nodes) + + +def _read_scope_nodes( + function: ast.FunctionDef | ast.AsyncFunctionDef, +) -> tuple[tuple[ast.AST, frozenset[str]], ...]: + """Return reads in a function and its lambdas with shadowed names tracked.""" + + nodes: list[tuple[ast.AST, frozenset[str]]] = [] + + def visit(node: ast.AST, shadowed: frozenset[str]) -> None: + for child in ast.iter_child_nodes(node): + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + if isinstance(child, ast.Lambda): + bound_names = frozenset( + argument.arg + for argument in ( + *child.args.posonlyargs, + *child.args.args, + *child.args.kwonlyargs, + ) + ) + if child.args.vararg is not None: + bound_names |= frozenset((child.args.vararg.arg,)) + if child.args.kwarg is not None: + bound_names |= frozenset((child.args.kwarg.arg,)) + visit(child.body, shadowed | bound_names) + continue + nodes.append((child, shadowed)) + visit(child, shadowed) + + visit(function, frozenset()) + return tuple(nodes) + + +class _TypedAttributeFlow: + """Infer config receiver types through annotations and assignments.""" + + def __init__( + self, + config_types: frozenset[type[object]], + nested_fields: dict[tuple[type[object], str], type[object]], + units: tuple[_SyntaxUnit, ...], + ) -> None: + self._config_types = config_types + self._nested_fields = nested_fields + self._units = units + types_by_name: dict[str, set[type[object]]] = {} + for config_type in config_types: + types_by_name.setdefault(config_type.__name__, set()).add(config_type) + self._types_by_name = { + name: frozenset(types) + for name, types in types_by_name.items() + } + types_by_source_qualname: dict[ + tuple[Path, str], + set[type[object]], + ] = {} + for config_type in config_types: + source_path = inspect.getsourcefile(config_type) + if source_path is None: + continue + types_by_source_qualname.setdefault( + ( + Path(source_path).resolve(), + config_type.__qualname__, + ), + set(), + ).add(config_type) + self._types_by_source_qualname = { + identity: frozenset(types) + for identity, types in types_by_source_qualname.items() + } + self._class_scopes_by_module_name: dict[ + tuple[str, str], + _ClassScope, + ] = {} + for unit in units: + for statement in unit.tree.body: + if isinstance(statement, ast.ClassDef): + self._class_scopes_by_module_name[ + (unit.module_name, statement.name) + ] = _ClassScope(unit.path.resolve(), statement.name) + self._class_attribute_types: dict[ + tuple[_ClassScope, str], + _TypeDomain, + ] = {} + self._type_aliases_by_source = { + unit.path.resolve(): self._type_aliases(unit) + for unit in units + } + self._function_return_aliases_by_source: dict[ + Path, + dict[str, _TypeDomain], + ] = {} + self._method_return_types: dict[ + tuple[_ClassScope, str], + _TypeDomain, + ] = {} + self._function_scopes = tuple( + (unit, function, owner) + for unit in units + for function, owner in _functions_with_owner(unit) + ) + self._scope_nodes_by_function = { + function: _scope_nodes(function) + for _unit, function, _owner in self._function_scopes + } + self._read_scope_nodes_by_function = { + function: _read_scope_nodes(function) + for _unit, function, _owner in self._function_scopes + } + self._index_declared_class_attributes() + self._index_callable_return_types() + + def _type_aliases(self, unit: _SyntaxUnit) -> dict[str, _TypeDomain]: + aliases: dict[str, set[_TypeReference]] = {} + source_path = unit.path.resolve() + for ( + candidate_source, + candidate_qualname, + ), config_types in self._types_by_source_qualname.items(): + if candidate_source == source_path and "." not in candidate_qualname: + aliases.setdefault(candidate_qualname, set()).update(config_types) + for ( + candidate_module, + candidate_name, + ), candidate_scope in self._class_scopes_by_module_name.items(): + if candidate_module != unit.module_name: + continue + if ( + candidate_scope.source_path, + candidate_scope.qualname, + ) not in self._types_by_source_qualname: + aliases.setdefault(candidate_name, set()).add(candidate_scope) + for statement in _module_imports(unit): + if statement.module is None: + continue + imported_module = self._resolved_import_module(unit, statement) + for imported in statement.names: + exact_types = { + candidate + for candidate in self._types_by_name.get( + imported.name, + frozenset(), + ) + if candidate.__module__ == imported_module + } + if exact_types: + aliases.setdefault( + imported.asname or imported.name, + set(), + ).update(exact_types) + continue + candidate_scope = self._class_scopes_by_module_name.get( + (imported_module, imported.name) + ) + if candidate_scope is not None: + aliases.setdefault( + imported.asname or imported.name, + set(), + ).add(candidate_scope) + return { + name: frozenset(candidates) + for name, candidates in aliases.items() + } + + @staticmethod + def _resolved_import_module( + unit: _SyntaxUnit, + statement: ast.ImportFrom, + ) -> str: + imported_module = statement.module or "" + if not statement.level: + return imported_module + package_parts = unit.module_name.split(".")[:-statement.level] + return ".".join( + part + for part in (*package_parts, imported_module) + if part + ) + + def _annotation_types( + self, + annotation: ast.expr | None, + source_path: Path, + ) -> _TypeDomain: + if annotation is None: + return frozenset() + annotation_source = ast.unparse(annotation) + return frozenset( + config_type + for name, config_types in self._type_aliases_by_source[ + source_path.resolve() + ].items() + if re.search(rf"\b{re.escape(name)}\b", annotation_source) + for config_type in config_types + ) + + def _index_declared_class_attributes(self) -> None: + """Index exact class-body annotations without importing source modules.""" + + def visit( + statements: list[ast.stmt], + source_path: Path, + owner_qualname: str | None = None, + ) -> None: + for statement in statements: + if not isinstance(statement, ast.ClassDef): + continue + qualname = ( + f"{owner_qualname}.{statement.name}" + if owner_qualname + else statement.name + ) + owner = _ClassScope(source_path.resolve(), qualname) + for member in statement.body: + if ( + isinstance(member, ast.AnnAssign) + and isinstance(member.target, ast.Name) + ): + declared_types = self._annotation_types( + member.annotation, + source_path, + ) + if declared_types: + self._class_attribute_types[ + (owner, member.target.id) + ] = declared_types + visit(statement.body, source_path, qualname) + + for unit in self._units: + visit(unit.tree.body, unit.path) + + def _index_callable_return_types(self) -> None: + """Index exact local/imported callables and method return annotations.""" + + module_returns: dict[tuple[str, str], _TypeDomain] = {} + for unit in self._units: + for statement in unit.tree.body: + if not isinstance( + statement, + (ast.FunctionDef, ast.AsyncFunctionDef), + ): + continue + return_types = self._annotation_types( + statement.returns, + unit.path, + ) + if return_types: + module_returns[(unit.module_name, statement.name)] = return_types + + for unit, function, owner in self._function_scopes: + return_types = self._annotation_types(function.returns, unit.path) + if return_types and owner is not None: + key = (owner, function.name) + self._method_return_types[key] = ( + self._method_return_types.get(key, frozenset()) + | return_types + ) + + for unit in self._units: + aliases: dict[str, _TypeDomain] = { + name: return_types + for (module_name, name), return_types in module_returns.items() + if module_name == unit.module_name + } + for statement in _module_imports(unit): + imported_module = self._resolved_import_module(unit, statement) + for imported in statement.names: + return_types = module_returns.get( + (imported_module, imported.name) + ) + if return_types: + aliases[imported.asname or imported.name] = return_types + self._function_return_aliases_by_source[ + unit.path.resolve() + ] = aliases + + @staticmethod + def _runtime_class_scope( + runtime_type: type[object], + ) -> _ClassScope | None: + try: + source_path = inspect.getsourcefile(runtime_type) + except TypeError: + return None + if source_path is None: + return None + return _ClassScope( + Path(source_path).resolve(), + runtime_type.__qualname__, + ) + + def _method_return_type( + self, + receiver: _TypeReference, + method_name: str, + ) -> _TypeDomain: + if isinstance(receiver, _ClassScope): + scopes = (receiver,) + else: + scopes = tuple( + scope + for candidate in receiver.__mro__ + if (scope := self._runtime_class_scope(candidate)) is not None + ) + return frozenset().union( + *( + self._method_return_types.get( + (scope, method_name), + frozenset(), + ) + for scope in scopes + ) + ) + + def _nested_type( + self, + config_type: type[object], + field_name: str, + ) -> type[object] | None: + for candidate in config_type.__mro__: + nested_type = self._nested_fields.get((candidate, field_name)) + if nested_type is not None: + return nested_type + return None + + def _attribute_type( + self, + receiver: _TypeReference, + attribute_name: str, + ) -> _TypeDomain: + if isinstance(receiver, _ClassScope): + return self._class_attribute_types.get( + (receiver, attribute_name), + frozenset(), + ) + nested_type = self._nested_type(receiver, attribute_name) + return ( + frozenset((nested_type,)) + if nested_type is not None + else frozenset() + ) + + def _infer( + self, + expression: ast.expr | None, + environment: dict[str, _TypeDomain], + owner: _ClassScope | None, + source_path: Path, + ) -> _TypeDomain: + if expression is None: + return frozenset() + if isinstance(expression, ast.Name): + return environment.get(expression.id, frozenset()) + if isinstance(expression, ast.Call): + if isinstance(expression.func, ast.Name): + return ( + self._type_aliases_by_source[source_path.resolve()].get( + expression.func.id, + frozenset(), + ) + | self._function_return_aliases_by_source[ + source_path.resolve() + ].get( + expression.func.id, + frozenset(), + ) + ) + if isinstance(expression.func, ast.Attribute): + receivers = self._infer( + expression.func.value, + environment, + owner, + source_path, + ) + if ( + isinstance(expression.func.value, ast.Name) + and expression.func.value.id == "self" + and owner is not None + ): + receivers |= frozenset((owner,)) + return frozenset().union( + *( + self._method_return_type( + receiver, + expression.func.attr, + ) + for receiver in receivers + ) + ) + return frozenset() + if isinstance(expression, ast.Attribute): + if ( + isinstance(expression.value, ast.Name) + and expression.value.id == "self" + and owner is not None + ): + owned_type = self._class_attribute_types.get( + (owner, expression.attr) + ) + if owned_type: + return owned_type + base_types = self._infer( + expression.value, + environment, + owner, + source_path, + ) + return frozenset().union( + *( + self._attribute_type( + base_type, + expression.attr, + ) + for base_type in base_types + ) + ) + if isinstance(expression, ast.IfExp): + return self._infer( + expression.body, + environment, + owner, + source_path, + ) | self._infer( + expression.orelse, + environment, + owner, + source_path, + ) + if isinstance(expression, ast.BoolOp): + return frozenset().union( + *( + self._infer(value, environment, owner, source_path) + for value in expression.values + ) + ) + return frozenset() + + def _function_environment( + self, + function: ast.FunctionDef | ast.AsyncFunctionDef, + owner: _ClassScope | None, + source_path: Path, + ) -> dict[str, _TypeDomain]: + environment = { + argument.arg: annotation_types + for argument in ( + *function.args.posonlyargs, + *function.args.args, + *function.args.kwonlyargs, + ) + if ( + annotation_types := self._annotation_types( + argument.annotation, + source_path, + ) + ) + } + owner_types = ( + self._types_by_source_qualname.get( + (owner.source_path.resolve(), owner.qualname), + frozenset(), + ) + if owner is not None + else frozenset() + ) + if owner is not None: + environment["self"] = frozenset((owner,)) | frozenset( + config_type + for config_type in self._config_types + if any( + owner_type in config_type.__mro__ + for owner_type in owner_types + ) + ) + return environment + + def _assignment_type( + self, + assignment: ast.Assign | ast.AnnAssign, + environment: dict[str, _TypeDomain], + owner: _ClassScope | None, + source_path: Path, + ) -> tuple[tuple[ast.expr, ...], _TypeDomain]: + if isinstance(assignment, ast.AnnAssign): + return ( + (assignment.target,), + self._annotation_types(assignment.annotation, source_path) + or self._infer( + assignment.value, + environment, + owner, + source_path, + ), + ) + return ( + tuple(assignment.targets), + self._infer( + assignment.value, + environment, + owner, + source_path, + ), + ) + + def _apply_local_assignments( + self, + function: ast.FunctionDef | ast.AsyncFunctionDef, + environment: dict[str, _TypeDomain], + owner: _ClassScope | None, + source_path: Path, + ) -> None: + while True: + changed = False + for node in self._scope_nodes_by_function[function]: + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "isinstance" + and len(node.args) == 2 + and isinstance(node.args[0], ast.Name) + and isinstance(node.args[1], ast.Name) + ): + narrowed_types = self._type_aliases_by_source[ + source_path.resolve() + ].get(node.args[1].id, frozenset()) + if narrowed_types: + variable_name = node.args[0].id + previous = environment.get(variable_name, frozenset()) + combined = previous | narrowed_types + if combined != previous: + environment[variable_name] = combined + changed = True + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + targets, assigned_types = self._assignment_type( + node, + environment, + owner, + source_path, + ) + if not assigned_types: + continue + for target in targets: + if ( + isinstance(target, ast.Name) + ): + previous = environment.get(target.id, frozenset()) + combined = previous | assigned_types + if combined != previous: + environment[target.id] = combined + changed = True + if not changed: + break + + def _collect_class_attribute_types(self) -> None: + while True: + changed = False + for unit, function, owner in self._function_scopes: + if owner is None: + continue + environment = self._function_environment( + function, + owner, + unit.path, + ) + self._apply_local_assignments( + function, + environment, + owner, + unit.path, + ) + for node in self._scope_nodes_by_function[function]: + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + targets, assigned_types = self._assignment_type( + node, + environment, + owner, + unit.path, + ) + if not assigned_types: + continue + for target in targets: + if ( + isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == "self" + ): + key = (owner, target.attr) + previous = self._class_attribute_types.get( + key, + frozenset(), + ) + combined = previous | assigned_types + if combined != previous: + self._class_attribute_types[key] = combined + changed = True + if not changed: + break + + @staticmethod + def _declares_field(config_type: type[object], field_name: str) -> bool: + return any( + field_name in getattr(candidate, "__annotations__", {}) + for candidate in config_type.__mro__ + ) + + def _field_owners_for_receiver( + self, + receiver_type: type[object], + field_name: str, + ) -> frozenset[type[object]]: + """Project a base-typed receiver to every concrete config override.""" + + return frozenset( + _declaring_owner(config_type, field_name) + for config_type in self._config_types + if receiver_type in config_type.__mro__ + and self._declares_field(config_type, field_name) + ) + + @staticmethod + def _shortcut_projection_fields() -> frozenset[ + _ConsumedField + ]: + """Resolve shortcut lambdas through their exact typed lifecycle owner.""" + + binding_methods: set[str] = set() + for method_name, method in inspect.getmembers( + MainWindowShortcutLifecycle, + predicate=inspect.isfunction, + ): + try: + hints = get_type_hints( + method, + localns={"ShortcutConfig": ShortcutConfig}, + ) + except (NameError, TypeError): + continue + callable_signature = get_args( + hints.get("key_from_config") + ) + if callable_signature == ([ShortcutConfig], str): + binding_methods.add(method_name) + assert binding_methods + + main_window_tree = ast.parse(inspect.getsource(OpenHCSMainWindow)) + owns_lifecycle = any( + isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == "self" + and target.attr == "shortcut_lifecycle" + for target in node.targets + ) + and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Name) + and node.value.func.id == MainWindowShortcutLifecycle.__name__ + for node in ast.walk(main_window_tree) + ) + assert owns_lifecycle + + consumed: set[_ConsumedField] = set() + for node in ast.walk(main_window_tree): + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in binding_methods + and isinstance(node.func.value, ast.Attribute) + and isinstance(node.func.value.value, ast.Name) + and node.func.value.value.id == "self" + and node.func.value.attr == "shortcut_lifecycle" + and node.args + and isinstance(node.args[0], ast.Lambda) + and node.args[0].args.args + ): + continue + projection = node.args[0] + parameter_name = projection.args.args[0].arg + for nested in ast.walk(projection.body): + if ( + isinstance(nested, ast.Attribute) + and isinstance(nested.ctx, ast.Load) + and isinstance(nested.value, ast.Name) + and nested.value.id == parameter_name + and _TypedAttributeFlow._declares_field( + ShortcutConfig, + nested.attr, + ) + ): + consumed.add( + ( + _owner_identity( + _declaring_owner(ShortcutConfig, nested.attr) + ), + nested.attr, + ) + ) + return frozenset(consumed) + + def consumed_fields( + self, + ) -> frozenset[_ConsumedField]: + self._collect_class_attribute_types() + consumed = set(self._shortcut_projection_fields()) + for unit, function, owner in self._function_scopes: + environment = self._function_environment( + function, + owner, + unit.path, + ) + self._apply_local_assignments( + function, + environment, + owner, + unit.path, + ) + for node, shadowed_names in self._read_scope_nodes_by_function[ + function + ]: + if ( + isinstance(node, ast.Attribute) + and isinstance(node.ctx, ast.Load) + ): + read_environment = { + name: types + for name, types in environment.items() + if name not in shadowed_names + } + for base_type in self._infer( + node.value, + read_environment, + owner, + unit.path, + ): + if not isinstance(base_type, type): + continue + for field_owner in self._field_owners_for_receiver( + base_type, + node.attr, + ): + consumed.add( + ( + _owner_identity(field_owner), + node.attr, + ) + ) + return frozenset(consumed) + + +def test_every_visible_config_leaf_has_typed_production_consumer() -> None: + """Reject rendered options without a receiver-specific production read.""" + + leaves, config_types, nested_fields = _config_graph() + syntax_units = _production_syntax_units() + consumed_fields = _TypedAttributeFlow( + config_types, + nested_fields, + syntax_units, + ).consumed_fields() + missing = { + leaf.path: f"{leaf.owner.__qualname__}.{leaf.field_name}" + for leaf in leaves + if (_owner_identity(leaf.owner), leaf.field_name) not in consumed_fields + } + + assert leaves + assert not missing, "\n".join( + f"{path}: {owner_field}" + for path, owner_field in sorted(missing.items()) + ) diff --git a/tests/unit/test_configuration_mirror_deletion.py b/tests/unit/test_configuration_mirror_deletion.py index 6b70d905d..6ed18803a 100644 --- a/tests/unit/test_configuration_mirror_deletion.py +++ b/tests/unit/test_configuration_mirror_deletion.py @@ -3,6 +3,8 @@ from __future__ import annotations import ast +import re +import textwrap from pathlib import Path @@ -27,6 +29,8 @@ "external/pyqt-reactive/src/pyqt_reactive/protocols/zmq_server_protocol.py", "openhcs/pyqt_gui/widgets/shared/server_browser/server_tree_population.py", "openhcs/core/lazy_placeholder.py", + "openhcs/utils/display_config_factory.py", + "openhcs/utils/enum_factory.py", ) @@ -35,11 +39,159 @@ def _python_files(): yield from root.rglob("*.py") +def _embedded_imports(source: str) -> tuple[ast.ImportFrom, ...]: + """Parse complete import statements embedded in prose/code templates.""" + + import_statements: list[ast.ImportFrom] = [] + pattern = re.compile( + r"(?m)^[ \t]*from\s+[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*" + r"\s+import\s+(?:\([^)]*\)|[^\n]+)" + ) + for match in pattern.finditer(source): + try: + parsed = ast.parse(textwrap.dedent(match.group(0))) + except SyntaxError: + continue + if len(parsed.body) == 1 and isinstance(parsed.body[0], ast.ImportFrom): + import_statements.append(parsed.body[0]) + return tuple(import_statements) + + def test_configuration_mirror_modules_remain_deleted() -> None: for relative_path in REQUIRED_DELETED_PATHS: assert not (PROJECT_ROOT / relative_path).exists(), relative_path +def test_false_viewer_options_remain_absent_from_config_declarations() -> None: + config_path = PROJECT_ROOT / "openhcs/core/config.py" + tree = ast.parse(config_path.read_text(), filename=str(config_path)) + forbidden_fields = { + "batch_size", + "fiji_executable_path", + } + occurrences = [ + f"{config_path.relative_to(PROJECT_ROOT)}:{node.lineno}:{node.target.id}" + for node in ast.walk(tree) + if isinstance(node, ast.AnnAssign) + and isinstance(node.target, ast.Name) + and node.target.id in forbidden_fields + ] + + assert occurrences == [] + + +def test_dtype_conversion_remains_owned_only_by_arraybridge() -> None: + constants_path = PROJECT_ROOT / "openhcs/constants/constants.py" + constants_tree = ast.parse( + constants_path.read_text(), + filename=str(constants_path), + ) + forbidden_declarations = { + node.name + for node in constants_tree.body + if isinstance(node, ast.ClassDef) + and node.name in {"DtypeConversion", "LiteralDtype"} + } + forbidden_assignments = { + target.id + for node in constants_tree.body + if isinstance(node, (ast.Assign, ast.AnnAssign)) + for target in ( + node.targets if isinstance(node, ast.Assign) else (node.target,) + ) + if isinstance(target, ast.Name) + and target.id in {"DtypeConversion", "LiteralDtype"} + } + forbidden_imports: list[str] = [] + forbidden_embedded_imports: list[str] = [] + for path in _python_files(): + tree = ast.parse(path.read_text(), filename=str(path)) + for node in ast.walk(tree): + if ( + isinstance(node, ast.ImportFrom) + and node.module + in { + "openhcs.constants", + "openhcs.constants.constants", + "openhcs.core.memory", + } + and any( + alias.name in {"DtypeConversion", "LiteralDtype"} + for alias in node.names + ) + ): + forbidden_imports.append( + f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}" + ) + if ( + isinstance(node, ast.Constant) + and isinstance(node.value, str) + ): + for embedded_import in _embedded_imports(node.value): + if ( + embedded_import.module + in { + "openhcs.constants", + "openhcs.constants.constants", + "openhcs.core.memory", + } + and any( + alias.name in {"DtypeConversion", "LiteralDtype"} + for alias in embedded_import.names + ) + ): + forbidden_embedded_imports.append( + f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}" + ) + + assert forbidden_declarations == set() + assert forbidden_assignments == set() + assert forbidden_imports == [] + assert forbidden_embedded_imports == [] + + +def test_cpu_cell_counter_has_no_false_segmentation_output_toggle() -> None: + path = ( + PROJECT_ROOT + / "openhcs/processing/backends/analysis/cell_counting_cpu.py" + ) + tree = ast.parse(path.read_text(), filename=str(path)) + function = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) + and node.name == "count_cells_single_channel" + ) + parameter_names = { + argument.arg + for argument in ( + *function.args.posonlyargs, + *function.args.args, + *function.args.kwonlyargs, + ) + } + + assert "return_segmentation_mask" not in parameter_names + + +def test_experimental_analysis_is_not_a_global_pipeline_config() -> None: + config_path = PROJECT_ROOT / "openhcs/core/config.py" + tree = ast.parse(config_path.read_text(), filename=str(config_path)) + analysis_config = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) + and node.name == "ExperimentalAnalysisConfig" + ) + decorators = {ast.unparse(decorator) for decorator in analysis_config.decorator_list} + + assert not any( + decorator == "global_pipeline_config" + or decorator.startswith("global_pipeline_config(") + for decorator in decorators + ) + + def test_legacy_config_namespace_imports_do_not_recur() -> None: occurrences: list[str] = [] for path in _python_files(): diff --git a/tests/unit/test_core_config_annotation_validation.py b/tests/unit/test_core_config_annotation_validation.py new file mode 100644 index 000000000..143dce5fb --- /dev/null +++ b/tests/unit/test_core_config_annotation_validation.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import pytest + +from openhcs.core.config import ( + FijiDisplayConfig, + FijiStreamingConfig, + GlobalPipelineConfig, + LazyFijiDisplayConfig, + LazyFijiStreamingConfig, + LazyNapariDisplayConfig, + LazyNapariStreamingConfig, + NapariDisplayConfig, + NapariStreamingConfig, + PlateMetadataConfig, +) +from python_introspect import AnnotationValidationError + + +@pytest.mark.parametrize( + ("config_type", "values"), + ( + (GlobalPipelineConfig, {"num_workers": 0}), + (PlateMetadataConfig, {"z_step": 0}), + (NapariDisplayConfig, {"colormap": " "}), + (FijiDisplayConfig, {"lut": " "}), + (NapariStreamingConfig, {"port": 0}), + (FijiStreamingConfig, {"port": 65_536}), + (NapariStreamingConfig, {"host": " "}), + (LazyNapariDisplayConfig, {"colormap": " "}), + (LazyFijiDisplayConfig, {"lut": " "}), + (LazyNapariStreamingConfig, {"port": 0}), + (LazyFijiStreamingConfig, {"port": 65_536}), + (LazyNapariStreamingConfig, {"host": " "}), + ), +) +def test_config_type_annotations_are_enforced_after_construction( + config_type, + values, +) -> None: + with pytest.raises(AnnotationValidationError): + config_type(**values) diff --git a/tests/unit/test_fiji_viewer_server.py b/tests/unit/test_fiji_viewer_server.py index 11e94aa1e..c5a6fc08f 100644 --- a/tests/unit/test_fiji_viewer_server.py +++ b/tests/unit/test_fiji_viewer_server.py @@ -7,7 +7,7 @@ import tifffile from zmqruntime.streaming import StreamingVisualizerServer -from openhcs.core.config import FijiDisplayConfig, FijiLUT, FijiStreamingConfig +from openhcs.core.config import FijiDisplayConfig, FijiStreamingConfig from openhcs.runtime.fiji_viewer_server import ( FijiBatchSettlementState, FijiBatchWireParser, @@ -16,7 +16,6 @@ FijiControlMessagePlan, FijiControlRequestContext, FijiDimensionAxis, - FijiDisplayConfigWireAdapter, FijiHyperstackCoordinates, FijiImageIntensityRange, FijiImagePayload, @@ -94,19 +93,19 @@ def close(self) -> None: self.visible = False -def test_fiji_display_config_wire_adapter_rehydrates_existing_config_type() -> None: +def test_fiji_display_config_rehydrates_its_own_wire_payload() -> None: default_config = FijiDisplayConfig() - config = FijiDisplayConfigWireAdapter.from_payload( + config = FijiDisplayConfig.from_display_payload( { "component_modes": default_config.component_modes(), "component_order": list(default_config.COMPONENT_ORDER), "lut": "Fire", "auto_contrast": False, } - ).to_config() + ) assert isinstance(config, FijiDisplayConfig) - assert config.lut is FijiLUT.FIRE + assert config.lut == "Fire" assert config.auto_contrast is False assert config.component_modes() == default_config.component_modes() @@ -399,10 +398,7 @@ def test_fiji_batch_message_preserves_scoped_wire_component_layout() -> None: } ], "display_config": { - "component_modes": { - component: component_modes[component] - for component in component_order - }, + "component_modes": component_modes, "component_order": list(component_order), "lut": "Grays", "auto_contrast": True, diff --git a/tests/unit/test_function_artifact_materialization.py b/tests/unit/test_function_artifact_materialization.py index 51ae47640..6b5551033 100644 --- a/tests/unit/test_function_artifact_materialization.py +++ b/tests/unit/test_function_artifact_materialization.py @@ -3,7 +3,7 @@ import numpy as np import pytest -from polystore.napari_stream import NapariDisplayPayload, NapariStreamingBackend +from polystore.napari_stream import NapariStreamingBackend from polystore.streaming import ( StreamingBatchMessageBuilder, StreamingBatchMessageRequest, @@ -127,10 +127,10 @@ ) from openhcs.core.pipeline.function_contracts import artifact_outputs from openhcs.microscopes.imagexpress import ImageXpressFilenameParser -from openhcs.utils.display_config_factory import ViewerDisplayConfigObject +from polystore.streaming.viewer_transport import ViewerDisplayConfigABC -class StreamingConfigStub(ViewerDisplayConfigObject): +class StreamingConfigStub(ViewerDisplayConfigABC): backend = SimpleNamespace(value="napari_stream") COMPONENT_ORDER = AllComponents.ordered_names() host = "127.0.0.1" @@ -144,8 +144,11 @@ def __init__(self, port): def component_modes(self): return {component: "stack" for component in self.COMPONENT_ORDER} - def get_colormap_name(self): - return self.colormap.value + def display_payload_extra(self): + return { + "colormap": self.colormap.value, + "variable_size_handling": self.variable_size_handling.value, + } def streaming_viewer_surface(self, _context): return StreamingViewerSurface( @@ -2344,7 +2347,10 @@ def fake_materialize( for component in streaming_config.COMPONENT_ORDER if component != "channel" ) - assert NapariDisplayPayload.from_display_config(stream_request.display_config) == { + assert stream_request.display_config.component_modes() == ( + streaming_config.component_modes() + ) + assert stream_request.display_config.display_payload_extra() == { "colormap": "gray", "variable_size_handling": "pad_to_max", } diff --git a/tests/unit/test_function_outputs.py b/tests/unit/test_function_outputs.py index 7b0b8e7d7..72e423a28 100644 --- a/tests/unit/test_function_outputs.py +++ b/tests/unit/test_function_outputs.py @@ -53,7 +53,7 @@ StreamingViewerRuntimeConfig, StreamingViewerSurface, ) -from openhcs.utils.display_config_factory import ViewerDisplayConfigObject +from polystore.streaming.viewer_transport import ViewerDisplayConfigABC def test_memory_output_writer_projects_runtime_image_payload_for_non_disk_storage(): @@ -108,7 +108,7 @@ def ensure_directory(self, path, backend): return None -class StreamingConfigStub(ViewerDisplayConfigObject): +class StreamingConfigStub(ViewerDisplayConfigABC): backend = SimpleNamespace(value="napari_stream") COMPONENT_ORDER = ("well", "site", "channel", "z_index", "timepoint") host = "127.0.0.1" @@ -118,6 +118,9 @@ class StreamingConfigStub(ViewerDisplayConfigObject): def component_modes(self): return {component: "stack" for component in self.COMPONENT_ORDER} + def display_payload_extra(self): + return {} + def streaming_viewer_surface(self, context): return StreamingViewerSurface( runtime_config=StreamingViewerRuntimeConfig( diff --git a/tests/unit/test_function_patterns.py b/tests/unit/test_function_patterns.py index 68159e8bd..bf71df843 100644 --- a/tests/unit/test_function_patterns.py +++ b/tests/unit/test_function_patterns.py @@ -2,9 +2,9 @@ import numpy as np import pytest -from arraybridge.decorators import DtypeConversionConfig +from arraybridge.decorators import DtypeConversion, DtypeConversionConfig -from openhcs.constants import AllComponents, DtypeConversion +from openhcs.constants import AllComponents from openhcs.core.artifacts import ( ArtifactInputProjectionPlan, ArtifactInputPlan, diff --git a/tests/unit/test_invocation_contract_provider.py b/tests/unit/test_invocation_contract_provider.py index 7717948c2..dbaa14395 100644 --- a/tests/unit/test_invocation_contract_provider.py +++ b/tests/unit/test_invocation_contract_provider.py @@ -342,6 +342,10 @@ def test_classification_rules_remain_public_while_declaring_prior_measurements() from openhcs.processing.backends.cellprofiler import ( classify_objects_single_measurement, ) + from openhcs.processing.backends.cellprofiler.classification import ( + ClassificationBinChoice, + SingleMeasurementClassificationRule, + ) source = ArtifactSpec.input("SubtractedRed", ImageArtifactType) objects = ArtifactSpec.output("Cells", ObjectLabelsArtifactType) @@ -363,18 +367,18 @@ def test_classification_rules_remain_public_while_declaring_prior_measurements() (source, objects, size_measurements, intensity_measurements) ) rules = ( - { - "measurement_feature": "AreaShape_Area", - "bin_choice": "custom", - "custom_thresholds": "0,5,20", - "bin_names": "Tiny,Small,Large", - }, - { - "measurement_feature": "Intensity_MeanIntensity_SubtractedRed", - "bin_choice": "custom", - "custom_thresholds": "0.05", - "bin_names": "White,Red", - }, + SingleMeasurementClassificationRule( + measurement_feature="AreaShape_Area", + bin_choice=ClassificationBinChoice.CUSTOM, + custom_thresholds=(0.0, 5.0, 20.0), + bin_names=("Tiny", "Small", "Large"), + ), + SingleMeasurementClassificationRule( + measurement_feature="Intensity_MeanIntensity_SubtractedRed", + bin_choice=ClassificationBinChoice.CUSTOM, + custom_thresholds=(0.05,), + bin_names=("White", "Red"), + ), ) pattern = ( classify_objects_single_measurement, diff --git a/tests/unit/test_materialization_core.py b/tests/unit/test_materialization_core.py index 0d58ee6a1..f9784c39d 100644 --- a/tests/unit/test_materialization_core.py +++ b/tests/unit/test_materialization_core.py @@ -102,11 +102,8 @@ class _TestViewerDisplayConfig(ViewerDisplayConfigABC): def component_modes(self): return {} - def get_colormap_name(self): - return "gray" - - def get_lut_name(self): - return "Grays" + def display_payload_extra(self): + return {} class _TestViewerFilenameParser(ViewerFilenameParserABC): diff --git a/tests/unit/test_mfd_preset_specs.py b/tests/unit/test_mfd_preset_specs.py index c9e498181..342a8b7e9 100644 --- a/tests/unit/test_mfd_preset_specs.py +++ b/tests/unit/test_mfd_preset_specs.py @@ -1,15 +1,17 @@ import importlib.util from pathlib import Path +from arraybridge.decorators import DtypeConversion + from openhcs.constants.constants import VariableComponents from openhcs.constants.input_source import InputSource from openhcs.core.config import LazyDtypeConfig -from openhcs.core.memory import DtypeConversion from openhcs.processing.backends.analysis.cell_counting_cpu import ( DetectionMethod, count_cells_single_channel, ) from openhcs.processing.backends.analysis.multi_template_matching import ( + OpenCVTemplateMatchMethod, multi_template_crop_reference_channel, ) from openhcs.processing.backends.analysis.skan_axon_analysis import ( @@ -81,7 +83,7 @@ def test_crop_analyze_spec_matches_expected_step_contract(): multi_template_crop_reference_channel, { "score_threshold": 0.1, - "method": 1, + "method": OpenCVTemplateMatchMethod.SQDIFF_NORMED, "template_path": MFD_WHOLE_DEVICE_TEMPLATE_PATH, "rotate_result": False, }, @@ -107,7 +109,6 @@ def test_crop_analyze_spec_matches_expected_step_contract(): "min_cell_area": 40, "max_cell_area": 200, "enable_preprocessing": False, - "return_segmentation_mask": True, "detection_method": DetectionMethod.WATERSHED, "dtype_config": LazyDtypeConfig( default_dtype_conversion=DtypeConversion.UINT8 @@ -141,7 +142,6 @@ def test_crop_analyze_cy5_overlay_adds_channel_4_cell_count(): "min_cell_area": 100, "max_cell_area": 1000, "enable_preprocessing": False, - "return_segmentation_mask": True, "detection_method": DetectionMethod.WATERSHED, "dtype_config": LazyDtypeConfig( default_dtype_conversion=DtypeConversion.UINT8 diff --git a/tests/unit/test_multi_template_matching.py b/tests/unit/test_multi_template_matching.py new file mode 100644 index 000000000..80273d670 --- /dev/null +++ b/tests/unit/test_multi_template_matching.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import inspect +from pathlib import Path + +import numpy as np +import pytest + +from openhcs.processing.backends.analysis import multi_template_matching +from openhcs.processing.backends.analysis.multi_template_matching import ( + OpenCVTemplateMatchMethod, + multi_template_crop, + multi_template_crop_reference_channel, + multi_template_crop_subset, +) + + +@pytest.mark.parametrize( + "function", + ( + multi_template_crop, + multi_template_crop_reference_channel, + multi_template_crop_subset, + ), +) +def test_public_template_matching_routes_selected_method_to_mtm( + monkeypatch: pytest.MonkeyPatch, + function, +) -> None: + received_methods: list[int] = [] + + monkeypatch.setattr( + multi_template_matching.cv2, + "imread", + lambda *_args, **_kwargs: np.ones((2, 2), dtype=np.uint8), + ) + + def match_templates(*_args, **kwargs): + received_methods.append(kwargs["method"]) + return [] + + monkeypatch.setattr( + multi_template_matching.MTM, + "matchTemplates", + match_templates, + ) + + inspect.unwrap(function)( + np.ones((1, 4, 4), dtype=np.uint8), + Path("template.tif"), + method=OpenCVTemplateMatchMethod.SQDIFF, + crop_enabled=False, + ) + + assert received_methods == [int(OpenCVTemplateMatchMethod.SQDIFF)] diff --git a/tests/unit/test_napari_roi_manager_integration.py b/tests/unit/test_napari_roi_manager_integration.py index a373f5c43..b6a28f4a2 100644 --- a/tests/unit/test_napari_roi_manager_integration.py +++ b/tests/unit/test_napari_roi_manager_integration.py @@ -53,6 +53,8 @@ def test_roi_manager_has_no_mandatory_external_distribution() -> None: @pytest.mark.unit def test_roi_feature_json_round_trip_preserves_native_values() -> None: pytest.importorskip("napari") + from napari.layers.shapes._shapes_constants import ShapeType + import openhcs.napari_roi_manager as roi_manager from openhcs.napari_roi_manager._dataclasses import RoiData @@ -60,7 +62,7 @@ def test_roi_feature_json_round_trip_preserves_native_values() -> None: rois = RoiData( data=[np.array(((0.0, 0.0), (1.0, 1.0)))], - shape_type=["line"], + shape_type=[ShapeType.LINE], names=["axon"], features={"area": [np.float32(4.5)], "position": [np.int64(7)]}, ) @@ -167,7 +169,12 @@ def bind(_layer) -> None: assert tuple(viewer.layers) == original_layers assert manager._layer is layer assert manager._roilist.rowCount() == 2 - assert manager._roilist.get_column("name") == ["first", "second"] + from openhcs.napari_roi_manager.widgets._roi_manager import RoiTableColumn + + assert manager._roilist._roi_model.column_values(RoiTableColumn.NAME) == [ + "first", + "second", + ] manager._roilist.selectRow(1) assert layer.selected_data == {1} @@ -183,5 +190,70 @@ def bind(_layer) -> None: assert server.roi_manager_widget is original_manager assert manager._layer is second_layer - assert manager._roilist.get_column("name") == ["third"] + assert manager._roilist._roi_model.column_values(RoiTableColumn.NAME) == [ + "third" + ] viewer.close() + + +@pytest.mark.unit +def test_roi_manager_virtualizes_thousands_over_one_native_shapes_owner( + qtbot, +) -> None: + pytest.importorskip("napari") + from napari.components import ViewerModel + from qtpy import QtCore + from qtpy.QtWidgets import QHeaderView, QTableView, QTableWidget + + from openhcs.napari_roi_manager.widgets._roi_manager import ( + QRoiManager, + RoiTableColumn, + ) + + member_count = 4_097 + viewer = ViewerModel() + layer = viewer.add_shapes( + [ + np.asarray( + ((index, 0.0), (index, 1.0), (index + 1.0, 0.0)), + dtype=float, + ) + for index in range(member_count) + ], + shape_type=["polygon"] * member_count, + features={ + "name": [f"ROI-{index:04d}" for index in range(member_count)], + "area": [0.5] * member_count, + }, + name="Large native ROI set", + visible=False, + ) + manager = QRoiManager(viewer) + qtbot.addWidget(manager) + table = manager._roilist + model = table._roi_model + + assert isinstance(table, QTableView) + assert not isinstance(table, QTableWidget) + assert manager.findChildren(QTableWidget) == [] + assert ( + table.horizontalHeader().sectionResizeMode(RoiTableColumn.SHAPE_TYPE) + is QHeaderView.ResizeMode.Interactive + ) + assert model.rowCount() == member_count + last_name = model.index(member_count - 1, RoiTableColumn.NAME) + assert model.data(last_name) == f"ROI-{member_count - 1:04d}" + + feature_events = [] + layer.events.features.connect(feature_events.append) + assert model.setData( + last_name, + "renamed-last", + QtCore.Qt.ItemDataRole.EditRole, + ) + assert layer.features["name"].iat[-1] == "renamed-last" + assert len(feature_events) == 1 + + table.selectRow(member_count - 1) + assert layer.selected_data == {member_count - 1} + manager.close() diff --git a/tests/unit/test_napari_streaming_handlers.py b/tests/unit/test_napari_streaming_handlers.py index d39b12c23..a380840ec 100644 --- a/tests/unit/test_napari_streaming_handlers.py +++ b/tests/unit/test_napari_streaming_handlers.py @@ -1,14 +1,20 @@ from __future__ import annotations +from dataclasses import replace from pathlib import Path import numpy as np import pytest +from napari.layers.shapes._shapes_constants import ShapeType from polystore.streaming_constants import StreamingDataType from polystore.streaming.identity import StreamProducerIdentity from openhcs.core.artifacts import ObjectArtifactSubjectBinding +from openhcs.core.config import ( + NapariDisplayConfig, + NapariVariableSizeHandling, +) from openhcs.core.runtime_plane_projection import RuntimePlaneAxis from openhcs.core.runtime_image_values import ( ImagePayloadMetadata, @@ -40,6 +46,7 @@ NapariPendingLayerUpdate, NapariLayerUpdateAuthority, NapariLayerRouteStateStore, + NapariShapeFeatureColumns, NapariShapeLayerPayload, NapariStreamLayerAddress, NapariStreamLayerItem, @@ -137,6 +144,7 @@ def test_napari_stream_context_reconstructs_exact_source_spatial_domain(): "test image payload", ), ViewerComponentAxisSemanticsAuthority.empty(), + NapariDisplayConfig(), ) assert context.address.components == {"well": "A01", "channel": 1} @@ -874,6 +882,7 @@ def test_napari_image_display_stacks_sites_without_rebasing_payload_color_axis() ) for site in (1, 2) ], + display_config=NapariDisplayConfig(), ) ) @@ -918,6 +927,7 @@ def test_napari_image_display_materializes_declared_source_spatial_domain(): ), ) ], + display_config=NapariDisplayConfig(), ) ) @@ -2128,6 +2138,11 @@ def add_shapes(self, data, *, name, **kwargs): "metadata": {"source_spatial_shape_yx": (2, 2)}, } ] + display_semantics = ViewerComponentAxisSemanticsAuthority.empty() + + class PendingTimer: + def stop(self): + pass work = pipeline.display_layer_batch( layer_key=route_key, @@ -2136,7 +2151,12 @@ def add_shapes(self, data, *, name, **kwargs): {}, data=shapes, stream_layer_data_type=StreamingDataType.SHAPES ) ], - display_payload=ViewerComponentAxisSemanticsAuthority.empty(), + display_payload=NapariPendingLayerUpdate.from_semantics( + timer=PendingTimer(), + data_type=StreamingDataType.SHAPES, + semantics=display_semantics, + display_config=NapariDisplayConfig(), + ), component_names_metadata=ViewerComponentNameMetadata.empty(), ) assert work.advance() @@ -2164,6 +2184,7 @@ def stop(self): timer=pending_timer, data_type=StreamingDataType.IMAGE, semantics=ViewerComponentAxisSemanticsAuthority.empty(), + display_config=NapariDisplayConfig(), ), ) server.component_groups = NapariComponentGroupStore() @@ -3122,6 +3143,7 @@ def close(self): timer=PendingTimer(route_key), data_type=StreamingDataType.IMAGE, semantics=ViewerComponentAxisSemanticsAuthority.empty(), + display_config=NapariDisplayConfig(), ), ) @@ -3326,6 +3348,7 @@ def __init__(self): ), image_metadata=image_metadata, plane_component_domain=ViewerComponentValueDomainPayload(()), + display_config=NapariDisplayConfig(), ), server=server, ) @@ -3399,6 +3422,7 @@ def __init__(self): plane_axis=RuntimePlaneAxis.RUNTIME_SLICE, ), plane_component_domain=ViewerComponentValueDomainPayload(()), + display_config=NapariDisplayConfig(), ), server=server, ) @@ -3416,6 +3440,155 @@ def __init__(self): ] +def test_napari_variable_size_policy_routes_per_well_or_pads_shared_route(): + napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") + + class _FakeDisplayPipeline: + def __init__(self): + self.scheduled = [] + + def schedule_layer_update(self, layer_key, data_type, route): + self.scheduled.append((layer_key, data_type, route)) + + class _FakeServer: + def __init__(self): + self.viewer = _FakeViewer() + self.layer_route_state = NapariLayerRouteStateStore.empty() + self.display_pipeline = _FakeDisplayPipeline() + self.component_groups = NapariComponentGroupStore() + self.replace_layers = False + + semantics = ViewerComponentAxisSemanticsAuthority.from_display_config( + ViewerMappingDisplayConfigInput( + { + "component_modes": {"well": "stack"}, + "component_order": ["well"], + } + ), + _component_value_domain({"well": ["A01", "B01"]}), + ) + producer = StreamProducerIdentity.pipeline_output( + output_kind="main", + output_key="main", + projection_key="main", + step_name="Variable Size", + pipeline_position=1, + ) + + def _stream(config: NapariDisplayConfig): + server = _FakeServer() + coordinator = napari_viewer_server.NapariComponentAwareDisplayCoordinator() + for well, shape in (("A01", (4, 6)), ("B01", (7, 9))): + coordinator.display( + data=np.ones(shape, dtype=np.uint8), + stream_layer_context=napari_viewer_server.NapariStreamLayerContext( + entries=semantics.entries, + layout=semantics.layout, + producer=producer, + address=NapariStreamLayerAddress( + components={"well": well}, + path=f"/tmp/{well}.tif", + stream_layer_data_type=StreamingDataType.IMAGE, + ), + image_metadata=ImagePayloadMetadata(), + plane_component_domain=ViewerComponentValueDomainPayload(()), + display_config=config, + ), + server=server, + ) + return server + + padded_server = _stream( + replace( + NapariDisplayConfig(), + variable_size_handling=NapariVariableSizeHandling.PAD_TO_MAX, + ) + ) + assert len(padded_server.component_groups) == 1 + padded_route = padded_server.display_pipeline.scheduled[-1][2] + padded_items = ( + napari_viewer_server.NapariVariableSizeDisplayStrategy.for_enum_member( + padded_route.display_config.variable_size_handling + ).materialize_layer_items( + list( + padded_server.component_groups.items_for( + padded_route.route_key + ) + ), + route_key=padded_route.route_key, + ) + ) + assert [item.data.shape for item in padded_items] == [(7, 9), (7, 9)] + assert np.count_nonzero(padded_items[0].data[4:, :]) == 0 + assert np.count_nonzero(padded_items[0].data[:, 6:]) == 0 + + separate_server = _stream( + replace( + NapariDisplayConfig(), + variable_size_handling=NapariVariableSizeHandling.SEPARATE_LAYERS, + ) + ) + assert len(separate_server.component_groups) == 2 + assert { + route.layout.components_for_mode("slice") + for _, _, route in separate_server.display_pipeline.scheduled + } == {("well",)} + assert sorted( + item.data.shape + for route_items in separate_server.component_groups.groups.values() + for item in route_items + ) == [(4, 6), (7, 9)] + assert sorted(separate_server.layer_route_state.layer_titles.values()) == [ + "2. Variable Size well A01", + "2. Variable Size well B01", + ] + + +def test_napari_configured_colormap_reaches_native_layer_and_invalid_name_fails(): + napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") + import napari + + route_key = "configured-colormap" + server = _FakeNapariServer() + server.layer_route_state = NapariLayerRouteStateStore.empty() + server.layer_route_state.set_title(route_key, "Configured colormap") + server.viewer = _FakeViewer() + pipeline = napari_viewer_server.NapariLayerDisplayPipeline(server) + configured = replace(NapariDisplayConfig(), colormap="magma") + + napari_viewer_server.NapariImageLayerDisplayHandler().handle( + napari_viewer_server.NapariLayerDisplayRequest( + pipeline=pipeline, + presentation=_axis_presentation( + layer_key=route_key, + projected_axis_components=(), + ), + items=[_layer_item({}, data=np.ones((3, 4), dtype=np.uint16))], + display_config=configured, + ) + ) + assert server.viewer.calls[-1][3]["colormap"] == "magma" + + viewer = napari.Viewer(show=False) + try: + with pytest.raises((KeyError, ValueError)): + NapariLayerUpdateAuthority().create_or_update( + layer_kind=NapariLayerKind.IMAGE, + viewer=viewer, + layers={}, + route_key="invalid-colormap", + layer_name="Invalid colormap", + data=np.ones((3, 4), dtype=np.uint8), + layer_kwargs=NapariImageLayerPresentationPolicy.layer_kwargs( + np.ones((3, 4), dtype=np.uint8), + ImagePayloadMetadata(), + "not-a-real-napari-colormap", + ), + ) + finally: + viewer.close() + + def test_napari_layer_title_disambiguation_uses_display_step_number(): napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") producer = StreamProducerIdentity.pipeline_output( @@ -3468,6 +3641,7 @@ def test_napari_layer_route_state_store_keeps_layer_labels_and_timers_together() timer=timer, data_type=StreamingDataType.IMAGE, semantics=ViewerComponentAxisSemanticsAuthority.empty(), + display_config=NapariDisplayConfig(), ) store.set_pending_update("nuclei", pending_update) @@ -3566,6 +3740,7 @@ def get_or_create(self, **_kwargs): timer=_FakeTimer(), data_type=StreamingDataType.IMAGE, semantics=ViewerComponentAxisSemanticsAuthority.empty(), + display_config=NapariDisplayConfig(), ), ) @@ -3641,6 +3816,7 @@ def get_or_create(self, **_kwargs): timer=_FakeTimer(), data_type=StreamingDataType.IMAGE, semantics=ViewerComponentAxisSemanticsAuthority.empty(), + display_config=NapariDisplayConfig(), ) server.layer_route_state.set_pending_update(route_key, update) pipeline = napari_viewer_server.NapariLayerDisplayPipeline(server) @@ -3697,6 +3873,7 @@ def get_or_create(self, **_kwargs): timer=_FakeTimer(), data_type=StreamingDataType.IMAGE, semantics=ViewerComponentAxisSemanticsAuthority.empty(), + display_config=NapariDisplayConfig(), ) server.layer_route_state.set_pending_update(route_key, update) pipeline.execute_scheduled_layer_update(route_key, update) @@ -3724,22 +3901,16 @@ def __init__(self, **kwargs): store = NapariBatchProcessorStore( debounce_policy=NapariLayerBatchDebouncePolicy( delay_ms=123, - max_wait_ms=456, ) ) server = object() - first = store.get_or_create(layer_key="nuclei", napari_server=server, batch_size=7) - second = store.get_or_create(layer_key="nuclei", napari_server=server, batch_size=9) + first = store.get_or_create(layer_key="nuclei", napari_server=server) + second = store.get_or_create(layer_key="nuclei", napari_server=server) assert first is second assert len(created) == 1 - assert first.kwargs == { - "napari_server": server, - "batch_size": 7, - "debounce_delay_ms": 123, - "max_debounce_wait_ms": 456, - } + assert first.kwargs == {"napari_server": server} def test_napari_batch_processor_returns_handler_owned_display_work(): @@ -4072,7 +4243,7 @@ def test_napari_shape_layer_payload_builds_native_nd_rois_from_plane_metadata(): ) assert payload.ndim == 3 - assert payload.shape_types == ["polygon", "path"] + assert payload.shape_types == [ShapeType.POLYGON, ShapeType.PATH] assert np.all(payload.data[0][:, 0] == 0) assert np.all(payload.data[1][:, 0] == 1) assert np.array_equal(payload.data[1][:, 1:], [[0, 1], [1, 1], [2, 1]]) @@ -4145,7 +4316,7 @@ def test_napari_shape_layer_payload_accepts_registered_native_ellipse_kind(): axis_projection=_axis_projection([], {}), ) - assert payload.shape_types == ["ellipse"] + assert payload.shape_types == [ShapeType.ELLIPSE] assert np.array_equal( payload.data[0], np.array( @@ -4168,8 +4339,29 @@ def test_napari_shape_layer_payload_assigns_distinct_stable_label_colors(): ndim=2, ) - assert len(payload.label_color_cycle) == 3 - assert len(set(payload.label_color_cycle)) == 3 + color_projection = payload.color_projection + + assert len(color_projection.cycle) == 3 + assert len(set(color_projection.cycle)) == 3 + + +def test_napari_shape_feature_columns_fill_sparse_late_columns_in_order(): + columns = NapariShapeFeatureColumns() + + columns.append({"area": 4.0}, label=1, path="first") + columns.append({"circularity": 0.8}, label=2, path="second") + columns.append( + {"area": 9.0, "circularity": 0.6}, + label=3, + path="third", + ) + + assert columns.values == { + "area": [4.0, None, 9.0], + "label": [1, 2, 3], + "path": ["first", "second", "third"], + "circularity": [None, 0.8, 0.6], + } def test_napari_shape_layer_payload_chunks_by_member_and_vertex_limits(): @@ -4180,7 +4372,12 @@ def test_napari_shape_layer_payload_chunks_by_member_and_vertex_limits(): np.zeros((8, 2)), np.zeros((2, 2)), ], - shape_types=["polygon", "polygon", "path", "path"], + shape_types=[ + ShapeType.POLYGON, + ShapeType.POLYGON, + ShapeType.PATH, + ShapeType.PATH, + ], features={"label": [1, 1, 2, 3], "path": ["a", "b", "c", "d"]}, ndim=2, ) @@ -4189,19 +4386,17 @@ def test_napari_shape_layer_payload_chunks_by_member_and_vertex_limits(): assert [len(chunk.data) for chunk in chunks] == [2, 2] assert [chunk.shape_types for chunk in chunks] == [ - ["polygon", "polygon"], - ["path", "path"], + [ShapeType.POLYGON, ShapeType.POLYGON], + [ShapeType.PATH, ShapeType.PATH], ] assert [chunk.features["path"] for chunk in chunks] == [ ["a", "b"], ["c", "d"], ] - assert payload.label_colors[0] == payload.label_colors[1] - assert payload.label_colors[1] != payload.label_colors[2] - assert ( - payload.colors_for_labels(chunks[1].features["label"]) - == (payload.label_colors[2:]) - ) + color_projection = payload.color_projection + assert color_projection.member_colors[0] == color_projection.member_colors[1] + assert color_projection.member_colors[1] != color_projection.member_colors[2] + assert len(color_projection.member_colors[2:]) == len(chunks[1].data) def test_napari_aggregate_axis_binding_uses_declared_component_not_equal_extent(): @@ -4372,6 +4567,7 @@ def test_napari_shapes_layer_display_applies_route_global_axis_translate(): stream_layer_data_type=StreamingDataType.SHAPES, ) ], + display_config=NapariDisplayConfig(), ) ) @@ -4478,6 +4674,7 @@ def add_shapes(self, data, *, name, **kwargs): stream_layer_data_type=StreamingDataType.SHAPES, ) ], + display_config=NapariDisplayConfig(), ) ) @@ -4491,8 +4688,14 @@ def add_shapes(self, data, *, name, **kwargs): assert len(server.viewer.calls) == 1 assert [len(data) for data, _kwargs in layer.add_calls] == [2, 1] - assert layer.add_calls[0][1]["edge_color"] == work.payload.label_colors[2:4] - assert layer.add_calls[1][1]["edge_color"] == work.payload.label_colors[4:] + assert ( + layer.add_calls[0][1]["edge_color"] + == work.color_projection.member_colors[2:4] + ) + assert ( + layer.add_calls[1][1]["edge_color"] + == work.color_projection.member_colors[4:] + ) assert len(layer.data) == 5 assert layer.features["label"] == [1, 2, 1, 2, 1] assert layer.edge_color == "label" @@ -4547,6 +4750,7 @@ def test_napari_shapes_display_work_batches_thousands_with_native_features(): stream_layer_data_type=StreamingDataType.SHAPES, ) ], + display_config=NapariDisplayConfig(), ) ) @@ -4603,6 +4807,7 @@ def test_napari_points_layer_display_applies_route_global_axis_translate(): stream_layer_data_type=StreamingDataType.POINTS, ) ], + display_config=NapariDisplayConfig(), ) ) diff --git a/tests/unit/test_napari_transport_ownership.py b/tests/unit/test_napari_transport_ownership.py index bc2ec996f..5153cd377 100644 --- a/tests/unit/test_napari_transport_ownership.py +++ b/tests/unit/test_napari_transport_ownership.py @@ -11,7 +11,9 @@ from polystore.streaming.identity import StreamProducerIdentity from zmqruntime.config import TransportMode from zmqruntime.transport import get_zmq_transport_url, remove_ipc_socket +from zmqruntime.viewer_protocol import ViewerBatchDisplayPayload +from openhcs.core.config import NapariDisplayConfig from openhcs.runtime.viewer_protocol import NapariViewerServerRequest from openhcs.runtime.zmq_config import OPENHCS_ZMQ_CONFIG @@ -75,10 +77,11 @@ def test_napari_transport_rep_follows_receiver_owned_shared_memory_copy( "producer_identity": producer.to_payload(), } ], - "display_config": { - "component_modes": {"well": "stack"}, - "component_order": ["well"], - }, + "display_config": ViewerBatchDisplayPayload( + component_modes=NapariDisplayConfig().component_modes(), + component_order=NapariDisplayConfig.COMPONENT_ORDER, + extra=NapariDisplayConfig().display_payload_extra(), + ).to_wire_mapping(), "component_value_domain": {"well": ["A01"]}, "component_names_metadata": {}, } @@ -160,6 +163,7 @@ def stop(self) -> None: timer=FakeTimer(), data_type=StreamingDataType.SHAPES, semantics=ViewerComponentAxisSemanticsAuthority.empty(), + display_config=NapariDisplayConfig(), ) server.layer_route_state.set_pending_update("large-shapes", update) settlement = server.layer_route_state.begin_settlement() diff --git a/tests/unit/test_processor_method_strategies.py b/tests/unit/test_processor_method_strategies.py index 3b9c3e432..3043b0412 100644 --- a/tests/unit/test_processor_method_strategies.py +++ b/tests/unit/test_processor_method_strategies.py @@ -2,25 +2,72 @@ from __future__ import annotations +import ast +from enum import Enum +import importlib +from pathlib import Path + import numpy as np from openhcs.core.callable_contract import CallableContract from openhcs.processing.backends.lib_registry.openhcs_registry import OpenHCSRegistry from openhcs.processing.backends.lib_registry.unified_registry import ProcessingContract -from openhcs.processing.backends.processors import numpy_processor +from openhcs.processing.backends.processors import method_axes, numpy_processor +from openhcs.processing.backends.processors.numpy_processor import ( + NumpyStackProjectionMethod, +) +from openhcs.processing.backends.processors.method_axes import ( + SpatialBinMethod, +) + + +def _enum_members_for_symbol( + tree: ast.Module, + symbol_name: str, +) -> set[str]: + """Resolve one local or imported enum owner without a name-specific mirror.""" + + local_class = next( + ( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == symbol_name + ), + None, + ) + if local_class is not None: + return { + target.id + for statement in local_class.body + if isinstance(statement, ast.Assign) + for target in statement.targets + if isinstance(target, ast.Name) + } + + imported_symbol = next( + ( + (statement.module, imported.name) + for statement in tree.body + if isinstance(statement, ast.ImportFrom) + for imported in statement.names + if (imported.asname or imported.name) == symbol_name + ), + None, + ) + assert imported_symbol is not None, symbol_name + module_name, attribute_name = imported_symbol + assert module_name is not None + enum_type = getattr(importlib.import_module(module_name), attribute_name) + assert isinstance(enum_type, type) and issubclass(enum_type, Enum) + return {member.name for member in enum_type} def test_numpy_processor_method_strategies_use_distinct_inherited_registries() -> None: assert set(numpy_processor.NumpySpatialBinStrategy.__registry__) == { - "max", - "mean", - "min", - "sum", + method.value for method in SpatialBinMethod } assert set(numpy_processor.NumpyStackProjectionStrategy.__registry__) == { - "max_projection", - "mean_projection", - "min_projection", + method.value for method in NumpyStackProjectionMethod } assert ( numpy_processor.NumpySpatialBinStrategy.__registry__ @@ -31,11 +78,17 @@ def test_numpy_processor_method_strategies_use_distinct_inherited_registries() - def test_numpy_processor_method_strategies_dispatch_behavior() -> None: stack = np.arange(2 * 4 * 4, dtype=np.uint16).reshape(2, 4, 4) - assert numpy_processor.spatial_bin_2d(stack, 2, "mean").shape == (2, 2, 2) - assert numpy_processor.spatial_bin_3d(stack, 2, "max").shape == (1, 2, 2) - assert numpy_processor.create_projection(stack, "mean_projection").shape == (4, 4) + assert numpy_processor.spatial_bin_2d( + stack, 2, SpatialBinMethod.MEAN + ).shape == (2, 2, 2) + assert numpy_processor.spatial_bin_3d( + stack, 2, SpatialBinMethod.MAX + ).shape == (1, 2, 2) + assert numpy_processor.create_projection( + stack, NumpyStackProjectionMethod.MEAN + ).shape == (4, 4) np.testing.assert_array_equal( - numpy_processor.create_projection(stack, "min_projection"), + numpy_processor.create_projection(stack, NumpyStackProjectionMethod.MIN), np.min(stack, axis=0), ) assert ( @@ -46,6 +99,86 @@ def test_numpy_processor_method_strategies_dispatch_behavior() -> None: ) +def test_projection_method_types_exactly_match_registered_dispatch() -> None: + processor_root = ( + Path(__file__).parents[2] + / "openhcs/processing/backends/processors" + ) + discovered: dict[str, set[str]] = {} + + for path in processor_root.glob("*_processor.py"): + tree = ast.parse(path.read_text(), filename=str(path)) + functions = { + node.name: node + for node in tree.body + if isinstance(node, ast.FunctionDef) + } + create_projection = functions.get("create_projection") + if create_projection is None: + continue + method_parameter = next( + argument + for argument in ( + *create_projection.args.args, + *create_projection.args.kwonlyargs, + ) + if argument.arg == "method" + ) + method_type_name = ast.unparse(method_parameter.annotation) + accepted_members = _enum_members_for_symbol(tree, method_type_name) + + strategy_roots = { + node.name + for node in tree.body + if isinstance(node, ast.ClassDef) + and any( + isinstance(base, ast.Subscript) + and ast.unparse(base.value) == "EnumKeyedStrategyMixin" + and ast.unparse(base.slice) == method_type_name + for base in node.bases + ) + and any( + isinstance(statement, ast.Assign) + and any( + isinstance(target, ast.Name) + and target.id == "__enum_member_attr__" + for target in statement.targets + ) + and isinstance(statement.value, ast.Constant) + and statement.value.value == "method" + for statement in node.body + ) + } + registered_members = { + statement.value.attr + for node in tree.body + if isinstance(node, ast.ClassDef) + and any( + isinstance(base, ast.Name) and base.id in strategy_roots + for base in node.bases + ) + for statement in node.body + if isinstance(statement, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "method" + for target in statement.targets + ) + and isinstance(statement.value, ast.Attribute) + } + discovered[path.name] = registered_members + assert registered_members == accepted_members, path + + assert discovered + + +def test_processor_method_roots_use_shared_enum_strategy_directly() -> None: + method_axes_source = ( + Path(method_axes.__file__).read_text(encoding="utf-8") + ) + + assert "RegisteredProcessorMethodStrategy" not in method_axes_source + + def test_openhcs_registry_discovery_does_not_replace_public_processor_callables() -> ( None ): @@ -61,4 +194,6 @@ def test_openhcs_registry_discovery_does_not_replace_public_processor_callables( is not declared_projection ) stack = np.arange(2 * 4 * 4, dtype=np.uint16).reshape(2, 4, 4) - assert numpy_processor.create_projection(stack, "mean_projection").shape == (4, 4) + assert numpy_processor.create_projection( + stack, NumpyStackProjectionMethod.MEAN + ).shape == (4, 4) diff --git a/tests/unit/test_pycodify_formatters.py b/tests/unit/test_pycodify_formatters.py index 2e532d907..3810df4fd 100644 --- a/tests/unit/test_pycodify_formatters.py +++ b/tests/unit/test_pycodify_formatters.py @@ -4,6 +4,7 @@ import inspect import pytest +from arraybridge.decorators import DtypeConversion from pycodify import Assignment, generate_python_source import openhcs.serialization.pycodify_formatters # noqa: F401 @@ -20,7 +21,6 @@ ) from openhcs.core.callable_contract import CallableContract from openhcs.core.function_step_document import FunctionStepDocumentAuthority -from openhcs.core.memory import DtypeConversion from openhcs.core.steps.function_step import FunctionStep from openhcs.processing.backends.cellprofiler.colocalization import ( measure_colocalization_objects, diff --git a/tests/unit/test_settings_binder.py b/tests/unit/test_settings_binder.py index 5667ad52b..c45f71215 100644 --- a/tests/unit/test_settings_binder.py +++ b/tests/unit/test_settings_binder.py @@ -890,8 +890,8 @@ def test_classify_objects_alias_settings_bind_from_module_declaration(): bound = _bind_declared_module_settings(module) assert _semantic_value(bound.kwargs["measurement_feature"]) == "Math_Ratio" assert _semantic_value(bound.kwargs["bin_choice"]) == "custom" - assert _semantic_value(bound.kwargs["custom_thresholds"]) == "0.25,0.75" - assert _semantic_value(bound.kwargs["bin_names"]) == "Low,High" + assert _semantic_value(bound.kwargs["custom_thresholds"]) == (0.25, 0.75) + assert _semantic_value(bound.kwargs["bin_names"]) == ("Low", "High") def test_crop_settings_bind_from_module_declaration(): From 3ccc94ed3e0e2594151925e43ad3219ffb0aff29 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 30 Jul 2026 15:29:46 -0400 Subject: [PATCH 5/7] Prepare OpenHCS 0.7.0 release metadata Synchronize package, Codex plugin, MCP bundle, and registry metadata for the typed-configuration and viewer-performance release. Document the removed inert options, owner-derived MCP diagnostics, ObjectState form-construction improvements, and virtualized ROI table benchmarks. --- CHANGELOG.md | 52 ++++++++++++++++++- openhcs/__init__.py | 2 +- .../codex/openhcs/.codex-plugin/plugin.json | 2 +- packaging/codex/openhcs/.mcp.json | 2 +- packaging/mcpb/openhcs/manifest.json | 2 +- packaging/mcpb/openhcs/pyproject.toml | 4 +- server.json | 6 +-- 7 files changed, 60 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34884a45e..91cdc1c28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `requests>=2.31.0` dependency for HTTP communication - Comprehensive test suite for LLM service and chat panel +## [0.7.0] - 2026-07-30 + +### Changed + +- Closed configuration and registered-function choices now use their nominal + enum types throughout the UI, schema, and runtime. CellProfiler text + conversion is confined to its import and source-setting boundaries. +- Napari and Fiji display configuration is declared by concrete typed + dataclasses that also own viewer wire projection. Redundant display payload + adapters and generated field-description factories were removed. +- Inert or misleading public options were removed instead of retaining + compatibility shims, including unused visualization dtype, streaming batch + size, Fiji executable path, custom aggregation, and singleton database + choices. +- ObjectState now owns direct-child topology and structural reindexing across + updates, checkpoints, and time travel. Synthesized lazy and injected config + classes preserve their authoritative declaration documentation. +- PolyStore viewer backends now consume the typed display contract directly + rather than reconstructing backend-specific payload mirrors. + +### Added + +- MCP architecture-symbol errors now explain that the symbol namespace is + curated, provide live near matches, and direct clients to architecture topic + discovery. +- MCP config-patch errors now report unknown nested fields with suggestions + derived from the reflected dataclass authoring schema. +- Generic annotation validation now rejects invalid worker counts, viewer + ports, blank hosts and registry names, and non-positive Z spacing at the + owning configuration boundary. + +### Performance + +- Configuration form construction now indexes ObjectState topology once and + reuses immutable construction metadata. In the representative 148-field + benchmark, median declared-field completion fell from 400.57 ms to + 330.20 ms. +- The first-party Napari ROI manager now uses a virtual table over the native + Shapes layer instead of rebuilding eager table widgets. Binding 4,097 ROIs + fell from 490 ms to 1.07 ms in the focused benchmark, while preserving native + geometry, features, colors, and selection ownership. + +### Fixed + +- Napari streaming now applies configured colormap and component display + semantics without duplicating ROI or layer state. +- Dynamic configuration classes retain docstrings and validation metadata, so + UI and MCP help describe the actual declarations rather than generated + placeholders. + ## [0.4.0] - 2025-11-05 ### Added @@ -131,4 +181,4 @@ See git history for changes in versions 0.3.14 and earlier. [0.4.0]: https://github.com/trissim/openhcs/compare/v0.3.15...v0.4.0 [0.3.15]: https://github.com/trissim/openhcs/releases/tag/v0.3.15 - +[0.7.0]: https://github.com/OpenHCSDev/OpenHCS/compare/v0.6.17...v0.7.0 diff --git a/openhcs/__init__.py b/openhcs/__init__.py index 9365f730e..f84d48617 100644 --- a/openhcs/__init__.py +++ b/openhcs/__init__.py @@ -14,7 +14,7 @@ from openhcs._source_dependencies import ensure_source_checkout_external_paths -__version__ = "0.6.17" +__version__ = "0.7.0" # Configure polystore defaults for OpenHCS integration os.environ.setdefault("POLYSTORE_METADATA_FILENAME", "openhcs_metadata.json") diff --git a/packaging/codex/openhcs/.codex-plugin/plugin.json b/packaging/codex/openhcs/.codex-plugin/plugin.json index d10497752..099e2fd1d 100644 --- a/packaging/codex/openhcs/.codex-plugin/plugin.json +++ b/packaging/codex/openhcs/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "openhcs", - "version": "0.6.17", + "version": "0.7.0", "description": "Inspect, author, validate, and run OpenHCS microscopy workflows through the local OpenHCS MCP server.", "author": { "name": "OpenHCSDev", diff --git a/packaging/codex/openhcs/.mcp.json b/packaging/codex/openhcs/.mcp.json index bb6066446..9f4a002d0 100644 --- a/packaging/codex/openhcs/.mcp.json +++ b/packaging/codex/openhcs/.mcp.json @@ -4,7 +4,7 @@ "command": "uvx", "args": [ "--from", - "openhcs[gui,mcp,viz]==0.6.17", + "openhcs[gui,mcp,viz]==0.7.0", "openhcs-mcp" ] } diff --git a/packaging/mcpb/openhcs/manifest.json b/packaging/mcpb/openhcs/manifest.json index 492c9bb8b..c6a64e684 100644 --- a/packaging/mcpb/openhcs/manifest.json +++ b/packaging/mcpb/openhcs/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.4", "name": "openhcs", "display_name": "OpenHCS", - "version": "0.6.17", + "version": "0.7.0", "description": "Operate local OpenHCS microscopy workflows through MCP.", "long_description": "Installs a pinned OpenHCS environment with the MCP server, PyQt user interface, and Napari/Fiji viewer runtimes. The MCP process runs over stdio and attaches to an authenticated, separately running OpenHCS UI when GUI tools are requested.", "author": { diff --git a/packaging/mcpb/openhcs/pyproject.toml b/packaging/mcpb/openhcs/pyproject.toml index f7737fb1e..5559aabbd 100644 --- a/packaging/mcpb/openhcs/pyproject.toml +++ b/packaging/mcpb/openhcs/pyproject.toml @@ -1,8 +1,8 @@ [project] name = "openhcs-mcpb-runtime" -version = "0.6.17" +version = "0.7.0" description = "Managed runtime for the OpenHCS MCP Bundle" requires-python = ">=3.11,<3.14" dependencies = [ - "openhcs[gui,mcp,viz]==0.6.17", + "openhcs[gui,mcp,viz]==0.7.0", ] diff --git a/server.json b/server.json index a6c3e6b01..7fa6dd45e 100644 --- a/server.json +++ b/server.json @@ -8,19 +8,19 @@ "url": "https://github.com/OpenHCSDev/OpenHCS", "source": "github" }, - "version": "0.6.17", + "version": "0.7.0", "packages": [ { "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "openhcs", - "version": "0.6.17", + "version": "0.7.0", "runtimeHint": "uvx", "runtimeArguments": [ { "type": "named", "name": "--with", - "value": "openhcs[gui,mcp,viz]==0.6.17", + "value": "openhcs[gui,mcp,viz]==0.7.0", "description": "Install the matching OpenHCS GUI, MCP, Napari, and Fiji dependencies." } ], From ec175020b02f97526957b93986453f9082114b4e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 30 Jul 2026 15:50:26 -0400 Subject: [PATCH 6/7] Preserve nominal choices through CellProfiler execution --- CHANGELOG.md | 3 + external/ObjectState | 2 +- .../backends/cellprofiler/area_occupied.py | 2 +- .../backends/cellprofiler/illumination.py | 6 +- .../backends/cellprofiler/outlines.py | 2 +- pyproject.toml | 2 +- tests/unit/test_settings_binder.py | 63 +++++++++++++++++++ 7 files changed, 72 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91cdc1c28..86d4cf544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dynamic configuration classes retain docstrings and validation metadata, so UI and MCP help describe the actual declarations rather than generated placeholders. +- Raw ObjectState reconstruction now preserves registered lazy runtime types, + preventing unresolved inherited viewer settings from being validated as + concrete values during pipeline compilation. ## [0.4.0] - 2025-11-05 diff --git a/external/ObjectState b/external/ObjectState index 4e0665ffb..1b433ed2f 160000 --- a/external/ObjectState +++ b/external/ObjectState @@ -1 +1 @@ -Subproject commit 4e0665ffbd15cf247bfc07979484f3474e520858 +Subproject commit 1b433ed2f191e7497c202670d194fc774573c1aa diff --git a/openhcs/processing/backends/cellprofiler/area_occupied.py b/openhcs/processing/backends/cellprofiler/area_occupied.py index 46441065a..82a541ca4 100644 --- a/openhcs/processing/backends/cellprofiler/area_occupied.py +++ b/openhcs/processing/backends/cellprofiler/area_occupied.py @@ -349,7 +349,7 @@ def postprocess_bound_settings( rows = cls.measurement_rows(module) kwargs: dict[str, object] = { cls.operand_choices_binding.require_parameter_name(): tuple( - row.operand.value for row in rows + row.operand for row in rows ), } for binding, operand in ( diff --git a/openhcs/processing/backends/cellprofiler/illumination.py b/openhcs/processing/backends/cellprofiler/illumination.py index 752925e41..6470a2dc0 100644 --- a/openhcs/processing/backends/cellprofiler/illumination.py +++ b/openhcs/processing/backends/cellprofiler/illumination.py @@ -339,10 +339,8 @@ def postprocess_bound_settings( method_values = setting_values(module, cls.method_setting) if len(method_values) > 1: repeated_kwargs["method"] = tuple( - ( - coerce_cellprofiler_enum(IlluminationCorrectionMethod, value).value - for value in method_values - ) + coerce_cellprofiler_enum(IlluminationCorrectionMethod, value) + for value in method_values ) repeated_kwargs.update( cls._repeated_bool_setting( diff --git a/openhcs/processing/backends/cellprofiler/outlines.py b/openhcs/processing/backends/cellprofiler/outlines.py index 50e304e36..c0f4d0eea 100644 --- a/openhcs/processing/backends/cellprofiler/outlines.py +++ b/openhcs/processing/backends/cellprofiler/outlines.py @@ -264,7 +264,7 @@ def postprocess_bound_settings( module, cls.max_type_binding.setting_name ), cls.source_kind_binding.require_parameter_name(): tuple( - row.source_kind.value for row in rows + row.source_kind for row in rows ), cls.color_binding.require_parameter_name(): tuple( row.color for row in rows diff --git a/pyproject.toml b/pyproject.toml index 1a8687b59..b26dcaf3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dependencies = [ # External dependencies (PyPI versions for production builds) "zmqruntime>=0.1.19", "pycodify>=0.1.3", - "objectstate>=1.0.21", + "objectstate>=1.0.22", "python-introspect>=0.1.7", "metaclass-registry>=0.1.6", "arraybridge>=0.2.11", diff --git a/tests/unit/test_settings_binder.py b/tests/unit/test_settings_binder.py index c45f71215..8ae315a40 100644 --- a/tests/unit/test_settings_binder.py +++ b/tests/unit/test_settings_binder.py @@ -1300,6 +1300,10 @@ def test_correct_illumination_module_class_binds_scope_and_slice_dispatch(): def test_correct_illumination_apply_module_class_preserves_repeated_pairs(): + from openhcs.processing.backends.cellprofiler.illumination import ( + IlluminationCorrectionMethod, + ) + module = ModuleBlock( name="CorrectIlluminationApply", module_num=6, @@ -1315,6 +1319,10 @@ def test_correct_illumination_apply_module_class_preserves_repeated_pairs(): ], ) bound = _bind_declared_module_settings(module) + assert bound.kwargs["method"] == ( + IlluminationCorrectionMethod.DIVIDE, + IlluminationCorrectionMethod.SUBTRACT, + ) assert _semantic_kwargs(bound.kwargs) == { "method": ("divide", "subtract"), "truncate_low": (False, True), @@ -2169,6 +2177,61 @@ def test_module_only_helper_bindings_use_declared_module_path(module, expected_k assert not bound.unmapped_kwargs +def test_repeated_choice_bindings_retain_nominal_enum_values(): + from openhcs.processing.backends.cellprofiler.area_occupied import OperandChoice + from openhcs.processing.backends.cellprofiler.outlines import OutlineSourceKind + + area_bound = _bind_declared_module_settings( + ModuleBlock( + name="MeasureImageAreaOccupiedBinary", + module_num=31, + setting_records=[ + ModuleSetting( + "Measure the area occupied in a binary image, or in objects?", + "Binary image", + ), + ModuleSetting("Select a binary image to measure", "MaskDNA"), + ModuleSetting("Select objects to measure", "None"), + ModuleSetting( + "Measure the area occupied in a binary image, or in objects?", + "Objects", + ), + ModuleSetting("Select a binary image to measure", "None"), + ModuleSetting("Select objects to measure", "Cells"), + ], + ) + ) + outline_bound = _bind_declared_module_settings( + ModuleBlock( + name="OverlayOutlines", + module_num=33, + setting_records=[ + ModuleSetting("Display outlines on a blank image?", "No"), + ModuleSetting("Select image on which to display outlines", "DNA"), + ModuleSetting("Name the output image", "OutlinedDNA"), + ModuleSetting("Outline display mode", "Color"), + ModuleSetting( + "Select method to determine brightness of outlines", + "Max of image", + ), + ModuleSetting("How to outline", "Thick"), + ModuleSetting("Select outlines to display", "None"), + ModuleSetting("Select objects to display", "Nuclei"), + ModuleSetting("Load outlines from an image or objects?", "Objects"), + ModuleSetting("Select outline color", "Green"), + ], + ) + ) + + assert area_bound.kwargs["operand_choices"] == ( + OperandChoice.BINARY_IMAGE, + OperandChoice.OBJECTS, + ) + assert outline_bound.kwargs["outline_source_kinds"] == ( + OutlineSourceKind.OBJECTS, + ) + + def test_filter_objects_requires_canonical_additional_object_count_row(): module = ModuleBlock( name="FilterObjects", From eae800839d9b9f14ba040a3335fef1bf90c3427f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 30 Jul 2026 16:17:44 -0400 Subject: [PATCH 7/7] Preserve nominal pipeline configuration callers --- CHANGELOG.md | 4 +++ .../processing/backends/cellprofiler/grid.py | 29 ++++++++++--------- .../loose_operaphenix_neurite_outgrowth.py | 2 +- .../test_cellprofiler_generated_pipeline.py | 2 +- .../test_count_cells_simple_dual_channel.py | 3 +- tests/integration/test_main.py | 7 +++-- .../test_neurite_outgrowth_synthetic_plate.py | 3 +- .../pyqt_gui/test_plate_manager_widget.py | 5 ++-- .../pyqt_gui/test_source_bindings_editor.py | 2 +- tests/unit/test_compilation_session.py | 8 ++--- .../test_loose_operaphenix_neurite_example.py | 4 ++- tests/unit/test_plane_store_sources.py | 22 +++++++++++--- tests/unit/test_settings_binder.py | 8 +++-- tests/unit/test_source_bindings.py | 5 ++-- 14 files changed, 67 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d4cf544..07fc13f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Raw ObjectState reconstruction now preserves registered lazy runtime types, preventing unresolved inherited viewer settings from being validated as concrete values during pipeline compilation. +- The portable installed neurite demo now supplies typed percentile settings, + and wheel integration fixtures preserve nested lazy-config identity. +- CellProfiler grid imports now retain their nominal shape and diameter choices + through artifact-contract planning. ## [0.4.0] - 2025-11-05 diff --git a/openhcs/processing/backends/cellprofiler/grid.py b/openhcs/processing/backends/cellprofiler/grid.py index 4564b5987..803b800e7 100644 --- a/openhcs/processing/backends/cellprofiler/grid.py +++ b/openhcs/processing/backends/cellprofiler/grid.py @@ -73,7 +73,6 @@ from openhcs.interop.cellprofiler.settings_binder import ( SettingsBinder, SettingToKeywordBinding, - coerce_cellprofiler_enum, parse_cellprofiler_bool, parse_cellprofiler_int, ) @@ -297,24 +296,26 @@ def _grid_cycle_scope(value: str) -> DefineGridCycleScope: ) -def _shape_choice(value: str) -> str: - return FragmentMatchedLiteral( - value=value, - fragments_to_literal={ - ("rectangle",): "rectangle_forced_location", - ("circle", "forced"): "circle_forced_location", - ("circle", "natural"): "circle_natural_location", - ("natural",): "natural_shape_and_location", - }, - ).literal +def _shape_choice(value: str) -> ShapeChoice: + return ShapeChoice( + FragmentMatchedLiteral( + value=value, + fragments_to_literal={ + ("rectangle",): ShapeChoice.RECTANGLE.value, + ("circle", "forced"): ShapeChoice.CIRCLE_FORCED.value, + ("circle", "natural"): ShapeChoice.CIRCLE_NATURAL.value, + ("natural",): ShapeChoice.NATURAL.value, + }, + ).literal + ) -def _diameter_choice(value: str) -> str: +def _diameter_choice(value: str) -> DiameterChoice: normalized = value.strip().lower() if "automatic" in normalized or normalized in {"yes", "true"}: - return "automatic" + return DiameterChoice.AUTOMATIC if "manual" in normalized or normalized in {"no", "false"}: - return "manual" + return DiameterChoice.MANUAL raise ValueError(f"Unsupported grid diameter choice: {value!r}.") diff --git a/openhcs/processing/presets/pipelines/loose_operaphenix_neurite_outgrowth.py b/openhcs/processing/presets/pipelines/loose_operaphenix_neurite_outgrowth.py index fa8ddeae1..14afb03b0 100644 --- a/openhcs/processing/presets/pipelines/loose_operaphenix_neurite_outgrowth.py +++ b/openhcs/processing/presets/pipelines/loose_operaphenix_neurite_outgrowth.py @@ -231,7 +231,7 @@ def build_loose_operaphenix_neurite_pipeline( measure_image_intensity, { "calculate_percentiles": True, - "percentiles": "10,50,90", + "percentiles": (10, 50, 90), }, ), processing_config=LazyProcessingConfig( diff --git a/tests/integration/test_cellprofiler_generated_pipeline.py b/tests/integration/test_cellprofiler_generated_pipeline.py index 80c80c8a6..1be32f3d8 100644 --- a/tests/integration/test_cellprofiler_generated_pipeline.py +++ b/tests/integration/test_cellprofiler_generated_pipeline.py @@ -175,7 +175,7 @@ def _execute_imported_cppipe_via_zmq( materialization_backend=MaterializationBackend.DISK, ), well_filter_config=( - None + LazyWellFilterConfig() if well_filter is None else LazyWellFilterConfig( well_filter=( diff --git a/tests/integration/test_count_cells_simple_dual_channel.py b/tests/integration/test_count_cells_simple_dual_channel.py index ef0fa56c2..4b06067a6 100644 --- a/tests/integration/test_count_cells_simple_dual_channel.py +++ b/tests/integration/test_count_cells_simple_dual_channel.py @@ -27,6 +27,7 @@ GlobalPipelineConfig, LazyPathPlanningConfig, LazyProcessingConfig, + LazyVFSConfig, MaterializationBackend, PathPlanningConfig, PipelineConfig, @@ -125,7 +126,7 @@ def test_dual_channel_count_runs_on_synthetic_plate_with_channel_stack( ) pipeline_config = PipelineConfig( path_planning_config=LazyPathPlanningConfig(output_dir_suffix=suffix), - vfs_config=vfs_config, + vfs_config=LazyVFSConfig.from_config(vfs_config), ) w1 = MetaXpressWavelengthSettings( diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index b472cd5f7..8a213a362 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -43,6 +43,7 @@ LazyDtypeConfig, LazyProcessingConfig, LazySequentialProcessingConfig, + LazyVFSConfig, ) from openhcs.core.orchestrator.gpu_scheduler import setup_global_gpu_registry from openhcs.core.orchestrator.orchestrator import PipelineOrchestrator @@ -525,7 +526,7 @@ def _initialize_orchestrator( sequential_processing_config=LazySequentialProcessingConfig( sequential_components=sequential_components if sequential_components else [] ), - vfs_config=VFSConfig(materialization_backend=materialization_backend), + vfs_config=LazyVFSConfig(materialization_backend=materialization_backend), ) # Convert plate_dir to Path - for OMERO, format as /omero/plate_{id} @@ -795,7 +796,9 @@ def _execute_pipeline_with_mode( if sequential_components else [] ), - vfs_config=VFSConfig(materialization_backend=materialization_backend), + vfs_config=LazyVFSConfig( + materialization_backend=materialization_backend + ), ) return _execute_pipeline_zmq( diff --git a/tests/integration/test_neurite_outgrowth_synthetic_plate.py b/tests/integration/test_neurite_outgrowth_synthetic_plate.py index 758f1415a..236369b07 100644 --- a/tests/integration/test_neurite_outgrowth_synthetic_plate.py +++ b/tests/integration/test_neurite_outgrowth_synthetic_plate.py @@ -21,6 +21,7 @@ GlobalPipelineConfig, LazyPathPlanningConfig, LazyProcessingConfig, + LazyVFSConfig, MaterializationBackend, PathPlanningConfig, PipelineConfig, @@ -92,7 +93,7 @@ def test_neurite_outgrowth_runs_on_synthetic_plate_as_2d_channel_stack( ) pipeline_config = PipelineConfig( path_planning_config=LazyPathPlanningConfig(output_dir_suffix=suffix), - vfs_config=vfs_config, + vfs_config=LazyVFSConfig.from_config(vfs_config), ) step = FunctionStep( name="MetaXpress-style neurite outgrowth", diff --git a/tests/unit/pyqt_gui/test_plate_manager_widget.py b/tests/unit/pyqt_gui/test_plate_manager_widget.py index b084ba34d..7a02369a1 100644 --- a/tests/unit/pyqt_gui/test_plate_manager_widget.py +++ b/tests/unit/pyqt_gui/test_plate_manager_widget.py @@ -32,7 +32,6 @@ NamedSourceBinding, SourceBindingMatchMethod, SourceBindingMatchPlan, - SourceBindingsConfig, SourceSelector, ) from openhcs.core.steps.function_step import FunctionStep @@ -444,7 +443,7 @@ def test_cellprofiler_import_applies_pipeline_config_to_orchestrator_state( plate_scope = str(plate_root) match_plan = SourceBindingMatchPlan(method=SourceBindingMatchMethod.ORDER) pipeline_config = PipelineConfig( - source_bindings_config=SourceBindingsConfig(match_plan=match_plan), + source_bindings_config=LazySourceBindingsConfig(match_plan=match_plan), ) step = FunctionStep( func=lambda image: image, @@ -918,7 +917,7 @@ def test_plate_selection_does_not_reseed_dirty_cellprofiler_config( plate_root.mkdir() plate_scope = str(plate_root) imported_config = PipelineConfig( - source_bindings_config=SourceBindingsConfig( + source_bindings_config=LazySourceBindingsConfig( match_plan=SourceBindingMatchPlan( method=SourceBindingMatchMethod.ORDER, ), diff --git a/tests/unit/pyqt_gui/test_source_bindings_editor.py b/tests/unit/pyqt_gui/test_source_bindings_editor.py index 1a7e46149..731078f19 100644 --- a/tests/unit/pyqt_gui/test_source_bindings_editor.py +++ b/tests/unit/pyqt_gui/test_source_bindings_editor.py @@ -2629,7 +2629,7 @@ def test_step_metadata_rule_cells_show_inherited_and_local_edit_markers() -> Non ) state = ObjectState( PipelineConfig( - source_bindings_config=SourceBindingsConfig( + source_bindings_config=LazySourceBindingsConfig( metadata_rules=(inherited_rule,), ) ), diff --git a/tests/unit/test_compilation_session.py b/tests/unit/test_compilation_session.py index a80ee97cc..70fe2b7a5 100644 --- a/tests/unit/test_compilation_session.py +++ b/tests/unit/test_compilation_session.py @@ -14,6 +14,7 @@ from openhcs.core.config import ( GlobalPipelineConfig, LazyNapariStreamingConfig, + LazySourceBindingsConfig, LazyStepSourceBindingsConfig, PipelineConfig, ProcessingConfig, @@ -41,7 +42,6 @@ NamedSourceBinding, SourceBindingMatchMethod, SourceBindingMatchPlan, - SourceBindingsConfig, SourceSelector, StepSourceBindingsConfig, ) @@ -266,7 +266,7 @@ def test_path_planner_source_binding_plan_comes_from_objectstate_snapshot(): ensure_global_config_context(GlobalPipelineConfig, GlobalPipelineConfig()) pipeline_state = ObjectState( PipelineConfig( - source_bindings_config=SourceBindingsConfig( + source_bindings_config=LazySourceBindingsConfig( metadata_rules=(metadata_rule,), match_plan=match_plan, ), @@ -567,7 +567,7 @@ def test_path_planner_preserves_metaxpress_primary_source_order() -> None: input_source=InputSource.PIPELINE_START, ) pipeline_config = PipelineConfig( - source_bindings_config=SourceBindingsConfig(bindings=pipeline_bindings) + source_bindings_config=LazySourceBindingsConfig(bindings=pipeline_bindings) ) session = CompilationSession.from_context( context=_context(), @@ -677,7 +677,7 @@ def test_compiler_pipeline_scope_prevents_cross_pipeline_source_binding_inherita first_orchestrator = SimpleNamespace( plate_path=plate_path, pipeline_config=PipelineConfig( - source_bindings_config=SourceBindingsConfig(bindings=(binding,)), + source_bindings_config=LazySourceBindingsConfig(bindings=(binding,)), ), ) second_orchestrator = SimpleNamespace( diff --git a/tests/unit/test_loose_operaphenix_neurite_example.py b/tests/unit/test_loose_operaphenix_neurite_example.py index f36950c61..8e2ee47d3 100644 --- a/tests/unit/test_loose_operaphenix_neurite_example.py +++ b/tests/unit/test_loose_operaphenix_neurite_example.py @@ -13,7 +13,7 @@ from objectstate.lazy_factory import ensure_global_config_context from openhcs.constants.constants import AllComponents, Microscope from openhcs.core.config import GlobalPipelineConfig -from openhcs.core.function_patterns import get_core_callable +from openhcs.core.function_patterns import get_core_callable, normalize_function_pattern from openhcs.core.orchestrator.orchestrator import PipelineOrchestrator from openhcs.core.progress import set_progress_queue from openhcs.processing.backends.cellprofiler.secondary import ( @@ -110,6 +110,8 @@ def test_neurite_example_declares_bounded_sources_and_final_user_result( ) assert get_core_callable(steps[6].func) is identify_secondary_objects assert get_core_callable(steps[7].func) is export_to_spreadsheet + signal_invocation = next(normalize_function_pattern(steps[1].func).iter_items()) + assert dict(signal_invocation.kwargs)["percentiles"] == (10, 50, 90) streamed_steps = [ step for step in steps if step.napari_streaming_config.enabled is True diff --git a/tests/unit/test_plane_store_sources.py b/tests/unit/test_plane_store_sources.py index 84851bc4d..6291d0660 100644 --- a/tests/unit/test_plane_store_sources.py +++ b/tests/unit/test_plane_store_sources.py @@ -6,7 +6,11 @@ from objectstate.lazy_factory import ensure_global_config_context from openhcs.constants.constants import AllComponents, Backend, OrchestratorState -from openhcs.core.config import GlobalPipelineConfig, PipelineConfig +from openhcs.core.config import ( + GlobalPipelineConfig, + LazySourceBindingsConfig, + PipelineConfig, +) from openhcs.core.image_file_serialization import ImageFileFormat from openhcs.core.orchestrator.orchestrator import PipelineOrchestrator from openhcs.core.steps.function_io import ( @@ -186,7 +190,9 @@ def test_saved_source_bindings_rebuild_canonical_store_projection( orchestrator = PipelineOrchestrator( plate_path=tmp_path, pipeline_config=PipelineConfig( - source_bindings_config=initial_bindings, + source_bindings_config=LazySourceBindingsConfig.from_config( + initial_bindings + ), ), ).initialize() initial_handler = orchestrator.microscope_handler @@ -208,7 +214,11 @@ def test_saved_source_bindings_rebuild_canonical_store_projection( ) ) orchestrator.apply_pipeline_config( - PipelineConfig(source_bindings_config=edited_bindings) + PipelineConfig( + source_bindings_config=LazySourceBindingsConfig.from_config( + edited_bindings + ) + ) ) assert orchestrator.state is OrchestratorState.CREATED @@ -279,7 +289,11 @@ def test_mixed_plane_stores_materialize_and_reopen_with_source_identity( ) orchestrator = PipelineOrchestrator( plate_path=tmp_path, - pipeline_config=PipelineConfig(source_bindings_config=source_bindings), + pipeline_config=PipelineConfig( + source_bindings_config=LazySourceBindingsConfig.from_config( + source_bindings + ) + ), ).initialize() context = orchestrator.create_context(axis_id="A01") projection = orchestrator.source_workspace_projection() diff --git a/tests/unit/test_settings_binder.py b/tests/unit/test_settings_binder.py index 8ae315a40..e92f6bf7f 100644 --- a/tests/unit/test_settings_binder.py +++ b/tests/unit/test_settings_binder.py @@ -23,6 +23,8 @@ from openhcs.processing.backends.cellprofiler.grid import ( DefineGridCycleScope, DefineGridManualModule, + DiameterChoice, + ShapeChoice, ) from openhcs.interop.cellprofiler.setting_names import setting_names from openhcs.interop.cellprofiler.module_declarations import ( @@ -974,9 +976,11 @@ def test_identify_objects_in_grid_settings_bind_from_module_declaration(): ], ) bound = _bind_declared_module_settings(module) + assert bound.kwargs["shape_choice"] is ShapeChoice.NATURAL + assert bound.kwargs["diameter_choice"] is DiameterChoice.AUTOMATIC assert _semantic_behavior_kwargs(module, bound.kwargs) == { - "shape_choice": "natural_shape_and_location", - "diameter_choice": "automatic", + "shape_choice": ShapeChoice.NATURAL.value, + "diameter_choice": DiameterChoice.AUTOMATIC.value, "circle_diameter": 20, } diff --git a/tests/unit/test_source_bindings.py b/tests/unit/test_source_bindings.py index df0b95b94..acc03f080 100644 --- a/tests/unit/test_source_bindings.py +++ b/tests/unit/test_source_bindings.py @@ -27,6 +27,7 @@ ) from openhcs.core.config import ( GlobalPipelineConfig, + LazySourceBindingsConfig, LazyStepSourceBindingsConfig, PipelineConfig, ) @@ -329,7 +330,7 @@ def test_step_source_bindings_inherit_plate_source_bindings_for_snapshot(): ensure_global_config_context(GlobalPipelineConfig, GlobalPipelineConfig()) pipeline_state = ObjectState( PipelineConfig( - source_bindings_config=SourceBindingsConfig( + source_bindings_config=LazySourceBindingsConfig( bindings=(binding,), source_stack_components=(AllComponents.Z_INDEX,), grouping_metadata_fields=("Plate",), @@ -425,7 +426,7 @@ def test_enabled_step_source_bindings_compile_inherited_bindings(): ensure_global_config_context(GlobalPipelineConfig, GlobalPipelineConfig()) pipeline_state = ObjectState( PipelineConfig( - source_bindings_config=SourceBindingsConfig(bindings=(binding,)), + source_bindings_config=LazySourceBindingsConfig(bindings=(binding,)), ), scope_id="plate", )