From cc74dfba025c66cf55ca0d2c34b1f8aaa057c931 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 15:41:41 -0400 Subject: [PATCH 1/9] CP1 receipt: plugin usage ranking, today's behaviour, target Co-Authored-By: Claude Opus 5.5 --- .../CP1-cellprofiler-plugins.md | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/refactor/consolidation-20261009/CP1-cellprofiler-plugins.md diff --git a/docs/refactor/consolidation-20261009/CP1-cellprofiler-plugins.md b/docs/refactor/consolidation-20261009/CP1-cellprofiler-plugins.md new file mode 100644 index 000000000..0c2beb1b6 --- /dev/null +++ b/docs/refactor/consolidation-20261009/CP1-cellprofiler-plugins.md @@ -0,0 +1,168 @@ +# CP1: CellProfiler plugin modules + +Branch `feature/CP1-cellprofiler-plugins`. Measured 2026-10-12 at `5235c1a95`. + +Two goals from the owner: converting a `.cppipe` that uses plugin modules works; plugin +usage is the demand list for what OpenHCS supports natively. + +## What is wrong + +- **One unknown module discards the pipeline.** `import_cellprofiler_pipeline` calls + `CellProfilerModule.require_module(name)` for every block. For a name with no + declaration it raises `KeyError("No CellProfiler module declaration is registered for + 'X'.")` from `source_bindings_for_modules` (`module_declarations.py:440`), before any + step exists. Observed with `GaussianFilter, SomeLabsPlugin, GaussianFilter`: no steps + returned. The GUI shows the message in a critical box; MCP returns + `load_pipeline_file_failed`. Recorded by + `tests/unit/test_cppipe_corpus.py::test_cppipe_import_rejects_unknown_module_at_nominal_boundary`. +- **No plugin module is declared.** 55 module names exist in + CellProfiler/CellProfiler-plugins; none has a `CellProfilerModule`. +- **A declared module with an absent optional dependency has no owner.** The function + catalogue leaves out a file whose `REQUIRED_DISTRIBUTIONS` is absent + (`_module_in_this_catalog`), but module classes register through package discovery, + which swallows `ImportError` (the class silently vanishes and the module looks unknown) + or raises `ModuleNotFoundError` depending on which discovery ran first. + `openhcs_registry_functions()` hands the catalogue every registered class's callable + regardless of `REQUIRED_DISTRIBUTIONS`. +- **Built-in gaps.** CellProfiler 4.2.8.1 (the pinned native environment + `.venv-cellprofiler39`) has 98 module files, 94 user-facing (4 are test fixtures: + `injectimage`, `inject_objects`, `measurementfixture`, `setting_validation`). 90 of 94 + have a declaration. Missing: `LoadData`, `LabelImages`, `SaveCroppedObjects`, + `CreateBatchFiles`. `LoadData` is why the in-tree `ExampleFly.cppipe` is listed + `KNOWN_INVALID`. +- **Restated per-module facts.** Adding a module is already one file (class plus + function) but two rosters restate the family: + `openhcs/processing/backends/cellprofiler/contracts.json` (89 hand-kept entries, no + reader) and `tests/unit/test_cellprofiler_compatibility_matrix.py:24` + (`len(report.modules) == 91`). + +## Evidence: what people use + +Sources, each with what it counts. + +1. **GitHub code search**, REST `search/code`, query + `":[module_num" extension:cppipe`, run 2026-10-12 for all 55 plugin names. + Count is indexed files; "repos" is distinct repositories among the first 100 hits. + Every hit was fetched and checked for the real header line. Total indexed `.cppipe`: + 1708 (`CellProfiler Pipeline extension:cppipe`). Undercounts: default branches only, + files under 384 kB, forks excluded, private repositories invisible. Overcounts: copies + of one pipeline across repositories (61 RunCellpose files are 51 distinct blobs). + `.cpproj` queries returned 0 for every module (5 `.cpproj` indexed at all). + Baselines: IdentifyPrimaryObjects 1120, MeasureObjectIntensity 1100, GaussianFilter 75, + Watershed 66, RunImageJMacro 5. +2. **Local caches** (`~/.cache/openhcs/cellprofiler_examples`, + `~/.cache/openhcs/benchmark_datasets`, `benchmark/`, `tests/`): 101 unique `.cppipe` + by SHA-256. Modules without a declaration: RunCellpose 1, Runilastik 1 (both tutorial + bonus pipelines), LoadData 1. The 22-source community corpus described in + `docs/plans/cellprofiler_community_cppipe_corpus_acquisition_20260719.md` is not on + this machine (`community_sources/` is absent), so its 275 unique files are not counted. +3. **CellProfiler's own pipelines** (code search restricted by repo/org): CellProfiler/examples + 43 files, no plugin; CellProfiler/tutorials 40 files: RunCellpose 6, RunStarDist 2, + Runilastik 1. `org:broadinstitute`: CallBarcodes 36, CompensateColors 22, RunCellpose 18. +4. **image.sc forum: counts are missing, not zero.** `forum.image.sc/search.json` + answered HTTP 403 "Crawler is not allowed!" to every request. To run by hand, per + module: `https://forum.image.sc/search?q=%20after%3A2024-10-12` (topics in + the last two years) and without the `after:` term; for generic names add + `%23cellprofiler`. +5. **Published statements** (no per-module tally was found). Weisbart et al., + arXiv:2306.01915v1: "As seen in Figure 2, Cellpose is a highly-requested plugin for + CellProfiler, due to its easy-to-tune segmentation networks."; "modules such as + CallBarcodes … are typically designated as plugins because, in our experience, only a + small fraction of CellProfiler users are performing this type of experiment." + +### Ranked demand list + +Classification is the plugin repository's own (active = `active_plugins/`, "supported"; +unmaintained = `unmaintained_plugins/`). The repository has no LICENSE file (GitHub +reports `license: null`); individual files carry BSD-3, MIT-style or GPL headers. +OpenHCS implementations here are written against the algorithm and the library calls, +not copied. + +| # | Module | GitHub files (repos) | Other counts | Algorithm | Dependencies | Plugin tier | Licence notes | Native OpenHCS support | +|---|---|---|---|---|---|---|---|---| +| 1 | RunCellpose | 61 (19) | tutorials 6, broadinstitute 18, local 1; revisions seen r3 45, r4 24, r5 9, r6 13 | Cellpose segmentation | cellpose, torch; plugin defaults to Docker | active | Cellpose code BSD-3; weights: Cellpose-SAM tagged BSD-3, v1 to v3 not established; README says training data is CC-BY-NC | declared module wired to the Cellpose function behind an extra | +| 2 | HistogramEqualization | 44 (6) | | global or adaptive (CLAHE) equalization | scikit-image | active | | catalogued function | +| 3 | CallBarcodes | 36 (4) | all in broadinstitute | sequencing-by-synthesis barcode calling against a CSV library | numpy, csv | active | | catalogued function with the CSV as a declared input file | +| 4 | DistanceTransform | 32 (4) | | Euclidean distance transform | scipy | active | | catalogued function | +| 5 | CompensateColors | 23 (5) | broadinstitute 22 | channel bleed-through compensation | numpy, scipy | active | | catalogued function | +| 6 | HistogramMatching | 9 (2) | | match histogram to a reference image or frame | scikit-image | active | | catalogued function | +| 7 | RunStarDist | 9 (4) | tutorials 2 | StarDist star-convex segmentation | stardist, tensorflow, csbdeep | active | code BSD-3; weights not established | declared module behind an extra; needs a model function | +| 8 | DeclumpObjects | 6 (2) | | seeded watershed split of objects | scikit-image | active | | catalogued function | +| 9 | MeasureRadialEntropy | 6 (1) | carpenter-singh-lab 6 | entropy of intensity across radial wedges | numpy | unmaintained | | catalogued function | +| 10 | EnforceObjectsOneToOne | 4 (3) | | one-to-one primary/secondary relabelling | numpy | active | | catalogued function | +| 11 | RunImageJScript | 4 (2) | | run an ImageJ script through PyImageJ | pyimagej, JVM | active | PyImageJ Apache-2.0 | declared module behind the `fiji` extra | +| 12 | LaplacianOfGaussian | 3 (1) | | LoG filter | scipy | unmaintained | | catalogued function | +| 13 | TopHatTransform | 3 (1) | | top-hat | scikit-image | unmaintained | | catalogued function | +| 14 | FilterObjects_StringMatch | 2 (1) | | filter objects by a string measurement | numpy | active | | catalogued function | +| 15 | EdgeDetection | 2 (2) | | Sobel | scikit-image | unmaintained | | catalogued function | +| 16 | CalculateMoments, ConvertOutlinesToObjects, EnhancedMeasureTexture, PixelShuffle, Runilastik, BlobDetection, ClassifyPixels-Unet | 1 each | Runilastik: tutorials 1, local 1 | | Runilastik: ilastik (GPL v2+), Docker or local install; ClassifyPixels-Unet: tensorflow | mixed | | | + +Two apparent hits are not plugin use: `MergeObjects` 361 are all the built-in +`SplitOrMergeObjects`; `RunImageJ` 2 are the CellProfiler 2 built-in. The other 31 +plugin names returned 0, including RunOmnipose (Omnipose NonCommercial License) and +RunVISTA2D (weights CC-BY-NC-SA-4.0). + +Full inventory (55 names: 40 dependency-free, 2 light, 13 heavy; 24 active, 31 +unmaintained-only) was produced from a shallow clone by AST parsing, never importing the +plugin code. It is not committed; the per-module settings it extracted are restated only +in each module's declaration. + +## Target + +- A plugin module is one more `CellProfilerModule`: one file with the class and its + function. Converter, settings parsing, declared outputs and forms derive from it. +- Optional dependencies are declared once, as the file's `REQUIRED_DISTRIBUTIONS`. The + catalogue exclusion, the class's registration and the "cannot run: install X" message + all derive from it. A pipeline naming such a module converts to its real step with its + settings; compilation of that step fails naming the distribution and the extra. +- A module name with no declaration converts to one step carrying the written name, + revision and settings, connected in the dataflow (outputs derived from what later + modules consume), blocking compilation with a message, replaceable by any function. +- No roster of modules outside the family. + +## Guards + +- `tests/unit/test_cellprofiler_undeclared_and_unavailable_modules.py` +- `tests/unit/test_cellprofiler_plugin_modules.py` (settings from real blocks, execution + against the real plugin's output, declared outputs) +- official30 parity stays 30/30 (the converter changed). + +## Correctness of a plugin conversion + +Plugins are outside the official30 native references. The pinned native environment +`.venv-cellprofiler39` (CellProfiler 4.2.8.1) can load dependency-free plugins with +`--plugins-directory`, so each native implementation is compared with the real plugin's +output on a small image; fixtures record the plugin repository commit. Heavy plugins +(Cellpose, StarDist, ilastik) cannot be run there without installing their dependency +into that environment, which was not done; their procedure is the same once a pinned +environment with the dependency exists. + +## Status + +See "Done" and "Remaining, in order" below; kept current after each commit. + +### Done + +(nothing yet) + +### Remaining, in order + +1. Mechanism: undeclared-module step; dependency-absent declared module; delete + `contracts.json`; derive the `== 91` count. +2. Pure plugins by rank: HistogramEqualization, CallBarcodes (barcode CSV as a declared + input file), DistanceTransform, CompensateColors, HistogramMatching, DeclumpObjects, + MeasureRadialEntropy, EnforceObjectsOneToOne, LaplacianOfGaussian, TopHatTransform, + FilterObjects_StringMatch, EdgeDetection, CalculateMoments, ConvertOutlinesToObjects, + EnhancedMeasureTexture, PixelShuffle, BlobDetection, then the zero-count ones: AddNoise, + BoxObjects, Gaussian_BySlice, MaskTopAndBottomZ, RescaleIntensitySlicewise, + VarianceTransform, and the unmaintained dependency-free ones (Active contour model, + CalculateHistogram, ConstrainObjects, GammaCorrection, IdentifyLinearObjects, + ImageGradient, MeasurePopulationContext, MeasureTrackQuality, MergeObjects, Random + walker algorithm, RescaleMeanSD, RescaleModePercentile, SeedObjects, ShollAnalysis, + SpectralAnalysis, TestAllThresholds, Transforms). Each: settings from a real `.cppipe` + block with its source repository and commit recorded, and a check against the plugin's + own code. +3. Built-ins to 94 of 94: LoadData (work out its meaning for source bindings), LabelImages, + SaveCroppedObjects, CreateBatchFiles (a declared no-op that says why). +4. Heavy: RunCellpose (declaration and full settings mapping onto the Cellpose surface of + branch `feature/cellpose-full-surface`; no second wrapper), RunStarDist, Runilastik. From 887d67920f9e6b2c994e7713f9c416f8798260b0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 15:47:51 -0400 Subject: [PATCH 2/9] A .cppipe with an undeclared module imports; absent dependencies named at compile A module name with no declaration becomes one step carrying the written name, revision and settings; its outputs are the names later modules consume, its inputs the names available before it. Compilation of that step is refused with a message from the module family. A function whose file declares REQUIRED_DISTRIBUTIONS that are not installed stays out of the catalogue and the compiler names the distribution and the extra to install. contracts.json (no reader) is deleted; the module count in the compatibility test is derived from the registry. Co-Authored-By: Claude Opus 5.5 --- benchmark/converter/cppipe_corpus.py | 6 +- .../pipeline/funcstep_contract_validator.py | 17 + .../cellprofiler/compile_time_contracts.py | 1 + .../cellprofiler/module_artifact_contracts.py | 109 ++- .../cellprofiler/module_declarations.py | 30 +- .../interop/cellprofiler/pipeline_import.py | 62 +- .../backends/cellprofiler/contracts.json | 759 ------------------ .../cellprofiler/undeclared_module.py | 352 ++++++++ .../backends/lib_registry/openhcs_registry.py | 101 ++- .../test_cellprofiler_compatibility_matrix.py | 6 +- ...iler_undeclared_and_unavailable_modules.py | 531 ++++++++++++ tests/unit/test_cppipe_corpus.py | 25 +- 12 files changed, 1206 insertions(+), 793 deletions(-) delete mode 100644 openhcs/processing/backends/cellprofiler/contracts.json create mode 100644 openhcs/processing/backends/cellprofiler/undeclared_module.py create mode 100644 tests/unit/test_cellprofiler_undeclared_and_unavailable_modules.py diff --git a/benchmark/converter/cppipe_corpus.py b/benchmark/converter/cppipe_corpus.py index a85ca6106..9c36f6a7d 100644 --- a/benchmark/converter/cppipe_corpus.py +++ b/benchmark/converter/cppipe_corpus.py @@ -54,8 +54,12 @@ def in_tree_cppipe_corpus() -> tuple[CPPipeCorpusCase, ...]: name="ExampleFly", cppipe_path=pipelines_dir / "ExampleFly.cppipe", status=CPPipeCorpusStatus.KNOWN_INVALID, + # LoadData has no declaration and names its images in a CSV file, + # not in its settings, so the images later modules read have no + # provider the importer can derive. expected_error_substring=( - "No CellProfiler module declaration is registered for 'LoadData'" + "Module IdentifyPrimaryObjects references unknown image artifact " + "'OrigBlue'" ), ), CPPipeCorpusCase( diff --git a/openhcs/core/pipeline/funcstep_contract_validator.py b/openhcs/core/pipeline/funcstep_contract_validator.py index 50940b4e8..30e3101b0 100644 --- a/openhcs/core/pipeline/funcstep_contract_validator.py +++ b/openhcs/core/pipeline/funcstep_contract_validator.py @@ -453,6 +453,23 @@ def validate_external_library_installation(func: Callable, step_name: str) -> No Raises: ValueError: If the external library required by the function is not installed """ + from openhcs.processing.backends.lib_registry.openhcs_registry import ( + distributions_not_installed, + install_command_for_distributions, + ) + + # A file's declared distributions name the extra that installs them. + not_installed = distributions_not_installed(func) + if not_installed: + raise ValueError( + missing_external_library_error( + func.__name__, + step_name, + ", ".join(not_installed), + install_command_for_distributions(not_installed), + ) + ) + # Get the module name from the function module_name = func.__module__ if module_name is None: diff --git a/openhcs/interop/cellprofiler/compile_time_contracts.py b/openhcs/interop/cellprofiler/compile_time_contracts.py index 63d8f1f16..5af7a0cc6 100644 --- a/openhcs/interop/cellprofiler/compile_time_contracts.py +++ b/openhcs/interop/cellprofiler/compile_time_contracts.py @@ -217,6 +217,7 @@ def provider_for_pipeline( continue scope = invocation.contract.execution_scope try: + module_type.validate_compilation(invocation) invocation_blocks, consumed_kwarg_names = ( module_type.module_blocks_for_invocation( invocation=invocation, diff --git a/openhcs/interop/cellprofiler/module_artifact_contracts.py b/openhcs/interop/cellprofiler/module_artifact_contracts.py index 00609a423..8338fdaba 100644 --- a/openhcs/interop/cellprofiler/module_artifact_contracts.py +++ b/openhcs/interop/cellprofiler/module_artifact_contracts.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Iterable -from dataclasses import replace +from dataclasses import dataclass, replace from typing import TYPE_CHECKING from openhcs.core.artifacts.plans import ( @@ -43,6 +43,35 @@ from openhcs.core.pipeline.artifact_planning import ArtifactProducer +@dataclass(frozen=True, slots=True) +class ArtifactInputReference: + """One module block's reference to a named input artifact.""" + + module: "ModuleBlock" + name: str + artifact_type: type[ArtifactType] + + +class UnknownArtifactInput(ValueError): + """A module block names an input artifact that nothing before it provides. + + ``reference`` says which block reads which artifact. Callers that re-raise + an error with added context build it from the message alone, so such a + copy has no reference. + """ + + reference: ArtifactInputReference | None = None + + @classmethod + def for_reference(cls, reference: ArtifactInputReference) -> "UnknownArtifactInput": + error = cls( + f"Module {reference.module.name} references unknown " + f"{reference.artifact_type.value} artifact {reference.name!r}." + ) + error.reference = reference + return error + + class UniqueReturnSlots: """A module whose callable fills each declared output from one return slot.""" @@ -855,21 +884,21 @@ def bind_artifact_input( ) @classmethod - def require_available_artifact_input( + def available_artifact_input_specs( cls, - module: "ModuleBlock", - *, - binding: SettingToKeywordBinding, name: str, - invocation_key: "FunctionInvocationKey", + artifact_type: type[ArtifactType], + *, step_context: "ArtifactDeclarationStepContext", - ) -> ArtifactSpec: - """Return one exact context-owned input artifact or fail.""" + ) -> tuple[ArtifactSpec, ...]: + """Return the distinct declarations that provide one named input here. + + A source binding, the scoped main flow and earlier producers are the + only providers of an input artifact. + """ - artifact_type = binding.require_artifact_type() - input_ref = ArtifactSpec.input(name, artifact_type).ref() source_binding = step_context.source_bindings.binding_for_artifact_ref( - input_ref + ArtifactSpec.input(name, artifact_type).ref() ) main_flow_spec = step_context.main_flow_artifacts.by_name_and_artifact_type( name, @@ -882,13 +911,64 @@ def require_available_artifact_input( step_context=step_context, ) ) - candidates = ArtifactSpecCollection( + return ArtifactSpecCollection( ( *((source_binding.input_spec(),) if source_binding is not None else ()), *((main_flow_spec,) if main_flow_spec is not None else ()), *producer_specs, ) ).unique(conflict_context="available artifact input") + + @classmethod + def block_in_artifact_context( + cls, + module: "ModuleBlock", + *, + step_context: "ArtifactDeclarationStepContext", + ) -> "ModuleBlock": + """Return the block whose rows this declaration reads in ``step_context``. + + A declared module's setting rows already say which artifacts it reads. + """ + + del cls, step_context + return module + + @classmethod + def block_providing_artifact( + cls, + module: "ModuleBlock", + *, + name: str, + artifact_type: type[ArtifactType], + ) -> "ModuleBlock | None": + """Return ``module`` extended to provide an artifact a later module reads. + + A declared module's outputs are exactly the ones its setting rows name, + so it never gains one. + """ + + del cls, module, name, artifact_type + return None + + @classmethod + def require_available_artifact_input( + cls, + module: "ModuleBlock", + *, + binding: SettingToKeywordBinding, + name: str, + invocation_key: "FunctionInvocationKey", + step_context: "ArtifactDeclarationStepContext", + ) -> ArtifactSpec: + """Return one exact context-owned input artifact or fail.""" + + artifact_type = binding.require_artifact_type() + candidates = cls.available_artifact_input_specs( + name, + artifact_type, + step_context=step_context, + ) if len(candidates) == 1: return cls.bind_artifact_input(binding, candidates[0]) if len(candidates) > 1: @@ -931,9 +1011,8 @@ def require_available_artifact_input( f"{artifact_type.value}, but current artifacts " f"declare types {conflicting_types!r}." ) - raise ValueError( - f"Module {module.name} references unknown " - f"{artifact_type.value} artifact {name!r}." + raise UnknownArtifactInput.for_reference( + ArtifactInputReference(module, name, artifact_type) ) @classmethod diff --git a/openhcs/interop/cellprofiler/module_declarations.py b/openhcs/interop/cellprofiler/module_declarations.py index 01da17f64..e91cfaa95 100644 --- a/openhcs/interop/cellprofiler/module_declarations.py +++ b/openhcs/interop/cellprofiler/module_declarations.py @@ -437,7 +437,7 @@ def source_bindings_for_modules( for module in modules: if not module.enabled: continue - config = cls.require_module(module.name).contribute_source_bindings( + config = cls.declaration_for_block(module).contribute_source_bindings( module, config, ) @@ -448,12 +448,23 @@ def emits_function_step(cls) -> bool: """Return whether an enabled parsed module emits an executable step.""" return True + @classmethod + def adjacent_blocks_share_step(cls) -> bool: + """Return whether adjacent blocks of this module may lower into one step.""" + return True + @classmethod def validate_pipeline_import(cls, module: "ModuleBlock") -> None: """Validate whether one parsed module can enter a headless pipeline.""" del cls, module + @classmethod + def validate_compilation(cls, invocation: "NormalizedFunctionItem") -> None: + """Validate whether one public invocation of this module can compile.""" + + del cls, invocation + @classmethod def uses_cellprofiler_runtime_adapter(cls) -> bool: """Return whether axis execution requires the CellProfiler workspace adapter.""" @@ -545,6 +556,23 @@ def require_module(cls, module_name: str) -> type["CellProfilerModule"]: ) return module_type + @classmethod + def declaration_for_block(cls, module: "ModuleBlock") -> type["CellProfilerModule"]: + """Return the declaration that imports one parsed module block. + + A registered module name or alias selects its declaration exactly as + ``require_module`` does. Any other name is imported by the member of + this family that stands for a module OpenHCS has no declaration for. + """ + module_type = cls.for_module(module.name) + if module_type is not None: + return module_type + from openhcs.processing.backends.cellprofiler.undeclared_module import ( + UndeclaredModule, + ) + + return UndeclaredModule + @classmethod def require_module_name(cls) -> str: """Return this registered declaration's nominal module name.""" diff --git a/openhcs/interop/cellprofiler/pipeline_import.py b/openhcs/interop/cellprofiler/pipeline_import.py index 5e2a0e5c1..1ee990463 100644 --- a/openhcs/interop/cellprofiler/pipeline_import.py +++ b/openhcs/interop/cellprofiler/pipeline_import.py @@ -47,6 +47,9 @@ from openhcs.core.source_bindings import source_binding_group_keys_for_group_by from openhcs.core.steps.function_step import FunctionStep from openhcs.core.vfs_protocol import FileManagerLike +from openhcs.interop.cellprofiler.module_artifact_contracts import ( + UnknownArtifactInput, +) from openhcs.interop.cellprofiler.module_declarations import CellProfilerModule from openhcs.interop.cellprofiler.parser import CPPipeParser, ModuleBlock from openhcs.interop.cellprofiler.settings_binder import ( @@ -231,6 +234,7 @@ def _public_pipeline( while ( run_end < len(executable_modules) and executable_modules[run_end][0] is module_type + and module_type.adjacent_blocks_share_step() ): run_end += 1 @@ -303,7 +307,7 @@ def _parsed_pipeline_declaration( for module in modules: if not module.enabled: continue - module_type = CellProfilerModule.require_module(module.name) + module_type = CellProfilerModule.declaration_for_block(module) module_type.validate_pipeline_import(module) if not module_type.emits_function_step(): continue @@ -336,12 +340,21 @@ def _parsed_pipeline_declaration( group_by=Ungrouped, input_source=ReadPipelineStart, ) - target_units = _parsed_target_units( - executable_modules, - binder=binder, - step_context=forward_context, - inherited_processing_config=pipeline_processing, - ) + while True: + try: + target_units = _parsed_target_units( + executable_modules, + binder=binder, + step_context=forward_context, + inherited_processing_config=pipeline_processing, + ) + except UnknownArtifactInput as unknown: + executable_modules = _modules_providing_unknown_input( + executable_modules, + unknown, + ) + else: + break resolved_executable_modules = tuple(executable_modules) return _ParsedPipelineDeclaration( executable_modules=resolved_executable_modules, @@ -353,6 +366,37 @@ def _parsed_pipeline_declaration( ) +def _modules_providing_unknown_input( + executable_modules: list[tuple[type[CellProfilerModule], ModuleBlock]], + unknown: UnknownArtifactInput, +) -> list[tuple[type[CellProfilerModule], ModuleBlock]]: + """Give an unprovided input to the nearest earlier block that can provide it. + + Target units number the executable blocks from one, so the reading block's + number is its position. Each declaration decides whether its block provides + the artifact; when none before the reader does, the reference stays unknown. + """ + + reference = unknown.reference + if reference is None: + raise unknown + reader_position = reference.module.module_num - 1 + for position in range(reader_position - 1, -1, -1): + module_type, module = executable_modules[position] + providing_module = module_type.block_providing_artifact( + module, + name=reference.name, + artifact_type=reference.artifact_type, + ) + if providing_module is not None: + return [ + *executable_modules[:position], + (module_type, providing_module), + *executable_modules[position + 1 :], + ] + raise unknown + + def _pipeline_processing_config( executable_modules: list[tuple[type[CellProfilerModule], ModuleBlock]], source_bindings: SourceBindingsConfig, @@ -453,6 +497,10 @@ def _parsed_target_units( step_index=target_position, source_bindings=source_bindings, ) + module = module_type.block_in_artifact_context( + module, + step_context=declaration_context, + ) contract = module_type.callable_contract( module=module, invocation_key=invocation_key, diff --git a/openhcs/processing/backends/cellprofiler/contracts.json b/openhcs/processing/backends/cellprofiler/contracts.json deleted file mode 100644 index 179e18401..000000000 --- a/openhcs/processing/backends/cellprofiler/contracts.json +++ /dev/null @@ -1,759 +0,0 @@ -{ - "Align": { - "function_name": "align", - "contract": "flexible", - "category": "channel_operation", - "confidence": 1.0, - "reasoning": "Align consumes two named image planes together and returns two aligned image outputs, so the converter must preserve the resolved two-image stack instead of slicing it as independent images.", - "validated": true - }, - "CalculateMath": { - "function_name": "calculate_math", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function performs pixel-wise mathematical operations on a single input image. It does not require volumetric data (Z-stacks) or multiple distinct channels to execute its logic; it treats the input as a single entity to be transformed by scalars and exponents.", - "validated": true - }, - "CalculateStatistics": { - "function_name": "calculate_statistics", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function calculates statistics on an image and associated metadata. It does not require a volumetric Z-stack (z_projection) nor does it require multiple channels simultaneously (channel_operation) to perform its core logic. It follows the standard pattern of processing single-channel image data per site.", - "validated": true - }, - "ClassifyObjectsSingleMeasurement": { - "aliases": [ - "ClassifyObjects" - ], - "function_name": "classify_objects_single_measurement", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 0.0, - "reasoning": "Could not load function source code", - "validated": true - }, - "Closing": { - "function_name": "closing", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The 'closing' function is a standard morphological operation used for noise removal and smoothing. It operates on a single image (channel/site) at a time and does not inherently require a Z-stack or multiple channels to perform its computation.", - "validated": true - }, - "ColorToGray": { - "function_name": "color_to_gray", - "contract": "flexible", - "category": "channel_operation", - "confidence": 1.0, - "reasoning": "The function 'color_to_gray' explicitly operates on multiple channels (RGB or specific channel indices) to combine or split them. It requires the orchestrator to stack channels into a (C, H, W) array so it can perform operations like weighted sums (contributions) or splitting across the channel dimension.", - "validated": true - }, - "Combineobjects": { - "function_name": "combineobjects", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "CombineObjects is a single-channel operation that merges or modifies object labels within a single image or site. It does not require volumetric z-stacks or multiple distinct color channels to perform its logic.", - "validated": true - }, - "ConvertImageToObjects": { - "function_name": "convert_image_to_objects", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "This function converts a label or binary image into an objects data structure. It operates on a single image input (typically a single channel or a single site) and does not require access to multiple channels or a full Z-stack to perform its logic.", - "validated": true - }, - "ConvertObjectsToImage": { - "function_name": "convert_objects_to_image", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "This function converts a single label matrix (objects) into an image representation. It operates on a per-site basis and does not require a Z-stack or multiple input channels to perform its core logic. Even if it produces a color output, it is a single-channel-to-image transformation that fits the standard image_operation workflow.", - "validated": true - }, - "CorrectIlluminationApply": { - "function_name": "correct_illumination_apply", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "Illumination correction is a single-channel operation applied to individual images to correct for spatial intensity variations. It does not require access to multiple z-slices or multiple channels simultaneously to perform its calculation.", - "validated": true - }, - "CorrectIlluminationCalculate": { - "function_name": "correct_illumination_calculate", - "contract": "flexible", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "CorrectIlluminationCalculate is designed to calculate an illumination correction function (a 'background' or 'shading' map) for a single channel. It does not require volumetric 3D data (Z-stacks) to function, nor does it compare or combine multiple channels. It processes individual images (or stacks of sites to calculate a mean/median model) to determine spatial intensity variations.", - "validated": true - }, - "CreateBatchFiles": { - "function_name": "create_batch_files", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The 'create_batch_files' function is a utility for managing file paths and metadata for cluster processing. It does not require volumetric data (Z-stacks) or multiple channels simultaneously to perform its logic. As it operates on standard image data without specific multi-dimensional requirements, it falls under the default image_operation category.", - "validated": true - }, - "Crop": { - "function_name": "crop", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "Cropping is a spatial operation performed on individual images. It does not require knowledge of other Z-slices or other channels to execute; it simply removes pixels based on coordinates or a mask. Therefore, it follows the standard image_operation workflow where sites are processed independently.", - "validated": true - }, - "DefineGridManual": { - "aliases": [ - "DefineGrid" - ], - "function_name": "define_grid_manual", - "function_variants": [ - "define_grid_automatic" - ], - "contract": "pure_2d", - "category": "image_operation", - "confidence": 0.0, - "reasoning": "Could not load function source code", - "validated": true - }, - "DilateImage": { - "aliases": [ - "Dilation" - ], - "function_name": "dilate_image", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "Dilation is a morphological operation typically performed on a single-channel image. It does not inherently require a z-stack (volumetric data) or multiple channels (multispectral data) to function; it processes individual sites/planes independently.", - "validated": true - }, - "DilateObjects": { - "function_name": "dilate_objects", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "DilateObjects is a morphological operation performed on a single set of labels (and optionally an image). It does not require multiple channels or a Z-stack to function; it processes individual sites/planes independently.", - "validated": true - }, - "DisplayDataOnImage": { - "function_name": "display_data_on_image", - "contract": "flexible", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function overlays text or color-coded measurements onto a single image or set of labels. It operates on a per-image/per-site basis and does not require access to multiple channels simultaneously or a full Z-stack to perform its logic.", - "validated": true - }, - "DisplayDensityPlot": { - "function_name": "display_density_plot", - "contract": "flexible", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function generates a density plot for a single image input. It does not require volumetric z-stacks or multiple channels to perform its core visualization task; it processes a single intensity distribution at a time.", - "validated": true - }, - "DisplayHistogram": { - "function_name": "display_histogram", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function generates a histogram for a single image and its corresponding labels. It does not require volumetric z-stacks or multiple channels simultaneously to perform its calculation; it processes a single channel/site independently.", - "validated": true - }, - "DisplayPlatemap": { - "function_name": "display_platemap", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function displays data mapped to a plate layout. It does not require volumetric z-stacks or multiple channels simultaneously to perform its core logic; it processes image-level or object-level measurements which are standard single-channel/per-site operations.", - "validated": true - }, - "DisplayScatterPlot": { - "function_name": "display_scatter_plot", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function generates a visualization based on measurements from a single image/site. It does not require volumetric data (Z-stacks) or multiple channels simultaneously to perform its core logic of plotting X vs Y measurements.", - "validated": true - }, - "EditObjectsManually": { - "function_name": "edit_objects_manually", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "EditObjectsManually is a single-channel operation used to refine segmentation labels based on a corresponding image. It does not require a Z-stack for volumetric processing nor multiple channels simultaneously; it processes individual sites/images independently.", - "validated": true - }, - "EnhanceEdges": { - "function_name": "enhance_edges", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "EnhanceEdges is a classic image processing filter (Sobel, Canny, etc.) that operates on a single 2D image at a time. It does not require volumetric data (Z-stacks) or multiple color channels to perform its edge detection logic.", - "validated": true - }, - "EnhanceOrSuppressFeatures": { - "function_name": "enhance_or_suppress_features", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "EnhanceOrSuppressFeatures is a classic single-channel image processing operation used to highlight specific structures (speckles, neurites, holes) or suppress noise. It operates on individual 2D images or 2D slices independently and does not require access to multiple channels or a full Z-stack to perform its mathematical transformations.", - "validated": true - }, - "ErodeImage": { - "aliases": [ - "Erosion" - ], - "function_name": "erode_image", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "Erosion is a morphological operation typically performed on a single-channel image. It does not require multiple channels (channel_operation) or a full z-stack (z_projection) to function; it processes individual image planes independently.", - "validated": true - }, - "ErodeObjects": { - "function_name": "erode_objects", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function performs a morphological erosion on object labels. This is a single-channel operation that does not require access to multiple channels or a full Z-stack to function; it processes individual 2D images (or 3D volumes if provided, but it doesn't 'need' the Z-dimension for its core logic in the context of the orchestrator's stacking).", - "validated": true - }, - "ExpandOrShrinkObjects": { - "function_name": "expand_or_shrink_objects", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "This function performs morphological operations (expanding or shrinking) on label matrices. It operates on a single channel of objects at a time and does not require volumetric Z-stacks or multiple color channels to perform its logic.", - "validated": true - }, - "ExportToDatabase": { - "function_name": "export_to_database", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "ExportToDatabase is a terminal operation that saves measurements or image metadata. It does not require volumetric z-stacks or multiple channels simultaneously to perform its core logic; it processes data on a per-site basis.", - "validated": true - }, - "ComputeAggregateMeasurements": { - "function_name": "compute_aggregate_measurements", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 0.0, - "reasoning": "Could not load function source code", - "validated": true - }, - "FillObjects": { - "function_name": "fill_objects", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The fill_objects function is a morphological operation used to fill holes or convex hulls within segmented objects. It operates on a single label matrix and its corresponding image, processing each site/image independently. It does not require volumetric z-stacks or multiple channels to perform its logic.", - "validated": true - }, - "FilterObjects": { - "function_name": "filter_objects", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function filters objects based on measurements or spatial properties. It operates on a single image and its corresponding label mask. It does not require a volumetric Z-stack or multiple channels simultaneously to perform its logic.", - "validated": true - }, - "FindMaxima": { - "function_name": "find_maxima", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The find_maxima function is a peak-finding algorithm typically used for identifying seed points or local intensity maxima within a single image. It does not require a volumetric Z-stack to function, nor does it require multiple channels simultaneously; it operates on the intensity values of a single 2D plane (or site) at a time.", - "validated": true - }, - "FlagImage": { - "function_name": "flag_image", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The flag_image function performs Quality Control or metadata flagging based on image measurements. It does not require volumetric z-stack data (z_projection) nor does it require multiple channels simultaneously (channel_operation) to evaluate its criteria. It operates on a per-image/per-site basis.", - "validated": true - }, - "FlipAndRotate": { - "function_name": "flip_and_rotate", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The flip_and_rotate function performs geometric transformations on an image. It does not require volumetric data (z-stacks) or multiple channels simultaneously to perform its logic; it can be applied to individual image planes independently.", - "validated": true - }, - "GaussianFilter": { - "function_name": "gaussian_filter", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "Gaussian filtering is a standard image processing operation that is applied to individual 2D images or sites independently. It does not require access to multiple channels simultaneously or a full Z-stack to perform its core function.", - "validated": true - }, - "GrayToColor": { - "function_name": "gray_to_color", - "contract": "pure_2d", - "category": "channel_operation", - "confidence": 1.0, - "reasoning": "GrayToColor combines multiple grayscale images into one color or stacked output. The runtime image payload is an ordered multichannel family, so this remains a pure 2D channel operation with module-level scheme dispatch.", - "validated": true - }, - "IdentifyDeadWorms": { - "function_name": "identify_dead_worms", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "IdentifyDeadWorms is a specialized segmentation/morphology function designed to detect specific shapes (worms) in a single-channel image. It does not require volumetric Z-stacks to function, nor does it require multiple color channels simultaneously to perform its detection logic.", - "validated": true - }, - "IdentifyObjectsInGrid": { - "function_name": "identify_objects_in_grid", - "function_variants": [ - "identify_objects_in_grid_with_guides" - ], - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "IdentifyObjectsInGrid is a segmentation function that defines a grid of objects based on spatial coordinates. It operates on a single image (site) at a time and does not require volumetric Z-stacks or multiple color channels to define the grid geometry.", - "validated": true - }, - "IdentifyObjectsManually": { - "function_name": "identify_objects_manually", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "IdentifyObjectsManually is a standard segmentation/annotation task performed on a single image (or site). It does not require a volumetric Z-stack to function, nor does it inherently require multiple channels simultaneously to define object boundaries.", - "validated": true - }, - "IdentifyPrimaryObjects": { - "function_name": "identify_primary_objects", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "IdentifyPrimaryObjects is a classic single-channel segmentation function. It processes an individual grayscale image (typically a nuclear stain like DAPI) to detect objects. It does not require volumetric Z-stacks or multiple color channels simultaneously to perform its core logic.", - "validated": true - }, - "IdentifySecondaryObjects": { - "function_name": "identify_secondary_objects", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "IdentifySecondaryObjects is a single-channel segmentation task that expands from existing primary labels using a single intensity image. It does not require multiple channels or a full Z-stack to perform its core logic; it processes individual sites/planes independently.", - "validated": true - }, - "IdentifyTertiaryObjects": { - "function_name": "identify_tertiary_objects", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "IdentifyTertiaryObjects performs a subtraction of primary labels from secondary labels (e.g., identifying the cytoplasm by subtracting the nucleus from the whole cell). This is a single-channel/single-site operation that does not require a Z-stack or multiple image channels simultaneously.", - "validated": true - }, - "ImageMath": { - "function_name": "image_math", - "contract": "flexible", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "ImageMath performs pixel-wise arithmetic on an image. It does not require volumetric context (Z-stacks) nor does it inherently require multiple channels to be stacked in the first dimension to function; it operates on the input array provided, typically a single channel per site.", - "validated": true - }, - "InvertForPrinting": { - "function_name": "invert_for_printing", - "contract": "flexible", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function performs a visual transformation (inverting for printing) that is typically applied to individual images or sites. It does not require a volumetric z-stack (z_projection) nor does it inherently require multiple input channels to be stacked as a single array (channel_operation) to perform its core logic, as it can process single-channel images independently.", - "validated": true - }, - "LabelImages": { - "function_name": "label_images", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The label_images function is a standard image processing operation that adds metadata or labels to individual images. It does not require volumetric z-stacks (Z, H, W) to perform its logic, nor does it require multiple channels (C, H, W) simultaneously to function. It operates on a per-site basis, making it a standard image_operation.", - "validated": true - }, - "MakeProjection": { - "function_name": "make_projection", - "contract": "volumetric_to_slice", - "category": "z_projection", - "confidence": 1.0, - "reasoning": "The function 'make_projection' is designed to collapse a 3D volumetric stack into a 2D image. It requires the Z-slices to be stacked along the first dimension (Z, H, W) to perform operations like Average or Maximum projection, which aligns perfectly with the z_projection category.", - "validated": true - }, - "MaskImage": { - "function_name": "mask_image", - "contract": "flexible", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function applies a mask to a single image. It does not require volumetric z-stacks or multiple channels simultaneously to perform its core logic; it operates on a per-image/per-site basis.", - "validated": true - }, - "MaskObjects": { - "function_name": "mask_objects", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function performs a masking operation on a single image/label set. It does not require a volumetric Z-stack to function, nor does it require multiple distinct color channels simultaneously; it operates on a per-site basis where the image and labels correspond to the same field of view.", - "validated": true - }, - "MatchTemplate": { - "function_name": "match_template", - "contract": "flexible", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The match_template function performs template matching (cross-correlation) on a single image channel. It does not require volumetric Z-stacks or multiple color channels to function; it processes a single 2D image against a template.", - "validated": true - }, - "MeasureColocalization": { - "aliases": [ - "MeasureCorrelation" - ], - "function_name": "measure_colocalization", - "function_variants": [ - "measure_colocalization_objects" - ], - "contract": "pure_2d", - "category": "channel_operation", - "confidence": 1.0, - "reasoning": "Colocalization analysis inherently requires comparing two or more channels simultaneously to calculate spatial overlap and correlation metrics. Therefore, the function needs the orchestrator to stack channels into the first dimension (C, H, W).", - "validated": true - }, - "MeasureGranularity": { - "function_name": "measure_granularity", - "function_variants": [ - "measure_granularity_objects" - ], - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "MeasureGranularity is a texture-based analysis function that calculates a size spectrum of objects within a single image. It operates on a per-channel basis and does not require volumetric Z-stacks or multiple channels simultaneously to perform its calculations.", - "validated": true - }, - "MeasureImageAreaOccupiedBinary": { - "aliases": [ - "MeasureImageAreaOccupied" - ], - "function_name": "measure_image_area_occupied", - "function_variants": [ - "measure_image_area_occupied_binary", - "measure_image_area_occupied_objects", - "measure_image_volume_occupied_binary", - "measure_image_volume_occupied_objects" - ], - "contract": "flexible", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "Generic row runner preserves CellProfiler's ordered binary-image/object row semantics and handles multi-image payloads after source/artifact resolution.", - "validated": true - }, - "MeasureImageIntensity": { - "function_name": "measure_image_intensity", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "MeasureImageIntensity calculates statistics (mean, median, min, max) for a single image channel. It does not require a 3D z-stack to function, nor does it require multiple channels simultaneously to compute its metrics. It is a standard single-channel operation applied per site.", - "validated": true - }, - "Measureimageoverlap": { - "function_name": "measureimageoverlap", - "contract": "flexible", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "MeasureImageOverlap in CellProfiler typically compares two images (often a ground truth and a test segmentation) or measures the overlap of a single image against itself/a reference. It does not inherently require a 3D Z-stack or a multi-channel composite to function; it operates on individual image planes (sites) to calculate spatial overlap metrics.", - "validated": true - }, - "MeasureImageQuality": { - "function_name": "measure_image_quality", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "MeasureImageQuality calculates metrics like blur, saturation, and intensity on a per-image basis. It does not require volumetric data (Z-stacks) or multiple channels simultaneously to perform its calculations; it is typically applied to individual grayscale channels across sites.", - "validated": true - }, - "MeasureImageSkeleton": { - "function_name": "measure_image_skeleton", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "Skeletonization is a morphological operation performed on a single binary image (or single channel). It does not require multiple channels or a volumetric Z-stack to function; it processes individual sites/images independently.", - "validated": true - }, - "MeasureObjectIntensity": { - "function_name": "measure_object_intensity", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "Measuring object intensity is a single-channel operation performed on a per-image basis. It does not require access to multiple channels simultaneously (channel_operation) nor does it require a full Z-stack to calculate values for a 2D label map (z_projection). It follows the standard pattern of processing one image/site at a time.", - "validated": true - }, - "MeasureObjectIntensityDistribution": { - "function_name": "measure_object_intensity_distribution", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "MeasureObjectIntensityDistribution calculates spatial distribution of intensities for a single image channel relative to a set of labels. It does not require multiple channels simultaneously nor does it require a 3D z-stack to perform its calculations; it processes a single 2D image/site at a time.", - "validated": true - }, - "MeasureObjectNeighbors": { - "function_name": "measure_object_neighbors", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "MeasureObjectNeighbors calculates spatial relationships between objects within a single image or site. It does not require a volumetric Z-stack to function, nor does it require multiple channels simultaneously; it operates on a single label matrix and its corresponding intensity image. Therefore, it follows the standard per-site processing flow.", - "validated": true - }, - "MeasureObjectOverlap": { - "function_name": "measure_object_overlap", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function measures overlap between ground truth and test labels for a specific image. It does not require a volumetric z-stack to function, nor does it require multiple distinct spectral channels to be stacked in the first dimension; it operates on a per-site basis using the provided image and label sets.", - "validated": true - }, - "MeasureObjectSizeShape": { - "function_name": "measure_object_size_shape", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "This function measures morphological features of objects within a single image and its corresponding label mask. It does not require multiple channels (colocalization) or a Z-stack (volumetric projection) to perform its core logic; it processes a single site's spatial data independently.", - "validated": true - }, - "MeasureObjectSkeleton": { - "function_name": "measure_object_skeleton", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function performs skeletonization on a single image and its corresponding labels. It does not require a volumetric z-stack or multiple color channels to perform its operation; it processes a single channel/site independently.", - "validated": true - }, - "MeasureTexture": { - "function_name": "measure_texture", - "function_variants": [ - "measure_texture_objects" - ], - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "Texture measurement is a single-channel operation that analyzes spatial patterns within a single image. It does not require multiple channels simultaneously nor does it require a volumetric z-stack to compute its metrics.", - "validated": true - }, - "Medialaxis": { - "function_name": "medialaxis", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The medialaxis function (skeletonization) is a morphological operation performed on a single binary image. It does not require a z-stack or multiple channels to compute the skeleton of an object.", - "validated": true - }, - "Medianfilter": { - "function_name": "medianfilter", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "Median filtering is a standard image processing operation typically applied to single-channel 2D images. It does not inherently require a Z-stack or multiple channels to function; it processes spatial neighborhoods within a single image plane.", - "validated": true - }, - "Morph": { - "function_name": "morph", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The morph function performs morphological operations (like thinning, erosion, or dilation) which are standard single-channel image processing tasks. It does not require volumetric z-stacks or multiple channels simultaneously to function; it processes individual 2D image planes independently.", - "validated": true - }, - "Morphologicalskeleton": { - "function_name": "morphologicalskeleton", - "contract": "pure_3d", - "category": "z_projection", - "confidence": 0.95, - "reasoning": "The function includes a 'volumetric' parameter, indicating it is designed to handle 3D data. In the OpenHCS orchestration model, processing 3D volumes (Z, H, W) requires setting variable_components to Z_INDEX, which corresponds to the 'z_projection' category.", - "validated": true - }, - "Opening": { - "function_name": "opening", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The 'opening' function is a standard morphological operation used for noise removal or smoothing. It operates on a single image (2D or 3D) and does not inherently require multiple channels or a specific Z-stack projection logic to function. It is a per-image/per-site operation.", - "validated": true - }, - "OverlayObjects": { - "function_name": "overlay_objects", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function overlays a label mask onto a single image. It does not require a volumetric z-stack (Z, H, W) to perform its logic, nor does it inherently require multiple input channels (C, H, W) to be stacked by the orchestrator; it processes a single image and its corresponding label set per site.", - "validated": true - }, - "OverlayOutlines": { - "function_name": "overlay_outlines", - "contract": "flexible", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function overlays one or more image/object outline rows onto a base or blank image; the resolved OpenHCS payload can contain the base image plus outline-image rows as a stack.", - "validated": true - }, - "Reducenoise": { - "function_name": "reducenoise", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The 'reducenoise' function (typically implementing Non-Local Means or similar denoising) operates on a single image at a time to remove noise. It does not require a Z-stack to function, nor does it require multiple channels simultaneously to perform its calculation. It is a standard single-channel image enhancement operation.", - "validated": true - }, - "RelateObjects": { - "function_name": "relate_objects", - "contract": "flexible", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "RelateObjects establishes parent-child relationships between segmented objects (labels). It operates on a per-site basis and does not require volumetric z-stacks or multiple spectral channels simultaneously to perform its core logic.", - "validated": true - }, - "RemoveHoles": { - "function_name": "remove_holes", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The remove_holes function is a morphological operation typically applied to binary or grayscale images to fill small voids. It operates on a single image at a time and does not require access to multiple channels or a full Z-stack to perform its logic.", - "validated": true - }, - "RescaleIntensity": { - "function_name": "rescale_intensity", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "RescaleIntensity is a single-channel operation that adjusts the pixel intensity values of an image. It does not require knowledge of other channels or a full Z-stack to perform its calculation; it can be applied to each site/image independently.", - "validated": true - }, - "Resize": { - "function_name": "resize", - "function_variants": [ - "resize_volumetric" - ], - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The resize function operates on the spatial dimensions (H, W) of an image. It does not require access to a full Z-stack or multiple channels simultaneously to perform its operation; it can be applied to each site/channel/z-slice independently.", - "validated": true - }, - "ResizeObjects": { - "function_name": "resize_objects", - "function_variants": [ - "resize_objects_3d" - ], - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function performs spatial resizing on a single image and its corresponding label mask. It does not require access to multiple channels simultaneously or a full Z-stack to perform its operation; it can process each site/channel/z-slice independently.", - "validated": true - }, - "RunImagejMacro": { - "function_name": "run_imagej_macro", - "contract": "flexible", - "category": "image_operation", - "confidence": 0.95, - "reasoning": "The run_imagej_macro function is a general-purpose wrapper for external ImageJ scripts. In the context of CellProfiler/OpenHCS, it typically processes individual images (sites) sequentially. It does not inherently require a full Z-stack or multiple channels to function, making it a standard image_operation where each site is processed independently.", - "validated": true - }, - "SaveCroppedObjects": { - "function_name": "save_cropped_objects", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function processes a single image and its corresponding label map to export cropped objects. It does not require a volumetric z-stack (Z, H, W) to perform its logic, nor does it require multiple channels (C, H, W) simultaneously; it operates on a per-site, per-channel basis.", - "validated": true - }, - "SaveImages": { - "function_name": "save_images", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The SaveImages function is designed to save a single image (or a single channel/site) to disk. It does not require a full Z-stack to perform its operation, nor does it inherently require multiple channels simultaneously to function. In the OpenHCS architecture, saving is treated as a per-site, per-channel operation.", - "validated": true - }, - "ShrinkToObjectCenters": { - "function_name": "shrink_to_object_centers", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function performs morphological operations on labels and images to reduce objects to their centroids. This is a single-channel operation that does not require z-stacks or multiple channels simultaneously; it processes individual sites/images independently.", - "validated": true - }, - "Smooth": { - "function_name": "smooth", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The 'smooth' function is a standard image processing operation designed to reduce noise or blur a single image. It does not require access to multiple channels simultaneously (channel_operation) nor does it require a full z-stack to perform its operation (z_projection). It operates on a per-image/per-site basis.", - "validated": true - }, - "SplitOrMergeObjects": { - "function_name": "split_or_merge_objects", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function performs object manipulation (splitting or merging) based on labels and an optional guide image. It operates on a per-site basis and does not require a full Z-stack or multiple channels to function; even when a guide image is used, it is typically the same channel or a single reference channel for that specific site.", - "validated": true - }, - "StraightenWorms": { - "function_name": "straighten_worms", - "contract": "flexible", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The function processes a single image and its corresponding labels/control points to straighten worm objects. It does not require volumetric Z-stacks or multiple color channels simultaneously to perform the geometric transformation; it operates on a per-site, per-channel basis.", - "validated": true - }, - "Threshold": { - "function_name": "threshold", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The threshold function is a standard single-channel operation that calculates a binary mask from an intensity image. It does not require volumetric z-stack information or multiple channels simultaneously to perform its core logic.", - "validated": true - }, - "Tile": { - "function_name": "tile", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "The Tile function is designed to arrange individual images (typically sites/fields of view) into a single large grid. It processes images independently and does not require volumetric Z-stacks or multiple channels simultaneously to perform its core logic. In the OpenHCS context, this is a per-site operation where the orchestrator handles the stacking of sites via variable_components=[Microscopy.Site].", - "validated": true - }, - "TrackObjects": { - "function_name": "track_objects", - "contract": "pure_3d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "TrackObjects consumes the full per-site label stack so it can preserve tracking state across frames and emit increasing image_number values. Slice-by-slice PURE_2D execution restarts state for every frame and is semantically wrong.", - "validated": true - }, - "UnmixColors": { - "function_name": "unmix_colors", - "contract": "flexible", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "FLEXIBLE preserves CellProfiler RGB payload semantics; PURE_2D would slice H,W,3 color images along the wrong axis.", - "validated": true - }, - "UntangleWorms": { - "function_name": "untangle_worms", - "contract": "pure_2d", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "UntangleWorms is a single-channel segmentation/morphology operation designed to identify individual worm objects from a binary or grayscale image. It processes individual sites independently and does not require volumetric Z-stacks or multiple color channels to perform its core logic.", - "validated": true - }, - "Watershed": { - "function_name": "watershed", - "contract": "unknown", - "category": "image_operation", - "confidence": 1.0, - "reasoning": "Watershed is a segmentation algorithm that operates on a single input image (typically a distance transform or an intensity gradient). It does not inherently require multiple channels or a full Z-stack to function; it processes individual sites/images independently.", - "validated": true - } -} diff --git a/openhcs/processing/backends/cellprofiler/undeclared_module.py b/openhcs/processing/backends/cellprofiler/undeclared_module.py new file mode 100644 index 000000000..245eec335 --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/undeclared_module.py @@ -0,0 +1,352 @@ +"""The step a CellProfiler module becomes when OpenHCS has no declaration for it. + +A ``.cppipe`` may name a module OpenHCS does not implement (a lab's plugin, a +built-in that has not been absorbed). The pipeline still imports: the module +becomes one ordinary ``FunctionStep`` that keeps the module's name, revision +and settings as written, is wired into the dataflow so later modules resolve +their inputs, and refuses to compile until its function is replaced. + +What the module reads and writes is derived from the pipeline around it, never +from the meaning of its settings: + +- an *output* is a name a later module reads that nothing before that reader + provides and that appears as a setting value of this block (the nearest such + block before the reader gets it); +- an *input* is a setting value naming an image or objects available before + this block. + +In a pipeline CellProfiler itself accepts, every name a module reads has an +earlier provider, so the derived outputs are exact for the names later modules +use. Outputs nothing later reads are not known and are not declared. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING, NoReturn + +import numpy as np + +from openhcs.core.artifacts.kinds import ( + ArtifactType, + ImageArtifactType, + ObjectLabelsArtifactType, +) +from openhcs.core.artifacts.plans import ArtifactInputPlan, ArtifactOutputPlan +from openhcs.core.memory.decorators import numpy +from openhcs.core.processing_contracts import FlexibleContract +from openhcs.interop.cellprofiler.module_declarations import CellProfilerModule +from openhcs.interop.cellprofiler.module_settings import BoundModuleSettings +from openhcs.interop.cellprofiler.parser import ModuleBlock, ModuleSetting +from openhcs.interop.cellprofiler.setting_names import ( + setting_names, + split_symbol_names, +) +from openhcs.interop.cellprofiler.settings_binder import SettingToKeywordBinding + +if TYPE_CHECKING: + from collections.abc import Callable + + from openhcs.core.callable_contract import CallableContract + from openhcs.core.function_patterns import ( + FunctionInvocationKey, + NormalizedFunctionItem, + ) + from openhcs.core.invocation_artifacts import ArtifactDeclarationStepContext + + +def _names(value: str | tuple[str, ...]) -> tuple[str, ...]: + """Return artifact names from the scalar-or-tuple form step kwargs carry.""" + + return (value,) if isinstance(value, str) else tuple(value) + + +@numpy(contract=FlexibleContract) +def undeclared_module( + image: np.ndarray, + module_name: str = "", + variable_revision_number: int | None = None, + settings: tuple[tuple[str, str], ...] = (), + input_images: str | tuple[str, ...] = (), + input_objects: str | tuple[str, ...] = (), + output_images: str | tuple[str, ...] = (), + output_objects: str | tuple[str, ...] = (), +) -> NoReturn: + """Stand in for a CellProfiler module that has no OpenHCS implementation. + + The step cannot run. Replace its function with one that produces the + listed outputs. + + Args: + module_name: The module's name as written in the CellProfiler pipeline. + variable_revision_number: The module's settings revision in the pipeline. + settings: The module's settings as written, in order, as (setting, value). + input_images: Images available before this module that its settings name. + input_objects: Objects available before this module that its settings name. + output_images: Images later modules read that this module's settings name. + output_objects: Objects later modules read that this module's settings name. + """ + + del image, variable_revision_number, settings, input_images, input_objects + raise NotImplementedError( + UndeclaredModule.no_implementation_message( + module_name, + output_images=_names(output_images), + output_objects=_names(output_objects), + ) + ) + + +@dataclass +class UndeclaredModuleBlock(ModuleBlock): + """A parsed block whose module has no declaration. + + ``written_settings`` are the block's rows from the file. ``setting_records`` + hold only the artifact rows derived for it, so a written setting can never + be read as an artifact row, whatever its text. + """ + + written_settings: tuple[tuple[str, str], ...] = () + + def written_names(self) -> tuple[str, ...]: + """Return the distinct artifact-name candidates among the written values.""" + + return tuple( + dict.fromkeys( + name + for _setting, value in self.written_settings + for name in split_symbol_names(value) + ) + ) + + +class UndeclaredModule(CellProfilerModule): + """Imports any module block whose name has no registered declaration.""" + + module_name = "UndeclaredModule" + function_name = "undeclared_module" + confidence = 0.0 + + input_image_binding = SettingToKeywordBinding.input( + "Input images", + ImageArtifactType, + repeated=True, + ) + input_object_binding = SettingToKeywordBinding.input( + "Input objects", + ObjectLabelsArtifactType, + repeated=True, + ) + output_image_binding = SettingToKeywordBinding.output( + "Output images", + ImageArtifactType, + repeated=True, + ) + output_object_binding = SettingToKeywordBinding.output( + "Output objects", + ObjectLabelsArtifactType, + repeated=True, + ) + setting_bindings = ( + input_image_binding, + input_object_binding, + output_image_binding, + output_object_binding, + ) + + @classmethod + def no_implementation_message( + cls, + module_name: str, + *, + output_images: tuple[str, ...], + output_objects: tuple[str, ...], + ) -> str: + """Say which module cannot run and what its replacement must produce.""" + + del cls + outputs = ( + *(f"image {name!r}" for name in output_images), + *(f"objects {name!r}" for name in output_objects), + ) + produced = ( + f"producing {', '.join(outputs)}" + if outputs + else "(no later module reads an output of it)" + ) + return ( + f"CellProfiler module {module_name!r} has no OpenHCS implementation, " + "so this step cannot run. Replace the step's function with a custom " + f"function {produced}." + ) + + @classmethod + def adjacent_blocks_share_step(cls) -> bool: + """Each block is its own step: adjacent blocks may be different modules.""" + return False + + @classmethod + def validate_compilation(cls, invocation: NormalizedFunctionItem) -> None: + """Refuse to compile: nothing implements the module this step stands for.""" + + kwargs = invocation.kwargs_dict + raise ValueError( + cls.no_implementation_message( + kwargs.get("module_name", ""), + output_images=_names(kwargs.get("output_images", ())), + output_objects=_names(kwargs.get("output_objects", ())), + ) + ) + + @classmethod + def validate_callable_artifact_abi( + cls, + func: Callable[..., NoReturn], + contract: CallableContract, + ) -> None: + """The callable never returns, so it has no return slots to check.""" + + del cls, func, contract + + @classmethod + def invocation_module_blocks( + cls, + module: ModuleBlock, + ) -> tuple[ModuleBlock, ...]: + """Set the written rows aside; artifact rows are derived, not read.""" + + del cls + return ( + UndeclaredModuleBlock( + name=module.name, + module_num=module.module_num, + enabled=module.enabled, + metadata=dict(module.metadata), + cppipe_path=module.cppipe_path, + written_settings=tuple( + (setting.name, setting.value) for setting in module.iter_settings() + ), + ), + ) + + @classmethod + def _undeclared_block(cls, module: ModuleBlock) -> UndeclaredModuleBlock: + if not isinstance(module, UndeclaredModuleBlock): + raise TypeError( + f"{cls.__name__} derives artifact rows only for the block its " + f"invocation_module_blocks built, got {type(module).__name__}." + ) + return module + + @classmethod + def _artifact_rows( + cls, + binding: SettingToKeywordBinding, + names: tuple[str, ...], + ) -> list[ModuleSetting]: + del cls + setting_name = setting_names(binding.setting_name)[0] + return [ModuleSetting(setting_name, name) for name in names] + + @classmethod + def block_in_artifact_context( + cls, + module: ModuleBlock, + *, + step_context: ArtifactDeclarationStepContext, + ) -> ModuleBlock: + """Read every image or objects available here that a written value names.""" + + block = cls._undeclared_block(module) + rows: list[ModuleSetting] = [] + for binding in cls.declared_artifact_bindings(plan_type=ArtifactInputPlan): + rows.extend( + cls._artifact_rows( + binding, + tuple( + name + for name in block.written_names() + if cls.available_artifact_input_specs( + name, + binding.require_artifact_type(), + step_context=step_context, + ) + ), + ) + ) + for binding in cls.declared_artifact_bindings(plan_type=ArtifactOutputPlan): + rows.extend( + cls._artifact_rows( + binding, + cls.artifact_names_for_binding(block, binding), + ) + ) + return replace(block, setting_records=rows) + + @classmethod + def block_providing_artifact( + cls, + module: ModuleBlock, + *, + name: str, + artifact_type: type[ArtifactType], + ) -> ModuleBlock | None: + """Provide an artifact a later module reads when a written value names it.""" + + block = cls._undeclared_block(module) + if name not in block.written_names(): + return None + for binding in cls.declared_artifact_bindings( + plan_type=ArtifactOutputPlan, + artifact_type=artifact_type, + ): + if name in cls.artifact_names_for_binding(block, binding): + return None + return replace( + block, + setting_records=[ + *block.setting_records, + *cls._artifact_rows(binding, (name,)), + ], + ) + return None + + @classmethod + def postprocess_bound_settings( + cls, + module: ModuleBlock, + bound: BoundModuleSettings, + ) -> BoundModuleSettings: + """Carry the module's written name, revision and settings as step kwargs.""" + + block = cls._undeclared_block(module) + return bound.with_kwargs( + { + "module_name": block.name, + "variable_revision_number": block.variable_revision_number, + "settings": block.written_settings, + } + ) + + @classmethod + def artifact_input_bindings_for_reconstruction( + cls, + module: ModuleBlock, + *, + invocation_key: FunctionInvocationKey, + step_context: ArtifactDeclarationStepContext, + ) -> tuple[SettingToKeywordBinding, ...]: + """Inputs are exactly the named ones; the main flow supplies no default.""" + + del cls, module, invocation_key, step_context + return () + + @classmethod + def derives_missing_output_identity( + cls, + binding: SettingToKeywordBinding, + ) -> bool: + """Outputs are exactly the named ones; none is invented for a binding.""" + + del cls, binding + return False diff --git a/openhcs/processing/backends/lib_registry/openhcs_registry.py b/openhcs/processing/backends/lib_registry/openhcs_registry.py index 1d0edbfc5..54f8e6420 100644 --- a/openhcs/processing/backends/lib_registry/openhcs_registry.py +++ b/openhcs/processing/backends/lib_registry/openhcs_registry.py @@ -12,6 +12,7 @@ import importlib.metadata import inspect import logging +import pkgutil from abc import ABC, abstractmethod from collections.abc import Callable from functools import lru_cache @@ -153,6 +154,23 @@ def eligible(module_name: str) -> bool: return selection.selected cls.__registry__.discover_matching(eligible) + # Full discovery skips a source that fails to import, and selection has + # nothing left to do once it ran. A source that declares a wanted key + # yet registered nothing is a failed import, not an absent declaration: + # importing it here lets its own error propagate. Declaration sources + # are the files of the catalog module's package. + if missing.difference( + key + for declaration in dict.values(cls.__registry__) + for key in declared_keys(declaration) + ): + package_name = cls.require_registry_catalog_module() + package = importlib.import_module(package_name) + for module_info in pkgutil.iter_modules( + package.__path__, f"{package_name}." + ): + if not module_info.ispkg and eligible(module_info.name): + importlib.import_module(module_info.name) return tuple( declaration for declaration in dict.values(cls.__registry__) @@ -369,11 +387,78 @@ def _distribution_excluded_by_package_metadata(name: str) -> bool: for marker in (requirement.marker for requirement in requirements) ): return False + return not _distribution_installed(name) + + +def _distribution_installed(name: str) -> bool: try: importlib.metadata.distribution(name) except importlib.metadata.PackageNotFoundError: - return True - return False + return False + return True + + +@lru_cache(maxsize=1) +def _openhcs_extras() -> tuple[str, ...]: + """Return the extras the installed OpenHCS distribution declares.""" + + return tuple(importlib.metadata.metadata("openhcs").get_all("Provides-Extra") or ()) + + +def distributions_not_installed(func: Callable[..., object]) -> tuple[str, ...]: + """Return the absent distributions the file defining ``func`` requires. + + The file's ``REQUIRED_DISTRIBUTIONS`` is the one declaration: the catalogue + reads its literal before importing the file, and this reads the value the + function was defined beside. + """ + + declared = inspect.unwrap(func) + if not inspect.isfunction(declared): + return () + return tuple( + name + for name in declared.__globals__.get(_REQUIRED_DISTRIBUTIONS_NAME, ()) + if not _distribution_installed(name) + ) + + +def install_command_for_distributions(names: tuple[str, ...]) -> str: + """Return the pip command that installs ``names``. + + The package requirements say which OpenHCS extra brings each distribution; + when several do, the extra that brings the fewest requirements is named. A + distribution no extra brings is installed by its own name. + """ + + requirements = _openhcs_requirements() + + def brought_by(extra: str) -> tuple[str, ...]: + return tuple( + canonicalize_name(requirement.name) + for requirement in requirements + if requirement.marker is not None + and requirement.marker.evaluate({"extra": extra}) + and not requirement.marker.evaluate({"extra": ""}) + ) + + extras: list[str] = [] + unbundled: list[str] = [] + for name in names: + providing = tuple( + extra + for extra in _openhcs_extras() + if canonicalize_name(name) in brought_by(extra) + ) + if providing: + extras.append(min(providing, key=lambda extra: len(brought_by(extra)))) + else: + unbundled.append(name) + targets = ( + *((f"'openhcs[{','.join(dict.fromkeys(extras))}]'",) if extras else ()), + *unbundled, + ) + return f"pip install {' '.join(targets)}" @lru_cache(maxsize=1024) @@ -745,8 +830,16 @@ def discover_functions(self) -> Dict[str, FunctionMetadata]: """Discover OpenHCS functions with memory type decorators and assign default contracts.""" functions = {} modules = self.get_modules_to_scan() + # A catalog module hands over functions defined in other files; each + # file's own distribution requirement decides, as it does for a scan. catalog_functions = { - module_name: module.openhcs_registry_functions() + module_name: tuple( + func + for func in module.openhcs_registry_functions() + if not _module_distributions_excluded_by_package_metadata( + inspect.unwrap(func).__module__ + ) + ) for module_name, module in modules if isinstance(module, OpenHCSFunctionCatalogModule) } @@ -761,8 +854,6 @@ def discover_functions(self) -> Dict[str, FunctionMetadata]: ) for module_name, module in modules: - import inspect - module_function_count = 0 if isinstance(module, OpenHCSFunctionCatalogModule): diff --git a/tests/unit/test_cellprofiler_compatibility_matrix.py b/tests/unit/test_cellprofiler_compatibility_matrix.py index 1d6e78ff5..643cfd13a 100644 --- a/tests/unit/test_cellprofiler_compatibility_matrix.py +++ b/tests/unit/test_cellprofiler_compatibility_matrix.py @@ -13,6 +13,7 @@ build_cellprofiler_compatibility_report_for_manifests, ) from openhcs.core.callable_contract import FunctionStepExecutionScope +from openhcs.interop.cellprofiler.module_declarations import CellProfilerModule from openhcs.interop.cellprofiler.module_settings import ( SettingBound, ) @@ -21,7 +22,10 @@ def test_compatibility_matrix_accounts_for_absorbed_modules() -> None: report = build_cellprofiler_compatibility_report(corpus_cases=()) - assert len(report.modules) == 91 + assert sorted(module.module_name for module in report.modules) == sorted( + module_type.require_module_name() + for module_type in CellProfilerModule.__registry__.values() + ) assert all( module.importable or module.is_infrastructure for module in report.modules ) diff --git a/tests/unit/test_cellprofiler_undeclared_and_unavailable_modules.py b/tests/unit/test_cellprofiler_undeclared_and_unavailable_modules.py new file mode 100644 index 000000000..3779d3201 --- /dev/null +++ b/tests/unit/test_cellprofiler_undeclared_and_unavailable_modules.py @@ -0,0 +1,531 @@ +"""A CellProfiler pipeline imports even when a module cannot run here. + +Two cases: the module has no OpenHCS declaration, or it has one but the +distribution its file requires is not installed. Both import into ordinary +steps and are refused at compile time with a message saying what to do. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +from objectstate.lazy_factory import ensure_global_config_context +from objectstate.object_state import ObjectState +from objectstate.object_state_registry import ObjectStateRegistry +from packaging.requirements import Requirement + +from benchmark.converter.cppipe_corpus import in_tree_cppipe_corpus +from openhcs.core.compiled_step_plan import CompiledStepPlan +from openhcs.core.config import GlobalPipelineConfig, PipelineConfig +from openhcs.core.function_patterns import normalize_function_pattern +from openhcs.core.function_step_transport import FunctionStepTransportAuthority +from openhcs.core.invocation_artifacts import ( + PipelineInvocationContractProviderAuthority, +) +from openhcs.core.pipeline.compilation_session import ( + CompilationSession, + ResolvedPipelineDefinition, +) +from openhcs.core.pipeline.funcstep_contract_validator import ( + FuncStepContractValidator, +) +from openhcs.core.steps.function_step import FunctionStep +from openhcs.interop.cellprofiler import pipeline_import +from openhcs.interop.cellprofiler.module_artifact_contracts import ( + UnknownArtifactInput, +) +from openhcs.interop.cellprofiler.module_declarations import CellProfilerModule +from openhcs.interop.cellprofiler.parser import CPPipeParser +from openhcs.interop.cellprofiler.pipeline_import import import_cellprofiler_pipeline +from openhcs.processing.backends.cellprofiler.undeclared_module import ( + UndeclaredModule, + undeclared_module, +) +from openhcs.processing.backends.lib_registry import openhcs_registry +from tests.context_support import executing_context + +_SOURCE = """CellProfiler Pipeline: https://cellprofiler.org +NamesAndTypes:[module_num:1|enabled:True] + Assignments count:1 + Select the image type:Grayscale image + Name to assign these images:DNA + Select the rule criteria:and (file does contain "DNA") +""" + +_UNDECLARED_IN_THE_MIDDLE = ( + _SOURCE + + """GaussianFilter:[module_num:2|variable_revision_number:1|enabled:True] + Select the input image:DNA + Name the output image:Smoothed + Sigma:2 +SomeLabsPlugin:[module_num:3|variable_revision_number:4|enabled:True] + Select the input image:Smoothed + Name the output image:Special + Strength:0.5 +GaussianFilter:[module_num:4|variable_revision_number:1|enabled:True] + Select the input image:Special + Name the output image:Final + Sigma:1 +""" +) + +_UNDECLARED_OBJECT_PRODUCER = ( + _SOURCE + + """RunCellpose:[module_num:2|variable_revision_number:3|enabled:True] + Select the input image:DNA + Detection mode:nuclei + Name the output object:Nuclei +MeasureObjectSizeShape:[module_num:3|variable_revision_number:3|enabled:True] + Select object sets to measure:Nuclei + Calculate the Zernike features?:No + Calculate the advanced features?:No +MeasureObjectIntensity:[module_num:4|variable_revision_number:4|enabled:True] + Select images to measure:DNA + Select objects to measure:Nuclei +""" +) + + +def _import(tmp_path: Path, text: str) -> tuple[list[FunctionStep], PipelineConfig]: + cppipe_path = tmp_path / "pipeline.cppipe" + cppipe_path.write_text(text, encoding="utf-8") + return import_cellprofiler_pipeline(cppipe_path) + + +def _kwargs(step: FunctionStep) -> dict[str, object]: + (invocation,) = normalize_function_pattern(step.func).iter_items() + return dict(invocation.kwargs_dict) + + +def _compile_contracts(steps: list[FunctionStep], pipeline_config: PipelineConfig): + """Build the pipeline's invocation contracts the way compilation does.""" + + global_config = GlobalPipelineConfig() + ensure_global_config_context(GlobalPipelineConfig, global_config) + ObjectStateRegistry.clear() + pipeline_state = ObjectState(pipeline_config, scope_id="pipeline") + try: + ObjectStateRegistry.register(pipeline_state, _skip_snapshot=True) + resolved_steps = [] + for index, step in enumerate(steps): + step_state = ObjectState( + step, + scope_id=f"pipeline::functionstep_{index}", + parent_state=pipeline_state, + ) + ObjectStateRegistry.register(step_state, _skip_snapshot=True) + resolved_steps.append(step_state.to_saved_resolved_object()) + session = CompilationSession.from_context( + context=executing_context( + step_plans={ + index: CompiledStepPlan( + step_index=index, + step_name=step.name, + axis_id="A01", + ) + for index, step in enumerate(resolved_steps) + }, + axis_id="A01", + ), + orchestrator=SimpleNamespace(pipeline_config=pipeline_state.to_object()), + global_config=global_config, + pipeline=ResolvedPipelineDefinition( + steps=resolved_steps, + step_scope_ids={ + index: f"pipeline::functionstep_{index}" + for index in range(len(resolved_steps)) + }, + step_provenance={index: {} for index in range(len(resolved_steps))}, + ), + ) + return PipelineInvocationContractProviderAuthority.provider_for_pipeline( + session.pipeline + ) + finally: + ObjectStateRegistry.clear() + + +def test_undeclared_module_becomes_one_wired_step_that_cannot_compile( + tmp_path: Path, +) -> None: + steps, pipeline_config = _import(tmp_path, _UNDECLARED_IN_THE_MIDDLE) + + assert [step.name for step in steps] == [ + "GaussianFilter", + "SomeLabsPlugin", + "GaussianFilter", + ] + assert _kwargs(steps[1]) == { + "module_name": "SomeLabsPlugin", + "variable_revision_number": 4, + "settings": ( + ("Select the input image", "Smoothed"), + ("Name the output image", "Special"), + ("Strength", "0.5"), + ), + "input_images": "Smoothed", + "output_images": "Special", + } + # The later module reads the undeclared step's output from the main flow. + assert steps[2].processing_config.input_source.__name__ == "ReadPreviousStep" + + source = FunctionStepTransportAuthority.source_from_pipeline(steps) + namespace: dict[str, object] = {} + exec(compile(source, "undeclared_pipeline.py", "exec"), namespace) + reconstructed = FunctionStepTransportAuthority.pipeline_steps_from_namespace( + namespace + ) + assert FunctionStepTransportAuthority.source_from_pipeline(reconstructed) == source + assert _kwargs(reconstructed[1]) == _kwargs(steps[1]) + + message = ( + "CellProfiler module 'SomeLabsPlugin' has no OpenHCS implementation, so " + "this step cannot run. Replace the step's function with a custom " + "function producing image 'Special'." + ) + with pytest.raises(ValueError) as compile_error: + _compile_contracts(steps, pipeline_config) + assert str(compile_error.value) == ( + "CellProfiler contract compilation failed for step 1 ('SomeLabsPlugin'), " + "invocation FunctionInvocationKey(function_name='undeclared_module', " + f"group_key='default', position=0), module UndeclaredModule: {message}" + ) + with pytest.raises(NotImplementedError) as call_error: + undeclared_module(None, **_kwargs(steps[1])) + assert str(call_error.value) == message + + +def test_replacing_the_undeclared_step_function_lets_the_pipeline_compile( + tmp_path: Path, +) -> None: + steps, pipeline_config = _import(tmp_path, _UNDECLARED_IN_THE_MIDDLE) + (replacement,) = normalize_function_pattern(steps[0].func).iter_items() + + steps[1].func = ( + replacement.contract.resolve_canonical_raw_callable(), + {"sigma": 3.0, "name_the_output_image": "Special"}, + ) + + assert _compile_contracts(steps, pipeline_config) is not None + + +def test_undeclared_module_provides_objects_a_measurement_module_reads( + tmp_path: Path, +) -> None: + steps, _pipeline_config = _import(tmp_path, _UNDECLARED_OBJECT_PRODUCER) + + assert [step.name for step in steps] == [ + "RunCellpose", + "MeasureObjectSizeShape", + "MeasureObjectIntensity", + ] + kwargs = _kwargs(steps[0]) + assert kwargs["input_images"] == "DNA" + assert kwargs["output_objects"] == "Nuclei" + assert "output_images" not in kwargs + + +def test_nearest_preceding_undeclared_module_provides_a_shared_name( + tmp_path: Path, +) -> None: + steps, _pipeline_config = _import( + tmp_path, + _SOURCE + + """FirstPlugin:[module_num:2|enabled:True] + Name the output image:Special +FirstPlugin:[module_num:3|enabled:False] + Name the output image:Final +SecondPlugin:[module_num:4|enabled:True] + Select the input image:Special + Name the output image:Special +GaussianFilter:[module_num:5|enabled:True] + Select the input image:Special + Name the output image:Final + Sigma:1 +""", + ) + + assert [step.name for step in steps] == [ + "FirstPlugin", + "SecondPlugin", + "GaussianFilter", + ] + assert "output_images" not in _kwargs(steps[0]) + assert _kwargs(steps[1])["output_images"] == "Special" + + +def test_adjacent_undeclared_modules_stay_separate_steps(tmp_path: Path) -> None: + steps, _pipeline_config = _import( + tmp_path, + _SOURCE + + """FirstPlugin:[module_num:2|enabled:True] + Select the input image:DNA +SecondPlugin:[module_num:3|enabled:True] + Select the input image:DNA +SecondPlugin:[module_num:4|enabled:True] + Select the input image:DNA +""", + ) + + assert [(step.name, _kwargs(step)["module_name"]) for step in steps] == [ + ("FirstPlugin", "FirstPlugin"), + ("SecondPlugin", "SecondPlugin"), + ("SecondPlugin", "SecondPlugin"), + ] + + +def test_name_no_undeclared_module_carries_stays_an_unknown_artifact( + tmp_path: Path, +) -> None: + with pytest.raises( + UnknownArtifactInput, + match="Module GaussianFilter references unknown image artifact 'Elsewhere'", + ): + _import( + tmp_path, + _SOURCE + + """SomeLabsPlugin:[module_num:2|enabled:True] + Name the output image:Special +GaussianFilter:[module_num:3|enabled:True] + Select the input image:Elsewhere + Name the output image:Final + Sigma:1 +""", + ) + + +def test_declared_pipeline_takes_no_undeclared_path(monkeypatch) -> None: + """Every hook the undeclared member overrides is inert for declared modules.""" + + hooks = ( + "adjacent_blocks_share_step", + "block_in_artifact_context", + "block_providing_artifact", + "validate_compilation", + ) + overriding = { + module_type + for module_type in CellProfilerModule.__registry__.values() + for hook in hooks + if hook in vars(module_type) + } + assert overriding == {UndeclaredModule} + + def fail(*_args: object, **_kwargs: object) -> None: + raise AssertionError("a declared pipeline asked for a derived output") + + monkeypatch.setattr(pipeline_import, "_modules_providing_unknown_input", fail) + (case,) = (case for case in in_tree_cppipe_corpus() if case.name == "ExampleHuman") + for module in CPPipeParser().parse(case.cppipe_path): + assert CellProfilerModule.declaration_for_block( + module + ) is CellProfilerModule.require_module(module.name) + steps, _pipeline_config = import_cellprofiler_pipeline(case.cppipe_path) + assert all( + CellProfilerModule.require_callable_contract_owner(invocation.contract) + is not UndeclaredModule + for step in steps + for invocation in normalize_function_pattern(step.func).iter_items() + ) + + +def _absent_distribution_requirements(monkeypatch) -> None: + monkeypatch.setattr( + openhcs_registry, + "_openhcs_requirements", + lambda: ( + Requirement('absent-probe>=1; extra == "probe"'), + Requirement('absent-probe>=1; extra == "everything"'), + Requirement('numpy>=1; extra == "everything"'), + ), + ) + monkeypatch.setattr( + openhcs_registry, "_openhcs_extras", lambda: ("everything", "probe") + ) + + +def test_declared_module_with_absent_distribution_imports_and_cannot_compile( + tmp_path: Path, + monkeypatch, +) -> None: + _absent_distribution_requirements(monkeypatch) + # The package attribute of this name is the function; the file is the module. + gaussian_filter_type = CellProfilerModule.require_module("GaussianFilter") + monkeypatch.setattr( + importlib.import_module(gaussian_filter_type.__module__), + "REQUIRED_DISTRIBUTIONS", + ("absent-probe",), + raising=False, + ) + + steps, _pipeline_config = _import( + tmp_path, + _SOURCE + + """GaussianFilter:[module_num:2|enabled:True] + Select the input image:DNA + Name the output image:Smoothed + Sigma:2 +""", + ) + + (invocation,) = normalize_function_pattern(steps[0].func).iter_items() + assert invocation.contract.function_name == "gaussian_filter" + assert invocation.kwargs_dict["sigma"] == 2.0 + with pytest.raises(ValueError) as error: + FuncStepContractValidator.validate_external_library_installation( + invocation.contract.func, + steps[0].name, + ) + assert ( + "Function 'gaussian_filter' in step 'GaussianFilter' requires external " + "library 'absent-probe' which is not installed." + ) in str(error.value) + assert "Install with: pip install 'openhcs[probe]'" in str(error.value) + + +def test_install_command_names_the_narrowest_extra_or_the_distribution( + monkeypatch, +) -> None: + _absent_distribution_requirements(monkeypatch) + + assert ( + openhcs_registry.install_command_for_distributions( + ("absent-probe", "numpy", "no-extra-brings-this") + ) + == "pip install 'openhcs[probe,everything]' no-extra-brings-this" + ) + + +@pytest.mark.parametrize( + "requirement, cataloged", + [ + ('absent-catalog-probe>=1; extra == "probe"', False), + ('numpy>=1; extra == "probe"', True), + ], +) +def test_catalog_module_cannot_hand_over_a_function_its_file_excludes( + tmp_path: Path, + monkeypatch, + requirement: str, + cataloged: bool, +) -> None: + distribution = Requirement(requirement).name + package_name = f"catalog_handover_probe_{distribution.replace('-', '_')}" + package = tmp_path / package_name + package.mkdir() + (package / "__init__.py").write_text( + "import sys\n" + "from openhcs.processing.backends.lib_registry.openhcs_registry import (\n" + " OpenHCSFunctionCatalogModule,\n" + ")\n" + "class Catalog(OpenHCSFunctionCatalogModule):\n" + " def openhcs_registry_functions(self):\n" + f" from {package_name}.implementation import process\n" + " return (process,)\n" + "sys.modules[__name__].__class__ = Catalog\n" + ) + (package / "implementation.py").write_text( + "from openhcs.core.memory.decorators import numpy\n" + f"REQUIRED_DISTRIBUTIONS = ({distribution!r},)\n" + "@numpy\n" + "def process(image):\n" + " import absent_catalog_probe\n" + " return image\n" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.setenv("OPENHCS_CPU_ONLY", "1") + monkeypatch.setattr( + openhcs_registry, + "_openhcs_requirements", + lambda: (Requirement(requirement),), + ) + registry = openhcs_registry.OpenHCSRegistry() + registry.MODULES_TO_SCAN = [package_name, f"{package_name}.implementation"] + + try: + functions = registry.discover_functions() + finally: + for name in (package_name, f"{package_name}.implementation"): + sys.modules.pop(name, None) + + assert [metadata.original_name for metadata in functions.values()] == ( + ["process"] if cataloged else [] + ) + + +def test_declaration_file_that_fails_to_import_is_not_reported_absent( + tmp_path: Path, + monkeypatch, +) -> None: + """Full discovery skips a broken file; the lookup by key must not.""" + + package_name = "declaration_import_failure_probe" + package = tmp_path / package_name + package.mkdir() + (package / "__init__.py").write_text( + "from typing import ClassVar\n" + "from metaclass_registry import (\n" + " AutoRegisterMeta, LazyDiscoveryDict, RegistryConfig,\n" + ")\n" + "from openhcs.processing.backends.lib_registry.openhcs_registry import (\n" + " OpenHCSFunctionCatalogDeclaration,\n" + ")\n" + "REGISTRY = LazyDiscoveryDict(enable_cache=False)\n" + "class Declaration(\n" + " OpenHCSFunctionCatalogDeclaration,\n" + " metaclass=AutoRegisterMeta,\n" + " registry_config=RegistryConfig(\n" + " registry_dict=REGISTRY,\n" + " key_attribute='module_name',\n" + " skip_if_no_key=True,\n" + " registry_name='probe declaration',\n" + f" discovery_package={package_name!r},\n" + " ),\n" + "):\n" + " __registry__ = REGISTRY\n" + " __registry_key__ = 'module_name'\n" + " __skip_if_no_key__ = True\n" + " module_name: ClassVar[str | None] = None\n" + f" registry_catalog_module: ClassVar[str] = {package_name!r}\n" + " @classmethod\n" + " def declared_function_names(cls):\n" + " return ()\n" + ) + (package / "working.py").write_text( + f"from {package_name} import Declaration\n" + "class Working(Declaration):\n" + " module_name = 'Working'\n" + ) + (package / "broken.py").write_text( + "import absent_declaration_dependency\n" + f"from {package_name} import Declaration\n" + "class Broken(Declaration):\n" + " module_name = 'Broken'\n" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + try: + import declaration_import_failure_probe as probe + + def lookup(key: str) -> tuple[type, ...]: + return probe.Declaration.discover_source_declarations( + frozenset((key,)), + ("module_name",), + str, + lambda declaration: frozenset((declaration.module_name,)), + ) + + assert sorted(probe.REGISTRY) == ["Working"] + assert [declaration.__name__ for declaration in lookup("Working")] == [ + "Working" + ] + assert lookup("NeverDeclared") == () + with pytest.raises(ModuleNotFoundError, match="absent_declaration_dependency"): + lookup("Broken") + finally: + for name in tuple(sys.modules): + if name.partition(".")[0] == package_name: + del sys.modules[name] diff --git a/tests/unit/test_cppipe_corpus.py b/tests/unit/test_cppipe_corpus.py index 29bee3d89..a067dd20a 100644 --- a/tests/unit/test_cppipe_corpus.py +++ b/tests/unit/test_cppipe_corpus.py @@ -16,6 +16,9 @@ from openhcs.core.function_step_transport import FunctionStepTransportAuthority from openhcs.core.steps.function_step import FunctionStep from openhcs.interop.cellprofiler import import_cellprofiler_pipeline +from openhcs.interop.cellprofiler.module_artifact_contracts import ( + UnknownArtifactInput, +) from openhcs.interop.cellprofiler.module_declarations import CellProfilerModule from openhcs.interop.cellprofiler.parser import CPPipeParser @@ -128,7 +131,7 @@ def test_in_tree_cppipe_corpus_import_expectations( if case.status is not CPPipeCorpusStatus.SUPPORTED: assert case.expected_error_substring is not None with pytest.raises( - KeyError, + UnknownArtifactInput, match=re.escape(case.expected_error_substring), ): import_cellprofiler_pipeline(case.cppipe_path) @@ -187,22 +190,36 @@ def test_in_tree_cppipe_corpus_import_expectations( ] -def test_cppipe_import_rejects_unknown_module_at_nominal_boundary( +def test_cppipe_import_keeps_a_module_without_declaration_as_its_own_step( tmp_path: Path, ) -> None: cppipe_path = tmp_path / "unsupported.cppipe" cppipe_path.write_text( """CellProfiler Pipeline: https://cellprofiler.org -UnsupportedModule:[module_num:1|enabled:True] +UnsupportedModule:[module_num:1|variable_revision_number:2|enabled:True] + Some setting:Some value +UnsupportedModule:[module_num:2|enabled:False] """, encoding="utf-8", ) + assert CellProfilerModule.for_module("UnsupportedModule") is None with pytest.raises( KeyError, match="No CellProfiler module declaration is registered", ): - import_cellprofiler_pipeline(cppipe_path) + CellProfilerModule.require_module("UnsupportedModule") + + (step,), _pipeline_config = import_cellprofiler_pipeline(cppipe_path) + + assert step.name == "UnsupportedModule" + function, kwargs = step.func + assert function.__name__ == "undeclared_module" + assert kwargs == { + "module_name": "UnsupportedModule", + "variable_revision_number": 2, + "settings": (("Some setting", "Some value"),), + } def test_cppipe_import_rejects_pipeline_without_modules(tmp_path: Path) -> None: From 0bdb355c7d0744c037fcb2f46ed1e07a94590390 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 15:56:41 -0400 Subject: [PATCH 3/9] Plugin modules HistogramEqualization, DistanceTransform, HistogramMatching Each is one file: the module declaration and its function. Settings are bound from real .cppipe blocks; outputs are compared with the real plugins run in CellProfiler 4.2.8.1 (plugin commit 5aaa4e5) on 18 cases, max difference 4.2e-7. Co-Authored-By: Claude Opus 5.5 --- .../cellprofiler/distance_transform.py | 72 ++++ .../cellprofiler/histogram_equalization.py | 142 ++++++++ .../cellprofiler/histogram_matching.py | 121 +++++++ .../cellprofiler_plugins/cases.cppipe | 198 +++++++++++ .../cellprofiler_plugins/make_reference.py | 133 +++++++ .../cellprofiler_plugins/reference.npz | Bin 0 -> 76874 bytes .../unit/test_cellprofiler_plugin_modules.py | 334 ++++++++++++++++++ 7 files changed, 1000 insertions(+) create mode 100644 openhcs/processing/backends/cellprofiler/distance_transform.py create mode 100644 openhcs/processing/backends/cellprofiler/histogram_equalization.py create mode 100644 openhcs/processing/backends/cellprofiler/histogram_matching.py create mode 100644 tests/fixtures/cellprofiler_plugins/cases.cppipe create mode 100644 tests/fixtures/cellprofiler_plugins/make_reference.py create mode 100644 tests/fixtures/cellprofiler_plugins/reference.npz create mode 100644 tests/unit/test_cellprofiler_plugin_modules.py diff --git a/openhcs/processing/backends/cellprofiler/distance_transform.py b/openhcs/processing/backends/cellprofiler/distance_transform.py new file mode 100644 index 000000000..369e1749a --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/distance_transform.py @@ -0,0 +1,72 @@ +"""CellProfiler plugin DistanceTransform. + +Upstream: CellProfiler-plugins ``active_plugins/distancetransform.py``. +""" + +from typing import ClassVar + +import numpy as np + +from openhcs.core.artifacts.kinds import ImageArtifactType +from openhcs.core.callable_contract import runtime_image_execution_mode +from openhcs.core.image_payload_execution_mode import FullStackExecution +from openhcs.core.image_shapes import apply_over_trailing_spatial_axes +from openhcs.core.memory.decorators import numpy +from openhcs.core.processing_contracts import FlexibleContract +from openhcs.core.runtime_image_values import ImagePayload +from openhcs.interop.cellprofiler.module_declarations import CellProfilerModule +from openhcs.interop.cellprofiler.settings_binder import ( + SettingToKeywordBinding, + parse_cellprofiler_bool, +) + + +class DistanceTransformModule(CellProfilerModule): + module_name = "DistanceTransform" + function_name = "distance_transform" + validated = True + setting_bindings: ClassVar[tuple[SettingToKeywordBinding, ...]] = ( + SettingToKeywordBinding.input("Select the input image", ImageArtifactType), + SettingToKeywordBinding.output("Name the output image", ImageArtifactType), + SettingToKeywordBinding( + "Rescale values from 0 to 1?", "rescale", parse_cellprofiler_bool + ), + ) + + +@runtime_image_execution_mode(FullStackExecution) +@numpy(contract=FlexibleContract) +def distance_transform(image: ImagePayload, rescale: bool = True) -> ImagePayload: + """Distance of every non-zero pixel to the nearest zero pixel. + + Distances are Euclidean and in units of the Y pixel size: the voxel spacing + is divided by its second entry, as CellProfiler does, so a volume with + thicker planes measures further across planes than within one. Each image + of a stack (2D plane or 3D volume, as its metadata declares) is transformed + on its own. + + The plugin's quirks are kept. Its documentation says it respects masks, but + the image mask does not take part: masked pixels count as image content, + and the output carries the input's mask. An image with no non-zero pixel + divides zero by zero when rescaling and becomes all NaN. + + Args: + rescale: Divide by the image's largest distance, giving values from 0 to + 1. Leave off to keep distances in pixel units. + """ + from scipy.ndimage import distance_transform_edt + + pixels = np.asarray(image.data) + spatial_rank = image.metadata.source_spatial_domain.spatial_rank + spacing = image.metadata.source_voxel_spacing.spacing_for_ndim(spatial_rank) + relative_spacing = tuple(np.divide(spacing, spacing[1])) + + def distances_of_one_image(one_image: np.ndarray) -> np.ndarray: + distances = distance_transform_edt(one_image, sampling=relative_spacing) + return distances / np.max(distances) if rescale else distances + + return image.with_pixels( + apply_over_trailing_spatial_axes( + pixels, spatial_rank, distances_of_one_image, dtype=np.float32 + ).astype(np.float32, copy=False) + ) diff --git a/openhcs/processing/backends/cellprofiler/histogram_equalization.py b/openhcs/processing/backends/cellprofiler/histogram_equalization.py new file mode 100644 index 000000000..6e69790fd --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/histogram_equalization.py @@ -0,0 +1,142 @@ +"""CellProfiler plugin HistogramEqualization. + +Upstream: CellProfiler-plugins ``active_plugins/histogramequalization.py``. +""" + +from typing import ClassVar + +import numpy as np + +from openhcs.core.artifacts.kinds import ImageArtifactType +from openhcs.core.callable_contract import runtime_image_execution_mode +from openhcs.core.image_payload_execution_mode import FullStackExecution +from openhcs.core.memory.decorators import numpy +from openhcs.core.pipeline.function_contracts import special_inputs +from openhcs.core.processing_contracts import FlexibleContract +from openhcs.core.runtime_image_values import ImagePayload +from openhcs.interop.cellprofiler.module_declarations import CellProfilerModule +from openhcs.interop.cellprofiler.setting_names import SettingNameFamily +from openhcs.interop.cellprofiler.settings_binder import ( + SettingToKeywordBinding, + parse_cellprofiler_bool, + parse_cellprofiler_float, + parse_cellprofiler_int, +) + + +class HistogramEqualizationModule(CellProfilerModule): + module_name = "HistogramEqualization" + function_name = "histogram_equalization" + validated = True + setting_bindings: ClassVar[tuple[SettingToKeywordBinding, ...]] = ( + SettingToKeywordBinding.input("Select the input image", ImageArtifactType), + SettingToKeywordBinding.output("Name the output image", ImageArtifactType), + SettingToKeywordBinding("Bins", "bins", parse_cellprofiler_int), + SettingToKeywordBinding.input( + "Mask", ImageArtifactType, runtime_parameter_name="mask_image" + ), + SettingToKeywordBinding("Local", "local", parse_cellprofiler_bool), + SettingToKeywordBinding("Tile Size", "tile_size", parse_cellprofiler_int), + SettingToKeywordBinding("Clip limit", "clip_limit", parse_cellprofiler_float), + SettingToKeywordBinding( + # Pipelines saved by earlier plugin versions carry the misspelling. + SettingNameFamily("Is your image 3D?", aliases=("Is you image 3D?",)), + "planes_form_a_volume", + parse_cellprofiler_bool, + ), + SettingToKeywordBinding( + "Do framewise calculation?", "framewise", parse_cellprofiler_bool + ), + SettingToKeywordBinding( + "Tile Size (Z)", "tile_size_z", parse_cellprofiler_int + ), + ) + + +@runtime_image_execution_mode(FullStackExecution) +@numpy(contract=FlexibleContract) +@special_inputs("mask_image") +def histogram_equalization( + image: ImagePayload, + mask_image: np.ndarray | None = None, + bins: int = 256, + local: bool = False, + tile_size: int = 50, + clip_limit: float = 0.01, + planes_form_a_volume: bool = False, + framewise: bool = False, + tile_size_z: int = 5, +) -> ImagePayload: + """Spread an image's intensities over the full range to raise contrast. + + The global method maps every pixel through the image's cumulative + histogram (scikit-image ``equalize_hist``). The local method equalizes + within tiles and limits how much any intensity is amplified + (``equalize_adapthist``, CLAHE). Each image of a stack (2D plane or 3D + volume, as its metadata declares) is equalized on its own. + + The plugin's quirks are kept. Whether a volume is treated as one is decided + by ``planes_form_a_volume``, not by the image: left off, a volume still + goes to scikit-image whole, with ``tile_size`` on all three axes for the + local method; turned on for a 2D image, ``framewise`` equalizes row by row. + The mask is another image's pixels (non-zero means inside), not the image's + own mask, which is ignored although the plugin's documentation says masks + are respected. The local method does not use the mask. A mask together with + ``planes_form_a_volume`` and ``framewise`` fails in the plugin (it offers + the whole mask to each plane) and is refused here. + + Args: + mask_image: Image whose non-zero pixels select what the global histogram is + computed from; the mapping is still applied to every pixel. Same + shape as the image. + bins: Number of histogram bins. + local: Equalize within tiles (CLAHE) instead of over the whole image. + tile_size: Tile edge in pixels for the local method; pick one that fits + at least one object of interest. + clip_limit: Local method only, 0 to 1; higher gives more contrast and + more amplified background. + planes_form_a_volume: Apply the 3D options below. + framewise: With ``planes_form_a_volume``, equalize each plane on its + own instead of the volume as a whole. + tile_size_z: Tile depth in planes for the local method on a whole + volume. + """ + from skimage.exposure import equalize_adapthist, equalize_hist + + each_plane = planes_form_a_volume and framewise + if mask_image is not None and not local and each_plane: + raise ValueError( + "HistogramEqualization cannot use a mask when equalizing a volume " + "framewise: the CellProfiler plugin fails on this combination." + ) + kernel_size = ( + (tile_size_z, tile_size, tile_size) + if planes_form_a_volume and not framewise + else tile_size + ) + + def equalize(pixels: np.ndarray, pixels_mask: np.ndarray | None) -> np.ndarray: + if local: + return equalize_adapthist( + pixels, kernel_size=kernel_size, nbins=bins, clip_limit=clip_limit + ) + # In double precision, as the scikit-image CellProfiler pins computes it. + return equalize_hist(pixels.astype(np.float64), nbins=bins, mask=pixels_mask) + + pixels = np.asarray(image.data) + masks = ( + None + if mask_image is None + else np.broadcast_to(ImagePayload.of(mask_image).data, pixels.shape) + ) + spatial_rank = image.metadata.source_spatial_domain.spatial_rank + equalized = np.empty(pixels.shape, dtype=np.float32) + for index in np.ndindex(pixels.shape[: pixels.ndim - spatial_rank]): + one_image = pixels[index] + one_mask = None if masks is None else masks[index] + equalized[index] = ( + np.stack([equalize(plane, one_mask) for plane in one_image]) + if each_plane + else equalize(one_image, one_mask) + ) + return image.with_pixels(equalized) diff --git a/openhcs/processing/backends/cellprofiler/histogram_matching.py b/openhcs/processing/backends/cellprofiler/histogram_matching.py new file mode 100644 index 000000000..9a234fb0b --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/histogram_matching.py @@ -0,0 +1,121 @@ +"""CellProfiler plugin HistogramMatching. + +Upstream: CellProfiler-plugins ``active_plugins/histogrammatching.py``. +""" + +from typing import ClassVar + +import numpy as np + +from openhcs.core.artifacts.kinds import ImageArtifactType +from openhcs.core.callable_contract import runtime_image_execution_mode +from openhcs.core.image_payload_execution_mode import FullStackExecution +from openhcs.core.memory.decorators import numpy +from openhcs.core.pipeline.function_contracts import special_inputs +from openhcs.core.processing_contracts import FlexibleContract +from openhcs.core.runtime_image_values import ImagePayload +from openhcs.core.source_spatial_domain import VolumeSourceSpatialDomain +from openhcs.interop.cellprofiler.module_declarations import CellProfilerModule +from openhcs.interop.cellprofiler.setting_names import SettingNameFamily +from openhcs.interop.cellprofiler.settings_binder import ( + SettingToKeywordBinding, + parse_cellprofiler_bool, + parse_cellprofiler_int, +) + + +class HistogramMatchingModule(CellProfilerModule): + module_name = "HistogramMatching" + function_name = "histogram_matching" + validated = True + ignored_settings = ( + # The plugin only uses this to show or hide other settings; what it + # computes depends on whether the image itself is a volume. Pipelines + # saved by earlier plugin versions carry the misspelling. + SettingNameFamily("Is your image 3D?", aliases=("Is you image 3D?",)), + # Written by a fork of the plugin (seen in TrynkaLab/tglow-pipeline); + # the plugin this module reproduces has no such setting. + "Plane number in 3d", + ) + setting_bindings: ClassVar[tuple[SettingToKeywordBinding, ...]] = ( + SettingToKeywordBinding.input("Select the input image", ImageArtifactType), + SettingToKeywordBinding.output("Name the output image", ImageArtifactType), + SettingToKeywordBinding( + "Use a frame within image as reference?", + "frame_is_reference", + parse_cellprofiler_bool, + ), + SettingToKeywordBinding.input( + "Image to use as reference", + ImageArtifactType, + runtime_parameter_name="reference", + ), + SettingToKeywordBinding("Frame number", "frame_number", parse_cellprofiler_int), + ) + + +@runtime_image_execution_mode(FullStackExecution) +@numpy(contract=FlexibleContract) +@special_inputs("reference") +def histogram_matching( + image: ImagePayload, + reference: np.ndarray | None = None, + frame_is_reference: bool = False, + frame_number: int = 5, +) -> ImagePayload: + """Remap an image's intensities so its histogram matches a reference's. + + Uses scikit-image ``match_histograms``. A 2D image is matched to the + reference image, which may have another shape. In a volume every plane is + matched to one plane of the same volume. Each image of a stack (2D plane or + 3D volume, as its metadata declares) is matched on its own. + + The plugin's quirks are kept. Which branch runs depends on the image, not + on the plugin's "Is your image 3D?" setting, which is not a parameter here. + A 2D image always uses the reference image, whatever ``frame_is_reference`` + says. A volume matched to a reference image fails in the plugin and is + refused here. ``frame_number`` counts from zero although the plugin's + setting only accepts 1 and above, so the plugin cannot pick the first + plane; here 0 picks it. + + Args: + reference: Image whose intensity distribution a 2D image takes on. + frame_is_reference: For a volume, match every plane to the plane chosen + by ``frame_number``. A volume requires this. + frame_number: Zero-based index of the reference plane within a volume. + """ + from skimage.exposure import match_histograms + + volumetric = isinstance( + image.metadata.source_spatial_domain, VolumeSourceSpatialDomain + ) + if volumetric and not frame_is_reference: + raise ValueError( + "HistogramMatching cannot match a volume to a reference image: the " + "CellProfiler plugin fails on this combination. Use a frame within " + "the image as reference." + ) + if not volumetric and reference is None: + raise ValueError("HistogramMatching of a 2D image requires a reference image.") + + pixels = np.asarray(image.data) + reference_pixels = ( + None if reference is None else np.asarray(ImagePayload.of(reference).data) + ) + spatial_rank = image.metadata.source_spatial_domain.spatial_rank + matched = np.empty(pixels.shape, dtype=np.float32) + for index in np.ndindex(pixels.shape[: pixels.ndim - spatial_rank]): + one_image = pixels[index] + if volumetric: + frame = one_image[frame_number] + matched[index] = np.stack( + [match_histograms(plane, frame) for plane in one_image] + ) + else: + matched[index] = match_histograms( + one_image, + reference_pixels[index] + if reference_pixels.ndim == pixels.ndim + else reference_pixels, + ) + return image.with_pixels(matched) diff --git a/tests/fixtures/cellprofiler_plugins/cases.cppipe b/tests/fixtures/cellprofiler_plugins/cases.cppipe new file mode 100644 index 000000000..5c3a69ca4 --- /dev/null +++ b/tests/fixtures/cellprofiler_plugins/cases.cppipe @@ -0,0 +1,198 @@ +CellProfiler Pipeline: http://www.cellprofiler.org +Version:5 +DateRevision:428 +GitHash: +ModuleCount:20 +HasImagePlaneDetails:False + +HistogramEqualization:[module_num:1|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Plane + Name the output image:EqualizedPlane + Bins:256 + Mask:Leave blank + Local:No + Tile Size:8 + Clip limit:0.01 + Is your image 3D?:No + Do framewise calculation?:No + Tile Size (Z):2 + +HistogramEqualization:[module_num:2|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Plane + Name the output image:EqualizedPlaneWithinMask + Bins:64 + Mask:MaskPlane + Local:No + Tile Size:8 + Clip limit:0.01 + Is your image 3D?:No + Do framewise calculation?:No + Tile Size (Z):2 + +HistogramEqualization:[module_num:3|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Plane + Name the output image:LocallyEqualizedPlane + Bins:128 + Mask:Leave blank + Local:Yes + Tile Size:8 + Clip limit:0.02 + Is your image 3D?:No + Do framewise calculation?:No + Tile Size (Z):2 + +HistogramEqualization:[module_num:4|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Plane + Name the output image:LocallyEqualizedPlaneMaskUnused + Bins:128 + Mask:MaskPlane + Local:Yes + Tile Size:8 + Clip limit:0.02 + Is your image 3D?:No + Do framewise calculation?:No + Tile Size (Z):2 + +HistogramEqualization:[module_num:5|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Volume + Name the output image:EqualizedVolume + Bins:256 + Mask:Leave blank + Local:No + Tile Size:8 + Clip limit:0.01 + Is your image 3D?:Yes + Do framewise calculation?:No + Tile Size (Z):2 + +HistogramEqualization:[module_num:6|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Volume + Name the output image:EqualizedVolumeWithinMask + Bins:256 + Mask:MaskVolume + Local:No + Tile Size:8 + Clip limit:0.01 + Is your image 3D?:Yes + Do framewise calculation?:No + Tile Size (Z):2 + +HistogramEqualization:[module_num:7|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Volume + Name the output image:EqualizedVolumeFramewise + Bins:256 + Mask:Leave blank + Local:No + Tile Size:8 + Clip limit:0.01 + Is your image 3D?:Yes + Do framewise calculation?:Yes + Tile Size (Z):2 + +HistogramEqualization:[module_num:8|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Volume + Name the output image:EqualizedVolumeFramewiseWithinMask + Bins:256 + Mask:MaskVolume + Local:No + Tile Size:8 + Clip limit:0.01 + Is your image 3D?:Yes + Do framewise calculation?:Yes + Tile Size (Z):2 + +HistogramEqualization:[module_num:9|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Volume + Name the output image:LocallyEqualizedVolume + Bins:128 + Mask:Leave blank + Local:Yes + Tile Size:8 + Clip limit:0.02 + Is your image 3D?:Yes + Do framewise calculation?:No + Tile Size (Z):2 + +HistogramEqualization:[module_num:10|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Volume + Name the output image:LocallyEqualizedVolumeFramewise + Bins:128 + Mask:Leave blank + Local:Yes + Tile Size:8 + Clip limit:0.02 + Is your image 3D?:Yes + Do framewise calculation?:Yes + Tile Size (Z):2 + +HistogramEqualization:[module_num:11|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Volume + Name the output image:LocallyEqualizedVolumeNotDeclared3D + Bins:128 + Mask:Leave blank + Local:Yes + Tile Size:8 + Clip limit:0.02 + Is your image 3D?:No + Do framewise calculation?:Yes + Tile Size (Z):2 + +DistanceTransform:[module_num:12|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:BinaryPlane + Name the output image:RescaledPlaneDistance + Rescale values from 0 to 1?:Yes + +DistanceTransform:[module_num:13|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:BinaryPlane + Name the output image:PlaneDistance + Rescale values from 0 to 1?:No + +DistanceTransform:[module_num:14|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:BinaryVolume + Name the output image:RescaledVolumeDistance + Rescale values from 0 to 1?:Yes + +DistanceTransform:[module_num:15|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:BinaryVolume + Name the output image:VolumeDistance + Rescale values from 0 to 1?:No + +HistogramMatching:[module_num:16|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Plane + Name the output image:PlaneMatchedToReference + Is your image 3D?:No + Use a frame within image as reference?:No + Image to use as reference :ReferencePlane + Frame number:5 + +HistogramMatching:[module_num:17|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Plane + Name the output image:PlaneMatchedToReferenceFrameSettingOn + Is your image 3D?:No + Use a frame within image as reference?:Yes + Image to use as reference :ReferencePlane + Frame number:2 + +HistogramMatching:[module_num:18|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Volume + Name the output image:VolumeMatchedToFrame + Is your image 3D?:Yes + Use a frame within image as reference?:Yes + Image to use as reference :None + Frame number:2 + +HistogramMatching:[module_num:19|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Volume + Name the output image:VolumeMatchedToFrameNotDeclared3D + Is your image 3D?:No + Use a frame within image as reference?:Yes + Image to use as reference :None + Frame number:1 + +HistogramMatching:[module_num:20|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Volume + Name the output image:VolumeMatchedToReference + Is your image 3D?:Yes + Use a frame within image as reference?:No + Image to use as reference :ReferencePlane + Frame number:2 diff --git a/tests/fixtures/cellprofiler_plugins/make_reference.py b/tests/fixtures/cellprofiler_plugins/make_reference.py new file mode 100644 index 000000000..f48092379 --- /dev/null +++ b/tests/fixtures/cellprofiler_plugins/make_reference.py @@ -0,0 +1,133 @@ +"""Reference outputs of CellProfiler plugins, computed by the plugins themselves. + +Run with the pinned native CellProfiler interpreter, never the OpenHCS one: + + nice -n 19 /.venv-cellprofiler39/bin/python -I \ + tests/fixtures/cellprofiler_plugins/make_reference.py \ + \ + \ + tests/fixtures/cellprofiler_plugins + +CellProfiler's own pipeline loader reads ``cases.cppipe`` (so the plugins parse +their own settings), then each module's ``run`` is called on a workspace whose +images are the arrays below. No image is read from or written to disk and Java +is not started. The script writes ``reference.npz`` (inputs and the pixels each +plugin stored) and ``reference.json`` (versions, voxel spacing, and the cases +in which the plugin itself raised). +""" + +import json +import sys +from pathlib import Path + +import numpy + +VOXEL_SPACING_ZYX = (5.0, 2.0, 2.0) + + +def input_images(): + random = numpy.random.RandomState(20261012) + rows, columns = numpy.mgrid[0:24, 0:32] + plane = 0.15 + 0.5 * numpy.exp(-((rows - 9) ** 2 + (columns - 12) ** 2) / 40.0) + plane = plane + 0.1 * columns / 31.0 + 0.08 * random.rand(24, 32) + reference_plane = random.beta(2.0, 5.0, size=(20, 28)) + planes, volume_rows, volume_columns = numpy.mgrid[0:4, 0:20, 0:24] + volume = 0.1 + 0.12 * planes + volume = volume + 0.3 * numpy.exp( + -((volume_rows - 8) ** 2 + (volume_columns - 14) ** 2) / 30.0 + ) + volume = volume + 0.1 * random.rand(4, 20, 24) + mask_plane = numpy.zeros((24, 32)) + mask_plane[4:20, 6:26] = 1.0 + mask_plane[10:14, 10:14] = 0.5 + mask_volume = numpy.zeros((4, 20, 24)) + mask_volume[1:4, 3:17, 5:20] = 1.0 + binary_plane = numpy.zeros((24, 32), dtype=bool) + binary_plane[3:15, 4:18] = True + binary_plane[16:22, 20:30] = True + binary_plane[8, 9] = False + binary_volume = numpy.zeros((4, 20, 24), dtype=bool) + binary_volume[0:4, 4:16, 5:19] = True + binary_volume[2, 9, 11] = False + return { + "Plane": plane.astype(numpy.float32), + "ReferencePlane": reference_plane.astype(numpy.float32), + "MaskPlane": mask_plane.astype(numpy.float32), + "Volume": volume.astype(numpy.float32), + "MaskVolume": mask_volume.astype(numpy.float32), + "BinaryPlane": binary_plane, + "BinaryVolume": binary_volume, + } + + +def main(plugin_directory, plugin_commit, fixture_directory): + import cellprofiler + import scipy + import skimage + from cellprofiler_core.image import Image, ImageSetList + from cellprofiler_core.measurement import Measurements + from cellprofiler_core.object import ObjectSet + from cellprofiler_core.pipeline import Pipeline + from cellprofiler_core.preferences import set_headless, set_plugin_directory + from cellprofiler_core.utilities.core.modules import fill_modules + from cellprofiler_core.workspace import Workspace + + set_headless() + set_plugin_directory(plugin_directory, globally=False) + fill_modules() + fixture_directory = Path(fixture_directory) + pipeline = Pipeline() + pipeline.load(str(fixture_directory / "cases.cppipe")) + inputs = input_images() + outputs = {} + failures = {} + for module in pipeline.modules(): + image_set_list = ImageSetList() + image_set = image_set_list.get_image_set(0) + for name, pixels in inputs.items(): + image_set.add( + name, + Image( + pixels, + dimensions=pixels.ndim, + spacing=VOXEL_SPACING_ZYX if pixels.ndim == 3 else None, + ), + ) + measurements = Measurements() + workspace = Workspace( + pipeline, module, image_set, ObjectSet(), measurements, image_set_list + ) + output_name = module.y_name.value + try: + module.run(workspace) + outputs[output_name] = image_set.get_image(output_name).pixel_data + except Exception as error: # noqa: BLE001 - whatever the plugin raises is the record + failures[output_name] = f"{type(error).__name__}: {error}" + finally: + measurements.close() + numpy.savez_compressed( + str(fixture_directory / "reference.npz"), + **{"input_" + name: pixels for name, pixels in inputs.items()}, + **{"output_" + name: pixels for name, pixels in outputs.items()}, + ) + (fixture_directory / "reference.json").write_text( + json.dumps( + { + "cellprofiler_plugins_commit": plugin_commit, + "cellprofiler": cellprofiler.__version__, + "scikit-image": skimage.__version__, + "scipy": scipy.__version__, + "numpy": numpy.__version__, + "python": sys.version.split()[0], + "voxel_spacing_zyx_of_volumes": VOXEL_SPACING_ZYX, + "plugin_raised": failures, + }, + indent=2, + sort_keys=True, + ) + + "\n" + ) + + +if __name__ == "__main__": + main(*sys.argv[1:]) diff --git a/tests/fixtures/cellprofiler_plugins/reference.npz b/tests/fixtures/cellprofiler_plugins/reference.npz new file mode 100644 index 0000000000000000000000000000000000000000..fa7bdd6e116d46c95e6a9d676d9d82f4009a67fa GIT binary patch literal 76874 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 dict[str | None, tuple[str, ...]]: + """Image names a block declares, keyed by the function parameter they feed.""" + module_type = CellProfilerModule.require_module(block.name) + declared = {} + for binding in module_type.artifact_bindings_for(block, plan_type=plan_type): + assert binding.require_artifact_type() is ImageArtifactType + declared[binding.runtime_parameter_name] = ( + module_type.artifact_names_for_binding(block, binding) + ) + return declared + + +def _output_name(block: ModuleBlock) -> str: + ((name,),) = _artifact_names(block, ArtifactOutputPlan).values() + return name + + +_CASES = { + _output_name(block): block + for block in CPPipeParser().parse(_FIXTURES / "cases.cppipe") +} + + +def _image(pixels: np.ndarray, *, volume: bool, stacked: bool = False) -> ImagePayload: + return ImagePayloadMetadata( + source_voxel_spacing=SourceVoxelSpacing( + values_zyx=_REFERENCE_FACTS["voxel_spacing_zyx_of_volumes"] if volume else () + ), + source_spatial_domain=( + VolumeSourceSpatialDomain() if volume else SourceSpatialDomain() + ), + plane_axis=RuntimeSlicePlaneAxis if stacked else NoPlaneAxis, + ).payload_with(pixels, None) + + +def _execute(block: ModuleBlock, image: ImagePayload) -> np.ndarray: + """Bind the block's settings and run its function the way the runtime does.""" + module_type = CellProfilerModule.require_module(block.name) + contract = replace( + CallableContract.from_callable( + module_type.require_callable() + ).with_prepared_signature(), + runtime_adapter=CellProfilerRuntimeAdapter.runtime_adapter_spec(), + ) + image_names = module_type.compile_time_parameter_names(contract) + kwargs = { + name: value + for name, value in module_type.bind_settings( + block, binder=SettingsBinder() + ).kwargs.items() + if name not in image_names + } + for parameter, names in _artifact_names(block, ArtifactInputPlan).items(): + if parameter is not None and names: + (name,) = names + kwargs[parameter] = _REFERENCE[f"input_{name}"] + return CellProfilerFunctionContractExecutor().execute( + contract, + contract.resolve_canonical_raw_callable(), + image, + kwargs, + execution_mode=contract.runtime_image_execution_mode, + ).data + + +def _input_pixels(block: ModuleBlock) -> np.ndarray: + (name,) = _artifact_names(block, ArtifactInputPlan)[None] + return _REFERENCE[f"input_{name}"] + + +@pytest.mark.parametrize("output_name", sorted(_CASES)) +def test_output_equals_what_the_plugin_stored(output_name: str) -> None: + block = _CASES[output_name] + pixels = _input_pixels(block) + image = _image(pixels, volume=pixels.ndim == 3) + + if output_name in _PLUGIN_RAISED: + with pytest.raises(ValueError, match="the CellProfiler plugin fails"): + _execute(block, image) + return + + expected = _REFERENCE[f"output_{output_name}"] + actual = _execute(block, image) + assert actual.dtype == expected.dtype == np.float32 + np.testing.assert_allclose(actual, expected, rtol=0, atol=_TOLERANCE) + + +@pytest.mark.parametrize( + "output_name", + ("EqualizedPlaneWithinMask", "RescaledPlaneDistance", "PlaneMatchedToReference"), +) +def test_each_image_of_a_stack_is_processed_on_its_own(output_name: str) -> None: + block = _CASES[output_name] + pixels = _input_pixels(block) + other = np.ascontiguousarray(pixels[::-1, ::-1]) + + stacked = _execute( + block, _image(np.stack((pixels, other)), volume=False, stacked=True) + ) + + np.testing.assert_array_equal( + stacked, + np.stack( + [_execute(block, _image(one, volume=False)) for one in (pixels, other)] + ), + ) + + +@pytest.mark.parametrize( + ("output_name", "inputs"), + ( + ("EqualizedPlane", {None: ("Plane",), "mask_image": ()}), + ( + "EqualizedPlaneWithinMask", + {None: ("Plane",), "mask_image": ("MaskPlane",)}, + ), + ("PlaneDistance", {None: ("BinaryPlane",)}), + ( + "PlaneMatchedToReference", + {None: ("Plane",), "reference": ("ReferencePlane",)}, + ), + # CellProfiler's blank value names no image. + ("VolumeMatchedToFrame", {None: ("Volume",), "reference": ()}), + ), +) +def test_block_declares_its_input_images_and_one_output_image( + output_name: str, inputs: dict[str | None, tuple[str, ...]] +) -> None: + block = _CASES[output_name] + + assert _artifact_names(block, ArtifactInputPlan) == inputs + assert _artifact_names(block, ArtifactOutputPlan) == {None: (output_name,)} + + +_SOURCE = """CellProfiler Pipeline: http://www.cellprofiler.org +NamesAndTypes:[module_num:1|enabled:True] + Assignments count:1 + Select the image type:Grayscale image + Name to assign these images:{image} + Select the rule criteria:and (file does contain "{image}") +""" + +# Blocks below are verbatim from pipelines found in the wild, apart from the one +# noted change. They carry both spellings of the 3D setting and a fork's extra +# setting. + +# CellProfiler/CellProfiler-plugins tests/headless_test/4.2.5_plugins_test_pipeline_BASIC.cppipe +_PLUGINS_TEST_PIPELINE = ( + _SOURCE.format(image="DNA") + + """DistanceTransform:[module_num:7|svn_version:'Unknown'|variable_revision_number:1|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:DNA + Name the output image:DistanceTransform + Rescale values from 0 to 1?:Yes + +HistogramEqualization:[module_num:9|svn_version:'Unknown'|variable_revision_number:1|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:DNA + Name the output image:HistogramEqualization + Bins:256 + Mask:Leave blank + Local:Yes + Tile Size:100 + Clip limit:0.01 + Is you image 3D?:No + Do framewise calculation?:No + Tile Size (Z):5 + +HistogramMatching:[module_num:10|svn_version:'Unknown'|variable_revision_number:1|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:DNA + Name the output image:HistogramMatching + Is you image 3D?:No + Use a frame within image as reference?:No + Image to use as reference :None + Frame number:5 +""" +) + +# Mas515/Image_analysis 1_Kuo_seg.cppipe +_KUO_SEGMENTATION_PIPELINE = ( + _SOURCE.format(image="GaussianFilterGFP") + + """HistogramEqualization:[module_num:8|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['256 bin size']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:GaussianFilterGFP + Name the output image:HistogramEqualizationGFP + Bins:200 + Mask:Leave blank + Local:Yes + Tile Size:50 + Clip limit:0.01 + Is you image 3D?:Yes + Do framewise calculation?:Yes + Tile Size (Z):5 +""" +) + +# Mas515/Image_analysis 2_Kuotracked.cppipe +_KUO_TRACKING_PIPELINE = ( + _SOURCE.format(image="ImageAfterMathAF647Invert") + + """DistanceTransform:[module_num:31|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:ImageAfterMathAF647Invert + Name the output image:DistanceTransformAF647 + Rescale values from 0 to 1?:No +""" +) + +# TrynkaLab/tglow-pipeline assets/cellprofiler/CELL_DIVIDER/2d_v1.cppipe. The +# block is disabled there; it is enabled here so that it imports. The +# GaussianFilter block only provides the reference image. +_TGLOW_PIPELINE = ( + _SOURCE.format(image="ctv") + + """GaussianFilter:[module_num:2|enabled:True] + Select the input image:ctv + Name the output image:actin + Sigma:1 + +HistogramMatching:[module_num:9|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:ctv + Name the output image:ctv_eq + Is your image 3D?:No + Use a frame within image as reference?:No + Image to use as reference :actin + Frame number:5 + Plane number in 3d:0 +""" +) + + +@pytest.mark.parametrize( + ("pipeline", "steps"), + ( + ( + _PLUGINS_TEST_PIPELINE, + ( + ("distance_transform", {}), + ("histogram_equalization", {"local": True, "tile_size": 100}), + ("histogram_matching", {}), + ), + ), + ( + _KUO_SEGMENTATION_PIPELINE, + ( + ( + "histogram_equalization", + { + "bins": 200, + "local": True, + "planes_form_a_volume": True, + "framewise": True, + }, + ), + ), + ), + (_KUO_TRACKING_PIPELINE, (("distance_transform", {"rescale": False}),)), + ( + _TGLOW_PIPELINE, + (("gaussian_filter", {}), ("histogram_matching", {})), + ), + ), + ids=("plugins_test_pipeline", "kuo_segmentation", "kuo_tracking", "tglow"), +) +def test_real_pipeline_blocks_import_as_steps_with_the_plugin_settings( + tmp_path: Path, + pipeline: str, + steps: tuple[tuple[str, dict[str, object]], ...], +) -> None: + """Imported steps keep only the settings that differ from the plugin defaults.""" + cppipe_path = tmp_path / "pipeline.cppipe" + cppipe_path.write_text(pipeline, encoding="utf-8") + + imported, _pipeline_config = import_cellprofiler_pipeline(cppipe_path) + + settings_of_steps = [] + for step in imported: + (invocation,) = normalize_function_pattern(step.func).iter_items() + function = invocation.contract.resolve_canonical_raw_callable() + settings_of_steps.append( + ( + invocation.contract.function_name, + { + name: value + for name, value in invocation.kwargs_dict.items() + if name in inspect.signature(function).parameters + }, + ) + ) + assert tuple(settings_of_steps) == steps From ebc3f1af9d2c13d43a6ef4661277aa46413b06c0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 16:10:07 -0400 Subject: [PATCH 4/9] RunCellpose, RunStarDist, Runilastik declared: they convert, compile names what is missing Every setting of each plugin is mapped or ignored with its reason. The modules have no model call here: RunCellpose's parameters follow the Cellpose surface of feature/cellpose-full-surface (signature recorded at 0d127cc9f). New extras cellpose and stardist; an environment installed before this commit needs its package metadata refreshed, because the catalogue reads extras from it. Co-Authored-By: Claude Opus 5.5 --- .../cellprofiler/module_without_function.py | 67 ++ .../backends/cellprofiler/run_cellpose.py | 342 +++++++++ .../backends/cellprofiler/run_ilastik.py | 70 ++ .../backends/cellprofiler/run_stardist.py | 194 +++++ pyproject.toml | 12 + .../cellpose_functions_signature.json | 678 +++++++++++++++++ .../test_cellprofiler_model_plugin_modules.py | 695 ++++++++++++++++++ ...iler_undeclared_and_unavailable_modules.py | 4 +- 8 files changed, 2060 insertions(+), 2 deletions(-) create mode 100644 openhcs/interop/cellprofiler/module_without_function.py create mode 100644 openhcs/processing/backends/cellprofiler/run_cellpose.py create mode 100644 openhcs/processing/backends/cellprofiler/run_ilastik.py create mode 100644 openhcs/processing/backends/cellprofiler/run_stardist.py create mode 100644 tests/fixtures/cellprofiler_plugins/cellpose_functions_signature.json create mode 100644 tests/unit/test_cellprofiler_model_plugin_modules.py diff --git a/openhcs/interop/cellprofiler/module_without_function.py b/openhcs/interop/cellprofiler/module_without_function.py new file mode 100644 index 000000000..d467f2f15 --- /dev/null +++ b/openhcs/interop/cellprofiler/module_without_function.py @@ -0,0 +1,67 @@ +"""A CellProfiler module OpenHCS declares before it has the function to run it. + +Such a module converts like any other: its settings bind to step kwargs and +its outputs are wired into the dataflow. Its file defines the step's function +with those kwargs, and that function cannot run. Compilation is refused twice +over, in the order a user can act on: first for a distribution the file +requires and that is not installed (the validator names the extra), then for +what OpenHCS itself still lacks. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, NoReturn + +from openhcs.interop.cellprofiler.module_declarations import CellProfilerModule +from openhcs.processing.backends.lib_registry.openhcs_registry import ( + distributions_not_installed, +) + +if TYPE_CHECKING: + from openhcs.core.function_patterns import NormalizedFunctionItem + + +def cellprofiler_binary(value: str) -> bool: + """Read a CellProfiler ``Binary`` setting as CellProfiler does: only "Yes" is true. + + CellProfiler loads settings by position, so a file saved by one revision of + a plugin and read by another can hold another setting's value in a Binary + row. CellProfiler reads any such value as false. + """ + + return value.strip() == "Yes" + + +class ModuleWithoutFunction(CellProfilerModule): + """Parent of declared modules whose function OpenHCS cannot run yet.""" + + what_openhcs_lacks: ClassVar[str] + """What must exist in OpenHCS before the module can run, as one sentence.""" + + @classmethod + def cannot_run_message(cls) -> str: + """Say which module cannot run and what OpenHCS lacks.""" + + return ( + f"CellProfiler module {cls.require_module_name()!r} is declared, so its " + f"settings convert, but it cannot run: {cls.what_openhcs_lacks} " + "Replace the step's function to run the pipeline." + ) + + @classmethod + def cannot_run(cls) -> NoReturn: + """Body of the module's function until OpenHCS has what it lacks.""" + + raise NotImplementedError(cls.cannot_run_message()) + + @classmethod + def validate_compilation(cls, invocation: NormalizedFunctionItem) -> None: + """Refuse to compile once every distribution the file requires is installed. + + While one is absent the pipeline's validator refuses with the install + command, which is the first thing the user can do. + """ + + if distributions_not_installed(invocation.contract.func): + return + raise ValueError(cls.cannot_run_message()) diff --git a/openhcs/processing/backends/cellprofiler/run_cellpose.py b/openhcs/processing/backends/cellprofiler/run_cellpose.py new file mode 100644 index 000000000..17098fb7e --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/run_cellpose.py @@ -0,0 +1,342 @@ +"""CellProfiler plugin RunCellpose. + +Upstream: CellProfiler-plugins ``active_plugins/runcellpose.py`` (settings +revision 8 at commit 5aaa4e5; every earlier revision's rows are declared too). + +The module's keywords are the parameters of OpenHCS's Cellpose functions +(``openhcs.processing.backends.lib_registry.derived.cellpose`` on branch +``feature/cellpose-full-surface``), plus the few things RunCellpose does around +the model. Those functions are not in this version of OpenHCS, so the step +converts and cannot run. + +CellProfiler writes each setting's label beside its value, and settings bind by +label here. That covers every layout the plugin has had, including the ones it +changed without raising its revision number (revision 3 exists with and +without ``Z rescaling factor (anisotropy)`` and ``Remove objects that are +touching the edge?``). +""" + +from typing import TYPE_CHECKING, ClassVar + +from openhcs.core.artifacts.kinds import ImageArtifactType, ObjectLabelsArtifactType +from openhcs.core.artifacts.relations import SourceStackLineageSourceRelation +from openhcs.core.artifacts.specs import ArtifactSpecCollection +from openhcs.core.callable_contract import runtime_image_execution_mode +from openhcs.core.image_payload_execution_mode import FullStackExecution +from openhcs.core.measurement_row_materialization import ( + DataclassMeasurementColumnarRows, +) +from openhcs.core.memory.decorators import numpy +from openhcs.core.processing_contracts import FlexibleContract +from openhcs.core.runtime_image_values import ImagePayload +from openhcs.core.runtime_object_labels import ObjectLabelValue +from openhcs.interop.cellprofiler.module_artifact_declarations import ( + MeasurementArtifactOutputModule, + ObjectArtifactOutputModule, +) +from openhcs.interop.cellprofiler.module_settings import BoundModuleSettings +from openhcs.interop.cellprofiler.module_without_function import ( + ModuleWithoutFunction, + cellprofiler_binary, +) +from openhcs.interop.cellprofiler.parser import ModuleBlock +from openhcs.interop.cellprofiler.setting_names import ( + SettingNameFamily, + setting_name_matches, +) +from openhcs.interop.cellprofiler.settings_binder import ( + ModuleOnlySettingBinding, + SettingToKeywordBinding, + parse_cellprofiler_float, + parse_cellprofiler_int, +) + +if TYPE_CHECKING: + from openhcs.core.function_patterns import FunctionInvocationKey + from openhcs.core.invocation_artifacts import ArtifactDeclarationStepContext + +REQUIRED_DISTRIBUTIONS = ("cellpose",) + +_CELLPOSE_VERSIONS = ("omnipose", "v2", "v3", "v4") +"""The plugin's ``Select Cellpose version`` choices, in the order of its rows. + +From revision 7 the plugin writes one ``Detection mode`` row for each of them, +in this order, and reads the row of the selected version. +""" + + +class RunCellposeModule( + MeasurementArtifactOutputModule, + ObjectArtifactOutputModule, + ModuleWithoutFunction, +): + module_name = "RunCellpose" + function_name = "run_cellpose" + what_openhcs_lacks = ( + "it runs through OpenHCS's Cellpose functions (segment, " + "segment_estimating_diameter and restore_and_segment of " + "openhcs.processing.backends.lib_registry.derived.cellpose), which this " + "version of OpenHCS does not have." + ) + + version_setting = "Select Cellpose version" + version_before_the_setting_existed = "v2" + """What the plugin's upgrade gives a pipeline written before revision 7.""" + mode_setting = "Detection mode" + custom_mode = "custom" + specify_diameter_setting = "Specify expected object diameter?" + diameter_setting = "Expected object diameter" + model_directory_setting = "Location of the pre-trained model file" + model_file_setting = "Pre-trained model file name" + restore_setting = "Preprocess image before segmentation?" + restore_model_setting = "Preprocessing model" + supply_nuclei_setting = "Supply nuclei image as well?" + save_probability_setting = "Save probability image?" + + input_image_binding = SettingToKeywordBinding.input( + "Select the input image", ImageArtifactType + ) + nuclei_image_binding = SettingToKeywordBinding.input( + "Select the nuclei image", + ImageArtifactType, + ) + probability_image_binding = SettingToKeywordBinding.output( + "Name the probability image", ImageArtifactType + ) + setting_bindings: ClassVar[tuple[SettingToKeywordBinding, ...]] = ( + input_image_binding, + SettingToKeywordBinding.output( + "Name the output object", ObjectLabelsArtifactType + ), + SettingToKeywordBinding( + supply_nuclei_setting, "supply_nuclei_image", cellprofiler_binary + ), + nuclei_image_binding, + SettingToKeywordBinding( + save_probability_setting, "save_probability_image", cellprofiler_binary + ), + probability_image_binding, + SettingToKeywordBinding("Use GPU", "gpu", cellprofiler_binary), + SettingToKeywordBinding( + "Flow threshold", "flow_threshold", parse_cellprofiler_float + ), + SettingToKeywordBinding( + "Cell probability threshold", "cellprob_threshold", parse_cellprofiler_float + ), + SettingToKeywordBinding( + "Stitch Threshold", "stitch_threshold", parse_cellprofiler_float + ), + SettingToKeywordBinding("Use 3D", "do_3D", cellprofiler_binary), + SettingToKeywordBinding("Minimum size", "min_size", parse_cellprofiler_int), + SettingToKeywordBinding("Invert images", "invert", cellprofiler_binary), + SettingToKeywordBinding( + "Z rescaling factor (anisotropy)", "anisotropy", parse_cellprofiler_float + ), + SettingToKeywordBinding( + "Rescale for anisotropy?", + "anisotropy_from_voxel_spacing", + cellprofiler_binary, + ), + SettingToKeywordBinding( + "Rescale images before running Cellpose", + "clip_to_percentiles", + cellprofiler_binary, + ), + SettingToKeywordBinding( + "Remove objects that are touching the edge?", + "remove_edge_objects", + cellprofiler_binary, + ), + # Read together in ``postprocess_bound_settings``. + ModuleOnlySettingBinding(version_setting), + ModuleOnlySettingBinding(mode_setting), + ModuleOnlySettingBinding(specify_diameter_setting), + ModuleOnlySettingBinding(diameter_setting), + ModuleOnlySettingBinding(model_directory_setting), + ModuleOnlySettingBinding(model_file_setting), + ModuleOnlySettingBinding(restore_setting), + ModuleOnlySettingBinding(restore_model_setting), + ) + ignored_settings = ( + # Where the plugin runs Cellpose. OpenHCS calls Cellpose in its own + # process; the version the pipeline asks for is ``cellpose_version``. + SettingNameFamily( + "Run CellPose in a Docker/Podman container or local python environment", + aliases=("Run CellPose in docker or local python environment",), + ), + "Select Cellpose docker image", + # Cellpose 3 has no network averaging (``net_avg`` ended with 2.x). + "Use averaging", + # A share of GPU memory is process state; device assignment owns it. + "GPU memory share for each worker", + # Passed only by the plugin's Omnipose path, which ``cellpose_version`` + # already names. + "Use Omnipose for mask reconstruction", + # Written by the plugin and never read by its ``run``. + "Rescale probability map?", + # The plugin shows the restored image in its window and never saves it. + "Save preprocessed image?", + "Name the preprocessed image", + # OpenHCS keeps one model per set of constructor arguments per process. + "Cache model between image sets", + ) + + @classmethod + def active_artifact_bindings( + cls, + module: ModuleBlock | None, + *, + invocation_key: "FunctionInvocationKey | None" = None, + ) -> tuple[SettingToKeywordBinding, ...]: + """The second image and the probability image exist only when asked for.""" + + bindings = super().active_artifact_bindings( + module, invocation_key=invocation_key + ) + if module is None: + return bindings + inactive = tuple( + binding + for binding, asked_by in ( + (cls.nuclei_image_binding, cls.supply_nuclei_setting), + (cls.probability_image_binding, cls.save_probability_setting), + ) + if not cellprofiler_binary(cls.setting_value(module, asked_by) or "No") + ) + return tuple(binding for binding in bindings if binding not in inactive) + + @classmethod + def artifact_output_relations( + cls, + module: ModuleBlock, + *, + binding: SettingToKeywordBinding, + name: str, + invocation_key: "FunctionInvocationKey", + step_context: "ArtifactDeclarationStepContext", + artifact_inputs: ArtifactSpecCollection, + output_position: int, + ) -> tuple[SourceStackLineageSourceRelation, ...]: + """The objects and the probability image lie on the input image. + + With a nuclei image the step reads two images, and neither output is + of the nuclei image. + """ + + del binding, name, invocation_key, step_context, output_position + (input_image,) = cls.artifact_names_for_binding(module, cls.input_image_binding) + return ( + SourceStackLineageSourceRelation( + source=artifact_inputs.require_by_name_and_artifact_type( + input_image, ImageArtifactType + ).ref() + ), + ) + + @classmethod + def _rows(cls, module: ModuleBlock, setting_name: str) -> tuple[str, ...]: + """Return every value written under one label, in file order.""" + + return tuple( + setting.value.strip() + for setting in module.iter_settings() + if setting_name_matches(setting.name, setting_name) + ) + + @classmethod + def postprocess_bound_settings( + cls, module: ModuleBlock, bound: BoundModuleSettings + ) -> BoundModuleSettings: + """Bind the model, the diameter and the restoration as the plugin reads them.""" + + (version,) = cls._rows(module, cls.version_setting) or ( + cls.version_before_the_setting_existed, + ) + modes = cls._rows(module, cls.mode_setting) + mode = modes[_CELLPOSE_VERSIONS.index(version)] if len(modes) > 1 else modes[0] + kwargs: dict[str, object] = {"cellpose_version": version} + if mode == cls.custom_mode: + _folder, _bar, directory = ( + cls.setting_value(module, cls.model_directory_setting) or "" + ).partition("|") + file_name = cls.setting_value(module, cls.model_file_setting) or "" + kwargs["pretrained_model"] = "/".join( + part for part in (directory.rstrip("/\\"), file_name) if part + ) + else: + kwargs["model_type"] = mode + # Cellpose 4 takes a diameter only when the pipeline says to; earlier + # versions estimate it when the pipeline gives zero. + diameter = parse_cellprofiler_float( + cls.setting_value(module, cls.diameter_setting) or "0" + ) + specified = version != "v4" or cellprofiler_binary( + cls.setting_value(module, cls.specify_diameter_setting) or "No" + ) + if specified and diameter > 0: + kwargs["diameter"] = diameter + # The plugin restores only with Cellpose 3. + if version == "v3" and cellprofiler_binary( + cls.setting_value(module, cls.restore_setting) or "No" + ): + kwargs["restore_type"] = cls.setting_value( + module, cls.restore_model_setting + ) + return bound.with_kwargs(kwargs) + + +@runtime_image_execution_mode(FullStackExecution) +@numpy(contract=FlexibleContract) +def run_cellpose( + image: ImagePayload, + cellpose_version: str = "v3", + model_type: str | None = None, + pretrained_model: str | None = None, + gpu: bool = False, + diameter: float | None = None, + flow_threshold: float = 0.4, + cellprob_threshold: float = 0.0, + stitch_threshold: float = 0.0, + do_3D: bool = False, + min_size: int = 15, + invert: bool = False, + anisotropy: float | None = None, + restore_type: str | None = None, + anisotropy_from_voxel_spacing: bool = True, + clip_to_percentiles: bool = False, + remove_edge_objects: bool = True, + supply_nuclei_image: bool = False, + save_probability_image: bool = False, +) -> tuple[ImagePayload, DataclassMeasurementColumnarRows, ObjectLabelValue]: + """Segment objects with Cellpose, as CellProfiler's RunCellpose plugin does. + + Not runnable in this version of OpenHCS. ``model_type``, ``pretrained_model``, + ``gpu``, ``diameter``, ``flow_threshold``, ``cellprob_threshold``, + ``stitch_threshold``, ``do_3D``, ``min_size``, ``invert``, ``anisotropy`` and + ``restore_type`` are parameters of OpenHCS's Cellpose functions and mean what + Cellpose documents. + + Args: + cellpose_version: Cellpose line the pipeline was written for + (``omnipose``, ``v2``, ``v3`` or ``v4``). Model names differ between + lines. + model_type: Name of a Cellpose pretrained model. + pretrained_model: A custom model file, used instead of ``model_type``. + diameter: Expected object diameter in pixels; unset lets Cellpose + estimate it. + restore_type: Name of a Cellpose restoration model run on the image + before segmentation; unset segments the image as it is. + anisotropy_from_voxel_spacing: For 3D, take ``anisotropy`` from the + image's plane spacing divided by its pixel size when it is unset. + clip_to_percentiles: Scale the image so its 1st and 99th percentiles + become 0 and 1, and clip, before Cellpose sees it. + remove_edge_objects: Discard objects touching the image border. The + plugin does this when it runs Cellpose in a container or runs + Cellpose 4. + supply_nuclei_image: The step's images are the input image and a nuclei + image; Cellpose is given the second as its nuclear channel unless + the model is ``nuclei``. + save_probability_image: Return Cellpose's cell probability, scaled + between its 1st and 99th percentiles, as the named image. + """ + RunCellposeModule.cannot_run() diff --git a/openhcs/processing/backends/cellprofiler/run_ilastik.py b/openhcs/processing/backends/cellprofiler/run_ilastik.py new file mode 100644 index 000000000..91d7a4939 --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/run_ilastik.py @@ -0,0 +1,70 @@ +"""CellProfiler plugin Runilastik. + +Upstream: CellProfiler-plugins ``active_plugins/runilastik.py`` (settings +revision 1 at commit 5aaa4e5). + +The plugin writes the image to an HDF5 file, runs the ilastik program on it +with a trained project, and reads the probabilities back. ilastik is a program, +not a Python distribution (PyPI has none), so this file requires no +distribution. OpenHCS has no function that runs an external program on a +step's images, so the step converts and cannot run. +""" + +from typing import ClassVar + +from openhcs.core.artifacts.kinds import ImageArtifactType +from openhcs.core.callable_contract import runtime_image_execution_mode +from openhcs.core.image_payload_execution_mode import FullStackExecution +from openhcs.core.memory.decorators import numpy +from openhcs.core.processing_contracts import FlexibleContract +from openhcs.core.runtime_image_values import ImagePayload +from openhcs.interop.cellprofiler.module_without_function import ( + ModuleWithoutFunction, +) +from openhcs.interop.cellprofiler.setting_names import SettingNameFamily +from openhcs.interop.cellprofiler.settings_binder import SettingToKeywordBinding + + +class RunilastikModule(ModuleWithoutFunction): + module_name = "Runilastik" + function_name = "run_ilastik" + what_openhcs_lacks = ( + "OpenHCS has no function that runs the ilastik program on a step's " + "images with a trained project file." + ) + setting_bindings: ClassVar[tuple[SettingToKeywordBinding, ...]] = ( + SettingToKeywordBinding.input("Select the input image", ImageArtifactType), + SettingToKeywordBinding.output("Name the output image", ImageArtifactType), + SettingToKeywordBinding("Executable", "executable", str), + SettingToKeywordBinding("Project file", "project_file", str), + SettingToKeywordBinding("Select the project type", "project_type", str), + ) + ignored_settings = ( + # Whether the plugin starts ilastik directly or inside a container. + SettingNameFamily( + "Run ilastik in a container (Docker or Podman) or local environment", + aliases=("Run ilastik in docker or local environment",), + ), + ) + + +@runtime_image_execution_mode(FullStackExecution) +@numpy(contract=FlexibleContract) +def run_ilastik( + image: ImagePayload, + executable: str | None = None, + project_file: str | None = None, + project_type: str = "Pixel Classification", +) -> ImagePayload: + """Classify pixels with a trained ilastik project, as CellProfiler's Runilastik does. + + Not runnable in this version of OpenHCS. + + Args: + executable: Path of the ilastik program, as written in the pipeline. + project_file: Path of the trained ilastik project (``.ilp``), as written + in the pipeline. + project_type: ``Pixel Classification`` exports the probabilities; + ``Autocontext (2-stage)`` exports the second stage's. + """ + RunilastikModule.cannot_run() diff --git a/openhcs/processing/backends/cellprofiler/run_stardist.py b/openhcs/processing/backends/cellprofiler/run_stardist.py new file mode 100644 index 000000000..d6d4abe4c --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/run_stardist.py @@ -0,0 +1,194 @@ +"""CellProfiler plugin RunStarDist. + +Upstream: CellProfiler-plugins ``active_plugins/runstardist.py`` (settings +revision 1 at commit 5aaa4e5). The plugin has had two layouts under that one +revision number: nine rows, where ``Model`` is 2D, 3D or Custom, and fifteen +rows, where ``Model Type`` is that choice and two ``Model`` rows name the 2D +and the 3D pretrained model. Both are declared. + +OpenHCS has no StarDist function, so the step converts and cannot run. +""" + +from typing import TYPE_CHECKING, ClassVar + +from openhcs.core.artifacts.kinds import ImageArtifactType, ObjectLabelsArtifactType +from openhcs.core.callable_contract import runtime_image_execution_mode +from openhcs.core.image_payload_execution_mode import FullStackExecution +from openhcs.core.measurement_row_materialization import ( + DataclassMeasurementColumnarRows, +) +from openhcs.core.memory.decorators import numpy +from openhcs.core.processing_contracts import FlexibleContract +from openhcs.core.runtime_image_values import ImagePayload +from openhcs.core.runtime_object_labels import ObjectLabelValue +from openhcs.interop.cellprofiler.module_artifact_declarations import ( + MeasurementArtifactOutputModule, + ObjectArtifactOutputModule, +) +from openhcs.interop.cellprofiler.module_settings import BoundModuleSettings +from openhcs.interop.cellprofiler.module_without_function import ( + ModuleWithoutFunction, + cellprofiler_binary, +) +from openhcs.interop.cellprofiler.parser import ModuleBlock +from openhcs.interop.cellprofiler.setting_names import setting_name_matches +from openhcs.interop.cellprofiler.settings_binder import ( + ModuleOnlySettingBinding, + SettingToKeywordBinding, + parse_cellprofiler_float, + parse_cellprofiler_int, +) + +if TYPE_CHECKING: + from openhcs.core.function_patterns import FunctionInvocationKey + +REQUIRED_DISTRIBUTIONS = ("stardist",) + +_MODEL_KINDS = ("2D", "3D") +"""The pretrained kinds, in the order of the fifteen-row layout's ``Model`` rows.""" + + +class RunStarDistModule( + MeasurementArtifactOutputModule, + ObjectArtifactOutputModule, + ModuleWithoutFunction, +): + module_name = "RunStarDist" + function_name = "run_stardist" + what_openhcs_lacks = ( + "OpenHCS has no StarDist function. It needs StarDist's " + "predict_instances as a catalogue function, with the model built by " + "from_pretrained or from a model folder." + ) + + model_kind_setting = "Model Type" + model_setting = "Model" + model_folder_setting = "Model folder" + tile_setting = "Tile input image?" + horizontal_tiles_setting = "Horizontal tiles" + vertical_tiles_setting = "Vertical tiles" + save_probability_setting = "Save probability image?" + + probability_image_binding = SettingToKeywordBinding.output( + "Name the probability image", ImageArtifactType + ) + setting_bindings: ClassVar[tuple[SettingToKeywordBinding, ...]] = ( + SettingToKeywordBinding.input("Select the input image", ImageArtifactType), + SettingToKeywordBinding.output( + "Name the output object", ObjectLabelsArtifactType + ), + SettingToKeywordBinding( + save_probability_setting, "save_probability_image", cellprofiler_binary + ), + probability_image_binding, + SettingToKeywordBinding( + "Probability threshold", "prob_thresh", parse_cellprofiler_float + ), + SettingToKeywordBinding( + "Overlap threshold", "nms_thresh", parse_cellprofiler_float + ), + # Read together in ``postprocess_bound_settings``. + ModuleOnlySettingBinding(model_kind_setting), + ModuleOnlySettingBinding(model_setting), + ModuleOnlySettingBinding(model_folder_setting), + ModuleOnlySettingBinding(tile_setting), + ModuleOnlySettingBinding(horizontal_tiles_setting), + ModuleOnlySettingBinding(vertical_tiles_setting), + ) + ignored_settings = ( + # A limit on GPU memory is process state; device assignment owns it. + "Manually set how much GPU memory each worker can use?", + "GPU memory (in GB) for each worker", + ) + + @classmethod + def active_artifact_bindings( + cls, + module: ModuleBlock | None, + *, + invocation_key: "FunctionInvocationKey | None" = None, + ) -> tuple[SettingToKeywordBinding, ...]: + """The probability image exists only when the pipeline saves it.""" + + bindings = super().active_artifact_bindings( + module, invocation_key=invocation_key + ) + if module is None or cellprofiler_binary( + cls.setting_value(module, cls.save_probability_setting) or "No" + ): + return bindings + return tuple( + binding + for binding in bindings + if binding is not cls.probability_image_binding + ) + + @classmethod + def postprocess_bound_settings( + cls, module: ModuleBlock, bound: BoundModuleSettings + ) -> BoundModuleSettings: + """Bind the model and the tiling as each layout of the plugin writes them.""" + + models = tuple( + setting.value.strip() + for setting in module.iter_settings() + if setting_name_matches(setting.name, cls.model_setting) + ) + kind = cls.setting_value(module, cls.model_kind_setting) + if kind is None: + # Nine rows: ``Model`` is the kind and no pretrained model is named. + (kind,), models = models, () + kwargs: dict[str, object] = {} + if kind in _MODEL_KINDS: + kwargs["model_dimensions"] = kind + if models: + kwargs["pretrained_model"] = models[_MODEL_KINDS.index(kind)] + else: + _folder, _bar, directory = ( + cls.setting_value(module, cls.model_folder_setting) or "" + ).partition("|") + kwargs["model_folder"] = directory + if cellprofiler_binary(cls.setting_value(module, cls.tile_setting) or "No"): + kwargs["n_tiles"] = tuple( + parse_cellprofiler_int(cls.setting_value(module, setting) or "1") + for setting in ( + cls.horizontal_tiles_setting, + cls.vertical_tiles_setting, + ) + ) + return bound.with_kwargs(kwargs) + + +@runtime_image_execution_mode(FullStackExecution) +@numpy(contract=FlexibleContract) +def run_stardist( + image: ImagePayload, + model_dimensions: str | None = None, + pretrained_model: str | None = None, + model_folder: str | None = None, + prob_thresh: float | None = None, + nms_thresh: float | None = None, + n_tiles: tuple[int, int] | None = None, + save_probability_image: bool = False, +) -> tuple[ImagePayload, DataclassMeasurementColumnarRows, ObjectLabelValue]: + """Segment star-convex objects with StarDist, as CellProfiler's RunStarDist does. + + Not runnable in this version of OpenHCS. ``prob_thresh``, ``nms_thresh`` and + ``n_tiles`` are arguments of StarDist's ``predict_instances``. + + Args: + model_dimensions: ``2D`` or ``3D`` for a pretrained model; unset for a + model folder, whose model decides. + pretrained_model: Name of a StarDist pretrained model. Unset when the + pipeline was written by a plugin version that had no model choice. + model_folder: Folder of a custom-trained model, as written in the + pipeline. + prob_thresh: Object probability threshold; unset uses the model's own. + nms_thresh: Overlap threshold of non-maximum suppression; unset uses + the model's own. + n_tiles: Number of horizontal and vertical tiles the image is predicted + in; unset predicts it whole. + save_probability_image: Return StarDist's object probability, resized + to the image, as the named image. + """ + RunStarDistModule.cannot_run() diff --git a/pyproject.toml b/pyproject.toml index 2b7408d96..55db966c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -184,6 +184,18 @@ bioformats = [ "polystore[bioformats]", ] +# Cellpose segmentation, size estimation and restoration as catalogue functions. +# The 3.x line has the nuclear, size and restoration models; it pins numpy<2.1. +cellpose = [ + "cellpose>=3.1.1.3,<4", +] + +# StarDist, required by the CellProfiler RunStarDist module's declaration. +# StarDist imports TensorFlow without declaring it: install TensorFlow too. +stardist = [ + "stardist>=0.9.2", +] + # All visualization tools (napari + fiji) viz = [ "napari>=0.7.1", diff --git a/tests/fixtures/cellprofiler_plugins/cellpose_functions_signature.json b/tests/fixtures/cellprofiler_plugins/cellpose_functions_signature.json new file mode 100644 index 000000000..ab1a4e56d --- /dev/null +++ b/tests/fixtures/cellprofiler_plugins/cellpose_functions_signature.json @@ -0,0 +1,678 @@ +{ + "branch": "feature/cellpose-full-surface", + "commit": "0d127cc9fe30378f11c2b41dccb36c704082224b", + "module": "openhcs.processing.backends.lib_registry.derived.cellpose", + "cellpose": "3.1.1.3", + "functions": { + "segment": { + "parameters": { + "image": { + "annotation": "ndarray", + "default": "_empty" + }, + "gpu": { + "annotation": "bool", + "default": "False" + }, + "pretrained_model": { + "annotation": "Optional[Annotated[pathlib.Path, PlateInputFileDeclaration()]]", + "default": "None" + }, + "model_type": { + "annotation": "Optional[SegmentationModel]", + "default": "None" + }, + "mkldnn": { + "annotation": "bool", + "default": "True" + }, + "diam_mean": { + "annotation": "float", + "default": "30.0" + }, + "nchan": { + "annotation": "int", + "default": "2" + }, + "pretrained_model_ortho": { + "annotation": "Optional[Annotated[pathlib.Path, PlateInputFileDeclaration()]]", + "default": "None" + }, + "backbone": { + "annotation": "str", + "default": "'default'" + }, + "batch_size": { + "annotation": "int", + "default": "8" + }, + "resample": { + "annotation": "bool", + "default": "True" + }, + "channels": { + "annotation": "Optional[tuple[int, int]]", + "default": "None" + }, + "channel_axis": { + "annotation": "Optional[int]", + "default": "None" + }, + "z_axis": { + "annotation": "Optional[int]", + "default": "None" + }, + "normalize": { + "annotation": "NormalizeOptions", + "default": "NormalizeOptions(lowhigh=None, percentile=None, normalize=True, norm3D=True, sharpen_radius=0, smooth_radius=0, tile_norm_blocksize=0, tile_norm_smooth3D=1, invert=False)" + }, + "invert": { + "annotation": "bool", + "default": "False" + }, + "rescale": { + "annotation": "Optional[float]", + "default": "None" + }, + "diameter": { + "annotation": "Optional[float]", + "default": "None" + }, + "flow_threshold": { + "annotation": "float", + "default": "0.4" + }, + "cellprob_threshold": { + "annotation": "float", + "default": "0.0" + }, + "do_3D": { + "annotation": "bool", + "default": "False" + }, + "anisotropy": { + "annotation": "Optional[float]", + "default": "None" + }, + "flow3D_smooth": { + "annotation": "int", + "default": "0" + }, + "stitch_threshold": { + "annotation": "float", + "default": "0.0" + }, + "min_size": { + "annotation": "int", + "default": "15" + }, + "max_size_fraction": { + "annotation": "float", + "default": "0.4" + }, + "niter": { + "annotation": "Optional[int]", + "default": "None" + }, + "augment": { + "annotation": "bool", + "default": "False" + }, + "tile_overlap": { + "annotation": "float", + "default": "0.1" + }, + "bsize": { + "annotation": "int", + "default": "224" + }, + "interp": { + "annotation": "bool", + "default": "True" + }, + "compute_masks": { + "annotation": "bool", + "default": "True" + }, + "dtype_config": { + "annotation": "LazyDtypeConfig", + "default": "LazyDtypeConfig(default_dtype_conversion=)" + } + }, + "outputs": [ + [ + "labels", + "ObjectLabelsArtifactType" + ], + [ + "cell_probability", + "ImageArtifactType" + ], + [ + "flow_field", + "ImageArtifactType" + ], + [ + "flow_rgb", + "ImageArtifactType" + ], + [ + "measurements", + "MeasurementsArtifactType" + ] + ] + }, + "segment_estimating_diameter": { + "parameters": { + "image": { + "annotation": "ndarray", + "default": "_empty" + }, + "gpu": { + "annotation": "bool", + "default": "False" + }, + "model_type": { + "annotation": "SegmentationModel", + "default": "SegmentationModel.CYTO3" + }, + "nchan": { + "annotation": "int", + "default": "2" + }, + "backbone": { + "annotation": "str", + "default": "'default'" + }, + "batch_size": { + "annotation": "int", + "default": "8" + }, + "channels": { + "annotation": "tuple[int, int]", + "default": "(0, 0)" + }, + "channel_axis": { + "annotation": "Optional[int]", + "default": "None" + }, + "invert": { + "annotation": "bool", + "default": "False" + }, + "normalize": { + "annotation": "NormalizeOptions", + "default": "NormalizeOptions(lowhigh=None, percentile=None, normalize=True, norm3D=True, sharpen_radius=0, smooth_radius=0, tile_norm_blocksize=0, tile_norm_smooth3D=1, invert=False)" + }, + "diameter": { + "annotation": "float", + "default": "30.0" + }, + "do_3D": { + "annotation": "bool", + "default": "False" + }, + "resample": { + "annotation": "bool", + "default": "True" + }, + "z_axis": { + "annotation": "Optional[int]", + "default": "None" + }, + "rescale": { + "annotation": "Optional[float]", + "default": "None" + }, + "flow_threshold": { + "annotation": "float", + "default": "0.4" + }, + "cellprob_threshold": { + "annotation": "float", + "default": "0.0" + }, + "anisotropy": { + "annotation": "Optional[float]", + "default": "None" + }, + "flow3D_smooth": { + "annotation": "int", + "default": "0" + }, + "stitch_threshold": { + "annotation": "float", + "default": "0.0" + }, + "min_size": { + "annotation": "int", + "default": "15" + }, + "max_size_fraction": { + "annotation": "float", + "default": "0.4" + }, + "niter": { + "annotation": "Optional[int]", + "default": "None" + }, + "augment": { + "annotation": "bool", + "default": "False" + }, + "tile_overlap": { + "annotation": "float", + "default": "0.1" + }, + "bsize": { + "annotation": "int", + "default": "224" + }, + "interp": { + "annotation": "bool", + "default": "True" + }, + "compute_masks": { + "annotation": "bool", + "default": "True" + }, + "dtype_config": { + "annotation": "LazyDtypeConfig", + "default": "LazyDtypeConfig(default_dtype_conversion=)" + } + }, + "outputs": [ + [ + "labels", + "ObjectLabelsArtifactType" + ], + [ + "cell_probability", + "ImageArtifactType" + ], + [ + "flow_field", + "ImageArtifactType" + ], + [ + "flow_rgb", + "ImageArtifactType" + ], + [ + "measurements", + "MeasurementsArtifactType" + ] + ] + }, + "estimate_diameter": { + "parameters": { + "image": { + "annotation": "ndarray", + "default": "_empty" + }, + "gpu": { + "annotation": "bool", + "default": "False" + }, + "pretrained_model": { + "annotation": "Optional[Annotated[pathlib.Path, PlateInputFileDeclaration()]]", + "default": "None" + }, + "model_type": { + "annotation": "Optional[SegmentationModel]", + "default": "None" + }, + "mkldnn": { + "annotation": "bool", + "default": "True" + }, + "diam_mean": { + "annotation": "float", + "default": "30.0" + }, + "nchan": { + "annotation": "int", + "default": "2" + }, + "pretrained_model_ortho": { + "annotation": "Optional[Annotated[pathlib.Path, PlateInputFileDeclaration()]]", + "default": "None" + }, + "backbone": { + "annotation": "str", + "default": "'default'" + }, + "pretrained_size": { + "annotation": "Optional[Annotated[pathlib.Path, PlateInputFileDeclaration()]]", + "default": "None" + }, + "channels": { + "annotation": "Optional[tuple[int, int]]", + "default": "None" + }, + "channel_axis": { + "annotation": "Optional[int]", + "default": "None" + }, + "normalize": { + "annotation": "NormalizeOptions", + "default": "NormalizeOptions(lowhigh=None, percentile=None, normalize=True, norm3D=True, sharpen_radius=0, smooth_radius=0, tile_norm_blocksize=0, tile_norm_smooth3D=1, invert=False)" + }, + "invert": { + "annotation": "bool", + "default": "False" + }, + "augment": { + "annotation": "bool", + "default": "False" + }, + "batch_size": { + "annotation": "int", + "default": "8" + }, + "dtype_config": { + "annotation": "LazyDtypeConfig", + "default": "LazyDtypeConfig(default_dtype_conversion=)" + } + }, + "outputs": [ + [ + "diameters", + "MeasurementsArtifactType" + ] + ] + }, + "restore": { + "parameters": { + "image": { + "annotation": "ndarray", + "default": "_empty" + }, + "gpu": { + "annotation": "bool", + "default": "False" + }, + "pretrained_model": { + "annotation": "Optional[Annotated[pathlib.Path, PlateInputFileDeclaration()]]", + "default": "None" + }, + "nchan": { + "annotation": "int", + "default": "1" + }, + "model_type": { + "annotation": "Optional[RestorationModel]", + "default": "None" + }, + "chan2": { + "annotation": "bool", + "default": "False" + }, + "diam_mean": { + "annotation": "float", + "default": "30.0" + }, + "batch_size": { + "annotation": "int", + "default": "8" + }, + "channels": { + "annotation": "Optional[tuple[int, int]]", + "default": "None" + }, + "channel_axis": { + "annotation": "Optional[int]", + "default": "None" + }, + "z_axis": { + "annotation": "Optional[int]", + "default": "None" + }, + "normalize": { + "annotation": "NormalizeOptions", + "default": "NormalizeOptions(lowhigh=None, percentile=None, normalize=True, norm3D=True, sharpen_radius=0, smooth_radius=0, tile_norm_blocksize=0, tile_norm_smooth3D=1, invert=False)" + }, + "rescale": { + "annotation": "Optional[float]", + "default": "None" + }, + "diameter": { + "annotation": "Optional[float]", + "default": "None" + }, + "tile": { + "annotation": "bool", + "default": "True" + }, + "do_3D": { + "annotation": "bool", + "default": "False" + }, + "tile_overlap": { + "annotation": "float", + "default": "0.1" + }, + "bsize": { + "annotation": "int", + "default": "224" + }, + "dtype_config": { + "annotation": "LazyDtypeConfig", + "default": "LazyDtypeConfig(default_dtype_conversion=)" + } + }, + "outputs": [] + }, + "restore_and_segment": { + "parameters": { + "image": { + "annotation": "ndarray", + "default": "_empty" + }, + "gpu": { + "annotation": "bool", + "default": "False" + }, + "pretrained_model": { + "annotation": "Optional[Annotated[pathlib.Path, PlateInputFileDeclaration()]]", + "default": "None" + }, + "model_type": { + "annotation": "Optional[SegmentationModel]", + "default": "None" + }, + "restore_type": { + "annotation": "RestorationModel", + "default": "RestorationModel.DENOISE_CYTO3" + }, + "nchan": { + "annotation": "int", + "default": "2" + }, + "chan2_restore": { + "annotation": "bool", + "default": "False" + }, + "batch_size": { + "annotation": "int", + "default": "8" + }, + "channels": { + "annotation": "Optional[tuple[int, int]]", + "default": "None" + }, + "channel_axis": { + "annotation": "Optional[int]", + "default": "None" + }, + "z_axis": { + "annotation": "Optional[int]", + "default": "None" + }, + "normalize": { + "annotation": "NormalizeOptions", + "default": "NormalizeOptions(lowhigh=None, percentile=None, normalize=True, norm3D=True, sharpen_radius=0, smooth_radius=0, tile_norm_blocksize=0, tile_norm_smooth3D=1, invert=False)" + }, + "rescale": { + "annotation": "Optional[float]", + "default": "None" + }, + "diameter": { + "annotation": "Optional[float]", + "default": "None" + }, + "tile_overlap": { + "annotation": "float", + "default": "0.1" + }, + "augment": { + "annotation": "bool", + "default": "False" + }, + "resample": { + "annotation": "bool", + "default": "True" + }, + "invert": { + "annotation": "bool", + "default": "False" + }, + "flow_threshold": { + "annotation": "float", + "default": "0.4" + }, + "cellprob_threshold": { + "annotation": "float", + "default": "0.0" + }, + "do_3D": { + "annotation": "bool", + "default": "False" + }, + "anisotropy": { + "annotation": "Optional[float]", + "default": "None" + }, + "stitch_threshold": { + "annotation": "float", + "default": "0.0" + }, + "min_size": { + "annotation": "int", + "default": "15" + }, + "niter": { + "annotation": "Optional[int]", + "default": "None" + }, + "interp": { + "annotation": "bool", + "default": "True" + }, + "bsize": { + "annotation": "int", + "default": "224" + }, + "flow3D_smooth": { + "annotation": "int", + "default": "0" + }, + "dtype_config": { + "annotation": "LazyDtypeConfig", + "default": "LazyDtypeConfig(default_dtype_conversion=)" + } + }, + "outputs": [ + [ + "labels", + "ObjectLabelsArtifactType" + ], + [ + "cell_probability", + "ImageArtifactType" + ], + [ + "flow_field", + "ImageArtifactType" + ], + [ + "flow_rgb", + "ImageArtifactType" + ], + [ + "measurements", + "MeasurementsArtifactType" + ] + ] + } + }, + "enums": { + "SegmentationModel": { + "CYTO3": "cyto3", + "NUCLEI": "nuclei", + "CYTO2_CP3": "cyto2_cp3", + "TISSUENET_CP3": "tissuenet_cp3", + "LIVECELL_CP3": "livecell_cp3", + "YEAST_PHC_CP3": "yeast_PhC_cp3", + "YEAST_BF_CP3": "yeast_BF_cp3", + "BACT_PHASE_CP3": "bact_phase_cp3", + "BACT_FLUOR_CP3": "bact_fluor_cp3", + "DEEPBACS_CP3": "deepbacs_cp3", + "CYTO2": "cyto2", + "CYTO": "cyto", + "CPX": "CPx", + "TRANSFORMER_CP3": "transformer_cp3", + "NEURIPS_CELLPOSE_DEFAULT": "neurips_cellpose_default", + "NEURIPS_CELLPOSE_TRANSFORMER": "neurips_cellpose_transformer", + "NEURIPS_GRAYSCALE_CYTO2": "neurips_grayscale_cyto2", + "CP": "CP", + "TN1": "TN1", + "TN2": "TN2", + "TN3": "TN3", + "LC1": "LC1", + "LC2": "LC2", + "LC3": "LC3", + "LC4": "LC4" + }, + "RestorationModel": { + "DENOISE_CYTO3": "denoise_cyto3", + "DEBLUR_CYTO3": "deblur_cyto3", + "UPSAMPLE_CYTO3": "upsample_cyto3", + "ONECLICK_CYTO3": "oneclick_cyto3", + "DENOISE_CYTO2": "denoise_cyto2", + "DENOISE_PER_CYTO2": "denoise_per_cyto2", + "DENOISE_SEG_CYTO2": "denoise_seg_cyto2", + "DENOISE_REC_CYTO2": "denoise_rec_cyto2", + "DEBLUR_CYTO2": "deblur_cyto2", + "DEBLUR_PER_CYTO2": "deblur_per_cyto2", + "DEBLUR_SEG_CYTO2": "deblur_seg_cyto2", + "DEBLUR_REC_CYTO2": "deblur_rec_cyto2", + "UPSAMPLE_CYTO2": "upsample_cyto2", + "UPSAMPLE_PER_CYTO2": "upsample_per_cyto2", + "UPSAMPLE_SEG_CYTO2": "upsample_seg_cyto2", + "UPSAMPLE_REC_CYTO2": "upsample_rec_cyto2", + "ONECLICK_CYTO2": "oneclick_cyto2", + "ONECLICK_PER_CYTO2": "oneclick_per_cyto2", + "ONECLICK_SEG_CYTO2": "oneclick_seg_cyto2", + "ONECLICK_REC_CYTO2": "oneclick_rec_cyto2", + "ANISO_CYTO2": "aniso_cyto2", + "DENOISE_NUCLEI": "denoise_nuclei", + "DENOISE_PER_NUCLEI": "denoise_per_nuclei", + "DENOISE_SEG_NUCLEI": "denoise_seg_nuclei", + "DENOISE_REC_NUCLEI": "denoise_rec_nuclei", + "DEBLUR_NUCLEI": "deblur_nuclei", + "DEBLUR_PER_NUCLEI": "deblur_per_nuclei", + "DEBLUR_SEG_NUCLEI": "deblur_seg_nuclei", + "DEBLUR_REC_NUCLEI": "deblur_rec_nuclei", + "UPSAMPLE_NUCLEI": "upsample_nuclei", + "UPSAMPLE_PER_NUCLEI": "upsample_per_nuclei", + "UPSAMPLE_SEG_NUCLEI": "upsample_seg_nuclei", + "UPSAMPLE_REC_NUCLEI": "upsample_rec_nuclei", + "ONECLICK_NUCLEI": "oneclick_nuclei", + "ONECLICK_PER_NUCLEI": "oneclick_per_nuclei", + "ONECLICK_SEG_NUCLEI": "oneclick_seg_nuclei", + "ONECLICK_REC_NUCLEI": "oneclick_rec_nuclei", + "ANISO_NUCLEI": "aniso_nuclei" + } + } +} diff --git a/tests/unit/test_cellprofiler_model_plugin_modules.py b/tests/unit/test_cellprofiler_model_plugin_modules.py new file mode 100644 index 000000000..ed5fa101d --- /dev/null +++ b/tests/unit/test_cellprofiler_model_plugin_modules.py @@ -0,0 +1,695 @@ +"""RunCellpose, RunStarDist and Runilastik convert; none can run yet. + +Each block marked *written by the plugin* is copied from a pipeline someone +saved, with its repository, path and blob or commit. A block marked *built from +the plugin source* has the rows of that revision's ``settings()`` in order, +reconstructed from ``upgrade_settings`` of CellProfiler-plugins +``active_plugins/runcellpose.py`` at commit 5aaa4e5, because no saved pipeline +of that revision was found (GitHub code search, 2026-10-12: revisions 3 to 6). +""" + +from __future__ import annotations + +import inspect +import json +import tomllib +from dataclasses import dataclass +from pathlib import Path + +import pytest +from packaging.requirements import Requirement + +from openhcs.core.function_patterns import normalize_function_pattern +from openhcs.core.pipeline.funcstep_contract_validator import ( + FuncStepContractValidator, +) +from openhcs.interop.cellprofiler.module_declarations import CellProfilerModule +from openhcs.interop.cellprofiler.module_settings import UnmappedModuleSettingsError +from openhcs.processing.backends.cellprofiler.run_cellpose import ( + RunCellposeModule, + run_cellpose, +) +from openhcs.processing.backends.cellprofiler.run_ilastik import RunilastikModule +from openhcs.processing.backends.cellprofiler.run_stardist import RunStarDistModule +from openhcs.processing.backends.lib_registry import openhcs_registry +from tests.unit.test_cellprofiler_undeclared_and_unavailable_modules import ( + _compile_contracts, + _import, +) + +_REPOSITORY = Path(__file__).parents[2] +_PLUGINS = "CellProfiler/CellProfiler-plugins@5aaa4e5" +_TUTORIALS = "CellProfiler/tutorials" +_FOSSA = "broadinstitute/scripts_notebooks_fossa" + + +@dataclass(frozen=True) +class Block: + """One module block, where it comes from and what it must convert to.""" + + name: str + source: str + module: type[CellProfilerModule] + text: str + images: tuple[str, ...] + """The images the block reads; the first is its input image.""" + arguments: dict[str, object] + """Every keyword of the step's function: as written, or what an absent row means.""" + measured: str | None + """The objects a following measurement module reads; ``None`` for an image.""" + + @property + def pipeline(self) -> str: + assignments = "".join( + f""" Select the image type:Grayscale image + Name to assign these images:{image} + Select the rule criteria:and (file does contain "{image}") +""" + for image in self.images + ) + following = ( + f"""GaussianFilter:[module_num:3|variable_revision_number:1|enabled:True] + Select the input image:{self.arguments["name_the_output_image"]} + Name the output image:Smoothed + Sigma:1 +""" + if self.measured is None + else f"""MeasureObjectSizeShape:[module_num:3|variable_revision_number:3|enabled:True] + Select object sets to measure:{self.measured} + Calculate the Zernike features?:No + Calculate the advanced features?:No +MeasureObjectIntensity:[module_num:4|variable_revision_number:4|enabled:True] + Select images to measure:{self.images[0]} + Select objects to measure:{self.measured} +""" + ) + return ( + "CellProfiler Pipeline: https://cellprofiler.org\n" + "NamesAndTypes:[module_num:1|enabled:True]\n" + f" Assignments count:{len(self.images)}\n" + f"{assignments}{self.text}{following}" + ) + + +_CELLPOSE_ROW_ABSENT = { + "model_type": None, + "pretrained_model": None, + "diameter": None, + "anisotropy": None, + "restore_type": None, + "clip_to_percentiles": False, + "remove_edge_objects": True, + "anisotropy_from_voxel_spacing": True, + "flow_threshold": 0.4, + "cellprob_threshold": 0.0, + "stitch_threshold": 0.0, + "do_3D": False, + "min_size": 15, + "invert": False, +} +"""What RunCellpose's function is given for a row a revision does not have. + +``upgrade_settings`` gives the same for the rows it adds, except edge removal, +which it never adds: revisions without the row get the plugin's default. +""" +_STARDIST_ROW_ABSENT = { + "model_dimensions": None, + "pretrained_model": None, + "model_folder": None, + "prob_thresh": None, + "nms_thresh": None, + "n_tiles": None, +} + + +def _cellpose( + name: str, source: str, text: str, *images: str, **written: object +) -> Block: + return Block( + name, + source, + RunCellposeModule, + text, + images, + {**_CELLPOSE_ROW_ABSENT, **written}, + written["name_the_output_object"], + ) + + +def _arguments(step) -> dict[str, object]: + """The step's keywords over its function's defaults (a default is not stored).""" + + (invocation,) = normalize_function_pattern(step.func).iter_items() + function = inspect.unwrap(invocation.contract.resolve_canonical_raw_callable()) + return { + **{ + name: parameter.default + for name, parameter in inspect.signature(function).parameters.items() + if parameter.default is not parameter.empty + }, + **invocation.kwargs_dict, + } + + +_CELLPOSE_REVISION_3_TAIL = """ Use GPU:No + Use averaging:Yes + Supply nuclei image as well?:No + Select the nuclei image:None + Save probability image?:No + Name the probability image:Probabilities + Location of the pre-trained model file:Elsewhere...| + Pre-trained model file name:cyto_0 + Flow threshold:0.4 + Cell probability threshold:0.0 + GPU memory share for each worker:0.1 + Stitch Threshold:0.0 + Use 3D:No + Minimum size:10 +""" + +_BLOCKS = ( + _cellpose( + "cellpose-r3-plugin-test-pipeline", + f"written by the plugin: {_PLUGINS} " + "tests/headless_test/4.2.5_plugins_test_pipeline_CELLPOSE.cppipe, module 5", + """RunCellpose:[module_num:2|svn_version:'Unknown'|variable_revision_number:3|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:DNA + Expected object diameter:12 + Detection mode:nuclei + Name the output object:Nuclei_pose +""" + + _CELLPOSE_REVISION_3_TAIL + + """ Z rescaling factor (anisotropy):1.0 + Use Omnipose for mask reconstruction:No + Invert images:No +""", + "DNA", + name_the_output_object="Nuclei_pose", + cellpose_version="v2", + model_type="nuclei", + diameter=12.0, + gpu=False, + supply_nuclei_image=False, + save_probability_image=False, + flow_threshold=0.4, + cellprob_threshold=0.0, + stitch_threshold=0.0, + do_3D=False, + min_size=10, + anisotropy=1.0, + invert=False, + ), + _cellpose( + "cellpose-r3-custom-model-and-probabilities", + f"written by the plugin: {_FOSSA} " + "cellprofiler/ToxPath_pipeline/analysis.cppipe " + "(blob 591e5b820446f8024536f1e5aef0d39fed5ddd3b), module 5", + """RunCellpose:[module_num:2|svn_version:'Unknown'|variable_revision_number:3|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:DNA + Expected object diameter:60 + Detection mode:custom + Name the output object:NucleiCP + Use GPU:No + Use averaging:Yes + Supply nuclei image as well?:No + Select the nuclei image:None + Save probability image?:Yes + Name the probability image:Probabilities + Location of the pre-trained model file:Elsewhere...|/home/ubuntu/bucket/projects/2022_10_04_AgNPCellRecovery_fossa_Cimini/workspace/model/2022_05_30_ToxPath + Pre-trained model file name:CP_20221130_102925 + Flow threshold:0.7 + Cell probability threshold:1 + GPU memory share for each worker:0.1 + Stitch Threshold:0.0 + Use 3D:No + Minimum size:200 + Use Omnipose for mask reconstruction:1.0 + Invert images:No +""", + "DNA", + name_the_output_object="NucleiCP", + cellpose_version="v2", + pretrained_model=( + "/home/ubuntu/bucket/projects/2022_10_04_AgNPCellRecovery_fossa_Cimini" + "/workspace/model/2022_05_30_ToxPath/CP_20221130_102925" + ), + diameter=60.0, + gpu=False, + supply_nuclei_image=False, + save_probability_image=True, + name_the_probability_image="Probabilities", + flow_threshold=0.7, + cellprob_threshold=1.0, + stitch_threshold=0.0, + do_3D=False, + min_size=200, + invert=False, + ), + _cellpose( + "cellpose-r3-edge-removal-row", + f"written by the plugin: {_FOSSA} " + "cellprofiler/mimic_nuclei_pipeline/MimicNuclei.cppipe " + "(blob c95ca18ec02485cc2626761c433db0c3b69d137d), module 7", + r"""RunCellpose:[module_num:2|svn_version:'Unknown'|variable_revision_number:3|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:LT + Expected object diameter:148 + Detection mode:custom + Name the output object:Cells + Use GPU:No + Use averaging:Yes + Supply nuclei image as well?:No + Select the nuclei image:None + Save probability image?:Yes + Name the probability image:Probabilities + Location of the pre-trained model file:Elsewhere...|D:\\2022_09_09_LiveCellPainting_fossa_Cimini\\workspace\\model\\2022_03_24_Acidification_LT + Pre-trained model file name:CP_20230327_164451 + Flow threshold:0.7 + Cell probability threshold:-4 + GPU memory share for each worker:0.1 + Stitch Threshold:0.0 + Use 3D:No + Minimum size:30 + Use Omnipose for mask reconstruction:No + Invert images:No + Remove objects that are touching the edge?:Yes +""", + "LT", + name_the_output_object="Cells", + cellpose_version="v2", + pretrained_model=( + r"D:\2022_09_09_LiveCellPainting_fossa_Cimini\workspace\model" + r"\2022_03_24_Acidification_LT/CP_20230327_164451" + ), + diameter=148.0, + gpu=False, + supply_nuclei_image=False, + save_probability_image=True, + name_the_probability_image="Probabilities", + flow_threshold=0.7, + cellprob_threshold=-4.0, + stitch_threshold=0.0, + do_3D=False, + min_size=30, + invert=False, + remove_edge_objects=True, + ), + _cellpose( + "cellpose-r6-tutorial", + f"written by the plugin: {_TUTORIALS} " + "BeginnerSegmentation/bonus_materials/bonus_3_cellpose.cppipe " + "(blob 6c6d98a26253c201b5a6ccc11f6379ec89a15cd0), module 5", + """RunCellpose:[module_num:2|svn_version:'Unknown'|variable_revision_number:6|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:OrigDNA + Rescale images before running Cellpose:No + Run CellPose in docker or local python environment:Docker + Select Cellpose docker image:cellprofiler/runcellpose_with_pretrained:2.3.2 + Expected object diameter:30 + Detection mode:nuclei + Name the output object:RunCellpose + Use GPU:No + Use averaging:No + Supply nuclei image as well?:No + Select the nuclei image:None + Save probability image?:No + Name the probability image:Probabilities + Location of the pre-trained model file:Elsewhere...| + Pre-trained model file name:cyto_0 + Flow threshold:0.4 + Cell probability threshold:0.0 + GPU memory share for each worker:0.1 + Stitch Threshold:0.0 + Use 3D:No + Minimum size:15 + Use Omnipose for mask reconstruction:No + Invert images:No + Remove objects that are touching the edge?:Yes + Rescale probability map?:No +""", + "OrigDNA", + name_the_output_object="RunCellpose", + cellpose_version="v2", + model_type="nuclei", + diameter=30.0, + clip_to_percentiles=False, + gpu=False, + supply_nuclei_image=False, + save_probability_image=False, + flow_threshold=0.4, + cellprob_threshold=0.0, + stitch_threshold=0.0, + do_3D=False, + min_size=15, + invert=False, + remove_edge_objects=True, + ), + _cellpose( + "cellpose-r1", + "built from the plugin source: revision 1 rows", + """RunCellpose:[module_num:2|variable_revision_number:1|enabled:True] + Select the input image:Cyto + Expected object diameter:0 + Detection mode:cyto + Name the output object:Cells + Use GPU:Yes + Use averaging:Yes + Supply nuclei image as well?:Yes + Select the nuclei image:DNA + Save probability image?:No + Name the probability image:Probabilities + Location of the pre-trained model file:Elsewhere...| + Pre-trained model file name:cyto_0 +""", + "Cyto", + "DNA", + name_the_output_object="Cells", + cellpose_version="v2", + model_type="cyto", + gpu=True, + supply_nuclei_image=True, + save_probability_image=False, + ), + _cellpose( + "cellpose-r8-cellpose-3-with-restoration", + "built from the plugin source: revision 8 rows of settings()", + """RunCellpose:[module_num:2|variable_revision_number:8|enabled:True] + Select the input image:DNA + Rescale images before running Cellpose:Yes + Run CellPose in a Docker/Podman container or local python environment:Python + Select Cellpose version:v3 + Select Cellpose docker image:cellprofiler/runcellpose_omnipose_no_pretrained:1.0.2 + Select Cellpose docker image:cellprofiler/runcellpose_no_pretrained:2.3.2 + Select Cellpose docker image:cellprofiler/runcellpose_with_pretrained:3.1.1.2 + Select Cellpose docker image:cellprofiler/runcellpose_with_pretrained:4.0.6 + Specify expected object diameter?:No + Expected object diameter:17 + Detection mode:cyto + Detection mode:cyto2 + Detection mode:tissuenet_cp3 + Detection mode:cpsam + Name the output object:Nuclei + Use GPU:No + Use averaging:No + Supply nuclei image as well?:No + Select the nuclei image:None + Save probability image?:No + Name the probability image:Probabilities + Location of the pre-trained model file:Default Input Folder| + Pre-trained model file name:cyto_0 + Flow threshold:0.4 + Cell probability threshold:0.0 + GPU memory share for each worker:0.1 + Stitch Threshold:0.25 + Use 3D:Yes + Minimum size:15 + Use Omnipose for mask reconstruction:No + Invert images:Yes + Remove objects that are touching the edge?:No + Rescale probability map?:Yes + Preprocess image before segmentation?:Yes + Preprocessing model:denoise_nuclei + Save preprocessed image?:No + Name the preprocessed image:Preprocessed + Cache model between image sets:No + Rescale for anisotropy?:No +""", + "DNA", + name_the_output_object="Nuclei", + cellpose_version="v3", + model_type="tissuenet_cp3", + diameter=17.0, + restore_type="denoise_nuclei", + clip_to_percentiles=True, + gpu=False, + supply_nuclei_image=False, + save_probability_image=False, + flow_threshold=0.4, + cellprob_threshold=0.0, + stitch_threshold=0.25, + do_3D=True, + min_size=15, + invert=True, + remove_edge_objects=False, + anisotropy_from_voxel_spacing=False, + ), + Block( + "stardist-nine-rows-plugin-test-pipeline", + f"written by the plugin: {_PLUGINS} " + "tests/headless_test/4.2.5_plugins_test_pipeline_STARDIST.cppipe, module 5", + RunStarDistModule, + """RunStarDist:[module_num:2|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:DNA + Model:2D + Name the output object:RunStarDist + Tile input image?:No + Horizontal tiles:1 + Vertical tiles:1 + Save probability image?:Yes + Name the probability image:Probabilities + Model folder:Elsewhere...| +""", + ("DNA",), + { + **_STARDIST_ROW_ABSENT, + "name_the_output_object": "RunStarDist", + "model_dimensions": "2D", + "save_probability_image": True, + "name_the_probability_image": "Probabilities", + }, + "RunStarDist", + ), + Block( + "stardist-nine-rows-custom-model", + f"written by the plugin: {_TUTORIALS} Deprecated_Tutorials/" + "Plugins_runCellpose_runStarDist/runCellpose_runStarDist_tutorial_materials/" + "Blastoid (Stardist 3D)/blastoid_3D-Stardist.cppipe " + "(blob a51ccbb88aa1d665e02b4c4bce7fd5e1654a5fd1), module 6", + RunStarDistModule, + """RunStarDist:[module_num:2|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Resize + Model:Custom + Name the output object:RunStarDist + Tile input image?:No + Horizontal tiles:1 + Vertical tiles:1 + Save probability image?:Yes + Name the probability image:Probabilities + Model folder:Elsewhere...|/Users/bdiazroh/Documents/GitHub/stardist/models/examples/3D_demo +""", + ("Resize",), + { + **_STARDIST_ROW_ABSENT, + "name_the_output_object": "RunStarDist", + "model_folder": ( + "/Users/bdiazroh/Documents/GitHub/stardist/models/examples/3D_demo" + ), + "save_probability_image": True, + "name_the_probability_image": "Probabilities", + }, + "RunStarDist", + ), + Block( + "stardist-fifteen-rows", + "built from the plugin source: rows of settings() at 5aaa4e5", + RunStarDistModule, + """RunStarDist:[module_num:2|variable_revision_number:1|enabled:True] + Select the input image:DNA + Model Type:3D + Name the output object:Nuclei + Tile input image?:Yes + Horizontal tiles:2 + Vertical tiles:3 + Save probability image?:No + Name the probability image:Probabilities + Model folder:Default Input Folder| + Model:Versatile (fluorescent nuclei) + Model:3D_demo + Probability threshold:0.5 + Overlap threshold:0.4 + Manually set how much GPU memory each worker can use?:No + GPU memory (in GB) for each worker:0.5 +""", + ("DNA",), + { + **_STARDIST_ROW_ABSENT, + "name_the_output_object": "Nuclei", + "model_dimensions": "3D", + "pretrained_model": "3D_demo", + "n_tiles": (2, 3), + "prob_thresh": 0.5, + "nms_thresh": 0.4, + "save_probability_image": False, + }, + "Nuclei", + ), + Block( + "ilastik-tutorial", + f"written by the plugin: {_TUTORIALS} " + "BeginnerSegmentation/bonus_materials/bonus_2_ilastik.cppipe " + "(blob e474095d4b2e3da38d6f836f89355ceb6cd243b5), module 11", + RunilastikModule, + """Runilastik:[module_num:2|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:OrigRNA + Name the output image:Runilastik + Run ilastik in docker or local environment:Local + Executable: + Project file: + Select the project type:Pixel Classification +""", + ("OrigRNA",), + { + "executable": None, + "project_file": None, + "name_the_output_image": "Runilastik", + "project_type": "Pixel Classification", + }, + None, + ), +) +_REQUIRING_A_DISTRIBUTION = {RunCellposeModule: "cellpose", RunStarDistModule: "stardist"} + +blocks = pytest.mark.parametrize("block", _BLOCKS, ids=lambda block: block.name) + + +@pytest.fixture(autouse=True) +def package_metadata_of_this_source_tree(monkeypatch) -> None: + """Read OpenHCS's extras from ``pyproject.toml``. + + The installed metadata is what the environment was built from, which can + predate an extra this source tree declares. + """ + + project = tomllib.loads((_REPOSITORY / "pyproject.toml").read_text())["project"] + extras = project["optional-dependencies"] + requirements = tuple( + Requirement( + f"{name}; ({marker}) and extra == '{extra}'" + if marker + else f"{name}; extra == '{extra}'" + ) + for extra, entries in extras.items() + for name, _separator, marker in (entry.partition(";") for entry in entries) + ) + monkeypatch.setattr(openhcs_registry, "_openhcs_requirements", lambda: requirements) + monkeypatch.setattr(openhcs_registry, "_openhcs_extras", lambda: tuple(extras)) + + +@blocks +def test_block_converts_to_its_declared_step_feeding_the_next_module( + tmp_path: Path, block: Block +) -> None: + steps, _pipeline_config = _import(tmp_path, block.pipeline) + + assert steps[0].name == block.module.require_module_name() + (invocation,) = normalize_function_pattern(steps[0].func).iter_items() + assert ( + CellProfilerModule.require_callable_contract_owner(invocation.contract) + is block.module + ) + assert _arguments(steps[0]) == block.arguments + assert [step.name for step in steps[1:]] == ( + ["GaussianFilter"] + if block.measured is None + else ["MeasureObjectSizeShape", "MeasureObjectIntensity"] + ) + + +@blocks +def test_compiling_is_refused_with_the_install_command_then_with_what_is_lacking( + tmp_path: Path, monkeypatch, block: Block +) -> None: + steps, pipeline_config = _import(tmp_path, block.pipeline) + (invocation,) = normalize_function_pattern(steps[0].func).iter_items() + distribution = _REQUIRING_A_DISTRIBUTION.get(block.module) + + if distribution is not None: + # The later modules' contracts compile against this step's outputs. + assert _compile_contracts(steps, pipeline_config) is not None + with pytest.raises(ValueError) as not_installed: + FuncStepContractValidator.validate_external_library_installation( + invocation.contract.func, steps[0].name + ) + assert ( + f"Function '{invocation.contract.function_name}' in step " + f"'{steps[0].name}' requires external library '{distribution}' which " + "is not installed." + ) in str(not_installed.value) + assert f"Install with: pip install 'openhcs[{distribution}]'" in str( + not_installed.value + ) + monkeypatch.setattr(openhcs_registry, "_distribution_installed", lambda _: True) + + FuncStepContractValidator.validate_external_library_installation( + invocation.contract.func, steps[0].name + ) + with pytest.raises(ValueError) as lacking: + _compile_contracts(steps, pipeline_config) + assert str(lacking.value).endswith(block.module.cannot_run_message()) + assert block.module.what_openhcs_lacks in str(lacking.value) + with pytest.raises(NotImplementedError) as called: + inspect.unwrap(invocation.contract.resolve_canonical_raw_callable())(None) + assert str(called.value) == block.module.cannot_run_message() + + +def test_a_row_no_revision_of_the_plugin_writes_is_not_silently_dropped( + tmp_path: Path, +) -> None: + block = _BLOCKS[0] + + with pytest.raises(UnmappedModuleSettingsError, match="a_setting_of_the_future"): + _import( + tmp_path, + block.pipeline.replace( + " Invert images:No\n", + " Invert images:No\n A setting of the future:1\n", + ), + ) + + +def test_cellpose_keywords_are_parameters_of_the_recorded_cellpose_functions() -> None: + """Checked against the functions' signatures recorded from their branch.""" + + recorded = json.loads( + ( + _REPOSITORY + / "tests/fixtures/cellprofiler_plugins/cellpose_functions_signature.json" + ).read_text() + ) + assert recorded["branch"] == "feature/cellpose-full-surface" + assert recorded["module"] in RunCellposeModule.what_openhcs_lacks + functions = recorded["functions"] + segment = functions["segment"]["parameters"] + around_the_model = { + "cellpose_version", + "anisotropy_from_voxel_spacing", + "clip_to_percentiles", + "remove_edge_objects", + "supply_nuclei_image", + "save_probability_image", + } + parameters = dict(inspect.signature(inspect.unwrap(run_cellpose)).parameters) + del parameters["image"] + + of_the_functions = set(parameters) - around_the_model + assert of_the_functions - {"restore_type"} <= set(segment) + assert "restore_type" in functions["restore_and_segment"]["parameters"] + assert around_the_model.isdisjoint( + name for function in functions.values() for name in function["parameters"] + ) + # A name is a model's value in the recorded enums; a file is a path. + as_recorded = {"model_type": "None", "pretrained_model": "None"} + assert { + name: repr(parameters[name].default) + for name in of_the_functions - {"restore_type"} + } == { + name: as_recorded.get(name, segment[name]["default"]) + for name in of_the_functions - {"restore_type"} + } + # Every function the module names is recorded, with the objects it returns. + for name in ("segment", "segment_estimating_diameter", "restore_and_segment"): + assert name in RunCellposeModule.what_openhcs_lacks + assert ["labels", "ObjectLabelsArtifactType"] in functions[name]["outputs"] + assert ["cell_probability", "ImageArtifactType"] in functions[name]["outputs"] + # Model names the blocks use that the recorded Cellpose 3 has. + models = set(recorded["enums"]["SegmentationModel"].values()) + assert {"nuclei", "cyto", "tissuenet_cp3"} <= models + assert "denoise_nuclei" in recorded["enums"]["RestorationModel"].values() diff --git a/tests/unit/test_cellprofiler_undeclared_and_unavailable_modules.py b/tests/unit/test_cellprofiler_undeclared_and_unavailable_modules.py index 3779d3201..81e9746c4 100644 --- a/tests/unit/test_cellprofiler_undeclared_and_unavailable_modules.py +++ b/tests/unit/test_cellprofiler_undeclared_and_unavailable_modules.py @@ -75,7 +75,7 @@ _UNDECLARED_OBJECT_PRODUCER = ( _SOURCE - + """RunCellpose:[module_num:2|variable_revision_number:3|enabled:True] + + """SomeLabsSegmenter:[module_num:2|variable_revision_number:3|enabled:True] Select the input image:DNA Detection mode:nuclei Name the output object:Nuclei @@ -219,7 +219,7 @@ def test_undeclared_module_provides_objects_a_measurement_module_reads( steps, _pipeline_config = _import(tmp_path, _UNDECLARED_OBJECT_PRODUCER) assert [step.name for step in steps] == [ - "RunCellpose", + "SomeLabsSegmenter", "MeasureObjectSizeShape", "MeasureObjectIntensity", ] From a4b4251cc0fa2c21b04b6847b2bb295e098b2c9a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 16:11:25 -0400 Subject: [PATCH 5/9] CP1 receipt: what is done, blocked and remaining Co-Authored-By: Claude Opus 5.5 --- .../CP1-cellprofiler-plugins.md | 83 +++++++++++++++++-- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/docs/refactor/consolidation-20261009/CP1-cellprofiler-plugins.md b/docs/refactor/consolidation-20261009/CP1-cellprofiler-plugins.md index 0c2beb1b6..532b57df9 100644 --- a/docs/refactor/consolidation-20261009/CP1-cellprofiler-plugins.md +++ b/docs/refactor/consolidation-20261009/CP1-cellprofiler-plugins.md @@ -143,14 +143,83 @@ See "Done" and "Remaining, in order" below; kept current after each commit. ### Done -(nothing yet) +- **Mechanism** (`887d67920`). `CellProfilerModule.declaration_for_block` returns the + registered class or `UndeclaredModule` + (`openhcs/processing/backends/cellprofiler/undeclared_module.py`); `for_module` and + `require_module` are unchanged. The undeclared step keeps the written name, revision + and settings as kwargs. Outputs: when a later module's input resolves to nothing + (`UnknownArtifactInput`), the importer asks earlier blocks, nearest first, through the + family hook `block_providing_artifact`; only `UndeclaredModule` answers, and only for + names among its setting values. Inputs: setting values naming an available artifact. + Compile refusal: family hook `validate_compilation`, inside the existing provider + wrapping. Message: `CellProfiler module 'X' has no OpenHCS implementation, so this + step cannot run. Replace the step's function with a custom function producing image + 'Y'.` Absent distribution: `distributions_not_installed` and + `install_command_for_distributions` in `openhcs_registry.py`, read by the generic + validator (`funcstep_contract_validator.py`), so every function gets `requires external + library 'D' which is not installed. … Install with: pip install 'openhcs[extra]'`; + the catalogue filters functions by their own file's `REQUIRED_DISTRIBUTIONS`. + Parity argument: for declared pipelines the lookup returns the same class, only + `UndeclaredModule` overrides the new hooks (asserted by a test), and the retry runs + only on an error that used to end the import. + Not covered: names consumed outside `require_available_artifact_input` (measurements, + sidecar files) are not derived; output lineage is declared only with exactly one image + input; `UndeclaredModule` appears in the compatibility matrix as a registered module. +- **Deleted** `openhcs/processing/backends/cellprofiler/contracts.json`: certificate + **unused** (no reader in `openhcs`, `benchmark`, `tests`, `scripts`, `external/`). + The `== 91` count is compared with the registry's names. +- **Plugins** (`0bdb355c7`): HistogramEqualization, DistanceTransform, HistogramMatching. + One file each (72 to 141 lines), nothing restated elsewhere. Reference: the real + plugins run in CellProfiler 4.2.8.1 at plugin commit `5aaa4e5` + (`tests/fixtures/cellprofiler_plugins/make_reference.py`); 18 cases, max difference + 4.2e-7 (global equalization, scikit-image 0.26 versus 0.18.3), 0.0 elsewhere. Two + combinations the plugins crash on are refused. HistogramMatching ignores "Plane number + in 3d", a setting only the tglow fork writes: unverified what the fork does with it. +- **Model plugins declared** (`ebc3f1af9`): RunCellpose, RunStarDist, Runilastik convert + to real steps with every setting mapped or ignored with a reason; none can run. + Extras `cellpose`, `stardist`. RunCellpose's parameter names and defaults follow + `segment` on `feature/cellpose-full-surface` (recorded at `0d127cc9f`, Cellpose + 3.1.1.3). Real blocks tested: r3 (three layouts), r6; r1 and r8 built from the plugin + source; r2, r4, r5, r7 untested. + +### Cost of adding a module + +Before: one backend file, plus an entry in `contracts.json` by convention (89 of 92 +had one) and the `== 91` edit. After: one backend file. Observed for the three plugins: +72, 141 and 121 lines, no other file. A module with an optional dependency adds a +`REQUIRED_DISTRIBUTIONS` line and, once per distribution, an extra in `pyproject.toml`. + +### Blocked + +- RunCellpose running: needs `feature/cellpose-full-surface` (draft PR #1234). Its + function cannot be named as the module's callable as it stands: its module imports + Cellpose at top level, its model parameter is an enum defined there, and it returns + raw arrays under fixed names where the module family needs label values under + setting-named outputs. At merge: replace the body of `run_cellpose` with the call and + label conversion and drop the `ModuleWithoutFunction` parent. +- GPU memory share setting: DL1 (`refactor/DL1-device-and-memory-types`, remote branch, + no PR seen). Preparation before readiness: DL4 (draft #1235). Provenance: DL3 (draft + #1236). Files between steps: DL2 (no remote branch seen). +- Docker/Podman execution of RunCellpose and Runilastik: no branch covers an + out-of-process runner. +- RunStarDist: no StarDist function exists in OpenHCS. Runilastik: no ilastik + distribution on PyPI; needs a function that runs an external program. +- Revision upgrades have no owner in the family (five modules read + `variable_revision_number` in their own hooks). Proposed: a per-binding "value when + the row is absent". ### Remaining, in order -1. Mechanism: undeclared-module step; dependency-absent declared module; delete - `contracts.json`; derive the `== 91` count. -2. Pure plugins by rank: HistogramEqualization, CallBarcodes (barcode CSV as a declared - input file), DistanceTransform, CompensateColors, HistogramMatching, DeclumpObjects, +1. In flight when this was written (agents working in the tree, not yet committed): + requirements read from the running source tree's `pyproject.toml` (so new extras do + not need an environment refresh) and the `.gitignore` `*.json` rule narrowed + (`tests/fixtures/cellprofiler_plugins/reference.json` is missing from history until + then); the four built-ins; CallBarcodes, CompensateColors, DeclumpObjects, + MeasureRadialEntropy, EnforceObjectsOneToOne. +2. Gates: none run yet (`verify_branch.sh cp1`, `smoke_local.sh`, `guardrails_local.sh`, + official30 parity under `official30.lock`). +3. Pure plugins by rank: HistogramEqualization (done), CallBarcodes (barcode CSV as a declared + input file), DistanceTransform (done), CompensateColors, HistogramMatching (done), DeclumpObjects, MeasureRadialEntropy, EnforceObjectsOneToOne, LaplacianOfGaussian, TopHatTransform, FilterObjects_StringMatch, EdgeDetection, CalculateMoments, ConvertOutlinesToObjects, EnhancedMeasureTexture, PixelShuffle, BlobDetection, then the zero-count ones: AddNoise, @@ -162,7 +231,7 @@ See "Done" and "Remaining, in order" below; kept current after each commit. SpectralAnalysis, TestAllThresholds, Transforms). Each: settings from a real `.cppipe` block with its source repository and commit recorded, and a check against the plugin's own code. -3. Built-ins to 94 of 94: LoadData (work out its meaning for source bindings), LabelImages, +4. Built-ins to 94 of 94: LoadData (work out its meaning for source bindings), LabelImages, SaveCroppedObjects, CreateBatchFiles (a declared no-op that says why). -4. Heavy: RunCellpose (declaration and full settings mapping onto the Cellpose surface of +5. Heavy (declared; see Blocked): RunCellpose (declaration and full settings mapping onto the Cellpose surface of branch `feature/cellpose-full-surface`; no second wrapper), RunStarDist, Runilastik. From a38300b35a44ef8974f4e55fdc29707f19c037c6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 16:21:41 -0400 Subject: [PATCH 6/9] Declared requirements come from the code that is running A source tree answers from its pyproject.toml; an installed package from its distribution metadata. Both are found from openhcs.__file__. More than one metadata directory for an installed package is an error naming each. A new extra no longer needs the environment's metadata refreshed. Co-Authored-By: Claude Opus 5.5 --- .../backends/lib_registry/openhcs_registry.py | 44 +--- openhcs/utils/declared_requirements.py | 218 ++++++++++++++++++ tests/integration/test_basicpy_real_fit.py | 4 +- .../test_cellprofiler_model_plugin_modules.py | 25 -- ...iler_undeclared_and_unavailable_modules.py | 23 +- .../unit/test_declared_requirements_source.py | 149 ++++++++++++ tests/unit/test_library_registry_discovery.py | 32 +-- 7 files changed, 412 insertions(+), 83 deletions(-) create mode 100644 openhcs/utils/declared_requirements.py create mode 100644 tests/unit/test_declared_requirements_source.py diff --git a/openhcs/processing/backends/lib_registry/openhcs_registry.py b/openhcs/processing/backends/lib_registry/openhcs_registry.py index 54f8e6420..bae2d7deb 100644 --- a/openhcs/processing/backends/lib_registry/openhcs_registry.py +++ b/openhcs/processing/backends/lib_registry/openhcs_registry.py @@ -24,7 +24,6 @@ import numpy as np from metaclass_registry import LazyDiscoveryDict, import_module_preserving_root_logging -from packaging.requirements import Requirement from packaging.utils import canonicalize_name from openhcs.constants import VALID_MEMORY_TYPES, MemoryType @@ -42,6 +41,7 @@ FlexibleContract, ProcessingContract, ) +from openhcs.utils.declared_requirements import declared_requirements from openhcs.utils.environment import OpenHCSProcessEnvironment from openhcs.processing.custom_functions.runtime_registry import ( CustomFunctionCanonicalLookup, @@ -349,20 +349,7 @@ def _module_declares_allowed_memory_type( _REQUIRED_DISTRIBUTIONS_NAME = "REQUIRED_DISTRIBUTIONS" -@lru_cache(maxsize=1) -def _openhcs_requirements() -> tuple[Requirement, ...]: - """Return the installed OpenHCS distribution's declared requirements. - - Installed metadata is fixed for the life of the process; parsing it once - serves every module the catalog asks about. - """ - - return tuple( - Requirement(value) for value in importlib.metadata.requires("openhcs") or () - ) - - -def _distribution_excluded_by_package_metadata(name: str) -> bool: +def _distribution_excluded_by_declared_requirements(name: str) -> bool: """Return whether OpenHCS's own requirement markers leave ``name`` out here. A distribution is legitimately absent only when no OpenHCS requirement for @@ -374,7 +361,7 @@ def _distribution_excluded_by_package_metadata(name: str) -> bool: canonical = canonicalize_name(name) requirements = tuple( requirement - for requirement in _openhcs_requirements() + for requirement in declared_requirements().requirements if canonicalize_name(requirement.name) == canonical ) if not requirements: @@ -398,13 +385,6 @@ def _distribution_installed(name: str) -> bool: return True -@lru_cache(maxsize=1) -def _openhcs_extras() -> tuple[str, ...]: - """Return the extras the installed OpenHCS distribution declares.""" - - return tuple(importlib.metadata.metadata("openhcs").get_all("Provides-Extra") or ()) - - def distributions_not_installed(func: Callable[..., object]) -> tuple[str, ...]: """Return the absent distributions the file defining ``func`` requires. @@ -431,7 +411,7 @@ def install_command_for_distributions(names: tuple[str, ...]) -> str: distribution no extra brings is installed by its own name. """ - requirements = _openhcs_requirements() + requirements = declared_requirements().requirements def brought_by(extra: str) -> tuple[str, ...]: return tuple( @@ -447,7 +427,7 @@ def brought_by(extra: str) -> tuple[str, ...]: for name in names: providing = tuple( extra - for extra in _openhcs_extras() + for extra in declared_requirements().extras if canonicalize_name(name) in brought_by(extra) ) if providing: @@ -504,7 +484,7 @@ def _module_in_this_catalog( "Skipping OpenHCS module %s - no allowed memory decorators", module_name ) return False - excluded = _module_distributions_excluded_by_package_metadata(module_name) + excluded = _module_distributions_excluded_by_declared_requirements(module_name) if excluded: logger.info( "Skipping OpenHCS module %s - package requirements exclude %s here", @@ -519,17 +499,17 @@ def _catalog_policy_context() -> dict[str, Any]: return { "allowed_memory_types": sorted(_allowed_openhcs_memory_types()), - "distributions_excluded_by_package_metadata": sorted( + "distributions_excluded_by_declared_requirements": sorted( { canonicalize_name(requirement.name) - for requirement in _openhcs_requirements() - if _distribution_excluded_by_package_metadata(requirement.name) + for requirement in declared_requirements().requirements + if _distribution_excluded_by_declared_requirements(requirement.name) } ), } -def _module_distributions_excluded_by_package_metadata( +def _module_distributions_excluded_by_declared_requirements( module_name: str, ) -> tuple[str, ...]: """Return the module's declared distributions this installation excludes.""" @@ -540,7 +520,7 @@ def _module_distributions_excluded_by_package_metadata( return tuple( name for name in _source_required_distributions(*module_source) - if _distribution_excluded_by_package_metadata(name) + if _distribution_excluded_by_declared_requirements(name) ) @@ -836,7 +816,7 @@ def discover_functions(self) -> Dict[str, FunctionMetadata]: module_name: tuple( func for func in module.openhcs_registry_functions() - if not _module_distributions_excluded_by_package_metadata( + if not _module_distributions_excluded_by_declared_requirements( inspect.unwrap(func).__module__ ) ) diff --git a/openhcs/utils/declared_requirements.py b/openhcs/utils/declared_requirements.py new file mode 100644 index 000000000..4b644fca6 --- /dev/null +++ b/openhcs/utils/declared_requirements.py @@ -0,0 +1,218 @@ +"""The distributions the running ``openhcs`` package declares. + +One question has one owner: which distributions does this OpenHCS require, +under which markers, and which extras bring them. The answer belongs to the code +that is running. A source tree writes it in the ``pyproject.toml`` beside the +package; an installed wheel carries it in the ``.dist-info`` that lists the +package's files. Installed metadata of a source tree is a copy of that +``pyproject.toml`` made at install time, so a source tree never reads it. +""" + +from __future__ import annotations + +import importlib.metadata +import tomllib +from abc import ABC, abstractmethod +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path, PurePosixPath +from typing import ClassVar, Self + +from packaging.markers import Marker +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name + + +@dataclass(frozen=True) +class DeclaredRequirements: + """Every requirement a package declares, and the extras it offers. + + A requirement an extra brings carries that extra in its marker, as + distribution metadata writes it. + """ + + requirements: tuple[Requirement, ...] + extras: tuple[str, ...] + + +class RequirementsDeclaration(ABC): + """One place where the requirements of a package directory are written.""" + + _kinds: ClassVar[list[type[RequirementsDeclaration]]] = [] + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + RequirementsDeclaration._kinds.append(cls) + + @classmethod + @abstractmethod + def describing(cls, package_directory: Path) -> tuple[Self, ...]: + """Return the declarations of this kind written for ``package_directory``.""" + + @property + @abstractmethod + def location(self) -> Path: + """The file or directory the declaration is read from.""" + + @abstractmethod + def read(self) -> DeclaredRequirements: + """Parse the declaration.""" + + @staticmethod + def of(package_directory: Path) -> RequirementsDeclaration: + """Return the one declaration written for the package in ``package_directory``.""" + + found = tuple( + declaration + for kind in RequirementsDeclaration._kinds + for declaration in kind.describing(package_directory) + ) + if len(found) != 1: + locations = ", ".join(str(declaration.location) for declaration in found) + raise RuntimeError( + f"The package at {package_directory} needs exactly one declaration " + "of its requirements: a pyproject.toml beside it naming the " + "package, or a .dist-info whose RECORD lists it. Found " + f"{len(found)}{': ' + locations if found else ''}." + ) + return found[0] + + +class SourceTreeDeclaration(RequirementsDeclaration): + """The ``[project]`` table of the ``pyproject.toml`` beside the package.""" + + def __init__(self, pyproject: Path, project: dict[str, object]) -> None: + self._pyproject = pyproject + self._project = project + + @classmethod + def describing(cls, package_directory: Path) -> tuple[Self, ...]: + pyproject = package_directory.parent / "pyproject.toml" + if not pyproject.is_file(): + return () + project = tomllib.loads(pyproject.read_text(encoding="utf-8")).get("project") + if not isinstance(project, dict): + return () + name = project.get("name") + if not isinstance(name, str) or canonicalize_name(name) != canonicalize_name( + package_directory.name + ): + return () + return (cls(pyproject, project),) + + @property + def location(self) -> Path: + return self._pyproject + + def read(self) -> DeclaredRequirements: + dynamic = self._project.get("dynamic", []) + assert isinstance(dynamic, list) + computed_at_build = {"dependencies", "optional-dependencies"} & set(dynamic) + if computed_at_build: + raise ValueError( + f"{self._pyproject} computes {sorted(computed_at_build)} at build " + "time; the requirements of this source tree are not written in it." + ) + dependencies = self._project.get("dependencies", []) + optional = self._project.get("optional-dependencies", {}) + assert isinstance(dependencies, list) and isinstance(optional, dict) + return DeclaredRequirements( + requirements=( + *(Requirement(text) for text in dependencies), + *( + _brought_by_extra(Requirement(text), extra) + for extra, texts in optional.items() + for text in texts + ), + ), + extras=tuple(optional), + ) + + +def _brought_by_extra(requirement: Requirement, extra: str) -> Requirement: + """Return ``requirement`` as metadata writes one an extra brings.""" + + selects_extra = f'extra == "{extra}"' + requirement.marker = Marker( + selects_extra + if requirement.marker is None + else f"({requirement.marker}) and {selects_extra}" + ) + return requirement + + +class InstalledDeclaration(RequirementsDeclaration): + """The metadata of the installed distribution whose RECORD lists the package.""" + + def __init__( + self, + distribution: importlib.metadata.PathDistribution, + metadata_directory: Path, + visible_metadata: tuple[Path, ...], + ) -> None: + self._distribution = distribution + self._metadata_directory = metadata_directory + self._visible_metadata = visible_metadata + + @classmethod + def describing(cls, package_directory: Path) -> tuple[Self, ...]: + package_file = PurePosixPath(package_directory.name, "__init__.py") + running_file = (package_directory / "__init__.py").resolve() + visible = _visible_metadata(package_directory.name) + return tuple( + cls(distribution, metadata_directory, tuple(visible)) + for metadata_directory, distribution in visible.items() + if distribution.read_text("RECORD") is not None + and any( + PurePosixPath(recorded) == package_file + for recorded in distribution.files or () + ) + and Path(str(distribution.locate_file(package_file))).resolve() + == running_file + ) + + @property + def location(self) -> Path: + return self._metadata_directory + + def read(self) -> DeclaredRequirements: + # Other readers of this distribution's metadata (version, origin, file + # list) take whichever copy the path finder meets first. An installation + # showing two copies has no single answer for them, so it is refused + # here rather than answered from one of the two. + if len(self._visible_metadata) != 1: + raise RuntimeError( + "More than one metadata directory for " + f"{self._distribution.metadata['Name']!r} is visible on sys.path: " + f"{', '.join(str(path) for path in self._visible_metadata)}. " + "Remove the stale one." + ) + return DeclaredRequirements( + requirements=tuple( + Requirement(text) for text in self._distribution.requires or () + ), + extras=tuple(self._distribution.metadata.get_all("Provides-Extra") or ()), + ) + + +def _visible_metadata( + distribution_name: str, +) -> dict[Path, importlib.metadata.PathDistribution]: + """Return every metadata directory for ``distribution_name`` on ``sys.path``.""" + + context = importlib.metadata.DistributionFinder.Context(name=distribution_name) + return { + Path(str(distribution._path)).resolve(): distribution + for distribution in importlib.metadata.MetadataPathFinder.find_distributions( + context + ) + } + + +@lru_cache(maxsize=1) +def declared_requirements() -> DeclaredRequirements: + """Return what the running ``openhcs`` declares, parsed once per process.""" + + import openhcs + + return RequirementsDeclaration.of(Path(openhcs.__file__).parent).read() diff --git a/tests/integration/test_basicpy_real_fit.py b/tests/integration/test_basicpy_real_fit.py index 4025252c4..1d55a0bf4 100644 --- a/tests/integration/test_basicpy_real_fit.py +++ b/tests/integration/test_basicpy_real_fit.py @@ -17,7 +17,7 @@ def test_real_algorithm_uses_numpy_transport_and_is_cpu_catalog_eligible(monkeyp from openhcs.processing.backends.lib_registry.openhcs_registry import ( _catalog_memory_types, _module_declares_allowed_memory_type, - _module_distributions_excluded_by_package_metadata, + _module_distributions_excluded_by_declared_requirements, ) monkeypatch.setenv("OPENHCS_CPU_ONLY", "true") @@ -30,7 +30,7 @@ def test_real_algorithm_uses_numpy_transport_and_is_cpu_catalog_eligible(monkeyp basic_flatfield_correction_jax.__module__, frozenset({MemoryType.NUMPY.value}) ) assert ( - _module_distributions_excluded_by_package_metadata( + _module_distributions_excluded_by_declared_requirements( basic_flatfield_correction_jax.__module__ ) == () diff --git a/tests/unit/test_cellprofiler_model_plugin_modules.py b/tests/unit/test_cellprofiler_model_plugin_modules.py index ed5fa101d..4aabe5939 100644 --- a/tests/unit/test_cellprofiler_model_plugin_modules.py +++ b/tests/unit/test_cellprofiler_model_plugin_modules.py @@ -12,12 +12,10 @@ import inspect import json -import tomllib from dataclasses import dataclass from pathlib import Path import pytest -from packaging.requirements import Requirement from openhcs.core.function_patterns import normalize_function_pattern from openhcs.core.pipeline.funcstep_contract_validator import ( @@ -550,29 +548,6 @@ def _arguments(step) -> dict[str, object]: blocks = pytest.mark.parametrize("block", _BLOCKS, ids=lambda block: block.name) -@pytest.fixture(autouse=True) -def package_metadata_of_this_source_tree(monkeypatch) -> None: - """Read OpenHCS's extras from ``pyproject.toml``. - - The installed metadata is what the environment was built from, which can - predate an extra this source tree declares. - """ - - project = tomllib.loads((_REPOSITORY / "pyproject.toml").read_text())["project"] - extras = project["optional-dependencies"] - requirements = tuple( - Requirement( - f"{name}; ({marker}) and extra == '{extra}'" - if marker - else f"{name}; extra == '{extra}'" - ) - for extra, entries in extras.items() - for name, _separator, marker in (entry.partition(";") for entry in entries) - ) - monkeypatch.setattr(openhcs_registry, "_openhcs_requirements", lambda: requirements) - monkeypatch.setattr(openhcs_registry, "_openhcs_extras", lambda: tuple(extras)) - - @blocks def test_block_converts_to_its_declared_step_feeding_the_next_module( tmp_path: Path, block: Block diff --git a/tests/unit/test_cellprofiler_undeclared_and_unavailable_modules.py b/tests/unit/test_cellprofiler_undeclared_and_unavailable_modules.py index 81e9746c4..b7d15abef 100644 --- a/tests/unit/test_cellprofiler_undeclared_and_unavailable_modules.py +++ b/tests/unit/test_cellprofiler_undeclared_and_unavailable_modules.py @@ -46,6 +46,7 @@ undeclared_module, ) from openhcs.processing.backends.lib_registry import openhcs_registry +from openhcs.utils.declared_requirements import DeclaredRequirements from tests.context_support import executing_context _SOURCE = """CellProfiler Pipeline: https://cellprofiler.org @@ -336,16 +337,16 @@ def fail(*_args: object, **_kwargs: object) -> None: def _absent_distribution_requirements(monkeypatch) -> None: monkeypatch.setattr( openhcs_registry, - "_openhcs_requirements", - lambda: ( - Requirement('absent-probe>=1; extra == "probe"'), - Requirement('absent-probe>=1; extra == "everything"'), - Requirement('numpy>=1; extra == "everything"'), + "declared_requirements", + lambda: DeclaredRequirements( + requirements=( + Requirement('absent-probe>=1; extra == "probe"'), + Requirement('absent-probe>=1; extra == "everything"'), + Requirement('numpy>=1; extra == "everything"'), + ), + extras=("everything", "probe"), ), ) - monkeypatch.setattr( - openhcs_registry, "_openhcs_extras", lambda: ("everything", "probe") - ) def test_declared_module_with_absent_distribution_imports_and_cannot_compile( @@ -440,8 +441,10 @@ def test_catalog_module_cannot_hand_over_a_function_its_file_excludes( monkeypatch.setenv("OPENHCS_CPU_ONLY", "1") monkeypatch.setattr( openhcs_registry, - "_openhcs_requirements", - lambda: (Requirement(requirement),), + "declared_requirements", + lambda: DeclaredRequirements( + requirements=(Requirement(requirement),), extras=() + ), ) registry = openhcs_registry.OpenHCSRegistry() registry.MODULES_TO_SCAN = [package_name, f"{package_name}.implementation"] diff --git a/tests/unit/test_declared_requirements_source.py b/tests/unit/test_declared_requirements_source.py new file mode 100644 index 000000000..a45b0bee5 --- /dev/null +++ b/tests/unit/test_declared_requirements_source.py @@ -0,0 +1,149 @@ +"""The running package's requirements come from where that code declares them.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from openhcs.utils.declared_requirements import ( + InstalledDeclaration, + RequirementsDeclaration, + SourceTreeDeclaration, + declared_requirements, +) + +_PACKAGE = "requirements_probe" + + +def _package(parent: Path) -> Path: + package = parent / _PACKAGE + package.mkdir(parents=True) + (package / "__init__.py").write_text("") + return package + + +def _metadata(directory: Path, *lines: str, record: str | None = None) -> Path: + directory.mkdir(parents=True) + name = "METADATA" if directory.suffix == ".dist-info" else "PKG-INFO" + (directory / name).write_text( + "\n".join( + ("Metadata-Version: 2.1", f"Name: {_PACKAGE}", "Version: 1.0", *lines, "") + ) + ) + if record is not None: + (directory / "RECORD").write_text(record) + return directory + + +def _names(requirements) -> list[str]: + return [str(requirement) for requirement in requirements] + + +def test_source_tree_reads_its_pyproject_not_the_metadata_copied_at_install( + tmp_path, monkeypatch +) -> None: + package = _package(tmp_path / "tree") + (tmp_path / "tree" / "pyproject.toml").write_text( + "[project]\n" + f'name = "{_PACKAGE.replace("_", "-")}"\n' + 'dependencies = ["core-probe>=1; python_version >= \'3\'"]\n' + "[project.optional-dependencies]\n" + 'old = ["old-probe"]\n' + "new = [\"new-probe>=2; platform_system != 'Nowhere'\"]\n" + ) + # An editable install: metadata copied before ``new`` existed, and a RECORD + # that lists the path hook rather than the package. + _metadata( + tmp_path / "site" / f"{_PACKAGE}-1.0.dist-info", + "Provides-Extra: old", + 'Requires-Dist: old-probe; extra == "old"', + record=f"__editable__.{_PACKAGE}-1.0.pth,,\n", + ) + monkeypatch.syspath_prepend(str(tmp_path / "site")) + + declaration = RequirementsDeclaration.of(package) + declared = declaration.read() + + assert isinstance(declaration, SourceTreeDeclaration) + assert declaration.location == tmp_path / "tree" / "pyproject.toml" + assert declared.extras == ("old", "new") + assert _names(declared.requirements) == [ + 'core-probe>=1; python_version >= "3"', + 'old-probe; extra == "old"', + 'new-probe>=2; platform_system != "Nowhere" and extra == "new"', + ] + brought_by_new = [ + requirement.name + for requirement in declared.requirements + if requirement.marker.evaluate({"extra": "new"}) + and not requirement.marker.evaluate({"extra": ""}) + ] + assert brought_by_new == ["new-probe"] + + +def _wheel_install(site: Path) -> Path: + package = _package(site) + _metadata( + site / f"{_PACKAGE}-1.0.dist-info", + "Provides-Extra: old", + "Requires-Dist: core-probe>=1", + 'Requires-Dist: old-probe; extra == "old"', + record=f"{_PACKAGE}/__init__.py,,\n{_PACKAGE}-1.0.dist-info/METADATA,,\n", + ) + return package + + +def test_installed_wheel_reads_the_metadata_that_lists_the_package( + tmp_path, monkeypatch +) -> None: + package = _wheel_install(tmp_path / "site") + monkeypatch.syspath_prepend(str(tmp_path / "site")) + + declaration = RequirementsDeclaration.of(package) + declared = declaration.read() + + assert isinstance(declaration, InstalledDeclaration) + assert declaration.location == tmp_path / "site" / f"{_PACKAGE}-1.0.dist-info" + assert declared.extras == ("old",) + assert _names(declared.requirements) == [ + "core-probe>=1", + 'old-probe; extra == "old"', + ] + + +def test_installed_wheel_with_a_second_visible_metadata_directory_is_refused( + tmp_path, monkeypatch +) -> None: + package = _wheel_install(tmp_path / "site") + stray = _metadata(tmp_path / "stray" / f"{_PACKAGE}.egg-info") + monkeypatch.syspath_prepend(str(tmp_path / "site")) + monkeypatch.syspath_prepend(str(tmp_path / "stray")) + + declaration = RequirementsDeclaration.of(package) + + with pytest.raises(RuntimeError) as refused: + declaration.read() + assert str(stray) in str(refused.value) + assert str(tmp_path / "site" / f"{_PACKAGE}-1.0.dist-info") in str(refused.value) + + +def test_package_without_a_declaration_is_refused(tmp_path) -> None: + package = _package(tmp_path / "nowhere") + + with pytest.raises(RuntimeError, match="exactly one declaration"): + RequirementsDeclaration.of(package) + + +def test_running_source_tree_declares_what_its_pyproject_declares() -> None: + """This checkout is a source tree, whatever the environment was built from.""" + + import tomllib + + import openhcs + + pyproject = Path(openhcs.__file__).parents[1] / "pyproject.toml" + project = tomllib.loads(pyproject.read_text())["project"] + + declared_requirements.cache_clear() + assert declared_requirements().extras == tuple(project["optional-dependencies"]) diff --git a/tests/unit/test_library_registry_discovery.py b/tests/unit/test_library_registry_discovery.py index 6eefb58c9..bd91cd250 100644 --- a/tests/unit/test_library_registry_discovery.py +++ b/tests/unit/test_library_registry_discovery.py @@ -72,6 +72,7 @@ def test_native_catalog_excludes_modules_whose_distribution_markers_exclude_here from openhcs.processing.backends.lib_registry.openhcs_registry import ( OpenHCSRegistry, ) + from openhcs.utils.declared_requirements import DeclaredRequirements package_name = "native_catalog_distribution_probe" package = tmp_path / package_name @@ -88,8 +89,10 @@ def test_native_catalog_excludes_modules_whose_distribution_markers_exclude_here monkeypatch.setenv("OPENHCS_CPU_ONLY", "1") monkeypatch.setattr( openhcs_registry, - "_openhcs_requirements", - lambda: (Requirement(requirement),), + "declared_requirements", + lambda: DeclaredRequirements( + requirements=(Requirement(requirement),), extras=() + ), ) registry = OpenHCSRegistry() registry.MODULES_TO_SCAN = [f"{package_name}.processing"] @@ -97,7 +100,7 @@ def test_native_catalog_excludes_modules_whose_distribution_markers_exclude_here if outcome == "excluded": assert registry.get_modules_to_scan() == [] assert registry.cache_discovery_context()[ - "distributions_excluded_by_package_metadata" + "distributions_excluded_by_declared_requirements" ] == ["absent-catalog-probe"] elif outcome == "broken": with pytest.raises(ModuleNotFoundError, match="absent_catalog_probe"): @@ -125,7 +128,7 @@ def test_basicpy_catalog_module_follows_the_package_basicpy_marker( import packaging.markers from openhcs.processing.backends.lib_registry.openhcs_registry import ( - _module_distributions_excluded_by_package_metadata, + _module_distributions_excluded_by_declared_requirements, ) environment = packaging.markers.default_environment() @@ -149,7 +152,7 @@ def distribution_without_basicpy(name: str): importlib.metadata, "distribution", distribution_without_basicpy ) - assert _module_distributions_excluded_by_package_metadata( + assert _module_distributions_excluded_by_declared_requirements( "openhcs.processing.backends.enhance.basic_processor_jax" ) == (("openhcs-basicpy",) if excluded else ()) @@ -1001,22 +1004,23 @@ def pump_server(): assert {"openhcs", "skimage"}.issubset(result["library_roots"]) -def test_installed_requirements_are_parsed_once_per_process(monkeypatch): - """Every module's distribution question reads one parse of the metadata.""" +def test_declared_requirements_are_parsed_once_per_process(monkeypatch): + """Every module's distribution question reads one parse of the declaration.""" from openhcs.processing.backends.lib_registry import openhcs_registry + from openhcs.utils import declared_requirements as owner reads = [] - real_requires = openhcs_registry.importlib.metadata.requires + real_of = owner.RequirementsDeclaration.of - def counted(name): - reads.append(name) - return real_requires(name) + def counted(package_directory): + reads.append(package_directory.name) + return real_of(package_directory) - openhcs_registry._openhcs_requirements.cache_clear() - monkeypatch.setattr(openhcs_registry.importlib.metadata, "requires", counted) + owner.declared_requirements.cache_clear() + monkeypatch.setattr(owner.RequirementsDeclaration, "of", staticmethod(counted)) try: for _ in range(3): openhcs_registry._catalog_policy_context() assert reads == ["openhcs"] finally: - openhcs_registry._openhcs_requirements.cache_clear() + owner.declared_requirements.cache_clear() From 061f60740e2503f9f306e16889bf3011ad86a148 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 16:21:41 -0400 Subject: [PATCH 7/9] Stop ignoring every JSON file The blanket *.json rule kept needed test fixtures out of commits. Generated JSON stays covered by paper/build/, output/ and tests/integration/tests_data/. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index fb6b75297..78a04b0cb 100644 --- a/.gitignore +++ b/.gitignore @@ -128,26 +128,9 @@ paper/.current-* # Build runs, snapshots and intermediates are local. Main publication artifacts # are selected explicitly; do not force-add review history or microscopy dumps. paper/build/ +# Generated JSON is ignored by the directory it is written to (the three rules +# here), never by extension: JSON fixtures, manifests and records are source. output/ -*.json -!examples/*.json -!paper/figures/cellprofiler_coverage_evidence.json -!openhcs/resources/installer_contract.json -!website/assets/agent/cold-start-workflow-record.json -!website/assets/agent/neurite-outgrowth-workflow-record.json -!website/assets/gallery/release-media-record.json -!website/assets/gallery/source-capture-evidence.json -!docs/source/development/mcp_knowledge_base_manifest.json -!docs/audits/*.json -!server.json -!packaging/codex/openhcs/.mcp.json -!packaging/codex/openhcs/.codex-plugin/plugin.json -!packaging/mcpb/openhcs/manifest.json -!benchmark/manifests/official23_plus_monolayer.json -!benchmark/manifests/extra_provided_not_in_23_axis1.json -!benchmark/manifests/official23_plus_extra6_axis1.json -!benchmark/manifests/annotated_validation_20260915_preparation.json -!benchmark/native_refs/**/.cellprofiler_benchmark_reference.json tests/integration/tests_data/ # Embedded repositories From 1bbd184820a237ad6c426e9409d268e5db0d419f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 16:39:47 -0400 Subject: [PATCH 8/9] Built-ins LoadData, LabelImages, SaveCroppedObjects, CreateBatchFiles: 94 of 94 declared LoadData reads its CSV at import as a declared source file: each image or objects name becomes a source binding and each row a source set paired as listed (new pairing in core source bindings). SaveCroppedObjects matches the native module on 9 cases. LabelImages and CreateBatchFiles are settings-only modules. Unsupported LoadData values are refused at import. Co-Authored-By: Claude Opus 5.5 --- benchmark/converter/cppipe_corpus.py | 11 +- ...ofiler_openhcs_runtime_unification_plan.md | 4 - openhcs/core/processing_contracts.py | 29 + openhcs/core/runtime_slice_projection.py | 4 +- openhcs/core/source_binding_workspace.py | 79 ++ openhcs/core/source_bindings.py | 119 +- .../module_artifact_declarations.py | 47 + .../cellprofiler/module_declarations.py | 11 +- .../interop/cellprofiler/pipeline_import.py | 16 +- .../cellprofiler/create_batch_files.py | 70 + .../backends/cellprofiler/infrastructure.py | 10 +- .../backends/cellprofiler/label_images.py | 120 ++ .../backends/cellprofiler/load_data.py | 502 +++++++ .../cellprofiler/save_cropped_objects.py | 603 +++++++++ .../widgets/source_bindings_editor.py | 13 +- .../cases.cppipe | 96 ++ .../make_reference.py | 149 +++ .../reference.json | 60 + .../reference.npz | Bin 0 -> 32410 bytes .../test_source_bindings_editor_guards.py | 22 + .../test_cellprofiler_backend_public_api.py | 12 - .../test_cellprofiler_builtin_gap_modules.py | 1178 +++++++++++++++++ .../test_cellprofiler_compatibility_matrix.py | 3 +- .../unit/test_cellprofiler_pipeline_import.py | 1 + .../unit/test_cellprofiler_source_bindings.py | 6 +- tests/unit/test_cppipe_corpus.py | 8 +- tests/unit/test_k4_source_binding_guards.py | 29 + 27 files changed, 3154 insertions(+), 48 deletions(-) create mode 100644 openhcs/processing/backends/cellprofiler/create_batch_files.py create mode 100644 openhcs/processing/backends/cellprofiler/label_images.py create mode 100644 openhcs/processing/backends/cellprofiler/load_data.py create mode 100644 openhcs/processing/backends/cellprofiler/save_cropped_objects.py create mode 100644 tests/fixtures/cellprofiler_builtin_gap_modules/cases.cppipe create mode 100644 tests/fixtures/cellprofiler_builtin_gap_modules/make_reference.py create mode 100644 tests/fixtures/cellprofiler_builtin_gap_modules/reference.json create mode 100644 tests/fixtures/cellprofiler_builtin_gap_modules/reference.npz create mode 100644 tests/unit/test_cellprofiler_builtin_gap_modules.py diff --git a/benchmark/converter/cppipe_corpus.py b/benchmark/converter/cppipe_corpus.py index 9c36f6a7d..f32999624 100644 --- a/benchmark/converter/cppipe_corpus.py +++ b/benchmark/converter/cppipe_corpus.py @@ -32,6 +32,7 @@ class CPPipeCorpusCase: cppipe_path: Path status: CPPipeCorpusStatus source_root: Path | None = None + expected_error: type[Exception] | None = None expected_error_substring: str | None = None @@ -54,12 +55,12 @@ def in_tree_cppipe_corpus() -> tuple[CPPipeCorpusCase, ...]: name="ExampleFly", cppipe_path=pipelines_dir / "ExampleFly.cppipe", status=CPPipeCorpusStatus.KNOWN_INVALID, - # LoadData has no declaration and names its images in a CSV file, - # not in its settings, so the images later modules read have no - # provider the importer can derive. + # LoadData names its images in a CSV file, and this pipeline's + # load_data.csv is not in the tree: it imports once that file is + # beside it, and says which file is missing until then. + expected_error=FileNotFoundError, expected_error_substring=( - "Module IdentifyPrimaryObjects references unknown image artifact " - "'OrigBlue'" + "LoadData(1) names its images in the CSV file 'load_data.csv'" ), ), CPPipeCorpusCase( diff --git a/docs/archive/architecture/cellprofiler_openhcs_runtime_unification_plan.md b/docs/archive/architecture/cellprofiler_openhcs_runtime_unification_plan.md index 0a5977c62..be3376a87 100644 --- a/docs/archive/architecture/cellprofiler_openhcs_runtime_unification_plan.md +++ b/docs/archive/architecture/cellprofiler_openhcs_runtime_unification_plan.md @@ -5044,10 +5044,6 @@ AnalysisConsolidationPlan RuntimeImageExportSpec RuntimeImageExportBitDepth _candidate_image_snapshots_for_equivalence -LoadDataModule -LabelImagesModule -CreateBatchFilesModule -SaveCroppedObjectsModule CellProfilerFunctionRuntimeMetadata CellProfilerFunctionCatalog CellProfilerFunctionReferenceTransportStrategy diff --git a/openhcs/core/processing_contracts.py b/openhcs/core/processing_contracts.py index f041fe346..a5bad0581 100644 --- a/openhcs/core/processing_contracts.py +++ b/openhcs/core/processing_contracts.py @@ -846,6 +846,35 @@ class TuplePure2DAuxiliaryOutputAggregator(FlatSequencePure2DAuxiliaryOutputAggr value_type = tuple +class DictPure2DAuxiliaryOutputAggregator(Pure2DAuxiliaryOutputAggregator): + """Unite dict auxiliary outputs (such as file bundles) across slices. + + Every slice contributes its own entries. A key two slices both return has + no single value, so it is an error rather than a silent overwrite. + """ + + value_type = dict + + def aggregate_values( + self, + values: list[dict[object, object]], + memory_type: str, + *, + plane_axis: type[RuntimePlaneAxis], + ) -> dict[object, object]: + del memory_type, plane_axis + united: dict[object, object] = {} + for value in values: + repeated = sorted(str(key) for key in united.keys() & value.keys()) + if repeated: + raise ValueError( + "PURE_2D dict auxiliary outputs of different slices share the " + f"keys {repeated!r}; each slice must name its own entries." + ) + united.update(value) + return united + + # --------------------------------------------------------------------------- diff --git a/openhcs/core/runtime_slice_projection.py b/openhcs/core/runtime_slice_projection.py index 121230c66..f45918eda 100644 --- a/openhcs/core/runtime_slice_projection.py +++ b/openhcs/core/runtime_slice_projection.py @@ -8,6 +8,7 @@ from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass, field from enum import Enum +from pathlib import PurePath from typing import TYPE_CHECKING, Any, TypeAlias, overload import numpy as np @@ -307,7 +308,7 @@ class RuntimeSliceProjection: Owned runtime values answer through ``RuntimeSliceProjectableValue``. Tuples and lists project item by item; other foreign values (arrays, - mappings, primitives, enums) carry no runtime-slice axis. + mappings, primitives, file paths, enums) carry no runtime-slice axis. """ FOREIGN_VALUE_TYPES: tuple[type, ...] = ( @@ -318,6 +319,7 @@ class RuntimeSliceProjection: bytearray, int, float, + PurePath, Enum, DtypeConversionConfig, type(None), diff --git a/openhcs/core/source_binding_workspace.py b/openhcs/core/source_binding_workspace.py index 9b50cfea9..83b31d5a2 100644 --- a/openhcs/core/source_binding_workspace.py +++ b/openhcs/core/source_binding_workspace.py @@ -20,6 +20,8 @@ ComponentSelector, ImportedMetadataTable, NamedSourceBinding, + ListedSourceFile, + PairAsListed, PairByMetadata, PairByOrder, BroadcastSource, @@ -502,6 +504,83 @@ def _matched_source_sets( ) +class ListedSourceSetAssembler(SourceSetAssembler): + """Build each source set from the files one listed set names.""" + + value_type = PairAsListed + + def _matched_source_sets( + self, + match_plan: SourceSetPairing, + candidates_by_alias: Mapping[str, tuple[SourceCandidate, ...]], + ) -> tuple[_SourceSet, ...]: + listed_sets = cast(PairAsListed, match_plan).source_sets + if not listed_sets: + raise ValueError("Pairing as listed requires at least one listed source set.") + source_sets: list[_SourceSet] = [] + for index, listed_set in enumerate(listed_sets): + unlisted = tuple( + alias + for alias in candidates_by_alias + if alias not in {listed.alias for listed in listed_set.files} + ) + if unlisted: + raise ValueError( + f"Listed source set {index + 1} names no file for matched " + f"source aliases {unlisted!r}." + ) + source_set = _source_set( + index, + { + listed.alias: self._listed_candidate( + index, listed, candidates_by_alias.get(listed.alias, ()) + ) + for listed in listed_set.files + }, + ) + if listed_set.metadata: + source_set = source_set.with_imported_metadata( + {item.field: item.value for item in listed_set.metadata}, + path=f"listed source set {index + 1}", + ) + source_sets.append(source_set) + return tuple(source_sets) + + @staticmethod + def _listed_candidate( + index: int, + listed: ListedSourceFile, + candidates: tuple[SourceCandidate, ...], + ) -> SourceCandidate: + """The one plate file ``listed`` names: the closest path match, if unique.""" + + shared_parts = tuple( + max( + listed.shared_trailing_parts(path) + for path in candidate.source_filter_path_identities() + ) + for candidate in candidates + ) + closest = max(shared_parts, default=0) + if closest == 0: + raise ValueError( + f"Listed source set {index + 1} names {listed.path!r} for source " + f"alias {listed.alias!r}, but the plate has no such file." + ) + matches = tuple( + candidate + for candidate, shared in zip(candidates, shared_parts, strict=True) + if shared == closest + ) + if len(matches) > 1: + raise ValueError( + f"Listed source set {index + 1} names {listed.path!r} for source " + f"alias {listed.alias!r}, which matches several plate files: " + f"{tuple(match.relative_path for match in matches)!r}." + ) + return matches[0] + + class SourceBindingProjectionStrategy(ABC, metaclass=AutoRegisterMeta): """Project one binding member through its declared projection role.""" diff --git a/openhcs/core/source_bindings.py b/openhcs/core/source_bindings.py index 0270462ce..ce6fa6a43 100644 --- a/openhcs/core/source_bindings.py +++ b/openhcs/core/source_bindings.py @@ -802,7 +802,14 @@ def keyed_by( ) -> SourceSetPairing: """This pairing keyed by ``dimensions``; pairing by order takes no keys.""" - return cls() + return cls().with_keys(dimensions) + + def with_keys( + self, dimensions: tuple[SourceBindingMatchDimension, ...] + ) -> SourceSetPairing: + """This pairing with its shared keys replaced and the rest of it kept.""" + + return self @staticmethod def named(name: str) -> type[SourceSetPairing]: @@ -833,11 +840,10 @@ class PairByMetadata(SourceSetPairing): dimensions: tuple[SourceBindingMatchDimension, ...] = () """Ordered shared metadata keys; each names one field per alias.""" - @classmethod - def keyed_by( - cls, dimensions: tuple[SourceBindingMatchDimension, ...] + def with_keys( + self, dimensions: tuple[SourceBindingMatchDimension, ...] ) -> SourceSetPairing: - return cls(dimensions=dimensions) + return replace(self, dimensions=dimensions) def __post_init__(self) -> None: object.__setattr__( @@ -851,6 +857,109 @@ def __post_init__(self) -> None: ) +@dataclass(frozen=True, slots=True) +class ListedSourceFile: + """The file one listed source set names for one alias.""" + + alias: str + """Source-binding alias the file belongs to.""" + + path: str + """File name, optionally preceded by folders, with ``/`` separators. + + A plate file is the listed one when one path ends with the other, compared by + whole path parts: ``a.tif`` names ``images/a.tif``, and so does + ``D:/run/images/a.tif``. + """ + + def __post_init__(self) -> None: + _require_name(self.alias, "ListedSourceFile.alias") + if not self.path_parts: + raise ValueError("ListedSourceFile.path cannot be empty.") + + @property + def path_parts(self) -> tuple[str, ...]: + """The path's parts, file name last.""" + + return tuple(part for part in self.path.split("/") if part) + + def shared_trailing_parts(self, plate_path: str) -> int: + """How many trailing path parts ``plate_path`` shares; 0 when it is another file.""" + + listed = self.path_parts + plate = tuple(part for part in plate_path.split("/") if part) + shared = min(len(listed), len(plate)) + return shared if listed[-shared:] == plate[-shared:] else 0 + + +@dataclass(frozen=True, slots=True) +class ListedMetadata: + """One metadata value a listed source set carries.""" + + field: str + """Metadata field name.""" + + value: SourceMetadataScalar + """The field's value for this source set.""" + + def __post_init__(self) -> None: + _require_name(self.field, "ListedMetadata.field") + object.__setattr__(self, "value", source_metadata_scalar(self.value)) + + +@dataclass(frozen=True, slots=True) +class ListedSourceSet: + """One source set written out in full: its file per alias and its metadata.""" + + files: tuple[ListedSourceFile, ...] = () + """The file of each alias in this set.""" + + metadata: tuple[ListedMetadata, ...] = () + """Metadata of the set as a whole, such as its plate, well and site.""" + + def __post_init__(self) -> None: + files = normalize_source_binding_values( + "ListedSourceSet.files", self.files, ListedSourceFile + ) + aliases = tuple(listed.alias for listed in files) + if len(set(aliases)) != len(aliases): + raise ValueError( + f"ListedSourceSet names an alias more than once: {aliases!r}." + ) + object.__setattr__(self, "files", files) + object.__setattr__( + self, + "metadata", + normalize_source_binding_values( + "ListedSourceSet.metadata", self.metadata, ListedMetadata + ), + ) + + +@dataclass(frozen=True, slots=True) +class PairAsListed(PairByOrder): + """Pair the aliases' sources as a table lists them, one source set per row. + + The order is the table's: set N holds the files row N names, whatever order + the plate's files come in. After assembly the sets are ordinary ordered sets, + which is why this is a kind of ``PairByOrder``. + """ + + name = "listed" + + source_sets: tuple[ListedSourceSet, ...] = () + """The source sets, in order.""" + + def __post_init__(self) -> None: + object.__setattr__( + self, + "source_sets", + normalize_source_binding_values( + "PairAsListed.source_sets", self.source_sets, ListedSourceSet + ), + ) + + @dataclass(frozen=True, slots=True) class ComponentSelector: """Component-axis key/value pair used either to select sources or assign identity.""" diff --git a/openhcs/interop/cellprofiler/module_artifact_declarations.py b/openhcs/interop/cellprofiler/module_artifact_declarations.py index c1a5177c0..ca1bcb895 100644 --- a/openhcs/interop/cellprofiler/module_artifact_declarations.py +++ b/openhcs/interop/cellprofiler/module_artifact_declarations.py @@ -61,6 +61,7 @@ ) from openhcs.interop.cellprofiler.settings_binder import ( MeasurementFeatureSettingBinding, + SettingsBinder, SettingToKeywordBinding, ) @@ -219,6 +220,7 @@ def contribute_source_bindings( cls, module: "ModuleBlock", config: "SourceBindingsConfig", + binder: "SettingsBinder", ) -> "SourceBindingsConfig": """Return source declarations contributed by this setup module.""" @@ -238,6 +240,51 @@ def validate_pipeline_import(cls, module: "ModuleBlock") -> None: ) +class SettingsOnlyCellProfilerModule(NonExecutableCellProfilerModule, ABC): + """Nominal root for modules whose import consists of checking their settings. + + The module emits no step and contributes nothing to the pipeline. Importing + it still reads every setting row: a row no ``setting_bindings`` entry names + is rejected, and each declared parser rejects a malformed value. + """ + + @classmethod + def validate_pipeline_import(cls, module: "ModuleBlock") -> None: + cls.checked_settings(module) + + @classmethod + def checked_settings(cls, module: "ModuleBlock") -> dict[str, object]: + """Return the parsed declared settings, or fail naming the bad row.""" + + from openhcs.interop.cellprofiler.module_settings import ( + ModuleSettingRowRecord, + UnmappedModuleSettingsError, + ) + from openhcs.interop.cellprofiler.settings_binder import SettingsBinder + + try: + bound = cls._bind_declared_settings(module, binder=SettingsBinder()) + except ValueError as error: + raise ValueError( + f"CellProfiler module {module.name}({module.module_num}) has a " + f"setting OpenHCS cannot accept: {error}" + ) from error + if bound.unmapped_kwargs: + raise UnmappedModuleSettingsError( + tuple( + ModuleSettingRowRecord( + module_name=module.name, + module_num=module.module_num, + setting_name=setting_name, + normalized_setting_name=setting_name, + value=value, + ) + for setting_name, value in sorted(bound.unmapped_kwargs.items()) + ) + ) + return dict(bound.kwargs) + + class ObjectArtifactInputModule(CellProfilerModule): """Parent for modules that consume object-label artifacts through declared settings.""" diff --git a/openhcs/interop/cellprofiler/module_declarations.py b/openhcs/interop/cellprofiler/module_declarations.py index e91cfaa95..228a77c02 100644 --- a/openhcs/interop/cellprofiler/module_declarations.py +++ b/openhcs/interop/cellprofiler/module_declarations.py @@ -77,6 +77,7 @@ NormalizedFunctionItem, ) from openhcs.interop.cellprofiler.parser import ModuleBlock, ModuleSetting + from openhcs.interop.cellprofiler.settings_binder import SettingsBinder def _required_string(value: object, name: str, owner: str) -> str: @@ -410,9 +411,13 @@ def contribute_source_bindings( cls, module: "ModuleBlock", config: "SourceBindingsConfig", + binder: "SettingsBinder", ) -> "SourceBindingsConfig": - """Return source declarations contributed by one parsed setup module.""" - del cls, module + """Return source declarations contributed by one parsed setup module. + + ``binder`` resolves the files a module's settings name. + """ + del cls, module, binder return config @classmethod @@ -420,6 +425,7 @@ def source_bindings_for_modules( cls, modules: Iterable["ModuleBlock"], config: "SourceBindingsConfig", + binder: "SettingsBinder", ) -> "SourceBindingsConfig": """Fold enabled modules into one public source-binding configuration.""" from openhcs.core.config import SourceBindingsConfig @@ -440,6 +446,7 @@ def source_bindings_for_modules( config = cls.declaration_for_block(module).contribute_source_bindings( module, config, + binder, ) return config diff --git a/openhcs/interop/cellprofiler/pipeline_import.py b/openhcs/interop/cellprofiler/pipeline_import.py index 1ee990463..34bb8f266 100644 --- a/openhcs/interop/cellprofiler/pipeline_import.py +++ b/openhcs/interop/cellprofiler/pipeline_import.py @@ -132,16 +132,18 @@ def import_cellprofiler_pipeline( modules = tuple(parser.parse(path, filemanager=filemanager, backend=backend)) if not modules: raise ValueError(f"CellProfiler pipeline {path} contains no modules.") + binder = SettingsBinder( + source_root=path.parent if source_root is None else source_root, + ) source_bindings = CellProfilerModule.source_bindings_for_modules( modules, SourceBindingsConfig(image_plane_sources=parser.image_plane_sources), + binder, ) return _public_pipeline( modules, source_bindings, - binder=SettingsBinder( - source_root=path.parent if source_root is None else source_root, - ), + binder=binder, ) @@ -166,16 +168,18 @@ def cellprofiler_terminal_artifact_specs( modules = tuple(parser.parse(path, filemanager=filemanager, backend=backend)) if not modules: raise ValueError(f"CellProfiler pipeline {path} contains no modules.") + binder = SettingsBinder( + source_root=path.parent if source_root is None else source_root, + ) source_bindings = CellProfilerModule.source_bindings_for_modules( modules, SourceBindingsConfig(image_plane_sources=parser.image_plane_sources), + binder, ) declaration = _parsed_pipeline_declaration( modules, source_bindings, - binder=SettingsBinder( - source_root=path.parent if source_root is None else source_root, - ), + binder=binder, ) terminal_specs: list[ArtifactSpec] = [] units = declaration.target_units diff --git a/openhcs/processing/backends/cellprofiler/create_batch_files.py b/openhcs/processing/backends/cellprofiler/create_batch_files.py new file mode 100644 index 000000000..630a80bdc --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/create_batch_files.py @@ -0,0 +1,70 @@ +"""CellProfiler CreateBatchFiles: settings checked at import, nothing executed.""" + +from __future__ import annotations + +from typing import ClassVar + +from openhcs.interop.cellprofiler.module_artifact_declarations import ( + SettingsOnlyCellProfilerModule, +) +from openhcs.interop.cellprofiler.settings_binder import ( + SettingToKeywordBinding, + parse_cellprofiler_bool, + parse_cellprofiler_int, +) + + +class CreateBatchFilesModule(SettingsOnlyCellProfilerModule): + """CreateBatchFiles has no counterpart in OpenHCS and does nothing here. + + In CellProfiler the module writes ``Batch_data.h5``: the pipeline, the + image-set list and a table of local-to-cluster path prefixes, so that + separate CellProfiler processes on a cluster can each run a slice of the + image sets. OpenHCS distributes the work of a plate itself (its + orchestrator hands execution axes to workers), compiles the pipeline once + for all of them, and reads sources through its own file manager, so there + is no batch file to write and no path prefix to rewrite. + + Carried over: every setting row of the block is read; the Yes/No rows and + the revision number must parse, and an unknown row fails the import. + + Not carried over: no ``Batch_data.h5`` is written, the output folder is + not used, and the local/cluster path mappings are not applied to anything. + The import returns steps and a pipeline configuration only, so the skipped + module is not reported to the user anywhere but this docstring. + """ + + module_name = "CreateBatchFiles" + validated = False + setting_bindings: ClassVar[tuple[SettingToKeywordBinding, ...]] = ( + SettingToKeywordBinding( + "Store batch files in default output folder?", + "wants_default_output_directory", + parse_cellprofiler_bool, + ), + SettingToKeywordBinding("Output folder path", "custom_output_directory", str), + SettingToKeywordBinding( + "Are the cluster computers running Windows?", + "remote_host_is_windows", + parse_cellprofiler_bool, + ), + SettingToKeywordBinding( + "Hidden- in batch mode", "batch_mode", parse_cellprofiler_bool + ), + SettingToKeywordBinding( + "Hidden- in distributed mode", "distributed_mode", parse_cellprofiler_bool + ), + SettingToKeywordBinding( + "Hidden- default input folder at time of save", + "default_image_directory", + str, + ), + SettingToKeywordBinding( + "Hidden- revision number", "revision", parse_cellprofiler_int + ), + SettingToKeywordBinding( + "Hidden- from old matlab", "from_old_matlab", parse_cellprofiler_bool + ), + SettingToKeywordBinding("Local root path", "local_root_paths", str), + SettingToKeywordBinding("Cluster root path", "cluster_root_paths", str), + ) diff --git a/openhcs/processing/backends/cellprofiler/infrastructure.py b/openhcs/processing/backends/cellprofiler/infrastructure.py index 42dc4ef0b..85e0e7530 100644 --- a/openhcs/processing/backends/cellprofiler/infrastructure.py +++ b/openhcs/processing/backends/cellprofiler/infrastructure.py @@ -77,7 +77,10 @@ repeating_setting_blocks, setting_name_matches, ) -from openhcs.interop.cellprofiler.settings_binder import parse_cellprofiler_bool +from openhcs.interop.cellprofiler.settings_binder import ( + SettingsBinder, + parse_cellprofiler_bool, +) from openhcs.interop.cellprofiler.source_metadata import ( CellProfilerSourceMetadataField, ) @@ -495,6 +498,7 @@ def contribute_source_bindings( cls, module: ModuleBlock, config: SourceBindingsConfig, + binder: SettingsBinder, ) -> SourceBindingsConfig: del cls return replace( @@ -606,6 +610,7 @@ def contribute_source_bindings( cls, module: ModuleBlock, config: SourceBindingsConfig, + binder: SettingsBinder, ) -> SourceBindingsConfig: del cls file_type = module.get_setting("What type of files are you loading?", "") @@ -913,6 +918,7 @@ def contribute_source_bindings( cls, module: ModuleBlock, config: SourceBindingsConfig, + binder: SettingsBinder, ) -> SourceBindingsConfig: enabled = _metadata_extraction_enabled(module) rules = list(config.metadata_rule_declarations) @@ -1205,6 +1211,7 @@ def contribute_source_bindings( cls, module: ModuleBlock, config: SourceBindingsConfig, + binder: SettingsBinder, ) -> SourceBindingsConfig: assignment_count = _names_and_types_count(module, "Assignments count") single_count = _names_and_types_count( @@ -1395,6 +1402,7 @@ def contribute_source_bindings( cls, module: ModuleBlock, config: SourceBindingsConfig, + binder: SettingsBinder, ) -> SourceBindingsConfig: del cls if module.get_setting("Do you want to group your images?", "No") != "Yes": diff --git a/openhcs/processing/backends/cellprofiler/label_images.py b/openhcs/processing/backends/cellprofiler/label_images.py new file mode 100644 index 000000000..8a519c7ae --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/label_images.py @@ -0,0 +1,120 @@ +"""CellProfiler LabelImages: settings checked at import, nothing executed.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar + +from openhcs.interop.cellprofiler.module_artifact_declarations import ( + SettingsOnlyCellProfilerModule, +) +from openhcs.interop.cellprofiler.setting_names import SettingNameFamily +from openhcs.interop.cellprofiler.settings_binder import SettingToKeywordBinding + +if TYPE_CHECKING: + from openhcs.interop.cellprofiler.parser import ModuleBlock + + +@dataclass(frozen=True, slots=True) +class PositiveCount: + """Parse a whole number of at least one; the error names the setting.""" + + setting: str + + def __call__(self, value: str) -> int: + try: + count = int(value.strip()) + except ValueError: + count = 0 + if count < 1: + raise ValueError( + f"{self.setting!r} must be a whole number of at least 1, " + f"got {value!r}." + ) + return count + + +@dataclass(frozen=True, slots=True) +class OneOf: + """Parse a value that must be one of CellProfiler's choices for the setting.""" + + setting: str + choices: tuple[str, ...] + + def __call__(self, value: str) -> str: + choice = value.strip() + if choice not in self.choices: + raise ValueError( + f"{self.setting!r} must be one of {self.choices!r}, got {value!r}." + ) + return choice + + +class LabelImagesModule(SettingsOnlyCellProfilerModule): + """LabelImages is read and checked, and writes no metadata in OpenHCS. + + CellProfiler 4.2.8.1 numbers the image sets of a run 1, 2, 3, ... and this + module turns that number into five image measurements: ``Metadata_Site``, + ``Metadata_Row`` (letters; two letters once a plate has more than 26 + rows), ``Metadata_Column``, ``Metadata_Well`` (row letters plus the + zero-padded column) and ``Metadata_Plate``, by dividing the image-set + number by sites per well, then by columns or rows depending on the order. + + OpenHCS has no place a module can put metadata computed from image order. + Source metadata comes from file names, folder names and imported tables + (``MetadataExtractionRule``, ``ImportedMetadataTable``), and an image set's + number is only assigned when measurements are exported. So this module + emits no step and none of the five measurements exist because of it. + + Carried over: the four settings are parsed; a count below 1, a value that + is not a whole number, an order other than ``Row``/``Column``, a missing + setting or an unknown setting row fails the import. + + Not carried over: ``Metadata_Plate``, ``Metadata_Well``, ``Metadata_Row``, + ``Metadata_Column`` and ``Metadata_Site``. A later module that names one of + them (a file-name tag, a grouping, an export column) only finds it when the + pipeline's Metadata module or the plate's own file names provide it. + """ + + module_name = "LabelImages" + validated = False + site_count_setting = SettingNameFamily("Number of image sites per well") + column_count_setting = SettingNameFamily("Number of columns per plate") + row_count_setting = SettingNameFamily("Number of rows per plate") + order_setting = SettingNameFamily("Order of image data") + setting_bindings: ClassVar[tuple[SettingToKeywordBinding, ...]] = ( + SettingToKeywordBinding( + site_count_setting, + "sites_per_well", + PositiveCount(site_count_setting.canonical), + ), + SettingToKeywordBinding( + column_count_setting, + "columns_per_plate", + PositiveCount(column_count_setting.canonical), + ), + SettingToKeywordBinding( + row_count_setting, + "rows_per_plate", + PositiveCount(row_count_setting.canonical), + ), + SettingToKeywordBinding( + order_setting, + "order", + OneOf(order_setting.canonical, ("Row", "Column")), + ), + ) + + @classmethod + def validate_pipeline_import(cls, module: ModuleBlock) -> None: + settings = cls.checked_settings(module) + missing = tuple( + binding.setting_name.canonical + for binding in cls.setting_bindings + if binding.require_parameter_name() not in settings + ) + if missing: + raise ValueError( + f"CellProfiler module {module.name}({module.module_num}) is " + f"missing the settings {missing!r}." + ) diff --git a/openhcs/processing/backends/cellprofiler/load_data.py b/openhcs/processing/backends/cellprofiler/load_data.py new file mode 100644 index 000000000..8e98c006a --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/load_data.py @@ -0,0 +1,502 @@ +""" +Converted from CellProfiler: LoadData +Original: cellprofiler_core.modules.loaddata + +LoadData names a pipeline's images in a CSV file instead of in its settings: one +row per image set, ``Image_FileName_`` (and optionally +``Image_PathName_`` or ``Image_URL_``) columns naming the file of +each image, ``Image_ObjectsFileName_`` columns naming label files, and +``Metadata_`` columns describing the image set. + +In OpenHCS that is a source declaration, made when the pipeline is imported: + +- every image and objects name becomes a named source binding, so later modules + resolve it; +- the rows become the source sets, paired exactly as listed (``PairAsListed``): + set N holds the files row N names, and a listed file the plate lacks is an + error when the plate is opened, never a silently shifted pairing; +- every ``Metadata_`` column becomes the set's metadata field ````, + typed as CellProfiler types the column (integer, else float, else text), with + ``Well`` composed from row and column tags when the CSV has no well column. + +Settings (CellProfiler 4 revision 6): + +- *Input data file location* / *Name of the file*: honoured. The CSV is an input + file of the pipeline and is resolved like every other one: *Default Input + Folder* is the folder holding the ``.cppipe`` (or the importer's + ``source_root``), its sub-folder choice a folder below it, *Elsewhere...* the + written folder, *URL* a downloaded file. The *Default Output Folder* choices + are refused: nothing has been written when a pipeline is imported. +- *Load images based on this data?*: ``Yes`` only. ``No`` attaches the CSV's + columns to image sets other modules build, which is refused. +- *Base image location*: every choice is read as "the plate". OpenHCS loads + images from the plate it is pointed at, so the folder written here (often a + path on the acquisition machine) locates nothing; a listed file is found in the + plate by its name, and by as many of its trailing folders as it takes to tell + it from files of the same name. +- *Process just a range of rows?* / *Rows to process*: honoured, with + CellProfiler's numbering (rows count from 1, without the header). +- *Group images by metadata?* / *Select metadata tags for grouping*: honoured as + the grouping fields of the source declaration. +- *Rescale intensities?*: ``Yes`` only (the scaling OpenHCS applies to every + source image). ``No`` is refused. + +Refused, each with a message naming the column: ``Series_``/``Frame_`` columns +(planes inside a multi-plane file), columns that are neither image, objects nor +``Metadata_`` columns (CellProfiler copies them to per-image measurements), and +a CSV combined with another module that names images. + +Not covered: images are declared grayscale, so a colour file is read as one +channel where CellProfiler keeps its colours. +""" + +from __future__ import annotations + +import csv +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, replace +from pathlib import Path +from typing import ClassVar +from urllib.parse import unquote, urlsplit + +from openhcs.core.axes import AxisFamily, ColourAxis +from openhcs.core.config import SourceBindingsConfig +from openhcs.core.runtime_tabular_values import FieldSpec +from openhcs.core.source_bindings import ( + ComponentSelector, + FromPipelineStart, + ListedMetadata, + ListedSourceFile, + ListedSourceSet, + MatchedSource, + NamedSourceBinding, + PairAsListed, + PrimaryPlaneRole, + SourceFileName, + SourceFilterClause, + SourceSelector, + TextContainsRegex, +) +from openhcs.core.source_metadata import SourceMetadataScalar +from openhcs.interop.cellprofiler.module_artifact_declarations import ( + SourceSetupCellProfilerModule, +) +from openhcs.interop.cellprofiler.parser import ModuleBlock +from openhcs.interop.cellprofiler.settings_binder import ( + CellProfilerSettingValue, + SettingsBinder, + SettingToKeywordBinding, + SourceFileSettingBinding, + parse_cellprofiler_bool, +) +from openhcs.processing.backends.cellprofiler.infrastructure import ( + CellProfilerSourceImageType, +) + +_FILE_LOCATION_SETTING = "Input data file location" +_FILE_NAME_SETTING = "Name of the file" + +_WELL_ROW_TAGS = frozenset({"wellrow", "well_row", "row"}) +_WELL_COLUMN_TAGS = frozenset( + {"wellcol", "well_col", "wellcolumn", "well_column", "column", "col"} +) +"""CellProfiler's spellings of the metadata tags that hold a well's row and column.""" + + +def _folder_choice(module: ModuleBlock, setting_name: str) -> tuple[str, str]: + """Split a CellProfiler folder setting into its choice and its written folder.""" + + choice, _, folder = module.get_setting(setting_name).partition("|") + return choice.strip(), folder.strip() + + +@dataclass(frozen=True, slots=True) +class LoadDataFileBinding(SourceFileSettingBinding): + """The CSV file LoadData reads, named by a folder setting and a file name.""" + + def bind( + self, + module: ModuleBlock, + kwargs: dict[str, CellProfilerSettingValue], + binder: SettingsBinder, + ) -> None: + choice, folder = _folder_choice(module, _FILE_LOCATION_SETTING) + name = module.get_setting(_FILE_NAME_SETTING).strip() + if not name or name == "None": + raise ValueError( + f"{module.name}({module.module_num}) names no CSV file in " + f"{_FILE_NAME_SETTING!r}." + ) + if choice in ("Default Input Folder", "URL"): + location = name + elif choice in ("Default Input Folder sub-folder", "Elsewhere..."): + separator = "\\" if "\\" in folder and "/" not in folder else "/" + location = folder.rstrip("/\\") + separator + name if folder else name + else: + raise ValueError( + f"{module.name}({module.module_num}) {_FILE_LOCATION_SETTING!r} is " + f"{choice!r}; the CSV must be in the Default Input Folder (the " + "pipeline's folder), a sub-folder of it, Elsewhere... or at a URL." + ) + try: + kwargs[self.require_parameter_name()] = binder.resolve_source_file(location) + except FileNotFoundError as error: + raise FileNotFoundError( + f"{module.name}({module.module_num}) names its images in the CSV " + f"file {location!r} ({_FILE_LOCATION_SETTING!r}: {choice!r}, " + f"{_FILE_NAME_SETTING!r}: {name!r}), looked for from " + f"{binder.source_root}. {error}" + ) from error + + +def _only_yes( + setting_name: str, parameter_name: str, reason: str +) -> SettingToKeywordBinding: + """A yes/no setting OpenHCS carries out only when it says yes.""" + + def parse(value: str) -> bool: + if not parse_cellprofiler_bool(value): + raise ValueError(f"LoadData {setting_name!r}: {value!r} {reason}") + return True + + return SettingToKeywordBinding(setting_name, parameter_name, parse) + + +def _base_image_location(value: str) -> str: + choice = value.partition("|")[0].strip() + if choice not in ( + "Default Input Folder", + "Default Output Folder", + "Default Input Folder sub-folder", + "Default Output Folder sub-folder", + "Elsewhere...", + "None", + ): + raise ValueError(f"LoadData 'Base image location' has unknown choice {choice!r}.") + return choice + + +def _row_range(value: str) -> tuple[int, int]: + try: + first, last = (int(part) for part in value.split(",")) + except ValueError as error: + raise ValueError( + f"LoadData 'Rows to process' must be two integers 'first,last', got {value!r}." + ) from error + if first < 1 or last < first: + raise ValueError( + f"LoadData 'Rows to process' must satisfy 1 <= first <= last, got {value!r}." + ) + return first, last + + +def _grouping_tags(value: str) -> tuple[str, ...]: + return tuple(tag.strip() for tag in value.split(",") if tag.strip()) + + +def _listed_path(folder: str, name: str) -> str: + """One listed file as ``/``-separated path parts, whatever spelled it. + + CSVs carry Windows paths, ``file:`` and ``http:`` URLs and bare names; only + the trailing parts identify the file inside a plate. + """ + + text = f"{folder}/{name}" if folder else name + if re.match(r"[A-Za-z][A-Za-z0-9+.-]*://", text) or text.casefold().startswith("file:"): + text = unquote(urlsplit(text).path) + return "/".join(part for part in text.replace("\\", "/").split("/") if part) + + +def _typed_column(tag: str, values: Sequence[str]) -> tuple[SourceMetadataScalar, ...]: + """Type one metadata column as CellProfiler does: integer, else float, else text.""" + + if tag.casefold() not in _WELL_COLUMN_TAGS: + for number in (int, float): + try: + return tuple(number(value) for value in values) + except ValueError: + continue + return tuple(values) + + +@dataclass(frozen=True, slots=True) +class _NamedFiles: + """The CSV columns locating the files of one image or objects name.""" + + name: str + source_type: CellProfilerSourceImageType + file_column: int | None = None + folder_column: int | None = None + url_column: int | None = None + + def listed_path(self, row: Sequence[str]) -> str: + if self.file_column is not None: + folder = "" if self.folder_column is None else row[self.folder_column] + return _listed_path(folder, row[self.file_column]) + if self.url_column is not None: + return _listed_path("", row[self.url_column]) + raise ValueError( + f"LoadData needs a FileName_{self.name} or URL_{self.name} column to " + f"match the PathName_{self.name} column." + ) + + +_FILE_COLUMN_FIELDS: Mapping[str, tuple[CellProfilerSourceImageType, str]] = { + "FileName": (CellProfilerSourceImageType.GRAYSCALE, "file_column"), + "PathName": (CellProfilerSourceImageType.GRAYSCALE, "folder_column"), + "URL": (CellProfilerSourceImageType.GRAYSCALE, "url_column"), + "ObjectsFileName": (CellProfilerSourceImageType.OBJECTS, "file_column"), + "ObjectsPathName": (CellProfilerSourceImageType.OBJECTS, "folder_column"), + "ObjectsURL": (CellProfilerSourceImageType.OBJECTS, "url_column"), +} +"""CellProfiler's column categories that locate a file, and what each one holds.""" + +_PLANE_COLUMN_CATEGORIES = frozenset( + {"Series", "Frame", "Channel", "ObjectsSeries", "ObjectsFrame", "ObjectsChannel"} +) + + +@dataclass(frozen=True, slots=True) +class LoadDataTable: + """The image sets one LoadData CSV lists, read once at import.""" + + path: Path + names: tuple[_NamedFiles, ...] + metadata_columns: Mapping[str, int] + rows: tuple[tuple[str, ...], ...] + + @classmethod + def read(cls, path: Path, row_range: tuple[int, int] | None) -> LoadDataTable: + with path.open(newline="", encoding="utf-8-sig") as handle: + reader = csv.reader(handle) + header = [cls._column_name(field) for field in next(reader, ())] + if not header: + raise ValueError(f"LoadData CSV file {path} is empty.") + rows: list[tuple[str, ...]] = [] + for number, row in enumerate(reader, start=1): + if row_range is not None and not row_range[0] <= number <= row_range[1]: + continue + if not row: + continue + if len(row) < len(header): + raise ValueError( + f"LoadData CSV file {path} line {number + 1} has {len(row)} " + f"values, expected {len(header)}." + ) + rows.append(tuple(row[: len(header)])) + if not rows: + raise ValueError(f"LoadData CSV file {path} lists no image sets.") + + names: dict[tuple[CellProfilerSourceImageType, str], _NamedFiles] = {} + metadata_columns: dict[str, int] = {} + plane_columns: list[str] = [] + other_columns: list[str] = [] + for index, column in enumerate(header): + category, _, feature = column.partition("_") + if category in _FILE_COLUMN_FIELDS and feature: + source_type, field_name = _FILE_COLUMN_FIELDS[category] + key = (source_type, feature) + names[key] = replace( + names.get(key, _NamedFiles(feature, source_type)), + **{field_name: index}, + ) + elif category in _PLANE_COLUMN_CATEGORIES and feature: + plane_columns.append(column) + elif category == "Metadata" and feature: + metadata_columns[feature] = index + else: + other_columns.append(column) + if not names: + raise ValueError( + f"LoadData CSV file {path} has no Image_FileName_ or " + "Image_URL_ column, so it names no image." + ) + if plane_columns: + raise ValueError( + f"LoadData CSV file {path} selects planes inside multi-plane files " + f"with columns {plane_columns!r}; OpenHCS lists whole files only." + ) + if other_columns: + raise ValueError( + f"LoadData CSV file {path} has columns {other_columns!r} that are " + "neither image, objects nor Metadata_ columns. CellProfiler copies " + "them to per-image measurements; OpenHCS carries Metadata_ columns only." + ) + return cls(path, tuple(names.values()), metadata_columns, tuple(rows)) + + @staticmethod + def _column_name(field: str) -> str: + """CellProfiler's column name: file columns lose their ``Image_`` prefix.""" + + name = field.strip() + unprefixed = name.removeprefix("Image_") + return unprefixed if unprefixed.partition("_")[0] in _FILE_COLUMN_FIELDS else name + + def metadata(self) -> Mapping[str, tuple[SourceMetadataScalar, ...]]: + """Each metadata tag's typed values, one per row; ``Well`` composed if absent.""" + + columns: dict[str, tuple[str, ...]] = { + tag: tuple(row[index] for row in self.rows) + for tag, index in self.metadata_columns.items() + } + folded = {tag.casefold(): tag for tag in columns} + row_tag = next((folded[tag] for tag in folded if tag in _WELL_ROW_TAGS), None) + column_tag = next( + (folded[tag] for tag in folded if tag in _WELL_COLUMN_TAGS), None + ) + typed = {tag: _typed_column(tag, values) for tag, values in columns.items()} + if "well" not in folded and row_tag is not None and column_tag is not None: + typed["Well"] = tuple( + row + column + for row, column in zip(columns[row_tag], columns[column_tag], strict=True) + ) + return typed + + def source_sets(self) -> tuple[ListedSourceSet, ...]: + metadata = self.metadata() + return tuple( + ListedSourceSet( + files=tuple( + ListedSourceFile(alias=named.name, path=named.listed_path(row)) + for named in self.names + ), + metadata=tuple( + ListedMetadata(field=tag, value=values[index]) + for tag, values in metadata.items() + ), + ) + for index, row in enumerate(self.rows) + ) + + def bindings(self, first_channel: int) -> tuple[NamedSourceBinding, ...]: + """One binding per name, selecting exactly the files its column lists.""" + + colour_axis = AxisFamily.active().one(ColourAxis) + bindings: list[NamedSourceBinding] = [] + channel = first_channel + for named in self.names: + file_names = dict.fromkeys( + named.listed_path(row).rpartition("/")[2] for row in self.rows + ) + binding = named.source_type.binding( + named.name, + SourceSelector( + filters=( + SourceFilterClause( + subject=SourceFileName, + match_type=TextContainsRegex, + value="^(?:" + "|".join(map(re.escape, file_names)) + ")$", + ), + ) + ), + FromPipelineStart, + source_set_role=MatchedSource, + ) + if named.source_type.projection_role is PrimaryPlaneRole: + # CellProfiler image names are the colour axis of the active family. + binding = replace( + binding, + component_identity=( + ComponentSelector(component=colour_axis, value=str(channel)), + ), + ) + channel += 1 + bindings.append(binding) + return tuple(bindings) + + +class LoadDataModule(SourceSetupCellProfilerModule): + module_name = "LoadData" + validated = True + supported_revision: ClassVar[int] = 6 + setting_bindings: ClassVar[tuple[SettingToKeywordBinding, ...]] = ( + LoadDataFileBinding(_FILE_NAME_SETTING, "table_path"), + _only_yes( + "Load images based on this data?", + "loads_images", + "would only attach the CSV's columns to image sets other modules " + "build, which OpenHCS does not do.", + ), + SettingToKeywordBinding( + "Base image location", "base_image_location", _base_image_location + ), + SettingToKeywordBinding( + "Process just a range of rows?", "wants_rows", parse_cellprofiler_bool + ), + SettingToKeywordBinding("Rows to process", "row_range", _row_range), + SettingToKeywordBinding( + "Group images by metadata?", "wants_groups", parse_cellprofiler_bool + ), + SettingToKeywordBinding( + "Select metadata tags for grouping", "grouping_tags", _grouping_tags + ), + _only_yes( + "Rescale intensities?", + "rescales_intensities", + "would scale by the file format's maximum instead of the image's " + "own metadata; OpenHCS scales every source image one way.", + ), + ) + + @classmethod + def contribute_source_bindings( + cls, + module: ModuleBlock, + config: SourceBindingsConfig, + binder: SettingsBinder, + ) -> SourceBindingsConfig: + if module.variable_revision_number != cls.supported_revision: + raise ValueError( + f"{module.name}({module.module_num}) is written at settings revision " + f"{module.variable_revision_number}; OpenHCS reads revision " + f"{cls.supported_revision} (CellProfiler 4). Re-save the pipeline " + "in CellProfiler 4." + ) + if config.binding_declarations: + raise ValueError( + f"{module.name}({module.module_num}) lists complete image sets, so it " + "cannot be combined with other modules that name images; the " + "pipeline already names " + f"{tuple(binding.alias for binding in config.binding_declarations)!r}." + ) + settings = binder.bind_declared(module, cls.setting_bindings) + table_path = settings["table_path"] + row_range = settings.get("row_range") if settings.get("wants_rows") else None + if not isinstance(table_path, Path): + raise TypeError("LoadData CSV file did not resolve to a path.") + if row_range is not None and not isinstance(row_range, tuple): + raise TypeError("LoadData row range did not parse to a pair.") + table = LoadDataTable.read(table_path, row_range) + + metadata = table.metadata() + grouping_fields = config.grouping_metadata_fields + if settings.get("wants_groups"): + grouping_tags = settings.get("grouping_tags", ()) + if not isinstance(grouping_tags, tuple): + raise TypeError("LoadData grouping tags did not parse to a tuple.") + unknown = tuple(tag for tag in grouping_tags if tag not in metadata) + if unknown or not grouping_tags: + raise ValueError( + f"{module.name}({module.module_num}) groups image sets by " + f"metadata tags {grouping_tags!r}, but the CSV file " + f"{table.path} has only {tuple(metadata)!r} " + "(its Metadata_ columns)." + ) + grouping_fields = tuple(str(tag) for tag in grouping_tags) + return replace( + config, + bindings=table.bindings(first_channel=1), + match_plan=PairAsListed(source_sets=table.source_sets()), + metadata_fields=FieldSpec.merge_exact( + ( + config.metadata_fields, + tuple( + FieldSpec(tag, type(values[0]), required=False) + for tag, values in metadata.items() + ), + ), + context="CellProfiler LoadData metadata columns", + ), + grouping_metadata_fields=grouping_fields, + ) diff --git a/openhcs/processing/backends/cellprofiler/save_cropped_objects.py b/openhcs/processing/backends/cellprofiler/save_cropped_objects.py new file mode 100644 index 000000000..1361b2bef --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/save_cropped_objects.py @@ -0,0 +1,603 @@ +"""CellProfiler SaveCroppedObjects: one image file per object.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from enum import Enum +from pathlib import PurePosixPath +from typing import TYPE_CHECKING, ClassVar + +import numpy as np +from metaclass_registry import AutoRegisterMeta +from metaclass_registry.strategies import EnumKeyedStrategyMixin + +from openhcs.core.artifacts.kinds import ( + ImageArtifactType, + ObjectLabelsArtifactType, + SpecialArtifactType, +) +from openhcs.core.artifacts.specs import ArtifactSpec, ArtifactSpecCollection +from openhcs.core.materialization import ( + FileBundleOptions, + MaterializationSpec, + Output, +) +from openhcs.core.memory.decorators import numpy +from openhcs.core.pipeline.function_contracts import ( + FullStackLabels, + object_label_input_execution_mode, + special_inputs, +) +from openhcs.core.processing_contracts import Pure2DContract, Pure3DContract +from openhcs.core.runtime_array_values import RuntimeArrayData +from openhcs.core.runtime_image_values import ImagePayload, ImagePayloadMetadata +from openhcs.core.runtime_object_labels import ( + ObjectLabelValue, + object_label_dense_array, +) +from openhcs.interop.cellprofiler.module_artifact_declarations import ( + ObjectArtifactInputModule, +) +from openhcs.interop.cellprofiler.module_declarations import CellProfilerModule +from openhcs.interop.cellprofiler.setting_names import ( + SettingNameFamily, + normalized_symbol_name, + optional_setting_value, +) +from openhcs.interop.cellprofiler.settings_binder import ( + SettingToKeywordBinding, + cellprofiler_enum_setting_parser, + parse_cellprofiler_bool, +) +from openhcs.processing.backends.cellprofiler.morphology import ( + ZStackFunctionVariantModule, +) +from openhcs.processing.backends.cellprofiler.save_images import SaveImagesBitDepth + +if TYPE_CHECKING: + from openhcs.core.function_patterns import FunctionInvocationKey + from openhcs.core.invocation_artifacts import ArtifactDeclarationStepContext + from openhcs.interop.cellprofiler.module_settings import BoundModuleSettings + from openhcs.interop.cellprofiler.parser import ModuleBlock + + +class CropExport(str, Enum): + """What SaveCroppedObjects writes for each object (CellProfiler's spelling).""" + + IMAGES = "Images" + MASKS = "Masks" + + +class CropFileFormat(str, Enum): + """File format of each written object (CellProfiler's spelling).""" + + PNG = "png" + TIFF_8 = "8-bit tiff" + TIFF_16 = "16-bit tiff" + + +class CropContent(EnumKeyedStrategyMixin[CropExport], ABC, metaclass=AutoRegisterMeta): + """The pixels written for one object, per ``CropExport`` member.""" + + __enum_member_attr__ = "export" + export: ClassVar[CropExport | None] = None + + @abstractmethod + def pixels(self, object_mask: np.ndarray, image: np.ndarray | None) -> np.ndarray: + """Return the pixels of the object whose pixels ``object_mask`` selects.""" + + +class ObjectMaskContent(CropContent): + """The object's mask over the whole image; it is not cropped.""" + + export = CropExport.MASKS + + def pixels(self, object_mask: np.ndarray, image: np.ndarray | None) -> np.ndarray: + del image + return object_mask + + +class CroppedImageContent(CropContent): + """The image inside the object's bounding box, zero outside the object.""" + + export = CropExport.IMAGES + + def pixels(self, object_mask: np.ndarray, image: np.ndarray | None) -> np.ndarray: + if image is None: + raise ValueError( + "SaveCroppedObjects saving cropped images requires the image to crop." + ) + if image.ndim == object_mask.ndim + 1: + # A colour image: every channel is cropped by the same object. + object_mask = np.repeat( + object_mask[..., np.newaxis], image.shape[-1], axis=-1 + ) + if image.shape != object_mask.shape: + raise ValueError( + "SaveCroppedObjects image to crop has shape " + f"{image.shape}, its objects have shape {object_mask.shape}." + ) + occupied = np.nonzero(object_mask) + box = tuple( + slice(int(indices.min()), int(indices.max()) + 1) for indices in occupied + ) + return image[box] * object_mask[box] + + +class CropFileEncoding( + EnumKeyedStrategyMixin[CropFileFormat], ABC, metaclass=AutoRegisterMeta +): + """File suffix and stored bit depth, per ``CropFileFormat`` member.""" + + __enum_member_attr__ = "file_format" + file_format: ClassVar[CropFileFormat | None] = None + suffix: ClassVar[str] + bit_depth: ClassVar[SaveImagesBitDepth] + stores_volumes: ClassVar[bool] = True + + def stored_pixels(self, pixels: np.ndarray, *, volume: bool) -> np.ndarray: + """Return ``pixels`` at this format's bit depth.""" + + if volume and not self.stores_volumes: + raise ValueError( + f"SaveCroppedObjects cannot write a 3D object as {self.suffix}; " + "choose a tiff format." + ) + return np.asarray(self.bit_depth.convert(ImagePayload.of(pixels)).data) + + +class PngCropEncoding(CropFileEncoding): + file_format = CropFileFormat.PNG + suffix = ".png" + bit_depth = SaveImagesBitDepth.UINT8 + stores_volumes = False + + +class Tiff8CropEncoding(CropFileEncoding): + file_format = CropFileFormat.TIFF_8 + suffix = ".tiff" + bit_depth = SaveImagesBitDepth.UINT8 + + +class Tiff16CropEncoding(CropFileEncoding): + file_format = CropFileFormat.TIFF_16 + suffix = ".tiff" + bit_depth = SaveImagesBitDepth.UINT16 + + +class CropDirectoryChoice(ABC, metaclass=AutoRegisterMeta): + """A choice of CellProfiler's ``Directory`` setting OpenHCS can write to. + + The setting is spelled ``|``. Each subclass is one choice, + registered under CellProfiler's text for it, and returns the directory + below the step's output folder. A choice with no subclass is refused. + """ + + __registry_key__ = "choice" + __skip_if_no_key__ = True + __registry__: ClassVar[dict[str, type[CropDirectoryChoice]]] = {} + + choice: ClassVar[str | None] = None + + @classmethod + @abstractmethod + def relative_directory(cls, path: str) -> str | None: + """Return the directory below the output folder, or None for the folder.""" + + @classmethod + def parse(cls, value: str) -> str | None: + """Return the relative directory one ``Directory`` setting value names.""" + + choice, _, path = value.partition("|") + choice_type = cls.__registry__.get(choice.strip()) + if choice_type is None: + raise ValueError( + f"SaveCroppedObjects 'Directory' choice {choice.strip()!r} is not " + "supported: OpenHCS writes crops below the step's output folder, " + f"so only {tuple(cls.__registry__)!r} can be imported." + ) + return choice_type.relative_directory(path.strip()) + + +class DefaultOutputFolder(CropDirectoryChoice): + """The output folder itself; CellProfiler ignores any path stored with it.""" + + choice = "Default Output Folder" + + @classmethod + def relative_directory(cls, path: str) -> None: + del path + + +class DefaultOutputSubfolder(CropDirectoryChoice): + """A fixed directory below the output folder.""" + + choice = "Default Output Folder sub-folder" + + @classmethod + def relative_directory(cls, path: str) -> str: + directory = PurePosixPath(path.replace("\\", "/")) + if not path or directory.is_absolute() or ".." in directory.parts: + raise ValueError( + "SaveCroppedObjects 'Directory' sub-folder must be a path below " + f"the output folder, got {path!r}." + ) + if "\\g<" in path or "(?P<" in path: + raise ValueError( + "SaveCroppedObjects 'Directory' sub-folder uses metadata tags " + f"({path!r}), which OpenHCS does not substitute for this module." + ) + return directory.as_posix() + + +class SaveCroppedObjectsModule( + ZStackFunctionVariantModule, + ObjectArtifactInputModule, + CellProfilerModule, +): + """One image file per object, written through file-bundle materialization. + + The step's function returns a mapping of relative file path to pixels and + declares it as a ``FileBundleOptions`` output, the materialization owner + the export modules use; the module writes nothing itself. Like SaveImages + it is an ordinary OpenHCS callable and leaves the image flow untouched. + A pipeline that processes Z stacks as volumes gets the 3D function, which + writes one volume per object. + + Carried over from CellProfiler 4.2.8.1, all eight settings: + + - *Images* or *Masks*. An image crop is the object's bounding box with + pixels outside the object set to zero (a colour image keeps its + channels). A mask is the size of the whole image, not cropped. + - File names ``_`` with the prefix image's file + name (without extension) as an optional prefix; object numbers are the + label values, so gaps in numbering stay gaps. + - Nested folders: a folder named after the prefix, or after the objects + when there is no prefix. + - ``png`` and ``8-bit tiff`` store 8 bits, ``16-bit tiff`` stores 16; + intensities are taken on CellProfiler's 0-1 scale first. + - Directory: the default output folder (a path stored beside that choice + is ignored, as CellProfiler ignores it) or a fixed sub-folder of it. + + Refused at import: any other directory choice (default input folder, + ``Elsewhere...``), a sub-folder that is absolute, leaves the output folder + or contains metadata tags, saving images with no image to crop, and a + file-name prefix with no prefix image. + + Differences from CellProfiler: + + - Files land below the OpenHCS step's results folder, not CellProfiler's + default output folder, and are not registered as images of the plate. + - The prefix is the file name OpenHCS reads the image under, which is the + plate's normalized name (``A01_s001_w1_z001_t001``) when the plate is + read through a virtual workspace, not the name of the file on disk. + - CellProfiler compresses tiffs with zlib level 6; OpenHCS writes them with + its own tiff settings. Pixel values are the same, file bytes are not. + - Pixels are converted to 8 or 16 bits by the SaveImages bit-depth + conversion, which equals CellProfiler's for values in [0, 1]. + CellProfiler refuses floating-point pixels outside [-1, 1]; OpenHCS + clips them instead. + - Without a prefix the file names do not depend on the image. When one + step invocation holds several images (several sites of a well), their + crops would carry the same names and the step fails saying so; + CellProfiler overwrites the earlier image's files silently. + - A png cannot hold a 3D object; the step fails instead of writing one. + """ + + module_name = "SaveCroppedObjects" + function_name = "save_cropped_objects" + function_variants = ("save_cropped_objects_3d",) + export_setting = SettingNameFamily( + "Do you want to save cropped images or object masks?" + ) + input_objects_setting = SettingNameFamily("Objects") + directory_setting = SettingNameFamily("Directory") + use_filename_setting = SettingNameFamily( + "Prefix saved crop image name with input image name?" + ) + filename_image_setting = SettingNameFamily("Select image name to use as a prefix") + nested_setting = SettingNameFamily("Save output crops in nested folders?") + file_format_setting = SettingNameFamily("Saved file format") + image_setting = SettingNameFamily("Image to crop") + + input_objects_binding = SettingToKeywordBinding.input( + input_objects_setting, + ObjectLabelsArtifactType, + runtime_parameter_name="labels", + ) + image_binding = SettingToKeywordBinding.input( + image_setting, + ImageArtifactType, + runtime_parameter_name="image_to_crop", + ) + filename_image_binding = SettingToKeywordBinding.input( + filename_image_setting, + ImageArtifactType, + runtime_parameter_name="filename_image", + ) + setting_bindings = ( + SettingToKeywordBinding( + export_setting, + "export", + cellprofiler_enum_setting_parser(CropExport), + ), + input_objects_binding, + SettingToKeywordBinding(directory_setting, "directory", CropDirectoryChoice.parse), + SettingToKeywordBinding( + use_filename_setting, "prefix_with_filename", parse_cellprofiler_bool + ), + filename_image_binding, + SettingToKeywordBinding(nested_setting, "nested_folders", parse_cellprofiler_bool), + SettingToKeywordBinding( + file_format_setting, + "file_format", + cellprofiler_enum_setting_parser(CropFileFormat), + ), + image_binding, + ) + + @classmethod + def uses_cellprofiler_runtime_adapter(cls) -> bool: + """The function is an ordinary OpenHCS callable, as SaveImages is.""" + + return False + + @classmethod + def _saves_images(cls, module: ModuleBlock) -> bool: + return ( + cellprofiler_enum_setting_parser(CropExport)( + optional_setting_value(module, cls.export_setting) + or CropExport.IMAGES.value + ) + is CropExport.IMAGES + ) + + @classmethod + def _prefixes_with_filename(cls, module: ModuleBlock) -> bool: + return parse_cellprofiler_bool( + optional_setting_value(module, cls.use_filename_setting) or "Yes" + ) + + @classmethod + def validate_pipeline_import(cls, module: ModuleBlock) -> None: + """Refuse the setting combinations CellProfiler itself cannot run.""" + + for wanted, setting, reason in ( + (cls._saves_images(module), cls.image_setting, "saves cropped images"), + ( + cls._prefixes_with_filename(module), + cls.filename_image_setting, + "prefixes file names with an image's file name", + ), + ): + if wanted and ( + normalized_symbol_name(optional_setting_value(module, setting) or "") + is None + ): + raise ValueError( + f"SaveCroppedObjects({module.module_num}) {reason} but " + f"{setting.canonical!r} names no image." + ) + CropDirectoryChoice.parse( + optional_setting_value(module, cls.directory_setting) + or DefaultOutputFolder.choice + ) + + @classmethod + def active_artifact_bindings( + cls, + module: ModuleBlock | None = None, + *, + invocation_key: FunctionInvocationKey | None = None, + ) -> tuple[SettingToKeywordBinding, ...]: + """The image to crop and the prefix image are inputs only when used.""" + + bindings = super().active_artifact_bindings( + module, invocation_key=invocation_key + ) + if module is None: + return bindings + unused = { + binding + for binding, used in ( + (cls.image_binding, cls._saves_images(module)), + (cls.filename_image_binding, cls._prefixes_with_filename(module)), + ) + if not used + } + return tuple(binding for binding in bindings if binding not in unused) + + @classmethod + def postprocess_bound_settings( + cls, module: ModuleBlock, bound: BoundModuleSettings + ) -> BoundModuleSettings: + """Give the function the objects' name, which every file name carries.""" + + bound = super().postprocess_bound_settings(module, bound) + (objects_name,) = cls.artifact_names_for_binding( + module, cls.input_objects_binding + ) + return bound.with_kwargs({"objects_name": objects_name}) + + @classmethod + def main_flow_output_specs( + cls, + main_flow_candidates: tuple[ArtifactSpec, ...], + ) -> tuple[ArtifactSpec, ...]: + """Writing crops leaves the pipeline's image flow as it was.""" + + del cls, main_flow_candidates + return () + + @classmethod + def artifact_contract_outputs( + cls, + module: ModuleBlock, + *, + invocation_key: FunctionInvocationKey, + step_context: ArtifactDeclarationStepContext, + artifact_inputs: ArtifactSpecCollection, + ) -> tuple[ArtifactSpec, ...]: + """Declare the crop files as one materialized file bundle.""" + + del invocation_key, step_context, artifact_inputs + return ( + ArtifactSpec.output( + cls.canonical_numbered_module_output_artifact_name( + module, + artifact_type=SpecialArtifactType, + output_position=0, + ), + SpecialArtifactType, + materialization=MaterializationSpec(FileBundleOptions()), + ), + ) + + +def _source_file_stem(filename_image: RuntimeArrayData | None) -> str: + source_path = ( + None + if filename_image is None + else ImagePayload.of( + filename_image + ).metadata.source_provenance.scalar_source_identity.path + ) + if source_path is None: + raise ValueError( + "SaveCroppedObjects prefixes file names with the input image's file " + "name, but the prefix image does not come from one source file." + ) + return PurePosixPath(str(source_path).replace("\\", "/")).stem + + +def _object_files( + labels: ObjectLabelValue, + image_to_crop: RuntimeArrayData | None, + filename_image: RuntimeArrayData | None, + *, + objects_name: str, + export: CropExport, + file_format: CropFileFormat, + directory: str | None, + prefix_with_filename: bool, + nested_folders: bool, +) -> dict[str, Output]: + """Return every object's file, keyed by its path below the output folder.""" + + label_data = object_label_dense_array(labels, dtype=np.int32) + content = CropContent.for_enum_member(export) + encoding = CropFileEncoding.for_enum_member(file_format) + # CellProfiler crops intensities on its 0-1 scale, whatever the file stored. + pixels = ( + None + if image_to_crop is None + else np.asarray( + ImagePayload.of(image_to_crop).normalize_intensity_payload().data + ) + ) + prefix = f"{_source_file_stem(filename_image)}_" if prefix_with_filename else "" + folder = PurePosixPath(directory or "") + if nested_folders: + folder = folder / (prefix[:-1] if prefix else objects_name) + files: dict[str, Output] = {} + for object_number in np.unique(label_data[label_data > 0]): + path = ( + folder / f"{prefix}{objects_name}_{int(object_number)}{encoding.suffix}" + ).as_posix() + stored = encoding.stored_pixels( + content.pixels(label_data == object_number, pixels), + volume=label_data.ndim == 3, + ) + # One object's file is not a plane of the plate, so it carries its own + # pixel facts and no plate address. + files[path] = Output( + path=path, + content=stored, + metadata=ImagePayloadMetadata.for_array(stored), + ) + return files + + +@numpy(contract=Pure2DContract) +@special_inputs("labels", "image_to_crop", "filename_image") +def save_cropped_objects( + image: ImagePayload, + labels: ObjectLabelValue, + image_to_crop: RuntimeArrayData | None = None, + filename_image: RuntimeArrayData | None = None, + *, + objects_name: str = "Objects", + export: CropExport = CropExport.IMAGES, + file_format: CropFileFormat = CropFileFormat.TIFF_8, + directory: str | None = None, + prefix_with_filename: bool = True, + nested_folders: bool = False, +) -> tuple[ImagePayload, dict[str, Output]]: + """Return one image file per object, keyed by its path below the output folder. + + Args: + labels: Objects to write, one file per object number. + image_to_crop: Image the crops are taken from when ``export`` is images. + filename_image: Image whose source file name prefixes every file name. + objects_name: Name of the objects, part of every file name. + export: Whether each file holds the cropped image or the object's mask. + file_format: File type and bit depth of each file. + directory: Directory below the step's output folder; None for the folder. + prefix_with_filename: Whether file names start with the source file name. + nested_folders: Whether files go into a folder named after the prefix, + or after the objects when there is no prefix. + """ + return image, _object_files( + labels, + image_to_crop, + filename_image, + objects_name=objects_name, + export=export, + file_format=file_format, + directory=directory, + prefix_with_filename=prefix_with_filename, + nested_folders=nested_folders, + ) + + +@numpy(contract=Pure3DContract) +@object_label_input_execution_mode(FullStackLabels) +@special_inputs("labels", "image_to_crop", "filename_image") +def save_cropped_objects_3d( + image: ImagePayload, + labels: ObjectLabelValue, + image_to_crop: RuntimeArrayData | None = None, + filename_image: RuntimeArrayData | None = None, + *, + objects_name: str = "Objects", + export: CropExport = CropExport.IMAGES, + file_format: CropFileFormat = CropFileFormat.TIFF_8, + directory: str | None = None, + prefix_with_filename: bool = True, + nested_folders: bool = False, +) -> tuple[ImagePayload, dict[str, Output]]: + """Return one volume file per 3D object, keyed by its path below the output folder. + + Args: + labels: Object volume to write, one file per object number. + image_to_crop: Volume the crops are taken from when ``export`` is images. + filename_image: Image whose source file name prefixes every file name. + objects_name: Name of the objects, part of every file name. + export: Whether each file holds the cropped volume or the object's mask. + file_format: File type and bit depth of each file; png cannot hold a volume. + directory: Directory below the step's output folder; None for the folder. + prefix_with_filename: Whether file names start with the source file name. + nested_folders: Whether files go into a folder named after the prefix, + or after the objects when there is no prefix. + """ + return image, _object_files( + labels, + image_to_crop, + filename_image, + objects_name=objects_name, + export=export, + file_format=file_format, + directory=directory, + prefix_with_filename=prefix_with_filename, + nested_folders=nested_folders, + ) diff --git a/openhcs/pyqt_gui/widgets/source_bindings_editor.py b/openhcs/pyqt_gui/widgets/source_bindings_editor.py index 81426bafc..abaed8f22 100644 --- a/openhcs/pyqt_gui/widgets/source_bindings_editor.py +++ b/openhcs/pyqt_gui/widgets/source_bindings_editor.py @@ -127,13 +127,14 @@ def to_plan( return next( item.default for item in fields(owner) if item.name == "match_plan" ) - return rows[0].method.keyed_by( - tuple( - SourceBindingMatchDimension(fields=row.fields) - for row in rows - if row.fields - ) + method = rows[0].method + keys = tuple( + SourceBindingMatchDimension(fields=row.fields) for row in rows if row.fields ) + current = owner.match_plan + # Rows show a pairing's keys only: whatever else the stored pairing + # declares (the source sets a table lists) is kept, not rebuilt empty. + return current.with_keys(keys) if type(current) is method else method.keyed_by(keys) def source_binding_suggestions( diff --git a/tests/fixtures/cellprofiler_builtin_gap_modules/cases.cppipe b/tests/fixtures/cellprofiler_builtin_gap_modules/cases.cppipe new file mode 100644 index 000000000..21f4dff1b --- /dev/null +++ b/tests/fixtures/cellprofiler_builtin_gap_modules/cases.cppipe @@ -0,0 +1,96 @@ +CellProfiler Pipeline: http://www.cellprofiler.org +Version:5 +DateRevision:426 +GitHash: +ModuleCount:9 +HasImagePlaneDetails:False + +SaveCroppedObjects:[module_num:1|svn_version:'Unknown'|variable_revision_number:3|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Do you want to save cropped images or object masks?:Images + Objects:Cells + Directory:Default Output Folder|Argonne/git_repos/JUMP_vision_model/rad_pipeline/segmented_image_temp + Prefix saved crop image name with input image name?:No + Select image name to use as a prefix:None + Save output crops in nested folders?:No + Saved file format:png + Image to crop:ImageAfterMath + +SaveCroppedObjects:[module_num:2|svn_version:'Unknown'|variable_revision_number:3|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Do you want to save cropped images or object masks?:Masks + Objects:Cells + Directory:Default Output Folder sub-folder|crops + Prefix saved crop image name with input image name?:Yes + Select image name to use as a prefix:DNA + Save output crops in nested folders?:Yes + Saved file format:8-bit tiff + Image to crop:None + +SaveCroppedObjects:[module_num:3|svn_version:'Unknown'|variable_revision_number:3|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Do you want to save cropped images or object masks?:Images + Objects:Cells + Directory:Default Output Folder| + Prefix saved crop image name with input image name?:Yes + Select image name to use as a prefix:DNA + Save output crops in nested folders?:No + Saved file format:16-bit tiff + Image to crop:ImageAfterMath + +SaveCroppedObjects:[module_num:4|svn_version:'Unknown'|variable_revision_number:3|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Do you want to save cropped images or object masks?:Masks + Objects:Cells + Directory:Default Output Folder| + Prefix saved crop image name with input image name?:No + Select image name to use as a prefix:None + Save output crops in nested folders?:Yes + Saved file format:png + Image to crop:None + +SaveCroppedObjects:[module_num:5|svn_version:'Unknown'|variable_revision_number:3|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Do you want to save cropped images or object masks?:Images + Objects:Cells + Directory:Default Output Folder sub-folder|color/deep + Prefix saved crop image name with input image name?:No + Select image name to use as a prefix:None + Save output crops in nested folders?:No + Saved file format:8-bit tiff + Image to crop:Color + +SaveCroppedObjects:[module_num:6|svn_version:'Unknown'|variable_revision_number:3|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Do you want to save cropped images or object masks?:Masks + Objects:Cells + Directory:Default Output Folder sub-folder|masks16 + Prefix saved crop image name with input image name?:No + Select image name to use as a prefix:None + Save output crops in nested folders?:No + Saved file format:16-bit tiff + Image to crop:None + +SaveCroppedObjects:[module_num:7|svn_version:'Unknown'|variable_revision_number:3|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Do you want to save cropped images or object masks?:Images + Objects:Cells + Directory:Default Output Folder| + Prefix saved crop image name with input image name?:Yes + Select image name to use as a prefix:DNA + Save output crops in nested folders?:Yes + Saved file format:png + Image to crop:Color + +SaveCroppedObjects:[module_num:8|svn_version:'Unknown'|variable_revision_number:3|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Do you want to save cropped images or object masks?:Images + Objects:Blobs + Directory:Default Output Folder| + Prefix saved crop image name with input image name?:No + Select image name to use as a prefix:None + Save output crops in nested folders?:No + Saved file format:8-bit tiff + Image to crop:Volume + +SaveCroppedObjects:[module_num:9|svn_version:'Unknown'|variable_revision_number:3|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Do you want to save cropped images or object masks?:Masks + Objects:Blobs + Directory:Default Output Folder| + Prefix saved crop image name with input image name?:Yes + Select image name to use as a prefix:DNA + Save output crops in nested folders?:No + Saved file format:16-bit tiff + Image to crop:None diff --git a/tests/fixtures/cellprofiler_builtin_gap_modules/make_reference.py b/tests/fixtures/cellprofiler_builtin_gap_modules/make_reference.py new file mode 100644 index 000000000..b6d70ee1b --- /dev/null +++ b/tests/fixtures/cellprofiler_builtin_gap_modules/make_reference.py @@ -0,0 +1,149 @@ +"""Files CellProfiler's own SaveCroppedObjects writes for ``cases.cppipe``. + +Run with the pinned native CellProfiler interpreter, never the OpenHCS one: + + nice -n 19 /.venv-cellprofiler39/bin/python -I \ + tests/fixtures/cellprofiler_builtin_gap_modules/make_reference.py \ + tests/fixtures/cellprofiler_builtin_gap_modules + +CellProfiler's pipeline loader reads ``cases.cppipe`` (so the module parses its +own settings). Each module's ``run`` is called on a workspace holding the arrays +below, with a fresh default output folder per module. Java is not started. The +script writes ``reference.npz`` (the inputs, and the pixels read back from every +file a module wrote, keyed ``file_/``) +and ``reference.json`` (versions, the source file name given to the modules, and +each module's file list or the error it raised). +""" + +import json +import sys +from pathlib import Path + +import numpy + +SOURCE_FILE_NAME = "plate1_A01_s1_w1.tif" + + +def inputs(): + random = numpy.random.RandomState(20261012) + rows, columns = numpy.mgrid[0:24, 0:32] + plane = 0.1 + 0.6 * numpy.exp(-((rows - 9) ** 2 + (columns - 12) ** 2) / 40.0) + plane = plane + 0.1 * columns / 31.0 + 0.15 * random.rand(24, 32) + dna = random.beta(2.0, 5.0, size=(24, 32)) + color = random.rand(24, 32, 3) + volume = random.rand(4, 20, 24) + cells = numpy.zeros((24, 32), dtype=numpy.int32) + cells[0:6, 0:9] = 1 # touches the image corner + cells[8:16, 10:20] = 2 + cells[10:13, 13:17] = 0 # a hole: the crop keeps the bounding box + cells[18:22, 4:8] = 5 # object numbers skip 3 and 4 + cells[19:23, 24:30] = 5 # the same object in two pieces + cells[15:20, 21:24] = 7 + cells[17, 22] = 0 + blobs = numpy.zeros((4, 20, 24), dtype=numpy.int32) + blobs[0:3, 2:9, 3:11] = 1 + blobs[1:4, 11:18, 12:22] = 3 + blobs[2, 13, 15] = 0 + return { + "ImageAfterMath": plane.astype(numpy.float32), + "DNA": dna.astype(numpy.float32), + "Color": color.astype(numpy.float32), + "Volume": volume.astype(numpy.float32), + "Cells": cells, + "Blobs": blobs, + } + + +def main(fixture_directory, scratch_directory): + import cellprofiler + import skimage + import skimage.io + import tifffile + from cellprofiler_core.image import Image, ImageSetList + from cellprofiler_core.measurement import Measurements + from cellprofiler_core.object import Objects, ObjectSet + from cellprofiler_core.pipeline import Pipeline + from cellprofiler_core.preferences import ( + set_default_output_directory, + set_headless, + ) + from cellprofiler_core.workspace import Workspace + + set_headless() + fixture_directory = Path(fixture_directory) + scratch_directory = Path(scratch_directory) + pipeline = Pipeline() + pipeline.load(str(fixture_directory / "cases.cppipe")) + arrays = inputs() + object_names = ("Cells", "Blobs") + files = {} + listing = {} + for module in pipeline.modules(): + output_directory = scratch_directory / str(module.module_num) + output_directory.mkdir(parents=True) + set_default_output_directory(str(output_directory)) + image_set_list = ImageSetList() + image_set = image_set_list.get_image_set(0) + for name, pixels in arrays.items(): + if name in object_names: + continue + volumetric = name == "Volume" + image_set.add(name, Image(pixels, dimensions=3 if volumetric else 2)) + object_set = ObjectSet() + for name in object_names: + objects = Objects() + objects.segmented = arrays[name] + object_set.add_objects(objects, name) + measurements = Measurements() + measurements.add_image_measurement("FileName_DNA", SOURCE_FILE_NAME) + workspace = Workspace( + pipeline, module, image_set, object_set, measurements, image_set_list + ) + try: + module.run(workspace) + except Exception as error: # noqa: BLE001 - whatever the module raises is the record + listing[str(module.module_num)] = f"{type(error).__name__}: {error}" + continue + finally: + measurements.close() + written = sorted( + path.relative_to(output_directory).as_posix() + for path in output_directory.rglob("*") + if path.is_file() + ) + listing[str(module.module_num)] = written + for relative in written: + path = output_directory / relative + pixels = ( + tifffile.imread(str(path)) + if path.suffix == ".tiff" + else skimage.io.imread(str(path)) + ) + files[f"file_{module.module_num}/{relative}"] = pixels + numpy.savez_compressed( + str(fixture_directory / "reference.npz"), + **{"input_" + name: pixels for name, pixels in arrays.items()}, + **files, + ) + (fixture_directory / "reference.json").write_text( + json.dumps( + { + "cellprofiler": cellprofiler.__version__, + "scikit-image": skimage.__version__, + "tifffile": tifffile.__version__, + "numpy": numpy.__version__, + "python": sys.version.split()[0], + "source_file_name": SOURCE_FILE_NAME, + "files_written": listing, + }, + indent=2, + sort_keys=True, + ) + + "\n" + ) + with open(str(scratch_directory / "native_dump.cppipe"), "w") as dump: + pipeline.dump(dump) + + +if __name__ == "__main__": + main(*sys.argv[1:]) diff --git a/tests/fixtures/cellprofiler_builtin_gap_modules/reference.json b/tests/fixtures/cellprofiler_builtin_gap_modules/reference.json new file mode 100644 index 000000000..fc3943fd4 --- /dev/null +++ b/tests/fixtures/cellprofiler_builtin_gap_modules/reference.json @@ -0,0 +1,60 @@ +{ + "cellprofiler": "4.2.8.1", + "files_written": { + "1": [ + "Cells_1.png", + "Cells_2.png", + "Cells_5.png", + "Cells_7.png" + ], + "2": [ + "crops/plate1_A01_s1_w1/plate1_A01_s1_w1_Cells_1.tiff", + "crops/plate1_A01_s1_w1/plate1_A01_s1_w1_Cells_2.tiff", + "crops/plate1_A01_s1_w1/plate1_A01_s1_w1_Cells_5.tiff", + "crops/plate1_A01_s1_w1/plate1_A01_s1_w1_Cells_7.tiff" + ], + "3": [ + "plate1_A01_s1_w1_Cells_1.tiff", + "plate1_A01_s1_w1_Cells_2.tiff", + "plate1_A01_s1_w1_Cells_5.tiff", + "plate1_A01_s1_w1_Cells_7.tiff" + ], + "4": [ + "Cells/Cells_1.png", + "Cells/Cells_2.png", + "Cells/Cells_5.png", + "Cells/Cells_7.png" + ], + "5": [ + "color/deep/Cells_1.tiff", + "color/deep/Cells_2.tiff", + "color/deep/Cells_5.tiff", + "color/deep/Cells_7.tiff" + ], + "6": [ + "masks16/Cells_1.tiff", + "masks16/Cells_2.tiff", + "masks16/Cells_5.tiff", + "masks16/Cells_7.tiff" + ], + "7": [ + "plate1_A01_s1_w1/plate1_A01_s1_w1_Cells_1.png", + "plate1_A01_s1_w1/plate1_A01_s1_w1_Cells_2.png", + "plate1_A01_s1_w1/plate1_A01_s1_w1_Cells_5.png", + "plate1_A01_s1_w1/plate1_A01_s1_w1_Cells_7.png" + ], + "8": [ + "Blobs_1.tiff", + "Blobs_3.tiff" + ], + "9": [ + "plate1_A01_s1_w1_Blobs_1.tiff", + "plate1_A01_s1_w1_Blobs_3.tiff" + ] + }, + "numpy": "1.24.4", + "python": "3.9.25", + "scikit-image": "0.18.3", + "source_file_name": "plate1_A01_s1_w1.tif", + "tifffile": "2022.4.8" +} diff --git a/tests/fixtures/cellprofiler_builtin_gap_modules/reference.npz b/tests/fixtures/cellprofiler_builtin_gap_modules/reference.npz new file mode 100644 index 0000000000000000000000000000000000000000..ca9530ea9d1e34540fde630e000639f6295c2899 GIT binary patch literal 32410 zcmZ6yRZtyF(6tL8!QGt%cXtgQ+@0XA8+Qo7-QC^YT{f_Bci*_X>&bi0`M;{~@2a_) zHFwip&rGjYl!5w+2>}5C`)~b#phQXUy@7{-_=5-mfeV2NVQB|&an_fzH8eLBHgh(0 zlrwa;V6+2xKtiDYznXuCApX<;7g@>AI>;8ULlin_$`4iO3-hR?Be!ixabd~{?Ba3i zG7+4XD%y=D`<6@8=Tb=V3{gH|b)BQXIKt}WOaj48T~m+xPn#fzcYuD6F35-Y@V;#d z5rM!-hOf=n|6Qa}SNA>BrwO~uTX-8PcH8{!jPh11In@EVPk z5^Oq?a)SWxFYe8!()T>&P?>!!)(EO`{=^5qSPv4iN|9OZo4t58Uun@qP1gR3roy_} z_GUhKHVh5^m3$(3>!5=ey=JV{YE5NvVUkftiY5&`zML=PEs8#dLq6*@@<;7b?t0TS zOn=Bzds7mli#0tOxHNLH^lK}SR+m`S*eWd|z4DIg%skBzmo~h4JgnSchL>%8ROUzs z7}|ak#2rjIQ=0W_fq)=B5#`EG3(wWk*CLn~!(q3Zd|g|l!C$DlsaO};%}`h4>tnoc z-WY5AP5+lWmC=!3-NGYll9`H3=2lM_(MO!DC9kmePIP#6cdUlu;wED_+huX$Y9(YE zd!w31w(@fcu$$=}J9RqY+FF!w#AdCZbqGNh29$TVrZ-EpIt&?=WyBQ;6RRxBcm(lY#5n4YaDo!I#R;T)Z_1E`ynoR!N&RlXn>;Pxs55 z8bm^0^jdU05=IM<7cdxDbxkLCvqZWe1C^ktB>mX&J+%I~A|cg&MMB%c;=I1EVVqnf zF*$x#6R)}k!t^X8jviN4V;xg;AN3&yR30Xv( z)69t<+S=BUIyI(ZtSG#ltpx?^a?BhzJvnC=I9gCt2@xdSXI>Gwh7 z`gmHlrziM=XJ&_2@O8_B<7asJcc*Q>(hyYg6Hv$hCVF*>^1#0X5C zcR96*gCgXFUy33|W^aR}juv}QtT}WBx4Y)(_);=rCQe}IF{QV{fkSgg91I;i?7WZI z@x+w(#Uhq(NmF3=0_hC9Tar6xpRk2*J+=c{!(hjG@Xq+MRKC}r{9lA?@~?>*7S-J{ zl46{ONqm~qb$DD2>=H-&EM3bD|V-S-u>Ltd58 zUDDxL+PH1b1{p7qnkHy>R8Pgw8bA1EW(*jNmiWTC%fb}(?Wj5R7O>#!p96yB(3f89 zm`}wpdZKuvbaNBu*9B5ruy*V*q^}wSG@-xEj0&E)6+7ya<)m#%ey63g&?=XboBS@A zwIQk=^{r&FH7=XHXa|(gDA8>=6*|)Yn3<0I;@PcPV*h>9S$e>jDGXPW7Htw_4)=^C zy#o%_Thv(})H)=D#F4>kZH;CM4th#GGjoX>-zC^-bzF>NMWDkcByty`i4;0`mu+ZF zXJQcoKW>;5PN!76(};CIM;-x+Gd^iqlsw)J0kQYG*U zG`C`-`qDTXMkg_Fg!);NdQfQ~xnxJ9pA=M#&$m3C;VUs& zBMp-~^9lkDDPhkAie1(b_43@il-Spom7KIBoq23+X}c7o{fZ9p?pfUO zoxc3iXMAib)Ga%;cm>7vna9AGoT3or>%99`cavo{zIgedX6ktWQ% zf-#c(OjtE|pxJKEHOsLL(3OXC)yP=x-)l@lwIRkB3jt2k#FIEg2fbrmh>IS92}2CC zn_b2hG4-J`M5M``DLCiwU5Qwwp~fwHgN1S}u?p4&U|l#ad<8@O`h>#6>bGZV;-#1l z>TaCg^LF~oOqnt^7Amt%`R=NyoeC%TEl!G=ALh|RCsnxKD`KEYF-*x4?F13fkaIAkIB2`YwO>TI+cF+%%^Aig)Td_DBAtwB z-j2XkgB-gVP=V-N&U$uvdK3sfENc#g7E@O#*1zhU`vloC!#wOv%o=|MXTeiYu?8L- z01kd;jeI<( zFiZ<%UZAGc$EtS#Fb`hibt->CB1zHX{XiD;doUG$6nsk@ZR2c@C6Y|2yMFlj+Qk6k zl%t$6=Z>y+^=4HyIS$6%OqCkuNttvOAfXh}8l*#l zogJj{a~BR(vV~K-w&fAE2I8;=TWi~r8Qj8qx4YiHh(H(3q2pyy%rUbgFTq23W`o#v zxtLxiLPbs;5GjP)s6*%tPlCQ39lbD5az;*`gTG>>&OUJaC&O#0-!fC(hb9(9Ahh1H z#_b&Xv-65w+0U!*$wbvHZS|cP>fpszPkc*pk5sWSxok1@Fd@}eIMbv;Dn31MesjuH zWE?(xcXs-aS`$gtLRQ~oT^wTAIf2&NbgZLNbrKYyqHlHbz+%hqid)iEC^zJ}SXI)z zSd(jyRNxx2GYjdb%ucR0EVx%Bm1Hz4&tnoiwWY3I4*HnD<-HSS8Iu#|U7t)SpXP9U%}AtF9B2pLJM=|# z&>f(II@=6Uw2xc;D=>$#!I<}eZnqQyhpB_Y>5ubPYS=i6nD3j8sJzueeK_v(p ze(aK`*}r~eM8&&>Q=qrqQ13^Iu2w)}@pIjUR*R=8z?D%KkulMK8?2JEP%8U5+lP?) z5+5*CKX#RLoIi4eKI9Y+NV?0jD3cu1uwab}N2IT2fblbh;#>`@U$KykfGKV6<%u(NNko&><$Avxk_wQ%{fn6=N$NmhDt$AYauR&(jE;|jAWSJuytXIqV30(ZtR2B} zsHj9(*8cB=87!iz$N_jRltWI!*9DEXdjH2m6*qPExb0+Q#<$l&w=L3Px@T6RRyv#Ey~Pd0a3IK+rP-?c%4_HLn*vc2b;}_&w^)z z#sWV$(3F@n+Uts zB#xa3xS-iEf71}P+M;u?S<OhGXTz~xuq%ym{R2a`XWLYAv#RzE2bo*1TJZv!cf z=4;KLfLFG}eQ_gUBY~o{((U>aw#M?(+5mm?M;!Mw*T<+GRc}i5Gs|zq7fO-moZhb; z5avQdhOLH-FGh=ySinTVLpmZ0E{jGbLbaWU4eL4rJIRS3 zprQLgji>@Zx9vF%SbrKHfD@h4G8TMe*7w~$Xxy5fFzsg(LMBYZUDxBMN&;*6OzP%yG6^&_ACSC4-V)hH)Wl9sQl6jGRvl+( zdHa8(_?lKid4yK0bw#yHxO^E~zmy$6){fSQBh9W<=AMK``!~%$hW#W~S-{lCN#=-A9ZG+=>TOy!_mAzs7*3%Q!ie6We%=@X$!*55~@dAQ@y@}^e5pL_$w(VzWPJIKAa0%_iSu4qcCaBcfsTxRxd#RJ?RTY_UvD-9+4T! zZ%SRZu}uiVs!{mb7+9KrCN(@i5=kT?_G=MH6t5zv$E4@XZZnZ*xQmRBuNcmfx&qoH zk^P;R5f2p@#0y@SWwj5%ak2z|ZMn#TRK6ljvJ+=$kq-OfcylC!OXN+@gknV}*H*z= zxxK}YgR{b>ST-wJF(d7YcXTLo2gw$!BvRjJFhpTxunegZUS)%in91F@hV6>*zhY;H zNXNsCi#k-l6Cr$_a#NK`ZCBZ3SpVo4X)5!Fp!{!kv~Bj zu*r*9691*{U6j6PU;O80KfYfNdv8Xeba`kRh%=?@t4Ehu3u#OwhYHxa3L}}Ui#@kc zW>aGn%+pUc0=$O3J||@XE{w0m4ckStX4Fhap60U+YeW(Uc)G`gpHPoxi?G|??$UEne)8Rn=dIQLNU?kkq);R5P$4jynh zNa|_C*+}D*AoYDAv^|}6+5K8nOY?8gq3ldDDp7cXUeNKLOz7HNWaJ-N-4#{oAQz6@ z6QNjj7h*Q-w<}!}KI;gFHX{{E#W*!5MLKgU&xe#c=ibl5!7PnwcxO&Bi`%*yQw$ zN?rFX=aC*Vd2$Q`aGL#`?^Hpr`BP~1m5|R4ecSjP?-~>0ApOnlByy14|~{m zL-f_;=LRRoTnZzIh-~bg+8q= zX|D@k>lK6XG~R!m0TR@-y8k)lo$0uwYG7LRmCl1#B!>!f+mL?D1`DTwRs%{oDqUpq z6{^&hNu=e`o08|`5A`9w7!kTzGQV0X4`!_Bk+#^%Mv;m*!jByyIgg>XJ24&%{a~Bx zu~d>lvGu|Ja6ijeSC~D|7o)1*jF#lk#%7fWF4#)3G@mFKyackkuTBpBPEZROSccb= z_)IY|Gq;DHsEV3@yO-%J-_@Wu&4J){uIMl5(9AY?5KsYrN2l~4&Q%>U>o^$90Ma~P z_@{Fqj%smtV5Th|rnYXbnNW=7+xOAx;@@H`54?>v_7uh&=&a4R1E5d4oz%oH+Fr`w zaV&%5_sd%4ED}cZ0&uo%%neaI$g7n9KZl6+2>puEiGHQ{Ux= zkV1ok7P*TAo3q}5`CxED6vYFI;xcvs(J>>nz@pub{R~PB$;VKSN5l^@)d;E{soIPt zXxx(siQrRu1T)6G-cdw_eau>>B`8pVPTcqT&>vy!zXGWY7Cc3Z=NpN0qAN{=TXJUS zcO#asFTI_Yd3NkvVGA?FQXeiX8=XK(D@};OR(b#n4bj<4mZS8RSO$9>qvJ&)^6Vxo zA{<|_fK!I?mb3tdG}=^GSASsE9N;8Oxa6D!({GswK_XGEa{k7Eo;Sm?6x3Gi{`BSx zd5V*i?^g=b-01{}wd*Z7l+YBs$5xQNP}$FBo3?g=n1k*8g!tc_eTCKtSN#1SXOlxf zp!^SKi`v`RJN^%8|J(X6{*(O|St*Y~8ke7-8D?b(I3-td2J&4O0bU*?-c#qUzT8Vphna^i0whzfKF6gH_BjSxY4mCQ zbIYw#EO^BaFU9fKi^bS4#k(qOtZezpq{?hYyC3#GCi4SQ4zu@Ca7vaAv*v6v)(Ady zGRGTJB7Wr`pNGondhZa`S)1F%$_fY8-XXo%hUAOp5+bu zazv|{hsF{c>X|WF1DOxi^zAc5(&oh93(TR&g*;jA^?&Hy1H=Z0)t!&i#x!noiz;-@J8w7N9fU* zka=F~NWh_UrsNHb1rw00-0Dw2B!^h$+Jz9Rh@NZmNJgajZEc(0avo_hVe!np0!llu zF|7E=j`sx6YQr)uabucL+P950gn1eysdphK#Azs0S7UJhbNh<|z& zg|AvtFYucXJ3=N1RdG#v0^h;pWE0miZRGbcVQf5KI3$smHAFnkP5hbC$Ge&MSZYX8k_t!rg?0_JccO?P=Ie1!-nkfQfn zvn-@WQ1vHS+8Dwe52WJ{oxx~riqRvTLBBI925025=e2)FS^85FS`v_Z(kmEM*xLtJ z)edCHib69;nP)eDX}R<%5#N@E%i1WAQI= z|8)DY65&Bm20MY|RBD{We0QkLlHaBiZ@-;FQ|NodO?JU_&k^*)LD$dheLlmbMjR#i z^0!ygq$CfLfn5~JdAR8k>qSLD*$j~>DpvZA<8k4HeD2mE|A_F8X$j$o5xQt8J)2~3 z`R&QhWw|3cuG~SsX|lKLDYxDDtVx^%GK}{+4P(@d@o^t&a{B%55iCsW+hP91@(p04T46l4L`KZbKfk3PGphPn4HxKOOZy6Xe%sxlUiU#ET{1$zWviVqMPO`n`T;~;yn(DB;bqGWfGP8Ct9@q1h4ojLB1QTiU2oACaNlo@h-8&Y{UGwywGl6ZKBVUQwNk#r;Fhv z4+`a$`fk^b%P-rGKxi1s zNS^w`9=>8oW+7^R@%n@&I?Fc*MIf@*KOl&R^+}dUn zYK2fpX2Mt?7%L_>&$2%uD0LGSd5mt=Bhq(Y6JG(fu!W!s|0w7C0qL%l&t z&Fm#fMZ^x}F)_04SJAxQhHyS+i4~k6>z;_05{4%VsUln7s=|*6J={wD0HD%=Z0T|o zQpfI!xirGXXxdMqi9{;wCkGEH`v*C=-C)p1t>83)8*=H4(;!UZym_skt(*Es*WYIX z^Y5Gt8PzP{@fAO!V0>&~%bhsgUr!gwt+W>+@d<9m1H_ zEo5R{t9hN@Ghx_vwO$2sHYGio>2;F?F7-e;PS#gesCZmGUovDf%diEbmL5HUGd{T5 z^aZ&rq-b8wFUGH%GTdHf?55QyIC=^Ca4i~bod;d$OA&0Twe--%!Ze+ojd1(G?tU;B z(q?|lFk-k*;as?~V;g#6@I3W5BNV9bga4U2i?J_c+9wPvVoo&e%JA#1nti@iCr!P4 z`fPfb?A+r)(y{5zX2yu>R`LW4FA<>IJYMRd~;l?-P{1lS)I4qRt;trpl)M-<8aP}F;2jf`?-;lI+(@s)7{ zy7o~iHNuj<&tQHmREdEL)t_7~haUFgoW~Z7)+#^F0E+=>B{o_s@dJMxv4Vej%{fRA zS_@fCgA|cFhT5mi><9zwvoSYM?}NgMIAs>j+YSx*Tx}RYJXwGbH`(AYKWIl#59Has zNC;9zN8NR*Kdff@ltW3BC8n*sKu<*J3uhPxT~;jh@x+lp%;2jCM|a!2PWptfcV$+f z!qFJs*goz~WyDLihS*~Qr0OHlFhhV_gqO;_rAICP>nPDvT^aN<0)r(gA41#gJCeK5 zFW#kH4>L>Z1+wPt%6%^HDAt1YFtLlbOR{Qy{%;CZ(W(SqsE{dkyW%MZX9D^`UyeP- zbqNP&O1OTqNXF4&MIO&9e?R6Qj_rAsKFljfj61Mo+u%aG%$FU=WT2~MH4?g``t4sP znek-d1_^W~5xqVp3%t83_HL)Im`GsB?GHbxu;=DKal^vFB%Q`z4dVbZ_6_r8X|H17 zrM4lKkhid04Z3GPTTReb?dBUwlLeDR?(MfkFuz%`+Ye{V70<*xn)9$sIg*CBTw~}{ z#{4)hsrUHF@Zy3u^tLA_!X@~ZgyLbjytt2yHe|cWNsyN_3pDvt%{L6rxK)J}3tEyj zy7(=>6u5|Uj7FW=o z8i&Bu?DcN|y4Q-ugV%CoIVi7mL_hRr1R-?w6D$$pA!P!eB0)~n%Gy`=E9`6{>D;Ce z28LA_l^XJJG@PAOb}#(LBEJB`TWk6mCzH{SY-f^3%%p|YBY{t*4?)Ov)3w;^LZqy} zRCz~BzXLO+&99rtX2(gS6E2v3c#Y*iDsV0@y{6{h7V#hCq?nBw-b^IXD=p*>#TUcc zO)KZEMt@GTHT`KoA_lK%$eqb^tY*@muVVgj(GAkH39@4UmKj>tu)QrZ)}#v;-OBRwG6LCWEGyn`yA>L z8P-q=474JJsk%>oGql}SVnogIvDRPUGZvCxZM-Tlvuc2s9pp9rgo1kN`JW3sV4d?@ z47XJzA67PE>XA+|Q`AvJ$Jhlo9MJRaxtl&+cI(0;hAp{zBXsV#zG9@&43<6f?)y@LKdy~FFF9?m>n^4;-RV!1?%>mR^qPhF`< z_7N%l3;XyN$aaIiD_`&IH@_VE0pnbUqJQM@KhyO}R??sLC6fz0$S>kR{KW~04cIptmK777+1(}T@ICgXOT1Nb6wZWrP! zcrFP^^sHoQ`L1LhI9-I1A#Xaz=!Gpjti)GyskZR}zDUdL;o0BGvi4-~3j9D>6V&D> zxWbSDLC+qi3+?YEYH>KidSQEBS)@~nl6LoARtwC_QAZ z9(~QCNY5Yc_>*I_Y3y;Os&vj9$Hcb>0Nye@b$U^<%ky5Pt-=IK^G3|ItKf1ohphmM zR2AbU$znQoZ|^1Q1k{4Yy3?;u`${yOa#iDPGRLO2{!*K5iArC}Jn?*FPIvwe;!1vB z>Eg~VN%{8Ot1O^UXNLV)H(}aC2eaiw2%`ZLMbuG+;9L&!jvH%u(9-ega4smlB)WfC zI|Iv!&rv-W*;Sj%XyCYeodT6jMT)9f1&y56OLhyC?eeAqr$ zHq(Qs3(=8#__`5Cb~Z9WaN;?q?(gLbz}t<6p*CYkxd_gS#65b=@4qy28v)iD8Gz_n z<;14O2;!jQHwKm7)v;hz{ZwE%#}95hQ@J@k(`iDZVaFiqNhD{3l4gFhiP0j@eIj-} z+{r%u@iM`_C?{tHV09xTTlB&|)G?B8K*C!;p*=!n4Z`z*9~do_GsVv_Eb(M2I1#P-u#Jfrg;4HeVg&<`7xP`H?$-(9@bsjKcyhhOUe z99Rgj%o)IE5TqDSiEuGC(*|sQ93C|aV90ue%P{d>%N{9Z-usZBYYDJC7-9|Kl+_74 zH1CxiQrgJ>rt(gN^04mrs=J8d2jLdpI+sxpNEA90=rHO4naH>XL)7MG>%64CPLMSB z>N*84>!*RNHngU3X+T-0m~DHDD)UF(13eflvUarvf8SAu+Bb!{x?r(=J1IOq*+Mz` z6ykWhEB1f`Cc$np+l3QK>>XR*tAE`%wD_f&ymV~jx7}yB+u-)4HE`|tno~{sAqhJ8 ze6xSNIJ=n%n|vX2rv}AM36e+L&0IX?7fQdoWG(N$__`;#3X2y*vWl)5wwfYjd%YT% zhH)NtjhH-?YSbbV4iPinlRhk=6V9M61JpyVp-!(nYMB%=-@hs)fizgA&dqIP^MAV_ zp6(xp3kt<=>#u~VwAhG#?6Lh)sy6`5_0-*5E6T@=i=>2=CtBW^yg|4<$fVI|@i`C& zSE1zlkFYv)`H-Flyr;6sX;6pr91+aK5f$0|c6w@zWtV8E==&i{@PKeW=NI~Clq8lT z8CWKD(a#DD9h>rEQQwG@Q!Y+_1@wsd0AR`)nGagmK~K(=gm_DdtsSHpB|S(92jOu5 zfy~L#5xY!9qhTus<&J8)j>fg{on0warw)k2A)w}xx9A({j)#1{WjbPyP|Luiwm6!r1J!)a{kBAjm|0fi&gR>Us&!of zcw07m9eo^1C+Vqb^N++QF}UxqT&z%jUzN)xOBZ8|WZ~hpnYNqV*{);|F|_lPjHGod zsj^^U{p$|Z^iPe|$rYHOBvwQ#CXC6A&4OJs1@*^ zQgR>c@Pq^7vO2~On@Ne@554u}7UY`w`se)w^g%<(YzxAEJT)?&QSPkk>{GzGI4<8S z-K^Y8^roi{;ajKefI}Cwssfs>nNeU=lb-Xk?fBrM?XvYMb0#dHNdL}|Wp*?KKlWj7 zhy7n=Q9`R+42Dcr4Rw82?161RmSX|j5|4^dg7^It(65#JtJtXhgNN^4Ai8xNW6CXU zyYd(laq0v_8ep&}cei&|=#nxhM{is-jH{pa``9K`xIGpabUkxra<~p`_-?TTlikRLup~*k zH-!6Q(GDK_=TXPQV)?5hI+qYO&P!I%E4P9dNV zFwLb6SKc~{XX%*Uj>>b!wTcDaci&+G3Xjuow&)Qkb7Z0)5Z^MY`DqEeMDXo;3ti33 z`yxyWd^^DPO&ou*amR&3Ly31dNTMfVAnXA?kIeyLlI`nKb<(eV5s^|fbL!_!ihz*l zbihdvf;*pvf6+$^pj46)Xle%*ZDrFjR^OG7_On8{!tjE6Z}S8Ge4!%jdw({r1qte)oe3Nbo)^pmUk_p#w`;hIES2SodT3A*_2V{!U-RH+$gPe zMMLK2DhBq0ZL*5w7KT|6ld5o}QlPkB3eou`-h*0__*+Vc08=}Rx~<=%^445R^8_Qc zh$=NPZVCJnRNZx57lIinn}FIX{hhKHxv<9fRTqYBFQC!LsjPUmgDOALXw8izHJ zB7sT!w>8$t{9m#SuQOroUduv9IRxp?iDb+T`{xKG9WEYpp|ylS@z_^m2%@7Pb?H;{ zQNiaqckfl&PR&mBN?rXRm$^p~snIl+DKk>RGDNZmw|#Fmdz*@QESz)3(1D?9 zh<}-k$jfDYZM;TpBeWDr?WE$$P%UD}B*WOT-?!KlmML`7qG{?3k-lplwvXU^^hT8> zM?;17(TvV_aNwA$CZx?Cc(vWczqwlCFAwFoqKq?&(UTRI$cmyL!nM121fXAG(Q#hg zV{}!I+!>DQaWHRf?2aI>bXtc&qc$Zk@?TGC!+TdYP7OA9Hzp8kQ&w4KD3zF;$nJFM z!YJs%pFT{b#YhQKdiTN7!=%2d)4~*;O;>E~;abB!u8(=qysmEggK)zX3hFWO_%xv* zW=%H~h9Q?@^}4QbCyHi3EVQT6Qk)6*aO5eC`5hVKDeg{qJ~cpeqltcp!Emhi_D-AF zcb{i8Sb@5!vw<}z{_k(P3ROZMR4dK;x4ik_iN6w!V=c3GCmc@avSQZ9Skl${X1i*K zU)w|KG@BO_J3S)E*j-TMZiBtUPV*z?XN&jUyrnSL$}8?WYL}eXNT~ zTs0NLeoJ}WETHMj$3tL^KIz;ZgIb#s_s8*isT52l`Ii^wET zA_hv=01pAp);{&F$0u&LI|+yY?9WY*Hq|A%TS#m=VQVetP`r`;qH)6P)*<*b$fsp88w6_gFsr7Ynk9j^UM;*k3DLS^KeF zHQ(QBSnfiH&A@TLeEC0rQ{PF8=sG~-ymLo*bW6|EJVk`-RCgZIPbnnGIBY$`Mr*CG z&=&}0K|ft>IP$5};IUnP_1JLcX7iIZTq2W?e^dUg>wXeiXqTdR#UDGywBp!0e;zWV z52|FskMy-3PU=eE{$Uz%9AzRS{uSTz;b2RPX)vdjev#TeLa3V-xhGv+s#}BadsiEw z+hPefCd`oWKNDB$yg&q9mFUXuq2l6!Kv1VVdd(y2w$?Y>=me4p_>HI?p%_YT3QtGZ zWfS@CPf2d6;+w-*RqpOk1LrslZ6pk_!h6l3&S}$y$FGy^C%w9ZHrhvS{WO-YWdaxE z)!H0j|4c?>m0LvDKCa(p(&E9@HiBZNC|*;Oy$jemy00-R5mf%R!TWPrHe?u23y=V~Go1NLaa{ z3~72qTG_~hrrND~?$%5Qpx1z<5Wvgk+ziO?C+zIjB30e!8jOFb5V;>?iBxkkUeiDJ zMf&H%svZe-w-b5X!(>^IMBr$`u)HAdM=8Q+^PF_Aq)qFGHutGMX(jTys}^P2W}xMi zHFeowZcpR+MUG=8I%vi$UW;>2(i8y+M9FLMA~rw40aP`@z~3|*yMA7)A|x1o1lVM= z=|o^b8>XIhLkuZT1s@HYGc70{wnE!$pctzReAOFir>T}Pmldxk(GBndjx%KW;)Abx zoNraB>24ZecZ^ ze$g+|CBWe)ewX|}yG^ojw_l-jct|5fcv~{I^k_)M_re@oAT}6VLS7d*K$*SF>bukY zjV~0Dr}JW5%1d{vZ!aEQgaFf~V5&3i?im=&`YFbbVU-c6{lbm>(T-*NWB)1;8e}v3 z_pOfY2#nTWTS{|BVEtD~#=6$+#oL=EEO)m#W;m(+t!znUdB(jo~(Il1gYBx%$h`TAnbvrDrq~ z+Mvm}kx_k2-2Un5VRU0W0qWb1c6MbjZ67ZGCY`%L#U9OIb5i5H(5S?k66epUOzqX%;4A30z%^dm8%T&5A?1E0n8eYl zyE{{mg6k}03J1=#D7MT3g8K>||dDq9Xahnq`dHP0$ zT5kBCQ2*QJ5X8r&CQtCs=0FVbZ`khtjN+-=+ql@8{*TY$-`;=mpYFfNN_o~t8T}lj z(;3wZ<7BWK)RFN(tFLZ2LX>*1D5mc!qik1`+dKOmQ^()y~bZ_q8 z@ZIDti_o~WZyIR)G}9wHN~O5HeJVCt`+HI)0e&(ndX05e868~9G{@{bQcP41i4Ywn zGp8W?+2=`gLq0}-`w1Lc!$FWs)jfSAyJUu|9D)zo1m?u&R4gCSwXS#N0=Of?e|^pd z5h@B4YI`KV0wh5L*Yxh(aJbiS#@PD}v((q2zTRvV6i!h&GkFCdcm+@AUs33-HImIr zeM_GswY*oKDdrd;tZB&vL5+%-`%}85|Hd>%j7;OxPc}ooecH~z@bkErusA`HdLRj} z+%}TEvl|rMr+#6aG5onFU=g!LLAlvU1sKa#mgk)h(-bp*{))UIh*V;n5cJURRa;MP ze+j#L3}H$C&KWjVuo%{$CJ8W5g`z#j3MI~Sx7(f25k<~b)+dIF$wZUH(W*VEk!JVS zD9BD>C6nje(M6G*)MCK`Q+WZ}CS(n5ib6Mx6PA^gBFfWzWd$u{gc>Fkd3QhuRWq0r zpzS52fgw&~WRy&hfxO}Fh5ws6nq+`qdTpQVHyCMQG8F;rR4#+t-7?r#)_5G^PVAbU z*V;jE&fCKz;nxK~J7(aGhd6&kjdLzNS9C&?P(OIS=PJz%Bt^DUSKJ7jj&pvDFWKF2 zyqeHpcDkSuzm)N%^rIO55(eK`69mC{R6Uj#ad>6dL33F&p4S3~vstMa1?a04e}$fY zOQq`>gvpbd%qj@jU(Pe`&kx8!(=87P4xXT1Jr&vC+SFax4MxS-J94cg?xknW1){rJ< zHXY1DAv`rr$j8?*i#w0c{*-C>BGcsxx0NrPX+aC>43ud7x)>@qqmt@!!8pk;0K3~a z8|i5|)~Uad=J*{#U}_Lp{OX2z6m6dMg2;QoZU7nu(}#`y8BymDn8;{DDxG7dfkHS>imVZ|F)`eepUXPiOg5XQU9t0`>So^b#ge02FBzMb>ei9b~3n;ypl@&zt zhVt@AX?+pQ)<2JoLBR3kshQ$SRVd>uDdTRWg&T!+TIb{2kXqSi?9yqRCQ=H*Cg>)CYN7d49~=* zYPhw%GTrS!AQ+@_HUGq>m%_BelhtN3R-a#1LYcFn0KP9D)b*T?e&(o?y=KCwWAMxM zeit6}reYW1g917sa1YU?=A71q4nhNYNR`sN7dS}m3kfrP>~|W6Q1LI;sV?|V zowS>olJt{-;AL_4Dw~0B2|VI;e);;1v21betS9FqTz+(g-G$K-3;MUHZwiPr0B>d- z2{nU4f5L`^nqI%gKYQEXi<@G0TOn4CGij26>u4b{~iGY>wal*zTKOyLO>tAi~$0tn+rx@%qDad5DGc zX%eGTY-ZOV_==}u$Hm-9rKyP?`>m7sT`KmjgXBtVYu|C=>kRUc{^8`I6 z3Xe#b*}oGB*GP1W%t9q)LKmj#Y0@^_mK zCB~@LBcQ;Kyr!+IcS03GqRS7l;R}|$#p8Zh2d~J|9aT0W*M52}xc1E$BnqXs>sbi{ zB&`7GEhTo{Yy!8_@$8@ftX_+jnN{{uX-%MA(qqHT?=p5Wh5a%iUuddnLf&S@mS8xO^Kx{*6Vvq51bC z&5Aw=X=n>!$9+LHV5N_jzqSMY!SwXHpa;IVNul^Qjrr_8(%i{`(^H`xrESo|s7RMm z-KN1fH|5KN>}M2tiATPhh7fgm=?xQ~n_!wyCeu1Apl#t#-By3wU>C_0inZib^}U5p zja*0|`_buqco-Y$a_M|R_1LBHX`xSR69WbeR1=Fy5G7&zTglZw9E9h{4SPXy0CGy! zm(XWFGJsg@#t}E)?OBWwSnGzJ`6LP@9mFFM$0F9-ZV!-j^+?8$B>ASTU;K7LcV#6? z`66B|V_5E=)JF8_I9KPZGJ7?6D#RZuy4N4A6Nb4m2G^iHaGzVL9xb#(M_CY3ohO4!y_71mhn~XA?)ZI_BQ+CcXIP?B)I?X}ti&5w zZasup(t46%4BGuNrnN2meiZNr{SCrzOoC}BIb<8~W;MP2PB-N4ysNqL|FrkjQBkf@ zzlsWigo2cm(gM=b2%>_tbeBW7(jW{HDlILIgh)tAcY`!YcT1;q+<856oS7phJZIhS zu64h?*6Sbm?B9+j_Oth%cLv!u)m$`gnq2GI49)xghEAy`z6~jIGtw#)>7#u&!_>4S zdIt+R)^!Q0(o@#0Uto39baGIU2Fv%9;+H#KRUyicoz-Ix51SH^&=8wOCeyJ?fPUC$zs{vTD8;Z25Tpx)VwPuiJN0>ilxz~wC=08PJBY{Lf50M3@EKz6w9cQ z&pjS^@+eoCG#P7M@y2b+yBcjxc($z0+t|$p#hFw>gk96t zL?$CH2=zXT?xMw@z9Po(+zzD%t`2lR8ws_@t&3}Z^3`S!e8}(C$J`69w|F#lzo%C4 z$)lq?S*xu?syuJ5W3mKO)OQKWP*sLTlx{yLC?A>8>Kph&r#Q z+1Z8hD8@@Kxc2CXGFsw~oho-D%qZ$L1X;joyu+I*<5FWaeow%lmaPlV9K>8by>M;5 zj!35+BsNs)AxT%W;k?}o{lgt41`|EC2@bXXLOrUw$C&k74yyxS? zm+$8r#7J%GUt{qe=G+coy85E{>E|zr4X6Y)x#Gp{@3i@5?5NgtDk z`TjOt2n$IX}$p_k(aiZg9tZT8vAYsiGf%;EcsrI`j=m}!WmF4af*6W&7CFfAVM z%$3U&XsKLJ$LY6FFN(<&y;)Bm;}y3zN_a(K6{kxwiR{sESXP8L2l>;Rm<&7*8pg;O3^7KesPsxFxWK$~umJ&!CKZ zAnF{gjhBfZW}(7H0vg;B*H->7Yfu7%0J9YKe(ZfRLur2$N zr_=!`D1%J7$|3ZeXL$zOya_^$YuhY@y%#1{`kGPvpRfee6dKjCDTt;^ZH%=gTp{eZ zN8C}KY}2y|FJ^shbg<;hCLn-P@b z=&JWb!lKLuIbfnr`^6_JFkPp!9pT030WI!CWsBWHP7qwsX=fmdjA={E zrhv=1t&ST1sWm=-9Ap{u+AJ_%Ai68V%zf{n0T*^}g_9K@J)srSz>H}FTJKc7G1>B7 zoT~{7-K)FAeWf5uG|_5~rWSOaw=W`#jd5{`xn%Il8LMq3<_o=aV=w?jgmRDFT{2AjIP~$g$R=3|T3}s9)6S zi+hatA%aS48OM7sCNDC%NS-_bWvkmiZeroq7g9`rR^I2Fxd!iVD*3*8Lm{gI*5Em) z_c#Pef^5_M^d0a-ldW)uZ0=QMDPSk3Zk?5mrr}xM5a}%K6-rxSoO-R&F91%yM9`IH ztM~zbxVf#u=0K0oxFhjs|FUTmLw#<1QVwMn64io%`LwlFx_n2~Uf^4Ou<><-BH_2I zO`1;G7hSeo@KHy(Bdg08ur9PwjtzY2&IH>NwIGAdzgCb4H}Z%|tVvGSbfvH!u(%|} zw2j}L)N0o6zrl133t_9od#7^ESXTAr$E85d+nnJk3ZiRGcLP(F_7wGsFU%U9A0?|5{iS}rd z#WPon{8Xr}X&B`c=_c-s8@4p7nvgGZk_so|x3t+E^RMZ1L||X-Yhn+t)3ARU-f<>< zz2S9@2FkSBY?*N{la69ChK=IZW6v0dY{$iZmZo5m&FHa%pk&2CGs67QCYLyGdc9GC zHc7`S{IOiOkwXG^EA1TZN6YsVZS&tAS!XB6n{g#MP~3AEK_h%`Cb&2E&^q1Dcie1J zb5BX)nJB)qPuN{cc3Hi6U7I^O1ogfHjy+F|^j?)UQmh_5t_g2-q8{E+FKIBVYYKXu zo@1jE%+kW4`^ZIR^xek6lIh}sg@jl#-gBem@QaRfcQbtN9?-EU5{8L=Mwgu^b}=Ar z0W*ur4`X)BQ(X^M1Pz1VQ_Zm5U$tDuc+M?sEIn7}?-8!eUuw?D>)op^G2X|fH_}2< zxuNDn1~z}mQ7rbHU3D9zbu>y^3qpv`Gkg+djWuA(d$qnb!P5_`Xnj&v!PnOAk+53*g=kfr z6wxcZcrk+F0!R7;4Re-R18$mve%ER(uySoHitzl5S^JfTVlbSxDtFF#)dUPwZfX!$ zJ1pQbQQ6Y0+CxWAYZW~BK>5)E~`#*@MT;iK5?=zw%a9T zII+KHM~(-THEDN2xMYII)zz=iyhX`TcsF8=QLfL&fQq-Uq$%ICexxo(N%FN`pO0O} zgh6Pa2gZt^h3cpb-s^5>IbRk$Enc?|42C9R=%Vej!&f2)&rsg#ip!b4r3m&14fyfC zDZCYSVK7*UBjG$#Zy8ySkb7gcr+4(V=9Ca~-37@pPg;vHPHEq!p^4W|l7;)~(q1e# z&}4~EqrXsn=7uKf{U+_bz}E!vcJ6DH!5aJ7CG~XCp%SqNrIY%t==Un@(CE#Seb*T3 zrF0{ShXWgOrFB&aIPmL2pVk-=DU1+bZcPBU~7+SY99dBW&w4~_CMS%o^hL>31O%cZ_g7^7-JdxwoZ?tJ7cfZE^Ll+Zj+}W>7wwe#Xuf!B>&gbM)C^ zicVs%yCPMz%Ll`vqiq6TY-T5$^;Vd*Us0=n``4F8-Oq=>sjqqDyg&zDHG%YO#d#*o zG0_e$rAB+kGY~L{BN*DsH`KluY+RI2LVA>EOHx(RQg~>C$t}T#9O!R0c9q!ebw{_k zF;No&nHddTM`A4jQJzHbyIsEe@q8J7In&_xhPHm;Ro1e$hLg&flqI1rvzO$l)fUw~ zb+K4XMd?F7HwiHxgE*Dt&Y2KTmjpInT#}3mXWvnJhGx=uMUi+5ZSIDytoo#jmBr?c z9=&a*OfbeWN?O2Em(Wh_bdJly-3l`JOcWT=4n3f@)yp&JuQLtf*nA8fIzO)wso%C` zHd69g9noPhFhSP3pL25^`%~ujyA%$>VJ{8R4b2d90t5^ab?2~+{xH5wiaCI&~oG!84&N!X1Vsskz|Y^ht1M~&)ppn|Dl|y zP`&o*%=_mB8Kc1rd_!0la0Toh7qdA=4QBCi1d#dQu4l}Sy3On#O_b%`irKxeI`c_p z^`>{M>w@7k5?cc6{j$4E#SdlO$-O}0mv*(9j0)@x*V|`A6-#Sgw#o7An9bTA*q4#? zMrw}@}S{Q=&s)LTeY(fK{=xjxzeD`W#paVPiETYBaL1+oEl0^yfh?XH(}y`~}Pe8N0-CZNq1 zM}!ZdKvcy&*oXvVI)-#5ag1U>@`VSJ9c||QQ5UD@If^l3FLWlP6^@%@UrM2ctBLKt z81uR5#HJ^aZ z=Y-+?2rZ5xJBrC-pxwg7k1Sf%%eTLj)*wA!Vxw!ggEp+RPqECku)3siPEgl!cF@F0 zHGZ-pr6RG9Nub@bPrK|%kLHM#g{fn)(&8&xmvAMnDa^ywts3kn`^h)?=C)m*M-r`1 z2~9}Lo^{%3!Ctu2tku|`YCiBmw5gSJ@d<|~zFFC1IiKL?VmbN)`u?nYDfbiD`ZLWO zF{1m(Ym+Wb?9R4meU_bPbWnFQ_w?b94WbE+!c5z-ztS{oTsstsE^Gcq+(*kEwIumL z*fhmNPBQ7Nna9M-!DKOMB{GSdJVN6RDtGn@kQ~mfKOt2JLnC9pEW3|A%KlkkOFTZI zRq&khEqbw~^%1PQ5}4CQU3Q-ZJX(Fg9QDBpcyIRT}WY;U}!OfEI z$$-d2I#rtSzM044T}tSLbOb>)L2!TGKoHU7%dCxV<_@!_^Tsqbv+%DS%f>xO4uSO8 zfUm}cdhJ-p*x2Itb{yot00M>q6tRdqPjI=h;ZfbA577b2k*W!%iLs`E265@}vC$zK zveH3saAP!r0#Gn*M4N9)T=2xzBgaWV*u5r5$%RrU7|w-~i=vG{FNlbUDDWmk^Yf9^ zK2qnA6hEORDW>1~ePMrs+?c&B49f!y>-~QJRetA6O+IFE6wL?KQv;Ie$H^vqMRsn@ zi7yU!nh6%SWC&0{9=e@@dz(6sWBzk2L|35A_`@s#W3$IU%=+GC1cm{$^f?O!feE#4 zjo9?ggFI@>%X8$drk-A|fmbu)u8(whdhJ{-%e1nRI?oyC#MjV{3g((FQ)~%c_@vq# zyzn~DpQ@(I6(m|>G{L#o_0Ge}c3JP}jlf7!so6azjL5__YCi(yq7L>wLI3%dSU;yw z$1`U>B#i29T5XHA@0+&^m3!Se@ZH?^E?ag&P8gl-eKB<$k8$P^`rJ0Y*512R&CVF#Iy_4w!MgRni2I!~Lr!Hgo{;#G_mkTB2 zx5+z4Z}8v&fe|m6Bo>953A=(C8J2~CqY9ZQ5*Ue(g;o@#M@s@`Km%E+9j;ak@qjMS zdOeD$XrP0$p;b&MBp9MmJLLI;4b0Fs03!ebMg#QAHsElfyg>YB#1uk$w*!MZcd3c6 zk<(6emkqty(4Ary2FLE{VpM)cZeFLsnbPa>cJdPm)7<=NRcYS+ER-J>jEz5eqQ~@g zsNN~$Bx9$hPvp#{$)LPN%SsGqoV&ZYl|4jNgtI>}?l!b>zzBeV(E$CjaqqZL1a-d~ zM{mU-Lxh-CA=W5NqSh?KKuuoL(^)ZmO|5REp}V(luvNL~f?8c)UmZ{1l?b%EA=Ze| z@D>cbqOp~O=;H@@{xCi4&=vqA00Kq>^lvPv`@Y1|85Icl{I2UEih{W%sYf zqY5C-`P`W^d?)LlnE~|F%-n*(+*r#}he_iuBa?;&lZFlCQyy@C&^XyAmIk`ICmRKT z0_a~`0dvl!A2U`s6QNq~N-?5PpM|r+w+}p`3K99QtYD`9nH2ymK)*Z}FjmmNkUDeW zc--08j@*-SJ*WsH287M=f{wssne`*i_3($XO(0ax3@$t<-z1T;5XYIHU;O1uaT%}Jg zMBXCgp+4CzEProb06YKz`~dw_XHyx&gntr1MCX=J+OU@uy_6T`N?C_ylpc3x)TB9x z(hivTrur{y+`E7!XF)sn$WeWe{l$971@!s@1&*D4&IqD>*_@B8W%I1HDgAnR<^H`| zUztI-z6uvz*&D1Hy)e|$-Ac=%P7SVLZX+d?t*T=;2TwJ_N1oaI;Q1klDc2!K?_awE zc7p*t00H~}{jxh?Vv%?EkwcPtiM7d$!OW7cXq8IRl*UDjKF~A4`Q?lzG=fX{UTs=) z6Riu9eoDEAPn&rWsmG!>Se7IX_Dv8~mY3W;azj(~Jje&c=G9yCu_&!GA8{BxM(mAT z#+RgAQlaWm>e@-U$xcf?aO2=>&5^HJ%dKe(N)3Is=G6vq)FKj<$bb&jXl1qO&`{aH zke;Yoq9PjjfIxKoPT(EHVz%4KC1)KJsR@-^{Rp0_y7+;06#$g z#{S$B^Z$2l;qYPkA4jrNw1v6QooM@iJ4{Vq z;#d0$@enEGv>-iMqPH19z_(yAXzS>h|GrM4uROqLfB;T_e!92*j&Fes2a=-xc7;Bh zJ`|A5pdeW`F-6LKv-9?jOopLpGaZuarx>jHaFPoqMb}$89cg72E&HTLdM&JQ92Xlv zqyCuffrO8r%`HVGdgMXKB}c?lAB(+R&mvDHh`dO1GX1nz(J8;b;JxRkW7m*PxaPCW;3&~{ zY1zz2zUTcV)-Vsc{k^PME2D><^8<`gxdeQ-zdU^*ZdbT_(Avgcj5C8Ic58q5&h7*P zLE?5d6*%S#%0x!NzDVs+*w-82x09`Ufu1ad8F%apN#9O5PEN@1G0&!@JZ3Lm8~~5C zC#d8IFf>ToVoq)BFQk~jx&G+uR74NNd58#qzJ_7rBQP2ufD@o!cKx^b2<3VgBW0~F zWf0{M@|H;j=NbH{m^9tsv6p0^Ae+KeD^*LaGTp5lbvPJ6oY*1rZjmYUIuQ3 zoJS_{(JPH((+T0usP9rJh7aE2FWZ-we4TxlS<|w7=`8GL!3~MVrEMU#etk3XJaFUoh4Y^;5bTnna|fskwU5yB#}w z)X|!#H_6j%|Blgl#G7h+h;Wn6nQ2L<0#U{p@yr>LlY@(m!9>f#$byOOr>7P$6d-^N zpr6|Dcdpei5h48;&-pnyV@eG3VZ7#5Zx<=XNAPJ)&Ii1gS`dMMsw?claH1=~2GD<_ zDWjQWo^9hJ$jDLxBk`7#UK7O+t{c6@)#oaPc??!PbV4!Yyka-8VgoZ z<7uvvpy+=%=EKAj+w-^(G%th%w;%@hgwLx(D3unYS{Wq=K!|5W9RXO1g1uYbL_ zTG1~Vb~KI*r!I8h7Idyq7tp!d5GjhH3B1l zFd|8=Ni1Bs(91|>aQ#7G@JOka9N01GxPMa#pYl?Vz9~~-Z$#x6EFA9ACg(7{ZEK4* z@k68*zLBlNhRaeRA;-&l75GuqUakTT*Gih?FGcq&;i$n^x2@u5b%0bt`e#05f$!z!=7D76dF~k|T4Z%h1S2_tJV{^DR@G8S>SADXK9#rce(7Llkxr#k zvX3usZ}tPqEH}X@rKduw1$}cn*Io|QV?8L#=1mJLugbNKn`l{*SLlff4QUPAR?zn! zqetrzzsdP-5HEztc26awREPVVM)Ni7{_OgsL{dC4^#-t+`t{85MV(sdEIx{|k@XU+ z5%#*5g)HFENgi{md3P22U~nICdHYVnB6kLcmwRfN{aOZN_|kLXgO*LS+|=1Wb5GOigK-H%>{}{7>wuk{9 zfB=4ge!7VN&TW(u(u6|fH;(&uuSOqx9D8ZU4^gmN=AF}3Zv=an%8;X%MSGBY-xp*= zl|7RAc_GMc>H_4diVBeon;s1F%hcFHRQY@6z^VJ-U`YgaUil9-2)cy+fba%tHUtmxnDf6G}db(es_X6P$ zP{>|D)cB5iN{}mlhAmfkRIM? z`klVcU@L^sc#n0z(`UU4sUzI1TJ{x53uEpusHkPtojj zpFyuVjvrW%y-8~D9qbosRPfG(UWu?$o*VvSDW~pf==BLJ1=IK+OF4bNLzUEFrAV9n zV=1Se1+dBSkBM set[str]: finally: del SourceFilterSubject.__registry__["u6b_throwaway_subject"] del SourceSetPairing.__registry__["u6b_throwaway_pairing"] + + +def test_pairing_rows_keep_what_the_stored_pairing_declares_beyond_its_keys(): + """Rows show a pairing's keys; the source sets a table lists are not keys.""" + from openhcs.core.config import SourceBindingsConfig + from openhcs.core.source_bindings import ( + ListedSourceFile, + ListedSourceSet, + PairAsListed, + PairByMetadata, + ) + + listed = PairAsListed( + source_sets=(ListedSourceSet(files=(ListedSourceFile("DNA", "a.tif"),)),) + ) + owner = SourceBindingsConfig(match_plan=listed) + rows = editor_module.SourceSetPairingRow.from_plan(listed) + + assert editor_module.SourceSetPairingRow.to_plan(rows, owner) == listed + assert editor_module.SourceSetPairingRow.to_plan( + (editor_module.SourceSetPairingRow(method=PairByMetadata),), owner + ) == PairByMetadata() diff --git a/tests/unit/test_cellprofiler_backend_public_api.py b/tests/unit/test_cellprofiler_backend_public_api.py index 0a6e9298f..cde01483e 100644 --- a/tests/unit/test_cellprofiler_backend_public_api.py +++ b/tests/unit/test_cellprofiler_backend_public_api.py @@ -386,15 +386,3 @@ def test_display_module_declaration_and_callable_are_colocated( "openhcs.processing.backends.cellprofiler.display_modules" ) assert implementation.__module__ == module_type.__module__ - - -@pytest.mark.parametrize( - "module_name", - ("LoadData", "LabelImages", "CreateBatchFiles", "SaveCroppedObjects"), -) -def test_unsupported_pass_through_modules_have_no_declaration( - module_name: str, -) -> None: - assert CellProfilerModule.for_module(module_name) is None - with pytest.raises(KeyError, match="No CellProfiler module declaration"): - CellProfilerModule.require_module(module_name) diff --git a/tests/unit/test_cellprofiler_builtin_gap_modules.py b/tests/unit/test_cellprofiler_builtin_gap_modules.py new file mode 100644 index 000000000..9a09b61c6 --- /dev/null +++ b/tests/unit/test_cellprofiler_builtin_gap_modules.py @@ -0,0 +1,1178 @@ +"""CellProfiler 4 built-in modules that had no OpenHCS declaration. + +Each case below is a settings block taken from a published pipeline. The +shared tests ask the same thing of every case: the module resolves, the block +parses and imports, and every setting row is accounted for. The rest of the +file checks what each module means in OpenHCS: SaveCroppedObjects against the +files CellProfiler 4.2.8.1 itself wrote (``tests/fixtures/ +cellprofiler_builtin_gap_modules``, produced by ``make_reference.py``), and the +two modules that execute nothing against their settings checks. +""" + +from __future__ import annotations + +import ast +import inspect +import json +import re +import shutil +import warnings +from dataclasses import dataclass, replace +from pathlib import Path + +import imageio.v3 as iio +import numpy as np +import pytest +import tifffile +from polystore.base import ensure_storage_registry, storage_registry +from polystore.filemanager import FileManager + +from benchmark.converter.cppipe_corpus import in_tree_cppipe_corpus +from openhcs.agent.services.artifact_plan_inspection_service import ( + AgentProgressQueue, + CompileInspectionInput, + InProcessCompileInspectionGateway, +) +from openhcs.core.artifacts.kinds import ImageArtifactType, ObjectLabelsArtifactType +from openhcs.core.config import ( + SourceBindingsConfig, + GlobalPipelineConfig, + LazyNapariStreamingConfig, + LazyPathPlanningConfig, +) +from openhcs.core.dataset_sources.source_bindings_source import SourceBindingsSource +from openhcs.core.function_patterns import normalize_function_pattern +from openhcs.core.function_step_transport import FunctionStepTransportAuthority +from openhcs.core.materialization import FileBundleOptions, MaterializationSpec +from openhcs.core.materialization.core import materialize +from openhcs.core.orchestrator.execution_result import MergeAllRuntimeRecords +from openhcs.core.orchestrator.orchestrator import PipelineOrchestrator +from openhcs.core.pipeline_document import PipelineDocumentCodec +from openhcs.core.processing_contracts import Pure2DAuxiliaryOutputAggregator +from openhcs.core.progress import ProgressExecutionContext +from openhcs.core.runtime_image_values import ImagePayloadMetadata +from openhcs.core.source_binding_workspace import SourceBindingWorkspaceProjector +from openhcs.core.source_bindings import ( + FromFileName, + MetadataExtractionRule, + PairAsListed, +) +from openhcs.core.source_metadata import SourceMetadataFields +from openhcs.core.steps.function_step import FunctionStep +from openhcs.interop.cellprofiler.module_artifact_declarations import ( + SettingsOnlyCellProfilerModule, +) +from openhcs.interop.cellprofiler.module_declarations import CellProfilerModule +from openhcs.interop.cellprofiler.module_settings import UnmappedModuleSettingsError +from openhcs.interop.cellprofiler.parser import CPPipeParser, ModuleBlock, ModuleSetting +from openhcs.interop.cellprofiler.pipeline_import import import_cellprofiler_pipeline +from openhcs.interop.cellprofiler.settings_binder import SettingsBinder +from tests.context_support import executing_context +from tests.materialization_plans import output_plan + +_HEADER = ( + "|svn_version:'Unknown'|variable_revision_number:{revision}|show_window:False" + "|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False]" +) + +def _preceding_modules(images: tuple[str, ...], *, process_as_3d: bool) -> str: + """Modules loading ``images`` and making the objects ``Cells`` from the first.""" + assignments = "".join( + f""" Select the image type:Grayscale image + Name to assign these images:{image} + Select the rule criteria:and (file does contain "{image}") +""" + for image in images + ) + return f"""CellProfiler Pipeline: https://cellprofiler.org +NamesAndTypes:[module_num:1|enabled:True] + Process as 3D?:{"Yes" if process_as_3d else "No"} + Relative pixel spacing in X:1.0 + Relative pixel spacing in Y:1.0 + Relative pixel spacing in Z:1.0 + Assignments count:{len(images)} +{assignments}ConvertImageToObjects:[module_num:2|variable_revision_number:1|enabled:True] + Select the input image:{images[0]} + Name the output objects:Cells + Convert to boolean image:Yes + Preserve original labels:No + Background label:0 + Connectivity:1 +""" + + +@dataclass(frozen=True) +class RealBlock: + """One module block of a published pipeline and what importing it yields.""" + + module_name: str + source: str + text: str + steps: tuple[str, ...] + """Steps the block itself becomes; () for a module that executes nothing.""" + + +REAL_BLOCKS = ( + RealBlock( + module_name="LabelImages", + # CellProfiler/CellProfiler, tests/frontend/resources/modules/labelimages/ + # v1.pipeline, commit b6afe692e1728cae71504d19f3421d4895e83ebf. The file is + # a 2010 pipeline whose setting labels start with '#'; this is the same + # block as CellProfiler 4.2.8.1 writes it back after loading that file. + source="CellProfiler/CellProfiler tests/frontend/resources/modules/labelimages/v1.pipeline", + text=( + "LabelImages:[module_num:3" + + _HEADER.format(revision=1) + + """ + Number of image sites per well:3 + Number of columns per plate:48 + Number of rows per plate:32 + Order of image data:Column +""" + ), + steps=(), + ), + RealBlock( + module_name="SaveCroppedObjects", + # abestroka/JUMP_vision_model, rad_pipeline/cropped_cells.cppipe, commit + # 2bb7a079e59531191118856a125505120b0317ca, module 28, settings verbatim. + source="abestroka/JUMP_vision_model rad_pipeline/cropped_cells.cppipe", + text=( + "SaveCroppedObjects:[module_num:3" + + _HEADER.format(revision=3) + + """ + Do you want to save cropped images or object masks?:Images + Objects:Cells + Directory:Default Output Folder|Argonne/git_repos/JUMP_vision_model/rad_pipeline/segmented_image_temp + Prefix saved crop image name with input image name?:No + Select image name to use as a prefix:None + Save output crops in nested folders?:No + Saved file format:png + Image to crop:ImageAfterMath +""" + ), + steps=("SaveCroppedObjects",), + ), + RealBlock( + module_name="CreateBatchFiles", + # BodenmillerGroup/ImcSegmentationPipeline, resources/pipelines/ + # 1_prepare_ilastik.cppipe, commit 3c4639a34d02abfbd01286131fef58202a0f5b7b, + # module 12, settings verbatim. The published block is disabled; it is + # enabled here so that the import reads it. + source="BodenmillerGroup/ImcSegmentationPipeline resources/pipelines/1_prepare_ilastik.cppipe", + text=( + "CreateBatchFiles:[module_num:3" + + _HEADER.format(revision=8) + + """ + Store batch files in default output folder?:Yes + Output folder path:/mnt/f2160748-a937-44bd-aca8-3adb8a839612/Data/Analysis/cp4_segmentation_example/cpout + Are the cluster computers running Windows?:No + Hidden- in batch mode:No + Hidden- in distributed mode:No + Hidden- default input folder at time of save:/home/vitoz + Hidden- revision number:0 + Hidden- from old matlab:No + Local root path:/home/vitoz + Cluster root path:/home/vitoz +""" + ), + steps=(), + ), +) + +_BY_NAME = {case.module_name: case for case in REAL_BLOCKS} +_real_blocks = pytest.mark.parametrize( + "case", REAL_BLOCKS, ids=[case.module_name for case in REAL_BLOCKS] +) + + +def _pipeline_text( + block_text: str, + *, + images: tuple[str, ...] = ("DNA", "ImageAfterMath"), + process_as_3d: bool = False, +) -> str: + return _preceding_modules(images, process_as_3d=process_as_3d) + block_text + + +def _import(tmp_path: Path, block_text: str) -> list[FunctionStep]: + cppipe_path = tmp_path / "pipeline.cppipe" + cppipe_path.write_text(_pipeline_text(block_text), encoding="utf-8") + steps, _pipeline_config = import_cellprofiler_pipeline(cppipe_path) + return steps + + +def _block(tmp_path: Path, block_text: str) -> ModuleBlock: + cppipe_path = tmp_path / "block.cppipe" + cppipe_path.write_text(_pipeline_text(block_text), encoding="utf-8") + return CPPipeParser().parse(cppipe_path)[-1] + + +def _read_settings(block: ModuleBlock) -> None: + """Read every setting row the way the import does, failing on an unread one.""" + module_type = CellProfilerModule.declaration_for_block(block) + module_type.validate_pipeline_import(block) + if not module_type.emits_function_step(): + return + coverage = module_type.bind_settings(block, binder=SettingsBinder()).setting_coverage + assert len(coverage) == len(block.iter_settings()) + assert all(record.status.is_covered for record in coverage) + + +def _with_setting(text: str, setting: str, value: str | None) -> str: + """Replace one setting row's value, or drop the row when ``value`` is None.""" + lines = text.splitlines() + (index,) = ( + position + for position, line in enumerate(lines) + if line.strip().startswith(setting + ":") + ) + lines[index : index + 1] = [] if value is None else [f" {setting}:{value}"] + return "\n".join(lines) + "\n" + + +@_real_blocks +def test_module_resolves(case: RealBlock) -> None: + module_type = CellProfilerModule.for_module(case.module_name) + + assert module_type is not None + assert module_type.module_name == case.module_name + assert module_type.emits_function_step() is bool(case.steps) + + +@_real_blocks +def test_real_block_parses_and_imports(case: RealBlock, tmp_path: Path) -> None: + block = _block(tmp_path, case.text) + assert block.name == case.module_name + assert len(block.iter_settings()) == len(case.text.strip().splitlines()) - 1 + + steps = _import(tmp_path, case.text) + + assert [step.name for step in steps] == ["ConvertImageToObjects", *case.steps] + + +@_real_blocks +def test_every_setting_row_of_the_real_block_is_read( + case: RealBlock, tmp_path: Path +) -> None: + block = _block(tmp_path, case.text) + _read_settings(block) + + block.setting_records.append(ModuleSetting("Not a setting of this module", "1")) + with pytest.raises(UnmappedModuleSettingsError, match="not_a_setting_of_this_module"): + _read_settings(block) + + +def test_disabled_block_is_not_read(tmp_path: Path) -> None: + """A disabled module is skipped whatever its settings say.""" + disabled = _BY_NAME["LabelImages"].text.replace("enabled:True", "enabled:False") + + steps = _import( + tmp_path, _with_setting(disabled, "Order of image data", "Diagonal") + ) + + assert [step.name for step in steps] == ["ConvertImageToObjects"] + + +@pytest.mark.parametrize( + ("module_name", "setting", "value", "message"), + ( + ("LabelImages", "Number of image sites per well", "0", "at least 1, got '0'"), + ("LabelImages", "Number of columns per plate", "twelve", "at least 1"), + ("LabelImages", "Number of rows per plate", "2.5", "at least 1"), + ("LabelImages", "Order of image data", "Diagonal", r"one of \('Row', 'Column'\)"), + ("LabelImages", "Number of rows per plate", None, "missing the settings"), + ( + "CreateBatchFiles", + "Are the cluster computers running Windows?", + "Maybe", + "must be Yes/No", + ), + ("CreateBatchFiles", "Hidden- revision number", "latest", "cannot accept"), + ("CreateBatchFiles", "Hidden- in batch mode", "2", "must be Yes/No"), + ), +) +def test_module_that_executes_nothing_still_refuses_a_bad_setting( + module_name: str, setting: str, value: str | None, message: str, tmp_path: Path +) -> None: + case = _BY_NAME[module_name] + assert issubclass( + CellProfilerModule.require_module(module_name), SettingsOnlyCellProfilerModule + ) + + with pytest.raises(ValueError, match=message) as error: + _import(tmp_path, _with_setting(case.text, setting, value)) + + assert f"{module_name}(3)" in str(error.value) + + +def test_label_images_block_in_its_2010_spelling_fails_loudly(tmp_path: Path) -> None: + """The parser drops rows whose label starts with '#', so nothing is guessed.""" + legacy = ( + "LabelImages:[module_num:3" + + _HEADER.format(revision=1) + + """ + # sites / well\\x3A:3 + # of columns\\x3A:48 + # of rows\\x3A:32 + Order\\x3A:Column +""" + ) + + with pytest.raises(UnmappedModuleSettingsError, match="order"): + _import(tmp_path, legacy) + + +@pytest.mark.parametrize( + ("setting", "value", "message"), + ( + ("Directory", "Elsewhere...|/data/crops", "'Elsewhere...' is not supported"), + ("Directory", "Default Input Folder|", "'Default Input Folder' is not supported"), + ( + "Directory", + "Default Input Folder sub-folder|crops", + "'Default Input Folder sub-folder' is not supported", + ), + ( + "Directory", + "Default Output Folder sub-folder|../outside", + "must be a path below the output folder", + ), + ( + "Directory", + "Default Output Folder sub-folder|/absolute", + "must be a path below the output folder", + ), + ("Directory", "Default Output Folder sub-folder|", "must be a path below"), + ( + "Directory", + "Default Output Folder sub-folder|(?Pcrops)", + "uses metadata tags", + ), + ("Saved file format", "jpeg", "CropFileFormat cannot be coerced from 'jpeg'"), + ( + "Do you want to save cropped images or object masks?", + "Outlines", + "CropExport cannot be coerced from 'Outlines'", + ), + ("Image to crop", "None", "saves cropped images but 'Image to crop'"), + ( + "Prefix saved crop image name with input image name?", + "Yes", + "prefixes file names with an image's file name but", + ), + ("Save output crops in nested folders?", "Sometimes", "must be Yes/No"), + ), +) +def test_save_cropped_objects_refuses_what_it_cannot_honour( + setting: str, value: str, message: str, tmp_path: Path +) -> None: + text = _with_setting(_BY_NAME["SaveCroppedObjects"].text, setting, value) + + with pytest.raises(ValueError, match=message): + _import(tmp_path, text) + + +def test_save_cropped_objects_step_round_trips_through_pipeline_source( + tmp_path: Path, +) -> None: + steps = _import(tmp_path, _BY_NAME["SaveCroppedObjects"].text) + + source = FunctionStepTransportAuthority.source_from_pipeline(steps) + namespace: dict[str, object] = {} + exec(compile(source, "saved_pipeline.py", "exec"), namespace) + reconstructed = FunctionStepTransportAuthority.pipeline_steps_from_namespace( + namespace + ) + + assert FunctionStepTransportAuthority.source_from_pipeline(reconstructed) == source + (invocation,) = normalize_function_pattern(reconstructed[-1].func).iter_items() + assert invocation.contract.function_name == "save_cropped_objects" + assert invocation.kwargs_dict["objects_name"] == "Cells" + + +# --- SaveCroppedObjects against the files CellProfiler 4.2.8.1 wrote ---------- + +_FIXTURES = Path(__file__).parents[1] / "fixtures" / "cellprofiler_builtin_gap_modules" +_REFERENCE = np.load(_FIXTURES / "reference.npz") +_REFERENCE_FACTS = json.loads((_FIXTURES / "reference.json").read_text()) +_NATIVE_CASES = { + str(block.module_num): block for block in CPPipeParser().parse(_FIXTURES / "cases.cppipe") +} + + +def _read_image(path: Path) -> np.ndarray: + return tifffile.imread(path) if path.suffix == ".tiff" else np.asarray(iio.imread(path)) + + +def _object_files(block: ModuleBlock) -> dict[str, np.ndarray]: + """Run the module's function on the arrays CellProfiler was given.""" + module_type = CellProfilerModule.require_module(block.name) + settings = module_type.bind_settings(block, binder=SettingsBinder()).kwargs + inputs = { + binding.runtime_parameter_name: _REFERENCE[ + "input_" + module_type.artifact_names_for_binding(block, binding)[0] + ] + for binding in module_type.active_artifact_bindings(block) + } + labels = inputs.pop("labels") + function = module_type.require_callable( + module_type.function_variants[0] if labels.ndim == 3 else None + ) + kwargs = { + name: value + for name, value in settings.items() + if name in inspect.signature(function).parameters + and name not in ("image_to_crop", "filename_image") + } + if "image_to_crop" in inputs: + kwargs["image_to_crop"] = inputs["image_to_crop"] + if "filename_image" in inputs: + kwargs["filename_image"] = ImagePayloadMetadata( + source_path="/plates/one/" + _REFERENCE_FACTS["source_file_name"] + ).payload_with(inputs["filename_image"], None) + image = labels.astype(np.float32) + returned_image, files = function(image, labels, **kwargs) + np.testing.assert_array_equal(np.asarray(returned_image), image) + return files + + +@pytest.mark.parametrize("case", sorted(_NATIVE_CASES)) +def test_save_cropped_objects_writes_the_files_cellprofiler_wrote( + case: str, tmp_path: Path +) -> None: + """Same file names and same pixels, in the function and on disk.""" + expected = _REFERENCE_FACTS["files_written"][case] + assert expected, "CellProfiler wrote no file for this case" + + files = _object_files(_NATIVE_CASES[case]) + + assert sorted(files) == expected + for path, output in files.items(): + native = _REFERENCE[f"file_{case}/{path}"] + assert output.content.dtype == native.dtype + np.testing.assert_array_equal(output.content, native) + + ensure_storage_registry() + filemanager = FileManager(dict(storage_registry)) + materialize( + MaterializationSpec(FileBundleOptions()), + data=files, + path=str(tmp_path / "SaveCroppedObjects_files.pkl"), + filemanager=filemanager, + backends=("disk",), + context=executing_context(filemanager=filemanager), + output_plan=output_plan(), + ) + written = sorted( + path.relative_to(tmp_path).as_posix() + for path in tmp_path.rglob("*") + if path.is_file() + ) + assert written == expected + for path in written: + native = _REFERENCE[f"file_{case}/{path}"] + stored = _read_image(tmp_path / path) + assert stored.dtype == native.dtype + np.testing.assert_array_equal(stored, native) + + +def test_object_files_of_several_images_unite_and_shared_names_are_refused() -> None: + """Planes of one invocation each add their files; a shared name is an error.""" + first, second, clash = {"a_1.png": 1}, {"b_1.png": 2}, {"a_1.png": 3} + + assert Pure2DAuxiliaryOutputAggregator.aggregate([first, second], "numpy") == { + "a_1.png": 1, + "b_1.png": 2, + } + with pytest.raises(ValueError, match=r"share the keys \['a_1.png'\]"): + Pure2DAuxiliaryOutputAggregator.aggregate([first, clash], "numpy") + + +# --- SaveCroppedObjects run by the orchestrator on a small plate --------------- + +_SAVE_CROPPED_OBJECTS = ( + "SaveCroppedObjects:[module_num:3" + + _HEADER.format(revision=3) + + """ + Do you want to save cropped images or object masks?:{export} + Objects:Cells + Directory:{directory} + Prefix saved crop image name with input image name?:{prefix} + Select image name to use as a prefix:{prefix_image} + Save output crops in nested folders?:No + Saved file format:{file_format} + Image to crop:{image} +""" +) + + +def _plane(value: int, *, second_object: bool = True) -> np.ndarray: + pixels = np.zeros((12, 15), dtype=np.uint8) + pixels[1:4, 1:5] = value + if second_object: + pixels[6:11, 8:13] = 200 + pixels[8, 10] = 0 + return pixels + + +def _run_on_plate( + tmp_path: Path, block_text: str, sources: dict[str, np.ndarray] +) -> tuple[bool, str, dict[str, np.ndarray]]: + """Import the block, run well A01, and return the crop files it left.""" + plate = tmp_path / "input" + plate.mkdir() + for name, pixels in sources.items(): + tifffile.imwrite(plate / name, pixels, photometric="minisblack") + volumes = any(pixels.ndim == 3 for pixels in sources.values()) + cppipe_path = tmp_path / "pipeline.cppipe" + cppipe_path.write_text( + _pipeline_text(block_text, images=("DNA",), process_as_3d=volumes) + ) + steps, imported = import_cellprofiler_pipeline(cppipe_path) + source_bindings = replace( + imported.source_bindings_config, + metadata_rules=( + MetadataExtractionRule( + source=FromFileName, + pattern=( + r"(?P[A-H]\d{2})_s(?P\d+)_w(?P\d+)" + r"_z(?P\d+)_t(?P\d+)_DNA\.tif" + ), + ), + ), + ) + if not volumes: + source_bindings = replace(source_bindings, source_stack_components=()) + config = replace( + imported, + dataset_source=SourceBindingsSource, + path_planning_config=LazyPathPlanningConfig( + global_output_folder=tmp_path / "results" + ), + source_bindings_config=source_bindings, + napari_streaming_config=LazyNapariStreamingConfig(enabled=False), + ) + bundle = ( + InProcessCompileInspectionGateway() + .compile( + CompileInspectionInput( + plate=plate, + pipeline_document=PipelineDocumentCodec.from_values( + pipeline_config=config, pipeline_steps=steps + ), + axis_filter=("A01",), + global_pipeline_config=GlobalPipelineConfig( + num_workers=1, use_threading=True + ), + progress_queue=AgentProgressQueue(), + ) + ) + .execution_bundle + ) + outcome = ( + PipelineOrchestrator(plate, pipeline_config=config) + .initialize() + .execute_compiled_bundle( + execution_bundle=bundle, + max_workers=1, + runtime_observation_mode=MergeAllRuntimeRecords, + progress_queue=AgentProgressQueue(), + progress_context=ProgressExecutionContext( + execution_id="save-cropped-objects", subject_id=str(plate) + ), + )["A01"] + ) + results = tmp_path / "results" / "input_openhcs" / "results" + crops = { + path.relative_to(results).as_posix(): _read_image(path) + for path in sorted(results.rglob("*Cells_?.*")) + } + error = "" if outcome.is_success() else str(outcome.error_message) + return outcome.is_success(), error, crops + + +def test_two_sites_of_a_well_each_leave_their_own_prefixed_crops( + tmp_path: Path, +) -> None: + """16-bit crops carry CellProfiler's 0-1 scale: an 8-bit 101 is 101 * 257.""" + succeeded, error, crops = _run_on_plate( + tmp_path, + _SAVE_CROPPED_OBJECTS.format( + export="Images", + directory="Default Output Folder sub-folder|crops", + prefix="Yes", + prefix_image="DNA", + file_format="16-bit tiff", + image="DNA", + ), + {f"A01_s{site}_w1_z1_t1_DNA.tif": _plane(100 + site) for site in (1, 2)}, + ) + + assert succeeded, error + hollow_square = np.full((5, 5), 200 * 257, dtype=np.uint16) + hollow_square[2, 2] = 0 + assert sorted(crops) == [ + f"crops/A01_s00{site}_w1_z001_t001_Cells_{number}.tiff" + for site in (1, 2) + for number in (1, 2) + ] + for site in (1, 2): + stem = f"crops/A01_s00{site}_w1_z001_t001_Cells" + np.testing.assert_array_equal( + crops[f"{stem}_1.tiff"], + np.full((3, 4), (100 + site) * 257, dtype=np.uint16), + ) + np.testing.assert_array_equal(crops[f"{stem}_2.tiff"], hollow_square) + + +def test_adjacent_blocks_each_leave_their_own_files(tmp_path: Path) -> None: + images = _SAVE_CROPPED_OBJECTS.format( + export="Images", + directory="Default Output Folder sub-folder|crops", + prefix="Yes", + prefix_image="DNA", + file_format="8-bit tiff", + image="DNA", + ) + masks = _SAVE_CROPPED_OBJECTS.format( + export="Masks", + directory="Default Output Folder sub-folder|masks", + prefix="No", + prefix_image="None", + file_format="png", + image="None", + ).replace("module_num:3", "module_num:4") + + succeeded, error, crops = _run_on_plate( + tmp_path, images + masks, {"A01_s1_w1_z1_t1_DNA.tif": _plane(101)} + ) + + assert succeeded, error + assert sorted(crops) == [ + "crops/A01_s001_w1_z001_t001_Cells_1.tiff", + "crops/A01_s001_w1_z001_t001_Cells_2.tiff", + "masks/Cells_1.png", + "masks/Cells_2.png", + ] + np.testing.assert_array_equal( + crops["crops/A01_s001_w1_z001_t001_Cells_1.tiff"], + np.full((3, 4), 101, dtype=np.uint8), + ) + first_object = np.zeros((12, 15), dtype=np.uint8) + first_object[1:4, 1:5] = 255 + np.testing.assert_array_equal(crops["masks/Cells_1.png"], first_object) + + +def test_volume_objects_are_written_as_one_volume_each(tmp_path: Path) -> None: + volume = np.stack((_plane(110), _plane(120), _plane(130, second_object=False))) + + succeeded, error, crops = _run_on_plate( + tmp_path, + _SAVE_CROPPED_OBJECTS.format( + export="Images", + directory="Default Output Folder|", + prefix="No", + prefix_image="None", + file_format="8-bit tiff", + image="DNA", + ), + {"A01_s1_w1_z1_t1_DNA.tif": volume}, + ) + + assert succeeded, error + assert sorted(crops) == ["Cells_1.tiff", "Cells_2.tiff"] + np.testing.assert_array_equal(crops["Cells_1.tiff"], volume[:, 1:4, 1:5]) + np.testing.assert_array_equal(crops["Cells_2.tiff"], volume[:2, 6:11, 8:13]) + + +def test_unprefixed_crops_of_two_sites_are_refused_not_overwritten( + tmp_path: Path, +) -> None: + succeeded, error, crops = _run_on_plate( + tmp_path, + _SAVE_CROPPED_OBJECTS.format( + export="Masks", + directory="Default Output Folder|", + prefix="No", + prefix_image="None", + file_format="png", + image="None", + ), + {f"A01_s{site}_w1_z1_t1_DNA.tif": _plane(100 + site) for site in (1, 2)}, + ) + + assert not succeeded + assert "share the keys ['Cells_1.png', 'Cells_2.png']" in error + assert crops == {} + + +# --- every built-in CellProfiler module has a declaration ---------------------- + +_CELLPROFILER_SITE_PACKAGES = Path( + "/home/ts/code/projects/openhcs/.venv-cellprofiler39/lib/python3.9/site-packages" +) +_CELLPROFILER_TEST_FIXTURE_MODULES = frozenset( + {"injectimage", "inject_objects", "measurementfixture", "setting_validation"} +) + + +def _module_names_declared_in(path: Path) -> tuple[str, ...]: + """Return the ``module_name`` class attributes of one CellProfiler source file.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", SyntaxWarning) + tree = ast.parse(path.read_text(encoding="utf-8-sig")) + return tuple( + statement.value.value + for class_definition in tree.body + if isinstance(class_definition, ast.ClassDef) + for statement in class_definition.body + if isinstance(statement, ast.Assign) + and isinstance(statement.value, ast.Constant) + and any( + isinstance(target, ast.Name) and target.id == "module_name" + for target in statement.targets + ) + ) + + +def test_every_built_in_cellprofiler_module_resolves() -> None: + if not _CELLPROFILER_SITE_PACKAGES.is_dir(): + pytest.skip("the pinned CellProfiler environment is not installed here") + module_files = tuple( + path + for package in ("cellprofiler/modules", "cellprofiler_core/modules") + for path in sorted((_CELLPROFILER_SITE_PACKAGES / package).glob("*.py")) + if not path.name.startswith("_") + and path.stem not in _CELLPROFILER_TEST_FIXTURE_MODULES + ) + assert module_files, "no CellProfiler module file was found" + + names_by_file = {path.name: _module_names_declared_in(path) for path in module_files} + + assert { + name: names for name, names in names_by_file.items() if len(names) != 1 + } == {}, "each module file declares exactly one module_name" + assert sorted( + name + for (name,) in names_by_file.values() + if CellProfilerModule.for_module(name) is None + ) == [] + + +# --------------------------------------------------------------------------- # +# LoadData: the CSV that names a pipeline's images +# --------------------------------------------------------------------------- # + +_PIPELINE_HEADER = "CellProfiler Pipeline: http://www.cellprofiler.org\nVersion:5\n\n" + +_BLOCK_OPTIONS = ( + "|svn_version:'Unknown'|variable_revision_number:6|show_window:False|notes:[]" + "|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False]\n" +) + +# benchmark/cellprofiler_pipelines/ExampleFly.cppipe (in this repository since +# ece9962b1), module 1. +_LOAD_DATA_EXAMPLE_FLY = ( + "LoadData:[module_num:1" + _BLOCK_OPTIONS + + " Input data file location:Default Input Folder|workspace\n" + " Name of the file:load_data.csv\n" + " Load images based on this data?:Yes\n" + " Base image location:Default Input Folder|\n" + " Process just a range of rows?:No\n" + " Rows to process:1,100000\n" + " Group images by metadata?:Yes\n" + " Select metadata tags for grouping:Position\n" + " Rescale intensities?:Yes\n" +) + +_TOXPATH_CSV_FOLDER = ( + "D:\\\\CellRecovery\\\\220601_cell recovery_6 4 2 and 0 days\\\\" + "220613_154914_20220606_cell recovery 6 4 2 and 0 day_2test_toxpath\\\\" + "220613_154914_Plate 1\\\\load_data_csv" +) +_CELLPAINTING_CSV_FOLDER = ( + "D:\\\\CellRecovery\\\\220525_cell recovery_ 6 4 2 and 0 days\\\\" + "220528_102915_20220528_cell recovery 6 4 2 and 0 days_cellpainting_1\\\\" + "220528_102915_Plate 1\\\\load_data_csv" +) + +# github.com/broadinstitute/scripts_notebooks_fossa at commit +# 1a81d37b7d47ce4da70282f0f9975d1908771f07: +# cellprofiler/ToxPath_pipeline/analysis.cppipe (blob 591e5b82), module 1. +_LOAD_DATA_TOXPATH = ( + "LoadData:[module_num:1" + _BLOCK_OPTIONS + + f" Input data file location:Default Input Folder|{_TOXPATH_CSV_FOLDER}\n" + " Name of the file:load_data_with_illum.csv\n" + " Load images based on this data?:Yes\n" + " Base image location:Default Input Folder|\n" + " Process just a range of rows?:No\n" + " Rows to process:1,100000\n" + " Group images by metadata?:Yes\n" + " Select metadata tags for grouping:Well,Site,Plate\n" + " Rescale intensities?:Yes\n" +) + +# Same repository and commit: cellprofiler/CellPainting_pipeline/analysis.cppipe +# (blob 621902bc), module 1. Its CSV is on the acquisition machine. +_LOAD_DATA_CELLPAINTING = _LOAD_DATA_TOXPATH.replace( + f"Default Input Folder|{_TOXPATH_CSV_FOLDER}", + f"Elsewhere...|{_CELLPAINTING_CSV_FOLDER}", +) + +_SMOOTH_DNA = ( + "\nGaussianFilter:[module_num:2" + _BLOCK_OPTIONS + + " Select the input image:OrigDNA\n" + " Name the output image:SmoothDNA\n" + " Sigma:1\n" +) + +_PLATE_FOLDERS = ("Plate 1/Images", "Plate 2/Images") + +# A Cell Painting style table: rows out of file order, folders as written on the +# acquisition machine, the same file names in two plate folders. +_LISTED_ROWS = ( + ("Plate 1", "r01c02f01", {"Plate": "P1", "Well": "A02", "Site": 1}), + ("Plate 1", "r01c01f02", {"Plate": "P1", "Well": "A01", "Site": 2}), + ("Plate 1", "r01c01f01", {"Plate": "P1", "Well": "A01", "Site": 1}), + ("Plate 2", "r01c01f01", {"Plate": "P2", "Well": "A01", "Site": 1}), +) +_CELLPAINTING_CSV = ( + "Image_FileName_OrigDNA,Image_PathName_OrigDNA,Image_FileName_OrigRNA," + "Image_PathName_OrigRNA,Metadata_Plate,Metadata_Well,Metadata_Site\n" + + "".join( + f"{stem}-ch1.tiff,D:\\run\\{plate}\\Images,{stem}-ch3.tiff," + f"D:\\run\\{plate}\\Images,{metadata['Plate']},{metadata['Well']}," + f"{metadata['Site']}\n" + for plate, stem, metadata in _LISTED_ROWS + ) +) +_PLAIN_CSV = "Image_FileName_OrigDNA,Metadata_Well\na.tif,A01\n" + + +def _load_data_pipeline(folder: Path, block: str, csv_text: str | None = None) -> Path: + pipeline = folder / "analysis.cppipe" + pipeline.write_text(_PIPELINE_HEADER + block + _SMOOTH_DNA, encoding="utf-8") + if csv_text is not None: + name = next( + line.partition(":")[2] + for line in block.splitlines() + if line.strip().startswith("Name of the file:") + ) + (folder / name).write_text(csv_text, encoding="utf-8") + return pipeline + + +def _load_data_bindings(pipeline: Path) -> SourceBindingsConfig: + _, config = import_cellprofiler_pipeline(pipeline) + return config.source_bindings_config.to_base_config() + + +def _plate_files(plate: Path, *, without: str | None = None) -> tuple[Path, ...]: + """Both plate folders holding every listed file name, plus one unlisted file.""" + + files: list[Path] = [] + for folder in _PLATE_FOLDERS: + (plate / folder).mkdir(parents=True) + for stem in ("r01c01f01", "r01c01f02", "r01c02f01", "r09c09f09"): + for channel in ("ch1", "ch3"): + path = plate / folder / f"{stem}-{channel}.tiff" + if without is None or not path.as_posix().endswith(without): + path.touch() + files.append(path) + return tuple(files) + + +def _projections(bindings: SourceBindingsConfig, plate: Path, files: tuple[Path, ...]): + ensure_storage_registry() + return ( + SourceBindingWorkspaceProjector(bindings) + .projection_set(plate, files, filemanager=FileManager(dict(storage_registry))) + .projections + ) + + +@pytest.mark.parametrize( + ("block", "missing_file"), + ( + (_LOAD_DATA_EXAMPLE_FLY, "'load_data.csv'"), + (_LOAD_DATA_TOXPATH, "'load_data_with_illum.csv'"), + ( + _LOAD_DATA_CELLPAINTING, + repr( + _CELLPAINTING_CSV_FOLDER.replace("\\\\", "\\") + + "\\load_data_with_illum.csv" + ), + ), + ), + ids=("example_fly", "toxpath_default_input_folder", "cellpainting_elsewhere"), +) +def test_load_data_without_its_csv_says_which_file_is_missing( + tmp_path: Path, block: str, missing_file: str +) -> None: + with pytest.raises(FileNotFoundError) as refused: + import_cellprofiler_pipeline(_load_data_pipeline(tmp_path, block)) + assert ( + f"LoadData(1) names its images in the CSV file {missing_file}" + in str(refused.value) + ) + + +def test_load_data_names_images_pairs_rows_and_carries_metadata(tmp_path: Path) -> None: + pipeline = _load_data_pipeline(tmp_path, _LOAD_DATA_TOXPATH, _CELLPAINTING_CSV) + steps, _ = import_cellprofiler_pipeline(pipeline) + bindings = _load_data_bindings(pipeline) + + # The later module resolves the name the CSV header defines. + assert len(steps) == 1 + assert [binding.alias for binding in bindings.binding_declarations] == [ + "OrigDNA", + "OrigRNA", + ] + assert bindings.grouping_metadata_fields == ("Well", "Site", "Plate") + assert {field.name: field.dtype for field in bindings.metadata_fields} == { + "Plate": str, + "Well": str, + "Site": int, + } + assert isinstance(bindings.match_plan, PairAsListed) + assert [ + tuple(listed.path for listed in source_set.files) + for source_set in bindings.match_plan.source_sets + ] == [ + ( + f"D:/run/{plate}/Images/{stem}-ch1.tiff", + f"D:/run/{plate}/Images/{stem}-ch3.tiff", + ) + for plate, stem, _ in _LISTED_ROWS + ] + + plate = tmp_path / "plates" + observed = [ + ( + projection.source_alias, + projection.ref.backend_address, + dict(SourceMetadataFields.declared_items(projection.source_metadata)), + ) + for projection in _projections(bindings, plate, _plate_files(plate)) + ] + # Image sets are the CSV's rows in the CSV's order, not the plate's file order; + # the r09c09f09 files the CSV does not list are in no image set. + assert observed == [ + (alias, f"{plate_folder}/Images/{stem}-{channel}.tiff", metadata) + for plate_folder, stem, metadata in _LISTED_ROWS + for alias, channel in (("OrigDNA", "ch1"), ("OrigRNA", "ch3")) + ] + + +@pytest.mark.parametrize( + ("csv_text", "without", "message"), + ( + ( + _CELLPAINTING_CSV, + "Plate 2/Images/r01c01f01-ch3.tiff", + "but the plate has no such file", + ), + ( + # Without its folder, a name two plate folders hold names neither. + _CELLPAINTING_CSV.replace("D:\\run\\Plate 2\\Images", ""), + None, + "matches several plate files", + ), + ), + ids=("listed_file_missing", "listed_file_ambiguous"), +) +def test_load_data_rows_the_plate_cannot_satisfy_are_refused_not_shifted( + tmp_path: Path, csv_text: str, without: str | None, message: str +) -> None: + bindings = _load_data_bindings( + _load_data_pipeline(tmp_path, _LOAD_DATA_TOXPATH, csv_text) + ) + plate = tmp_path / "plates" + with pytest.raises(ValueError, match=message): + _projections(bindings, plate, _plate_files(plate, without=without)) + + +def test_load_data_processes_only_the_requested_rows_and_composes_wells( + tmp_path: Path, +) -> None: + block = _LOAD_DATA_EXAMPLE_FLY.replace( + "Process just a range of rows?:No\n Rows to process:1,100000", + "Process just a range of rows?:Yes\n Rows to process:2,3", + ).replace("tags for grouping:Position", "tags for grouping:Well") + bindings = _load_data_bindings( + _load_data_pipeline( + tmp_path, + block, + "Image_URL_OrigDNA,Image_ObjectsFileName_Nuclei,Metadata_Row,Metadata_Column\n" + "file:///data/a.tif,a_labels.tif,A,01\n" + "file:///data/b.tif,b_labels.tif,B,02\n" + "file:///data/c.tif,c_labels.tif,C,03\n" + "file:///data/d.tif,d_labels.tif,D,04\n", + ) + ) + assert isinstance(bindings.match_plan, PairAsListed) + assert [ + ( + {listed.alias: listed.path for listed in source_set.files}, + {item.field: item.value for item in source_set.metadata}, + ) + for source_set in bindings.match_plan.source_sets + ] == [ + ( + {"OrigDNA": "data/b.tif", "Nuclei": "b_labels.tif"}, + {"Row": "B", "Column": "02", "Well": "B02"}, + ), + ( + {"OrigDNA": "data/c.tif", "Nuclei": "c_labels.tif"}, + {"Row": "C", "Column": "03", "Well": "C03"}, + ), + ] + assert { + binding.alias: binding.artifact_kind + for binding in bindings.binding_declarations + } == {"OrigDNA": ImageArtifactType, "Nuclei": ObjectLabelsArtifactType} + + +@pytest.mark.parametrize( + ("block", "csv_text", "message"), + ( + ( + _LOAD_DATA_TOXPATH.replace("on this data?:Yes", "on this data?:No"), + _PLAIN_CSV, + "'Load images based on this data?': 'No'", + ), + ( + _LOAD_DATA_TOXPATH.replace("intensities?:Yes", "intensities?:No"), + _PLAIN_CSV, + "'Rescale intensities?': 'No'", + ), + ( + _LOAD_DATA_TOXPATH.replace( + "Base image location:Default Input Folder|", "Base image location:URL|" + ), + _PLAIN_CSV, + "'Base image location' has unknown choice 'URL'", + ), + ( + _LOAD_DATA_TOXPATH.replace("revision_number:6", "revision_number:4"), + _PLAIN_CSV, + "settings revision 4", + ), + ( + _LOAD_DATA_TOXPATH.replace( + "Process just a range of rows?:No\n Rows to process:1,100000", + "Process just a range of rows?:Yes\n Rows to process:5,2", + ), + _PLAIN_CSV, + "'Rows to process' must satisfy", + ), + ( + _LOAD_DATA_TOXPATH.replace( + f"Default Input Folder|{_TOXPATH_CSV_FOLDER}", "Default Output Folder|" + ), + _PLAIN_CSV, + "'Input data file location' is 'Default Output Folder'", + ), + (_LOAD_DATA_TOXPATH, _PLAIN_CSV, "groups image sets by metadata tags"), + ( + _LOAD_DATA_EXAMPLE_FLY, + "Image_FileName_OrigDNA,Frame_OrigDNA,Metadata_Position\nmovie.avi,3,1\n", + "selects planes inside multi-plane files", + ), + ( + _LOAD_DATA_EXAMPLE_FLY, + "Image_FileName_OrigDNA,Titration_NaCl_uM,Metadata_Position\na.tif,750,1\n", + "['Titration_NaCl_uM'] that are neither image, objects nor Metadata_", + ), + ( + _LOAD_DATA_EXAMPLE_FLY, + "Metadata_Position,Metadata_Well\n1,A01\n", + "names no image", + ), + ( + _LOAD_DATA_EXAMPLE_FLY, + "Image_FileName_OrigDNA,Metadata_Position\n", + "lists no image sets", + ), + ), + ids=( + "measurements_only", + "no_rescaling", + "base_location_choice", + "old_revision", + "row_range", + "csv_in_output_folder", + "grouping_tag_absent", + "frame_column", + "measurement_column", + "no_image_column", + "no_rows", + ), +) +def test_load_data_refuses_what_it_cannot_carry_out( + tmp_path: Path, block: str, csv_text: str, message: str +) -> None: + with pytest.raises(ValueError, match=re.escape(message)): + import_cellprofiler_pipeline(_load_data_pipeline(tmp_path, block, csv_text)) + + +def test_load_data_refuses_a_second_module_that_names_images(tmp_path: Path) -> None: + pipeline = tmp_path / "both.cppipe" + pipeline.write_text( + _PIPELINE_HEADER + + "NamesAndTypes:[module_num:1|enabled:True]\n" + " Assignments count:1\n" + " Select the image type:Grayscale image\n" + " Name to assign these images:DNA\n" + ' Select the rule criteria:and (file does contain "DNA")\n\n' + + _LOAD_DATA_EXAMPLE_FLY.replace("module_num:1", "module_num:2"), + encoding="utf-8", + ) + (tmp_path / "load_data.csv").write_text(_PLAIN_CSV, encoding="utf-8") + with pytest.raises(ValueError, match=re.escape("already names ('DNA',)")): + import_cellprofiler_pipeline(pipeline) + + +def test_example_fly_imports_once_its_csv_is_beside_it(tmp_path: Path) -> None: + case = next(case for case in in_tree_cppipe_corpus() if case.name == "ExampleFly") + pipeline = tmp_path / case.cppipe_path.name + shutil.copy(case.cppipe_path, pipeline) + (tmp_path / "load_data.csv").write_text( + "Image_FileName_OrigBlue,Image_FileName_OrigGreen,Image_FileName_OrigRed," + "Metadata_Position\n" + "01_POS002_D.TIF,01_POS002_F.TIF,01_POS002_R.TIF,2\n", + encoding="utf-8", + ) + steps, config = import_cellprofiler_pipeline(pipeline) + bindings = config.source_bindings_config.to_base_config() + assert len(steps) == 10 + assert [binding.alias for binding in bindings.binding_declarations] == [ + "OrigBlue", + "OrigGreen", + "OrigRed", + ] + assert bindings.grouping_metadata_fields == ("Position",) + + +def test_load_data_image_sets_survive_the_pipeline_document(tmp_path: Path) -> None: + """The listed image sets are part of the saved pipeline, not of the CSV.""" + pipeline = _load_data_pipeline(tmp_path, _LOAD_DATA_TOXPATH, _CELLPAINTING_CSV) + steps, config = import_cellprofiler_pipeline(pipeline) + document = PipelineDocumentCodec.from_values( + pipeline_config=config, pipeline_steps=steps + ) + (tmp_path / "load_data_with_illum.csv").unlink() + + read_back = PipelineDocumentCodec.from_source(PipelineDocumentCodec.render(document)) + + bindings = config.source_bindings_config.to_base_config() + assert read_back.pipeline_config.source_bindings_config.to_base_config() == bindings + assert ( + _load_data_bindings( + _load_data_pipeline( + tmp_path, _LOAD_DATA_TOXPATH, _CELLPAINTING_CSV.replace("A02", "B07") + ) + ).declaration_identity() + != bindings.declaration_identity() + ) diff --git a/tests/unit/test_cellprofiler_compatibility_matrix.py b/tests/unit/test_cellprofiler_compatibility_matrix.py index 643cfd13a..05b151e33 100644 --- a/tests/unit/test_cellprofiler_compatibility_matrix.py +++ b/tests/unit/test_cellprofiler_compatibility_matrix.py @@ -348,9 +348,10 @@ def test_export_modules_are_processing_declarations_not_infrastructure( ) cppipe_modules = {module.module_name: module for module in report.cppipe_modules} + # CreateBatchFiles is declared and emits no step: OpenHCS distributes work itself. assert ( cppipe_modules["CreateBatchFiles"].absorption_coverage - is CPPipeModuleAbsorptionCoverage.MISSING_PROCESSING + is CPPipeModuleAbsorptionCoverage.INFRASTRUCTURE ) assert ( cppipe_modules["ExportToDatabase"].absorption_coverage diff --git a/tests/unit/test_cellprofiler_pipeline_import.py b/tests/unit/test_cellprofiler_pipeline_import.py index 5ca762110..874b771e3 100644 --- a/tests/unit/test_cellprofiler_pipeline_import.py +++ b/tests/unit/test_cellprofiler_pipeline_import.py @@ -1439,6 +1439,7 @@ def test_measurement_rosters_preserve_exact_subjects_across_public_transport() - CellProfilerModule.source_bindings_for_modules( modules, SourceBindingsConfig(image_plane_sources=parser.image_plane_sources), + SettingsBinder(source_root=cppipe_path.parent), ), binder=SettingsBinder(source_root=cppipe_path.parent), ) diff --git a/tests/unit/test_cellprofiler_source_bindings.py b/tests/unit/test_cellprofiler_source_bindings.py index c8eb83a33..fb94cde6f 100644 --- a/tests/unit/test_cellprofiler_source_bindings.py +++ b/tests/unit/test_cellprofiler_source_bindings.py @@ -50,6 +50,7 @@ TextDoesNotContain, ) from openhcs.interop.cellprofiler.module_declarations import CellProfilerModule +from openhcs.interop.cellprofiler.settings_binder import SettingsBinder from openhcs.interop.cellprofiler.parser import ( CPPipeParser, ModuleBlock, @@ -87,7 +88,9 @@ def _fold_setup_modules(*modules: ModuleBlock) -> SourceBindingsConfig: continue module_type = CellProfilerModule.require_module(module.name) assert module_type.emits_function_step() is False - config = module_type.contribute_source_bindings(module, config) + config = module_type.contribute_source_bindings( + module, config, SettingsBinder() + ) assert isinstance(config, SourceBindingsConfig) return config @@ -378,6 +381,7 @@ def test_images_setup_preserves_pipeline_owned_embedded_planes() -> None: SourceBindingsConfig( image_plane_sources=(ImagePlaneSource(uri="/data/A01.npy", index="2"),) ), + SettingsBinder(), ) assert config.source_filters[0].match_type is PathIsImage diff --git a/tests/unit/test_cppipe_corpus.py b/tests/unit/test_cppipe_corpus.py index a067dd20a..f3285acc9 100644 --- a/tests/unit/test_cppipe_corpus.py +++ b/tests/unit/test_cppipe_corpus.py @@ -16,9 +16,7 @@ from openhcs.core.function_step_transport import FunctionStepTransportAuthority from openhcs.core.steps.function_step import FunctionStep from openhcs.interop.cellprofiler import import_cellprofiler_pipeline -from openhcs.interop.cellprofiler.module_artifact_contracts import ( - UnknownArtifactInput, -) +from openhcs.interop.cellprofiler.settings_binder import SettingsBinder from openhcs.interop.cellprofiler.module_declarations import CellProfilerModule from openhcs.interop.cellprofiler.parser import CPPipeParser @@ -129,9 +127,10 @@ def test_in_tree_cppipe_corpus_import_expectations( case: CPPipeCorpusCase, ) -> None: if case.status is not CPPipeCorpusStatus.SUPPORTED: + assert case.expected_error is not None assert case.expected_error_substring is not None with pytest.raises( - UnknownArtifactInput, + case.expected_error, match=re.escape(case.expected_error_substring), ): import_cellprofiler_pipeline(case.cppipe_path) @@ -172,6 +171,7 @@ def test_in_tree_cppipe_corpus_import_expectations( == CellProfilerModule.source_bindings_for_modules( parsed_modules, SourceBindingsConfig(image_plane_sources=parser.image_plane_sources), + SettingsBinder(source_root=case.cppipe_path.parent), ) ) diff --git a/tests/unit/test_k4_source_binding_guards.py b/tests/unit/test_k4_source_binding_guards.py index 5f59e142b..856f42e7b 100644 --- a/tests/unit/test_k4_source_binding_guards.py +++ b/tests/unit/test_k4_source_binding_guards.py @@ -14,6 +14,9 @@ from openhcs.core.source_binding_selection import SourceBoundAnchorPatternPolicy from openhcs.core.source_binding_workspace import SourceSetAssembler from openhcs.core.source_bindings import ( + ListedSourceFile, + ListedSourceSet, + PairAsListed, PairByMetadata, PairByOrder, SourceBindingMatchDimension, @@ -155,6 +158,32 @@ def test_pairing_names_decode_case_insensitively(): assert PairByMetadata.keyed_by(keys) == PairByMetadata(dimensions=keys) +def test_rekeying_a_pairing_keeps_what_else_it_declares(): + keys = (SourceBindingMatchDimension((SourceBindingMatchField("DNA", "well"),)),) + listed = PairAsListed( + source_sets=(ListedSourceSet(files=(ListedSourceFile("DNA", "a.tif"),)),) + ) + assert listed.with_keys(keys) is listed + assert PairByMetadata().with_keys(keys) == PairByMetadata(dimensions=keys) + assert SourceSetPairing.named("listed") is PairAsListed + + +@pytest.mark.parametrize( + ("listed", "plate_path", "shared"), + ( + ("a.tif", "images/a.tif", 1), + ("D:/run/images/a.tif", "images/a.tif", 2), + ("images/a.tif", "a.tif", 1), + ("other/a.tif", "images/a.tif", 0), + ("a.tif", "images/ba.tif", 0), + ), +) +def test_a_listed_file_names_a_plate_file_by_whole_trailing_path_parts( + listed, plate_path, shared +): + assert ListedSourceFile("DNA", listed).shared_trailing_parts(plate_path) == shared + + def test_source_modules_compare_paths_only_by_identity(): """No resolve()/as_posix()/backslash rewriting decides path equality or spelling.""" From e3f1f51a2385705a884cadb4e7ebae69e3ab5671 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 17:25:25 -0400 Subject: [PATCH 9/9] WIP: checkpoint when the agent stopped at the usage limit (2026-10-12); tree may be mid-edit, tests not run Co-Authored-By: Claude Opus 5.5 --- .../backends/cellprofiler/call_barcodes.py | 820 +++++++++++++++ .../cellprofiler/compensate_colors.py | 935 +++++++++++++++++ .../backends/cellprofiler/declump_objects.py | 715 +++++++++++++ .../enforce_objects_one_to_one.py | 423 ++++++++ .../cellprofiler/measure_radial_entropy.py | 418 ++++++++ .../cellprofiler_plugins/Barcodes.csv | 9 + .../cellprofiler_plugins/cases.cppipe | 948 +++++++++++++++++- .../cellprofiler_plugins/make_reference.py | 230 ++++- .../cellprofiler_plugins/reference.json | 32 + .../cellprofiler_plugins/reference.npz | Bin 76874 -> 222743 bytes .../unit/test_cellprofiler_plugin_modules.py | 830 +++++++++++++-- 11 files changed, 5258 insertions(+), 102 deletions(-) create mode 100644 openhcs/processing/backends/cellprofiler/call_barcodes.py create mode 100644 openhcs/processing/backends/cellprofiler/compensate_colors.py create mode 100644 openhcs/processing/backends/cellprofiler/declump_objects.py create mode 100644 openhcs/processing/backends/cellprofiler/enforce_objects_one_to_one.py create mode 100644 openhcs/processing/backends/cellprofiler/measure_radial_entropy.py create mode 100644 tests/fixtures/cellprofiler_plugins/Barcodes.csv create mode 100644 tests/fixtures/cellprofiler_plugins/reference.json diff --git a/openhcs/processing/backends/cellprofiler/call_barcodes.py b/openhcs/processing/backends/cellprofiler/call_barcodes.py new file mode 100644 index 000000000..6af96b984 --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/call_barcodes.py @@ -0,0 +1,820 @@ +"""CellProfiler plugin CallBarcodes. + +Upstream: CellProfiler-plugins ``active_plugins/callbarcodes.py``. +""" + +from __future__ import annotations + +import csv +import inspect +import re +from abc import ABC, abstractmethod +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar + +import numpy as np + +from openhcs.core.aligned_image_payload import ( + AlignedImageStack, + pack_aligned_image_outputs, +) +from openhcs.core.artifacts.kinds import ImageArtifactType, ObjectLabelsArtifactType +from openhcs.core.artifacts.plans import ArtifactOutputPlan +from openhcs.core.callable_contract import KeywordRuntimeParameter +from openhcs.core.measurement_row_materialization import MeasurementSparseColumnarRows +from openhcs.core.memory.decorators import numpy +from openhcs.core.pipeline.function_contracts import ( + runtime_bound_parameters, + special_inputs, +) +from openhcs.core.processing_contracts import Pure2DContract +from openhcs.core.runtime_batch_contracts import SliceIndexRuntimeParameter +from openhcs.core.runtime_image_values import ImagePayload +from openhcs.core.runtime_measurements import MeasurementRowAxisField +from openhcs.core.runtime_object_labels import ( + ObjectLabelValue, + object_label_dense_array, +) +from openhcs.core.runtime_plane_projection import RuntimeSliceInvariantValue +from openhcs.core.runtime_tabular_values import FieldSpec +from openhcs.core.steps.function_runtime import RuntimeCallableArgument +from openhcs.core.vfs_protocol import PlateInputFile +from openhcs.interop.cellprofiler.cellprofiler_literals import ( + decode_cellprofiler_setting_literal, +) +from openhcs.interop.cellprofiler.module_artifact_declarations import ( + ObjectArtifactInputModule, + ObjectMeasurementArtifactOutputModule, + PriorMeasurementArtifactInputModule, +) +from openhcs.interop.cellprofiler.module_settings import BoundModuleSettings +from openhcs.interop.cellprofiler.parser import ModuleSetting +from openhcs.interop.cellprofiler.runtime.artifact_binding import ( + RuntimeInputBindingRequest, +) +from openhcs.interop.cellprofiler.runtime.measurement_recording import ( + NoObjectNameMeasurementRecordMixin, +) +from openhcs.interop.cellprofiler.runtime.object_input_policies import ( + LabelsObjectInputPolicy, +) +from openhcs.interop.cellprofiler.runtime.object_measurement_vectors import ( + CellProfilerObjectMeasurementVectorBinding, +) +from openhcs.interop.cellprofiler.runtime.primary_image_input_policies import ( + ObjectLabelDrivenPrimaryImageInputPolicy, +) +from openhcs.interop.cellprofiler.setting_names import ( + SettingNameFamily, + block_setting_value_by_prefix, + optional_setting_value, + repeating_setting_blocks, + required_setting_value, + setting_names, +) +from openhcs.interop.cellprofiler.settings_binder import ( + MeasurementFeatureSettingBinding, + SettingToKeywordBinding, + SourceFileSettingBinding, + parse_cellprofiler_bool, + parse_cellprofiler_float, + parse_cellprofiler_int, +) + +if TYPE_CHECKING: + from openhcs.core.function_patterns import FunctionInvocationKey + from openhcs.interop.cellprofiler.parser import ModuleBlock + from openhcs.interop.cellprofiler.settings_binder import ( + CellProfilerSettingValue, + SettingsBinder, + ) + +_BASES = "ACGT" +_PLUGIN_FAILS = "the CellProfiler plugin fails" + + +def _refuse(reason: str) -> ValueError: + return ValueError(f"CallBarcodes cannot run: {reason}; {_PLUGIN_FAILS} here.") + + +class BaseCalling(RuntimeSliceInvariantValue, ABC): + """How one base per sequencing cycle is read from an object's measurements.""" + + @abstractmethod + def feature_names(self, cycles: int) -> tuple[str, ...]: + """Measurements of the objects this calling reads, in the order it takes them.""" + + @abstractmethod + def barcodes( + self, values: tuple[np.ndarray, ...], cycles: int, object_name: str + ) -> tuple[list[str], np.ndarray | None]: + """Barcode per object, and its quality score where the calling has one. + + ``values`` holds one array (a value per object) per name of + ``feature_names(cycles)``, in that order. + """ + + @abstractmethod + def setting_rows(self) -> tuple[tuple[str, str], ...]: + """The CellProfiler setting rows (name, value) that describe this calling.""" + + +@dataclass(frozen=True, slots=True) +class BrightestBase(BaseCalling): + """Each cycle has one measurement per base; the largest names the base. + + CellProfiler's "One Hot Exponentially Multiplexed" encoding. + """ + + cycle_1_feature: str + """One of the four measurements of the first cycle, such as + ``Intensity_MaxIntensity_CorrCycle01_A``. The others are derived by + replacing its cycle number and its base letter.""" + + def feature_names(self, cycles: int) -> tuple[str, ...]: + _, _, after_cycle = self.cycle_1_feature.partition("Cycle") + number = re.search("[0-9]{1,2}", after_cycle) + base = re.search("[A-Z]", after_cycle) + if number is None or base is None or base.group() not in _BASES: + raise _refuse( + f"measurement {self.cycle_1_feature!r} does not name a cycle number " + "and one of the bases A, C, G, T after the word 'Cycle'" + ) + start = len(self.cycle_1_feature) - len(after_cycle) + width = len(number.group()) + names = [] + for cycle in range(1, cycles + 1): + characters = list(self.cycle_1_feature) + for letter in _BASES: + characters[start + base.start()] = letter + named = "".join(characters) + names.append( + named[: start + number.start()] + + str(cycle).zfill(width) + + named[start + number.end() :] + ) + return tuple(names) + + def barcodes( + self, values: tuple[np.ndarray, ...], cycles: int, object_name: str + ) -> tuple[list[str], np.ndarray | None]: + del object_name + names = self.feature_names(cycles) + object_count = len(values[0]) if values else 0 + called = [""] * object_count + scores = np.zeros((cycles, object_count)) + for cycle in range(cycles): + # CellProfiler lists an object's measurements by name, and the first + # of several equal maxima is the base called. + of_cycle = sorted( + range(cycle * len(_BASES), (cycle + 1) * len(_BASES)), + key=names.__getitem__, + ) + intensities = np.transpose( + np.array([np.asarray(values[index], dtype=float) for index in of_cycle]) + ).reshape(object_count, len(of_cycle)) + brightest = np.argmax(intensities, axis=1) + with np.errstate(invalid="ignore", divide="ignore"): + scores[cycle] = np.max(intensities, axis=1) / np.sum( + intensities, axis=1 + ) + for position, index in enumerate(brightest): + called[position] += _BASES[of_cycle[index] % len(_BASES)] + return called, scores.mean(axis=0) + + def setting_rows(self) -> tuple[tuple[str, str], ...]: + return ( + (_ENCODING_SETTING, _BRIGHTEST_BASE_ENCODING), + (_CYCLE_1_FEATURE_SETTING, self.cycle_1_feature), + ) + + +@dataclass(frozen=True, slots=True) +class BaseMeasurement(RuntimeSliceInvariantValue): + """One measurement that speaks for or against a base.""" + + base: str + """The base, one of A, C, G, T.""" + + cycle_1_feature: str + """The measurement of the first cycle; its name must hold ``Cycle`` followed + by the cycle number.""" + + positive_means_base: bool = True + """Whether a value at or above the threshold supports the base (otherwise it + rules the base out).""" + + def feature_names(self, cycles: int) -> tuple[str, ...]: + named_cycle = re.search("Cycle.*[0-9]{1,2}", self.cycle_1_feature) + if named_cycle is None: + raise _refuse( + f"measurement {self.cycle_1_feature!r} does not name a cycle number " + "after the word 'Cycle'" + ) + return tuple( + self.cycle_1_feature.replace( + named_cycle.group(), + re.sub( + "Cycle[0-9]{1,2}", + f"Cycle{cycle:02d}" if cycle < 10 else f"Cycle{cycle}", + named_cycle.group(), + ), + ) + for cycle in range(1, cycles + 1) + ) + + +@dataclass(frozen=True, slots=True) +class BasesOverThreshold(BaseCalling): + """A base is called in a cycle when it alone passes its measurements' tests. + + CellProfiler's "Exponentially Multiplexed" encoding. A cycle in which no + base or more than one base passes is called ``X``. + """ + + measurements: tuple[BaseMeasurement, ...] + """The tests of each base; a base passes when all of its tests do.""" + + minimum_positive_value: float = 1.0 + """A measurement at or above this value is positive.""" + + def feature_names(self, cycles: int) -> tuple[str, ...]: + return tuple( + name + for measurement in self.measurements + for name in measurement.feature_names(cycles) + ) + + def barcodes( + self, values: tuple[np.ndarray, ...], cycles: int, object_name: str + ) -> tuple[list[str], np.ndarray | None]: + if object_name != "Foci": + raise _refuse( + "with this encoding the plugin only finds the measurements of " + f"objects named 'Foci', not {object_name!r}" + ) + object_count = len(values[0]) if values else 0 + called = [""] * object_count + for cycle in range(cycles): + passes = np.ones((object_count, len(_BASES))) + for index, measurement in enumerate(self.measurements): + positive = ( + np.asarray(values[index * cycles + cycle], dtype=float) + >= self.minimum_positive_value + ) + passes[:, _BASES.index(measurement.base)] *= ( + positive if measurement.positive_means_base else ~positive + ) + for position, of_object in enumerate(passes): + called[position] += ( + _BASES[int(of_object.argmax())] if of_object.sum() == 1 else "X" + ) + return called, None + + def setting_rows(self) -> tuple[tuple[str, str], ...]: + rows = [(_ENCODING_SETTING, "Exponentially Multiplexed (SBS/ISS only)")] + for measurement in self.measurements: + base = measurement.base + rows += [ + (_BASE_SETTING, base), + ( + f"{_BASE_FEATURE_SETTING_START} first measurement indicating " + f"that {base} is encoded", + measurement.cycle_1_feature, + ), + ( + f"{_BASE_POSITIVE_SETTING_START} called {base}?", + "Yes" if measurement.positive_means_base else "No", + ), + ] + return (*rows, (_MINIMUM_SETTING, repr(self.minimum_positive_value))) + + +@dataclass(frozen=True, slots=True) +class BarcodeLibrary: + """Known barcodes with their position in the library file and their gene.""" + + entries: dict[str, tuple[int, str]] + + @classmethod + def read( + cls, + path: Path, + *, + barcode_column: str, + gene_column: str, + empty_vector_barcode: str | None, + ) -> BarcodeLibrary: + entries: dict[str, tuple[int, str]] = {} + position = 1 + with open(path) as library_file: + for row in csv.DictReader(library_file): + for column in (barcode_column, gene_column): + if column not in row: + raise ValueError( + f"Barcode library {path} has no column {column!r}." + ) + if row[barcode_column]: + # A repeated barcode keeps its first place in the search + # order and takes the later row's position and gene. + entries[row[barcode_column]] = (position, row[gene_column]) + position += 1 + if empty_vector_barcode is not None: + entries[empty_vector_barcode] = (position, "EmptyVector") + return cls(entries) + + def closest(self, called: str) -> tuple[float, str]: + """Share of matching bases and the library barcode that best matches.""" + by_start = {barcode[: len(called)]: barcode for barcode in self.entries} + if called in by_start: + return 1.0, by_start[called] + by_score: dict[float, str] = {} + for start, barcode in by_start.items(): + if len(start) < len(called): + raise _refuse( + f"library barcode {barcode!r} is shorter than the " + f"{len(called)} cycles called" + ) + # Of several barcodes with the same score the last one is kept. + by_score[ + sum(1 for a, b in zip(called, start) if a == b) / float(len(called)) + ] = barcode + if not by_score: + raise _refuse("the barcode library is empty") + best = max(by_score) + return best, by_score[best] + + +_ENCODING_SETTING = "What kind of encoding is used here?" +_BRIGHTEST_BASE_ENCODING = "One Hot Exponentially Multiplexed (ie 4-color SBS/ISS)" +_CYCLES_SETTING = "Number of cycles" +_CYCLE_1_FEATURE_SETTING = "Select one of the measures from Cycle 1 to use for calling" +_BASE_SETTING = "Base we are currently calling" +_BASE_FEATURE_SETTING_START = "Select the" +_BASE_POSITIVE_SETTING_START = "Does a positive value here mean the base SHOULD be" +_MINIMUM_SETTING = "Minimum value to be considered positive in a channel" +_MATCH_LIBRARY_SETTING = "Do you want to match to an external library of barcodes?" +_LIBRARY_FOLDER_SETTING = "Input data file location" +_HAS_EMPTY_VECTOR_SETTING = ( + "Do you have an empty vector barcode you would like to add to the barcode list?" +) + + +def _calling_of(module: ModuleBlock) -> BaseCalling: + encoding = optional_setting_value(module, _ENCODING_SETTING) + # Pipelines saved before the plugin had a second encoding carry no choice. + if encoding is None or encoding == _BRIGHTEST_BASE_ENCODING: + return BrightestBase(required_setting_value(module, _CYCLE_1_FEATURE_SETTING)) + minimum = optional_setting_value(module, _MINIMUM_SETTING) + return BasesOverThreshold( + tuple( + BaseMeasurement( + base=block[0].value.strip(), + cycle_1_feature=block_setting_value_by_prefix( + block, _BASE_FEATURE_SETTING_START + ).strip(), + positive_means_base=parse_cellprofiler_bool( + block_setting_value_by_prefix(block, _BASE_POSITIVE_SETTING_START) + ), + ) + for block in repeating_setting_blocks( + tuple( + setting + for setting in module.iter_settings() + if setting.name != _MINIMUM_SETTING + ), + start_name=_BASE_SETTING, + ) + ), + # The plugin's value for pipelines saved before it had the setting. + minimum_positive_value=1.0 if minimum is None else parse_cellprofiler_float(minimum), + ) + + +def _matches_library(module: ModuleBlock) -> bool: + # Pipelines saved before the plugin had the choice always match. + choice = optional_setting_value(module, _MATCH_LIBRARY_SETTING) + return choice is None or parse_cellprofiler_bool(choice) + + +@dataclass(frozen=True, slots=True) +class _BaseCallingBinding(MeasurementFeatureSettingBinding): + """The calling a block describes; its measurements are the block's prior inputs.""" + + def feature_names(self, module: ModuleBlock) -> tuple[str, ...]: + return _calling_of(module).feature_names( + parse_cellprofiler_int(required_setting_value(module, _CYCLES_SETTING)) + ) + + def bind( + self, + module: ModuleBlock, + kwargs: dict[str, CellProfilerSettingValue], + binder: SettingsBinder, + ) -> None: + del binder + kwargs[self.require_parameter_name()] = _calling_of(module) + + def records_from_kwargs( + self, kwargs: Mapping[str, object] + ) -> tuple[ModuleSetting, ...]: + calling = kwargs.get(self.require_parameter_name()) + if not isinstance(calling, BaseCalling): + return () + return tuple(ModuleSetting(name, value) for name, value in calling.setting_rows()) + + +@dataclass(frozen=True, slots=True) +class _BarcodeLibraryBinding(SourceFileSettingBinding): + """The library file named by CellProfiler's folder and file-name settings.""" + + def bind( + self, + module: ModuleBlock, + kwargs: dict[str, CellProfilerSettingValue], + binder: SettingsBinder, + ) -> None: + if not _matches_library(module): + return + name = decode_cellprofiler_setting_literal( + required_setting_value(module, self.setting_name) + ).strip() + choice, _, folder = decode_cellprofiler_setting_literal( + module.get_setting(_LIBRARY_FOLDER_SETTING) + ).partition("|") + choice, folder = choice.strip(), folder.strip() + if choice in ("Default Input Folder", ""): + # CellProfiler's default input folder is where the pipeline's files are. + location = name + elif choice in ("Default Input Folder sub-folder", "Elsewhere..."): + separator = "\\" if "\\" in folder and "/" not in folder else "/" + location = folder.rstrip("/\\") + separator + name if folder else name + else: + raise ValueError( + f"CallBarcodes cannot read its barcode library from {choice!r}; " + "name a folder of input files." + ) + kwargs[self.require_parameter_name()] = binder.resolve_source_file(location) + + def records_from_kwargs( + self, kwargs: Mapping[str, object] + ) -> tuple[ModuleSetting, ...]: + library = kwargs.get(self.require_parameter_name()) + if library is None: + return (ModuleSetting(_MATCH_LIBRARY_SETTING, "No"),) + return ( + ModuleSetting(_MATCH_LIBRARY_SETTING, "Yes"), + ModuleSetting(setting_names(self.setting_name)[0], str(library)), + ) + + +class _ObjectNameRuntimeParameter(KeywordRuntimeParameter): + """Name of the objects whose barcodes are called.""" + + parameter_name = "object_name" + annotation_type = str + parameter_default = "" + + +class _MeasurementValuesRuntimeParameter(KeywordRuntimeParameter): + """The measurements the calling reads, one array of object values per name.""" + + parameter_name = "measurement_values" + annotation_type = tuple[np.ndarray, ...] + parameter_default = () + + +class CallBarcodesInputPolicy( + ObjectLabelDrivenPrimaryImageInputPolicy, + LabelsObjectInputPolicy, +): + """Bind the objects, their name and the measurements their calling reads.""" + + @classmethod + def bind_runtime_inputs( + cls, + request: RuntimeInputBindingRequest, + ) -> dict[str, RuntimeCallableArgument]: + bound = super().bind_runtime_inputs(request) + object_spec = request.object_inputs[0] + labels = request.label_payload_for(object_spec) + parameters = inspect.signature(call_barcodes).parameters + calling = request.kwargs["calling"] + cycles = request.kwargs.get("cycles", parameters["cycles"].default) + return { + **bound, + _ObjectNameRuntimeParameter.require_parameter_name(): object_spec.name, + _MeasurementValuesRuntimeParameter.require_parameter_name(): tuple( + CellProfilerObjectMeasurementVectorBinding.for_object( + request, + object_ref=object_spec, + feature_name=feature_name, + labels=labels, + ) + .vector() + .runtime_value + for feature_name in calling.feature_names(cycles) + ), + } + + +class CallBarcodesModule( + NoObjectNameMeasurementRecordMixin, + CallBarcodesInputPolicy, + ObjectArtifactInputModule, + PriorMeasurementArtifactInputModule, + ObjectMeasurementArtifactOutputModule, +): + module_name = "CallBarcodes" + function_name = "call_barcodes" + validated = True + measurement_category_prefixes = (("barcode",),) + input_objects_binding = SettingToKeywordBinding.input( + "Input object name", ObjectLabelsArtifactType, runtime_parameter_name="labels" + ) + calling_binding = _BaseCallingBinding(_CYCLE_1_FEATURE_SETTING, "calling") + library_binding = _BarcodeLibraryBinding("Name of the file", "barcode_library") + # The second wording of each pair is the one earlier plugin versions saved. + retain_call_image_setting = SettingNameFamily( + "Retain an image of the barcodes color coded by match?", + aliases=("Retain an image of the barcodes color coded by call?",), + ) + retain_score_image_setting = SettingNameFamily( + "Retain an image of the barcodes color coded by score match?" + ) + call_image_binding = SettingToKeywordBinding.output( + "Enter the called barcode image name", ImageArtifactType + ) + score_image_binding = SettingToKeywordBinding.output( + "Enter the barcode score image name", ImageArtifactType + ) + setting_bindings: ClassVar[tuple[SettingToKeywordBinding, ...]] = ( + SettingToKeywordBinding(_CYCLES_SETTING, "cycles", parse_cellprofiler_int), + input_objects_binding, + calling_binding, + library_binding, + SettingToKeywordBinding( + SettingNameFamily( + "Select the column containing barcodes to match against", + aliases=("Select the column of barcodes to match against",), + ), + "barcode_column", + str, + ), + SettingToKeywordBinding( + SettingNameFamily( + "Select the column containing gene/transcript barcode names", + aliases=("Select the column with gene/transcript barcode names",), + ), + "gene_column", + str, + ), + SettingToKeywordBinding( + retain_call_image_setting, "retain_call_image", parse_cellprofiler_bool + ), + call_image_binding, + SettingToKeywordBinding( + retain_score_image_setting, "retain_score_image", parse_cellprofiler_bool + ), + score_image_binding, + SettingToKeywordBinding( + "What is the empty vector sequence?", "empty_vector_barcode", str + ), + ) + + @classmethod + def postprocess_bound_settings( + cls, module: ModuleBlock, bound: BoundModuleSettings + ) -> BoundModuleSettings: + kwargs = dict(bound.kwargs) + has_empty_vector = optional_setting_value(module, _HAS_EMPTY_VECTOR_SETTING) + if has_empty_vector is None or not parse_cellprofiler_bool(has_empty_vector): + kwargs["empty_vector_barcode"] = None + if not _matches_library(module): + # Without a library the plugin shows none of these and makes no image. + kwargs["retain_call_image"] = False + kwargs["retain_score_image"] = False + kwargs["empty_vector_barcode"] = None + kwargs.pop("barcode_column", None) + kwargs.pop("gene_column", None) + # Read by the calling and library bindings above. Each base's rows are + # worded per base ("Select the first measurement indicating that A is + # encoded"), so they are consumed by whatever name the block gives them. + base_rows = tuple( + setting.name + for block in repeating_setting_blocks( + tuple( + setting + for setting in module.iter_settings() + if setting.name != _MINIMUM_SETTING + ), + start_name=_BASE_SETTING, + ) + for setting in block + ) + return bound.with_replaced_kwargs(kwargs).with_consumed_settings( + _ENCODING_SETTING, + _MINIMUM_SETTING, + _MATCH_LIBRARY_SETTING, + _LIBRARY_FOLDER_SETTING, + _HAS_EMPTY_VECTOR_SETTING, + *base_rows, + ) + + @classmethod + def active_artifact_bindings( + cls, + module: ModuleBlock | None = None, + *, + invocation_key: FunctionInvocationKey | None = None, + ) -> tuple[SettingToKeywordBinding, ...]: + """An image is an output only when the block retains it.""" + bindings = super().active_artifact_bindings( + module, invocation_key=invocation_key + ) + if module is None: + return bindings + retained = { + cls.call_image_binding: cls.retain_call_image_setting, + cls.score_image_binding: cls.retain_score_image_setting, + } + return tuple( + binding + for binding in bindings + if binding not in retained + or ( + _matches_library(module) + and (value := optional_setting_value(module, retained[binding])) + is not None + and parse_cellprofiler_bool(value) + ) + ) + + +def _row_field(name: str) -> FieldSpec: + return FieldSpec(name, _MEASUREMENT_TYPES[name], required=False) + + +_OBJECT_NAME = MeasurementRowAxisField.OBJECT_NAME.value +_OBJECT_LABEL = MeasurementRowAxisField.OBJECT_LABEL.value +_SLICE_INDEX = MeasurementRowAxisField.SLICE_INDEX.value +_CALLED = "Barcode_BarcodeCalled" +_MATCHED_BARCODE = "Barcode_MatchedTo_Barcode" +_MATCHED_ID = "Barcode_MatchedTo_ID" +_MATCHED_GENE = "Barcode_MatchedTo_GeneCode" +_MATCHED_SCORE = "Barcode_MatchedTo_Score" +_MEAN_QUALITY = "Barcode_MeanQualityScore" +_MEAN_SCORE = "Barcode_MeanBarcodeScore" +_MEASUREMENT_TYPES: dict[str, type] = { + _SLICE_INDEX: int, + _OBJECT_NAME: str, + _OBJECT_LABEL: int, + _CALLED: str, + _MATCHED_BARCODE: str, + _MATCHED_ID: int, + _MATCHED_GENE: str, + _MATCHED_SCORE: float, + _MEAN_QUALITY: float, + _MEAN_SCORE: float, +} + + +@numpy(contract=Pure2DContract) +@special_inputs("labels") +@runtime_bound_parameters( + _ObjectNameRuntimeParameter, + _MeasurementValuesRuntimeParameter, + SliceIndexRuntimeParameter, +) +def call_barcodes( + image: ImagePayload, + labels: ObjectLabelValue, + calling: BaseCalling, + object_name: str = "", + measurement_values: tuple[np.ndarray, ...] = (), + cycles: int = 8, + barcode_library: PlateInputFile | None = None, + barcode_column: str = "", + gene_column: str = "", + empty_vector_barcode: str | None = None, + retain_call_image: bool = False, + retain_score_image: bool = False, + slice_index: int = 0, +) -> tuple[ImagePayload | AlignedImageStack, MeasurementSparseColumnarRows]: + """Read each object's barcode from its per-cycle measurements and look it up. + + For sequencing by synthesis: one base is called per cycle, the bases of all + cycles form the object's barcode, and the barcode is matched to a library + of known barcodes read from a CSV file. + + Measurements written, with CellProfiler's names. Per object: + ``Barcode_BarcodeCalled``; with ``BrightestBase`` calling + ``Barcode_MeanQualityScore`` (mean over cycles of the brightest value's + share of the cycle's sum); with a library ``Barcode_MatchedTo_Barcode``, + ``Barcode_MatchedTo_ID`` (the barcode's position among the library rows + that have a barcode, from 1), ``Barcode_MatchedTo_GeneCode`` and + ``Barcode_MatchedTo_Score`` (share of matching bases). Per image: + ``Barcode_MeanQualityScore`` and, with a library, + ``Barcode_MeanBarcodeScore``. + + The plugin's behaviour is kept, including: + + - Library barcodes are cut to the number of cycles. Of several barcodes + with the same start the last one is matched, of several equally close + barcodes the last one, and a barcode listed twice has the later row's + position and gene. + - A cycle without signal scores NaN, which makes the object's and the + image's mean quality NaN; its base is called ``A``. + - Images hold whole numbers as unsigned 16-bit, not intensities from 0 to + 1: the matched position, and 65535 times the match score rounded down. + - Objects are taken to be numbered 1 to N in measurement order. + - Without objects the images are zero, the image means are 0 and no object + measurement is written (the plugin writes empty columns); the plugin's + zero images take CellProfiler's label storage type, here they are + unsigned 16-bit like the others. + - ``BasesOverThreshold`` calling fails in the plugin unless the objects + are named ``Foci``, and so does a library barcode shorter than the + number of cycles when no barcode matches exactly; both are refused. + + One difference: with ``BrightestBase`` calling the plugin takes every + measurement of the objects whose name contains the text before ``Cycle``; + here the four measurements per cycle are the first cycle's with the cycle + number and the base letter (A, C, G, T) replaced. + + Args: + labels: The objects (such as sequencing foci) whose barcodes are called. + calling: How a base is read in each cycle: ``BrightestBase`` from the + largest of four measurements, ``BasesOverThreshold`` from + thresholded measurements. + cycles: Number of sequencing cycles, the length of a called barcode. + barcode_library: CSV file of known barcodes. Without it barcodes are + called but not matched and no image is made. + barcode_column: Column of the library holding the barcodes. + gene_column: Column of the library holding each barcode's gene or + transcript name. + empty_vector_barcode: Barcode of the empty vector, added to the library + under the gene name ``EmptyVector``. + retain_call_image: Make an image in which each object has its matched + barcode's position in the library. + retain_score_image: Make an image in which each object has its match + score. + """ + called, quality = calling.barcodes(measurement_values, cycles, object_name) + object_numbers = range(1, len(called) + 1) + object_rows: list[dict[str, object]] = [ + { + _OBJECT_NAME: object_name, + _OBJECT_LABEL: number, + _SLICE_INDEX: slice_index, + _CALLED: barcode, + } + for number, barcode in zip(object_numbers, called) + ] + image_row: dict[str, object] = {_SLICE_INDEX: slice_index} + if quality is not None: + for row, score in zip(object_rows, quality): + row[_MEAN_QUALITY] = float(score) + image_row[_MEAN_QUALITY] = float(np.mean(quality)) if called else 0.0 + + retained: list[ImagePayload] = [] + if barcode_library is not None: + label_plane = object_label_dense_array(labels, dtype=np.int32) + call_pixels = label_plane.astype(np.int64) + score_pixels = label_plane.astype(np.float64) + scores = [] + if called: + library = BarcodeLibrary.read( + barcode_library, + barcode_column=barcode_column, + gene_column=gene_column, + empty_vector_barcode=empty_vector_barcode, + ) + for number, barcode, row in zip(object_numbers, called, object_rows): + score, matched = library.closest(barcode) + position, gene = library.entries[matched] + scores.append(score) + row[_MATCHED_BARCODE] = matched + row[_MATCHED_ID] = position + row[_MATCHED_GENE] = gene + row[_MATCHED_SCORE] = float(score) + call_pixels[label_plane == number] = position + score_pixels[label_plane == number] = 65535 * score + else: + call_pixels[:] = 0 + score_pixels[:] = 0 + image_row[_MEAN_SCORE] = float(np.mean(scores)) if called else 0.0 + if retain_call_image: + retained.append(image.with_pixels(call_pixels.astype(np.uint16))) + if retain_score_image: + retained.append(image.with_pixels(score_pixels.astype(np.uint16))) + + rows = [*object_rows, *([image_row] if len(image_row) > 1 else [])] + names = dict.fromkeys(name for row in rows for name in row) + return ( + pack_aligned_image_outputs(tuple(retained)) if retained else image, + MeasurementSparseColumnarRows.from_rows( + rows, fields=tuple(_row_field(name) for name in names) + ), + ) diff --git a/openhcs/processing/backends/cellprofiler/compensate_colors.py b/openhcs/processing/backends/cellprofiler/compensate_colors.py new file mode 100644 index 000000000..11b171988 --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/compensate_colors.py @@ -0,0 +1,935 @@ +"""CellProfiler plugin CompensateColors. + +Upstream: CellProfiler-plugins ``active_plugins/compensatecolors.py``. +""" + +from __future__ import annotations + +import warnings +from abc import ABC, abstractmethod +from collections.abc import Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar + +import numpy as np + +from openhcs.core.aligned_image_payload import ( + AlignedImageStack, + pack_aligned_image_outputs, +) +from openhcs.core.artifacts.kinds import ImageArtifactType, ObjectLabelsArtifactType +from openhcs.core.artifacts.relations import ( + ArtifactSpecRelation, + GroupLineageSourceRelation, +) +from openhcs.core.artifacts.specs import ArtifactSpecCollection +from openhcs.core.memory.decorators import numpy +from openhcs.core.pipeline.function_contracts import ( + composed_image_payload, + special_inputs, +) +from openhcs.core.processing_contracts import FlexibleContract +from openhcs.core.runtime_image_values import ImagePayload +from openhcs.core.runtime_object_labels import ( + ObjectLabelValue, + object_label_dense_array, +) +from openhcs.core.runtime_plane_projection import RuntimeSliceInvariantValue +from openhcs.interop.cellprofiler.module_artifact_declarations import ( + ObjectArtifactInputModule, +) +from openhcs.interop.cellprofiler.module_settings import BoundModuleSettings +from openhcs.interop.cellprofiler.parser import ModuleSetting +from openhcs.interop.cellprofiler.runtime.object_input_policies import ( + SingleObjectLabelInputPolicy, +) +from openhcs.interop.cellprofiler.setting_names import ( + required_setting_value, + setting_names, + setting_values, +) +from openhcs.interop.cellprofiler.settings_binder import ( + SettingToKeywordBinding, + parse_cellprofiler_bool, + parse_cellprofiler_float, + parse_cellprofiler_int, +) + +if TYPE_CHECKING: + from openhcs.core.function_patterns import FunctionInvocationKey + from openhcs.interop.cellprofiler.parser import ModuleBlock + from openhcs.interop.cellprofiler.settings_binder import ( + CellProfilerSettingValue, + SettingsBinder, + ) + +_PLUGIN_FAILS = "the CellProfiler plugin fails" +_SIXTEEN_BIT = 65535 + +_WHERE_SETTING = "Select where to perform color compensation" +_WITHIN_OBJECTS = "Within objects" +_ENTIRE_IMAGE = "Across entire image" +_OBJECTS_SETTING = "Select an object to perform compensation within" +_RESCALE_AFTER_MASKING_SETTING = ( + "Should images be rescaled 0-1 before compensating but after masking to objects?" +) +_MATCHING_SETTING = "Should histogram matching be performed between the image groups?" +_TEMPLATE_CLASS_SETTING = ( + "What compensation class should serve as the template histogram?" +) + + +def _refuse(reason: str) -> ValueError: + return ValueError(f"CompensateColors cannot run: {reason}; {_PLUGIN_FAILS} here.") + + +def _stretched( + pixels: np.ndarray, low: float, high: float, new_low: float, new_high: float +) -> np.ndarray: + """Map ``low..high`` linearly onto ``new_low..new_high``, clipping outside. + + scikit-image 0.18's ``rescale_intensity``: the arithmetic is done in the + type of ``pixels`` and the result is double precision. Later versions keep + single precision, which changes the 16-bit rounding that follows. + """ + low, high = float(low), float(high) + clipped = np.clip(pixels, low, high) + if low == high: + return np.clip(clipped, new_low, new_high).astype(np.float64) + return np.asarray( + (clipped - low) / (high - low) * (new_high - new_low) + new_low, + dtype=np.float64, + ) + + +def _matched_to(pixels: np.ndarray, template: np.ndarray) -> np.ndarray: + """Give ``pixels`` the cumulative histogram of ``template``. + + scikit-image 0.18's ``match_histograms``, whose result is double precision + whatever the input. + """ + _, positions, counts = np.unique( + pixels, return_inverse=True, return_counts=True + ) + template_values, template_counts = np.unique(template, return_counts=True) + quantiles = np.cumsum(counts) / pixels.size + template_quantiles = np.cumsum(template_counts) / template.size + return np.interp(quantiles, template_quantiles, template_values)[positions] + + +class _Region(ABC): + """Where the compensation is computed.""" + + @abstractmethod + def object_mask(self, needed_for: str) -> np.ndarray: + """1 inside the objects and 0 outside, flattened.""" + + @abstractmethod + def masked(self, pixels: np.ndarray, image_count: int) -> np.ndarray: + """A class's pixels (``image_count`` images, flattened) within the region.""" + + +class _EntireImage(_Region): + def object_mask(self, needed_for: str) -> np.ndarray: + raise _refuse(f"{needed_for} needs objects to compensate within") + + def masked(self, pixels: np.ndarray, image_count: int) -> np.ndarray: + return pixels + + +@dataclass(frozen=True, slots=True) +class _WithinObjects(_Region): + mask: np.ndarray + + def object_mask(self, needed_for: str) -> np.ndarray: + return self.mask + + def masked(self, pixels: np.ndarray, image_count: int) -> np.ndarray: + inside = pixels * np.tile(self.mask, image_count) + # Outside the objects, and where a pixel is 0, the value is 1. + return np.where(inside == 0, 1, inside) + + +class RescaleAfterMasking(RuntimeSliceInvariantValue, ABC): + """Whether intensities are stretched once pixels outside the objects are set aside.""" + + choice: ClassVar[str] + """The wording of this choice in CellProfiler.""" + + @abstractmethod + def image(self, pixels: np.ndarray, region: _Region) -> np.ndarray: + """One image before it is rounded to 16-bit values.""" + + @abstractmethod + def class_pixels(self, pixels: np.ndarray) -> np.ndarray: + """The masked 16-bit values of all images of one class.""" + + +@dataclass(frozen=True, slots=True) +class NoRescaleAfterMasking(RescaleAfterMasking): + """Intensities are left as they are.""" + + choice: ClassVar[str] = "No" + + def image(self, pixels: np.ndarray, region: _Region) -> np.ndarray: + return pixels + + def class_pixels(self, pixels: np.ndarray) -> np.ndarray: + return pixels + + +@dataclass(frozen=True, slots=True) +class RescaleEachImage(RescaleAfterMasking): + """Each image's values inside the objects are stretched to the full range. + + Needs objects to compensate within. + """ + + choice: ClassVar[str] = "Yes, per image" + + def image(self, pixels: np.ndarray, region: _Region) -> np.ndarray: + shape = pixels.shape + inside = pixels.reshape(-1) * region.object_mask("rescaling each image") + present = inside[inside != 0] + if not present.size: + raise _refuse("an image is 0 everywhere inside the objects") + return _stretched( + inside, present.min(), present.max(), 1.0 / _SIXTEEN_BIT, 1.0 + ).reshape(shape) + + def class_pixels(self, pixels: np.ndarray) -> np.ndarray: + return pixels + + +@dataclass(frozen=True, slots=True) +class RescaleEachClass(RescaleAfterMasking): + """The values of all images of a class are stretched together to the full range. + + The range stretched is that of the 16-bit values above 1. + """ + + choice: ClassVar[str] = "Yes, per group" + + def image(self, pixels: np.ndarray, region: _Region) -> np.ndarray: + return pixels + + def class_pixels(self, pixels: np.ndarray) -> np.ndarray: + present = pixels[pixels > 1] + if not present.size: + raise _refuse("a class has no 16-bit value above 1 to rescale") + return _stretched(pixels, present.min(), present.max(), 1, _SIXTEEN_BIT) + + +_RESCALES_AFTER_MASKING: tuple[type[RescaleAfterMasking], ...] = ( + NoRescaleAfterMasking, + RescaleEachImage, + RescaleEachClass, +) + + +class ClassHistogramMatching(RuntimeSliceInvariantValue, ABC): + """Whether the classes are given the intensity histogram of one of them.""" + + choice: ClassVar[str] + """The wording of this choice in CellProfiler.""" + + @abstractmethod + def template_class_number(self) -> int: + """The class number written to CellProfiler's template setting.""" + + @abstractmethod + def template( + self, class_pixels: Mapping[int, np.ndarray], region: _Region + ) -> np.ndarray: + """The values whose histogram is matched, from the classes' 16-bit values.""" + + @abstractmethod + def before_masking( + self, number: int, pixels: np.ndarray, template: np.ndarray + ) -> np.ndarray: + """The 16-bit values of class ``number`` before the region masks them.""" + + @abstractmethod + def after_masking( + self, number: int, pixels: np.ndarray, template: np.ndarray + ) -> np.ndarray: + """The 16-bit values of class ``number`` once masked and rescaled.""" + + +@dataclass(frozen=True, slots=True) +class NoClassHistogramMatching(ClassHistogramMatching): + """Each class keeps its own histogram.""" + + choice: ClassVar[str] = "No" + + def template_class_number(self) -> int: + # The plugin's value of the setting it does not show. + return 1 + + def template( + self, class_pixels: Mapping[int, np.ndarray], region: _Region + ) -> np.ndarray: + return np.empty(0) + + def before_masking( + self, number: int, pixels: np.ndarray, template: np.ndarray + ) -> np.ndarray: + return pixels + + def after_masking( + self, number: int, pixels: np.ndarray, template: np.ndarray + ) -> np.ndarray: + return pixels + + +@dataclass(frozen=True, slots=True) +class _TemplateClassMatching(ClassHistogramMatching, ABC): + """Every other class is given the histogram of ``template_class``.""" + + template_class: int = 1 + """Number of the class whose histogram the other classes are given.""" + + def template_class_number(self) -> int: + return self.template_class + + def _template_class_pixels( + self, class_pixels: Mapping[int, np.ndarray] + ) -> np.ndarray: + if self.template_class not in class_pixels: + raise _refuse( + f"no image belongs to the template class {self.template_class}" + ) + return class_pixels[self.template_class] + + def _matched( + self, number: int, pixels: np.ndarray, template: np.ndarray + ) -> np.ndarray: + if number == self.template_class: + return pixels + return _matched_to(pixels, template) + + +@dataclass(frozen=True, slots=True) +class MatchBeforeMasking(_TemplateClassMatching): + """The classes are matched on whole images, before objects mask them. + + The template is the template class as it is before masking and rescaling. + """ + + choice: ClassVar[str] = "Yes, pre-masking or on unmasked images" + + def template( + self, class_pixels: Mapping[int, np.ndarray], region: _Region + ) -> np.ndarray: + return self._template_class_pixels(class_pixels) + + def before_masking( + self, number: int, pixels: np.ndarray, template: np.ndarray + ) -> np.ndarray: + return self._matched(number, pixels, template) + + def after_masking( + self, number: int, pixels: np.ndarray, template: np.ndarray + ) -> np.ndarray: + return pixels + + +@dataclass(frozen=True, slots=True) +class MatchAfterMasking(_TemplateClassMatching): + """The classes are matched on their values inside the objects. + + Needs objects to compensate within. The template is the masked template + class, not rescaled even where the classes are. + """ + + choice: ClassVar[str] = "Yes, post-masking to objects" + + def template( + self, class_pixels: Mapping[int, np.ndarray], region: _Region + ) -> np.ndarray: + pixels = self._template_class_pixels(class_pixels) + mask = region.object_mask("histogram matching after masking") + inside = np.tile(mask, pixels.size // mask.size) * pixels + return np.where(inside == 0, 1, inside) + + def before_masking( + self, number: int, pixels: np.ndarray, template: np.ndarray + ) -> np.ndarray: + return pixels + + def after_masking( + self, number: int, pixels: np.ndarray, template: np.ndarray + ) -> np.ndarray: + return self._matched(number, pixels, template) + + +_CLASS_HISTOGRAM_MATCHINGS: tuple[type[_TemplateClassMatching], ...] = ( + MatchBeforeMasking, + MatchAfterMasking, +) + + +def _bright_spots(pixels: np.ndarray, sigma: int) -> np.ndarray: + """The negated Laplacian of Gaussian of the 16-bit image, negatives set to 0.""" + from scipy.ndimage import gaussian_laplace + from skimage.util import img_as_uint + + try: + sixteen_bit = img_as_uint(pixels) + except ValueError as error: + raise _refuse( + "the Laplacian of Gaussian filter takes intensities from -1 to 1" + ) from error + filtered = -1 * gaussian_laplace(sixteen_bit.astype(float), sigma) + return np.clip(filtered, 0, _SIXTEEN_BIT) / _SIXTEEN_BIT + + +def _percentile(values: np.ndarray, percent: float) -> np.float64: + """The percentile between the two nearest values, as NumPy 1.24 computes it. + + Their difference is taken in the precision of ``values`` and the result is + double precision; NumPy 2 returns single precision for single precision. + """ + ordered = np.sort(values, axis=None) + quantile = np.float64(percent) / 100 + position = ordered.size * quantile + (1 + quantile * -1) - 1 + below = int(np.floor(position)) + lower, upper = ordered[below], ordered[min(below + 1, ordered.size - 1)] + share = np.float64(position - below) + if share >= 0.5: + return np.float64(upper - (upper - lower) * (1 - share)) + return np.float64(lower + (upper - lower) * share) + + +def _class_divisors( + planes: tuple[np.ndarray, ...], + members: Mapping[int, list[int]], + percent: float, +) -> dict[int, float]: + """Each class's percentile intensity relative to the smallest of them.""" + levels = { + number: _percentile( + np.concatenate([planes[index] for index in indexes]), percent + ) + for number, indexes in members.items() + } + lowest = np.min(list(levels.values())) + return {number: level / lowest for number, level in levels.items()} + + +def _unmixed(class_pixels: list[np.ndarray]) -> np.ndarray: + """Whole 16-bit values of each class with the other classes' share removed.""" + values = np.array(class_pixels) + by_pixel = values.T + brightest = by_pixel.argmax(axis=1) + with warnings.catch_warnings(): + # A class that is nowhere the brightest has no median: see the caller. + warnings.simplefilter("ignore", RuntimeWarning) + mixing = np.array( + [ + np.median(by_pixel[brightest == index], axis=0) + for index in range(len(class_pixels)) + ] + ).T + with np.errstate(divide="ignore", invalid="ignore"): + mixing = mixing / mixing.sum(axis=0) + try: + unmixing = np.linalg.inv(mixing) + except np.linalg.LinAlgError as error: + raise _refuse("the classes cannot be told apart") from error + unmixed = unmixing.dot(values) + # Not-a-number and infinite values become the most negative whole number + # (what the plugin's cast gives on x86), which is 0 once clipped. + return np.where(np.isfinite(unmixed), unmixed, -1).astype(np.int64) + + +@composed_image_payload +@numpy(contract=FlexibleContract) +@special_inputs("labels") +def compensate_colors( + image: np.ndarray, + compensation_classes: tuple[int, ...], + labels: ObjectLabelValue | None = None, + within_objects: bool = False, + rescale_inputs: bool = False, + rescale_after_masking: RescaleAfterMasking = NoRescaleAfterMasking(), + histogram_matching: ClassHistogramMatching = NoClassHistogramMatching(), + rescale_outputs: bool = False, + divide_by_class_percentile: bool = False, + class_percentile: float = 99.0, + tophat_filter: bool = False, + tophat_radius: int = 3, + laplacian_of_gaussian_filter: bool = False, + laplacian_of_gaussian_sigma: int = 1, + difference_of_gaussians_filter: bool = False, + difference_of_gaussians_low_sigma: int = 3, + difference_of_gaussians_high_sigma: int = 5, +) -> ImagePayload | AlignedImageStack: + """Remove each channel's bleed-through from the images of the other channels. + + For sequencing by synthesis: every image belongs to a class (one class per + base or dye, with one image per cycle). The pixels where a class is the + brightest give its typical share in every class; the inverse of these + shares turns the measured intensities into unmixed ones. One output image + is made per input image, in the order of the input images. + + The plugin's behaviour is kept, including: + + - Intensities are worked on as 16-bit values (times 65535, rounded) and the + unmixed values are cut to whole numbers before they are divided by 65535 + and clipped to 0..1. + - A class's share is the median over the pixels where it is brightest; + where classes tie, the lowest class number counts as brightest. + - Within objects every pixel outside them, and every pixel that is 0, is 1 + (of 65535) in all classes. These pixels count for the lowest class + number, so where objects cover the smaller part of the image that + class's shares are all equal. + - Where a class is nowhere the brightest, or the brightest pixels of a + class have a median of 0 in all classes (the Laplacian of Gaussian filter + across an entire image, where most pixels become 0), all output images + are 0. + - Filters run in the order top-hat, Laplacian of Gaussian, difference of + Gaussians, each on the result of the one before. The percentile that + classes are divided by is taken from the unfiltered images. + - Fails, and is refused here: classes with different numbers of images; + images of different sizes or volumes; rescaling each image or matching + histograms after masking without objects; a template class no image + belongs to; nothing to rescale inside the objects; a low sigma above the + high sigma; classes that cannot be told apart. + + One difference: the plugin keeps one image per output name, the last it + makes, so with a name used twice (its default name ``None`` among them) it + leaves fewer images than it was given. Here every input image needs an + output name of its own. + + Reproduced from older libraries, whose results differ from the installed + ones in precision: scikit-image 0.18's intensity rescaling and histogram + matching, and NumPy 1.24's percentile. + + Args: + compensation_classes: Class number of each input image, in the order + of the images. Every class needs the same number of images. + labels: Objects to compensate within, when ``within_objects`` is set. + within_objects: Use only the pixels inside the objects; without it the + entire image is used. + rescale_inputs: Stretch each (filtered) image to the full range first. + rescale_after_masking: Stretch intensities once pixels outside the + objects are set aside: ``NoRescaleAfterMasking``, + ``RescaleEachImage`` or ``RescaleEachClass``. + histogram_matching: Give all classes the histogram of one class: + ``NoClassHistogramMatching``, ``MatchBeforeMasking`` or + ``MatchAfterMasking``. + rescale_outputs: Stretch each compensated image to the full range. + divide_by_class_percentile: Divide each class's images by the class's + ``class_percentile`` intensity relative to the lowest such + intensity of all classes. + class_percentile: Percentile (0.1 to 100) used for that division. + tophat_filter: Apply a white top-hat filter to each image first. + tophat_radius: Radius in pixels of the top-hat filter's disk. + laplacian_of_gaussian_filter: Replace each image by its negated + Laplacian of Gaussian, which is bright at spots. + laplacian_of_gaussian_sigma: Sigma in pixels of that filter. + difference_of_gaussians_filter: Replace each image by the difference + of two Gaussian blurs of it. + difference_of_gaussians_low_sigma: Sigma in pixels of the narrow blur. + difference_of_gaussians_high_sigma: Sigma in pixels of the wide blur + that is subtracted. + """ + from scipy.ndimage import gaussian_filter, white_tophat + from skimage.morphology import disk + + stacked = np.asarray(image) + planes = ( + (stacked,) + if len(compensation_classes) == 1 + else tuple(stacked[index] for index in range(stacked.shape[0])) + ) + if len(planes) != len(compensation_classes): + raise ValueError( + f"CompensateColors got {len(planes)} image(s) for " + f"{len(compensation_classes)} class number(s)." + ) + shape = planes[0].shape + if len(shape) != 2: + raise _refuse("it works on single planes, not on volumes") + if any(plane.shape != shape for plane in planes): + raise _refuse("the images differ in size") + members: dict[int, list[int]] = {} + for index, number in enumerate(compensation_classes): + members.setdefault(number, []).append(index) + if len({len(indexes) for indexes in members.values()}) != 1: + raise _refuse("the classes have different numbers of images") + if ( + difference_of_gaussians_filter + and difference_of_gaussians_high_sigma < difference_of_gaussians_low_sigma + ): + raise _refuse("the difference of Gaussians' high sigma is below its low sigma") + + region: _Region + if within_objects: + if labels is None: + raise _refuse("no objects are named to compensate within") + region = _WithinObjects( + np.where(object_label_dense_array(labels, dtype=np.int32) > 0, 1, 0) + .astype(np.int64) + .reshape(-1) + ) + else: + region = _EntireImage() + + with np.errstate(divide="ignore", invalid="ignore"): + divisors: Mapping[int, float] = ( + _class_divisors(planes, members, class_percentile) + if divide_by_class_percentile + else dict.fromkeys(members, 1.0) + ) + sixteen_bit: list[np.ndarray] = [] + for plane, number in zip(planes, compensation_classes): + pixels = plane + if tophat_filter: + pixels = white_tophat(pixels, footprint=disk(tophat_radius)) + if laplacian_of_gaussian_filter: + pixels = _bright_spots(pixels, laplacian_of_gaussian_sigma) + if difference_of_gaussians_filter: + pixels = gaussian_filter( + pixels, difference_of_gaussians_low_sigma, mode="nearest" + ) - gaussian_filter( + pixels, difference_of_gaussians_high_sigma, mode="nearest" + ) + # The division keeps the image's precision, as it did in the + # plugin's NumPy, whatever the precision of the divisor. + pixels = pixels / pixels.dtype.type(divisors[number]) + if rescale_inputs: + pixels = _stretched( + pixels, pixels.min(), pixels.max(), 1.0 / _SIXTEEN_BIT, 1.0 + ) + pixels = rescale_after_masking.image(pixels, region) + sixteen_bit.append(np.round(pixels * _SIXTEEN_BIT).reshape(-1)) + + class_numbers = sorted(members) + class_pixels = { + number: np.concatenate([sixteen_bit[index] for index in members[number]]) + for number in class_numbers + } + template = histogram_matching.template(class_pixels, region) + prepared: list[np.ndarray] = [] + for number in class_numbers: + values = histogram_matching.before_masking( + number, class_pixels[number], template + ) + values = region.masked(values, len(members[number])) + values = rescale_after_masking.class_pixels(values) + prepared.append(histogram_matching.after_masking(number, values, template)) + unmixed = _unmixed(prepared) + + compensated: dict[int, np.ndarray] = {} + for values, number in zip(unmixed, class_numbers): + class_images = values.reshape(len(members[number]), *shape) / float(_SIXTEEN_BIT) + for index, pixels in zip(members[number], class_images): + pixels = np.clip(pixels, 0, 1) + if rescale_outputs: + pixels = _stretched(pixels, pixels.min(), pixels.max(), 0.0, 1.0) + compensated[index] = pixels.astype(np.float32) + return pack_aligned_image_outputs( + tuple(compensated[index] for index in range(len(planes))) + ) + + +@dataclass(frozen=True, slots=True) +class _WithinObjectsBinding(SettingToKeywordBinding): + """CellProfiler's choice of where to compensate, as whether objects are used.""" + + def bind( + self, + module: ModuleBlock, + kwargs: dict[str, CellProfilerSettingValue], + binder: SettingsBinder, + ) -> None: + del binder + kwargs[self.require_parameter_name()] = _compensates_within_objects(module) + + def records_from_kwargs( + self, kwargs: Mapping[str, object] + ) -> tuple[ModuleSetting, ...]: + name = self.require_parameter_name() + if name not in kwargs: + return () + return ( + ModuleSetting( + setting_names(self.setting_name)[0], + _WITHIN_OBJECTS if kwargs[name] else _ENTIRE_IMAGE, + ), + ) + + +def _compensates_within_objects(module: ModuleBlock) -> bool: + where = required_setting_value(module, _WHERE_SETTING) + if where not in (_WITHIN_OBJECTS, _ENTIRE_IMAGE): + raise ValueError( + f"CompensateColors({module.module_num}) compensates {_ENTIRE_IMAGE!r} " + f"or {_WITHIN_OBJECTS!r}, got {where!r}." + ) + return where == _WITHIN_OBJECTS + + +def _chosen(module: ModuleBlock, setting: str, kinds: tuple[type, ...]) -> type: + choice = required_setting_value(module, setting) + for kind in kinds: + if kind.choice == choice: + return kind + raise ValueError( + f"CompensateColors({module.module_num}) setting {setting!r} is one of " + f"{[kind.choice for kind in kinds]}, got {choice!r}." + ) + + +@dataclass(frozen=True, slots=True) +class _RescaleAfterMaskingBinding(SettingToKeywordBinding): + def bind( + self, + module: ModuleBlock, + kwargs: dict[str, CellProfilerSettingValue], + binder: SettingsBinder, + ) -> None: + del binder + kwargs[self.require_parameter_name()] = _chosen( + module, _RESCALE_AFTER_MASKING_SETTING, _RESCALES_AFTER_MASKING + )() + + def records_from_kwargs( + self, kwargs: Mapping[str, object] + ) -> tuple[ModuleSetting, ...]: + rescale = kwargs.get(self.require_parameter_name()) + if not isinstance(rescale, RescaleAfterMasking): + return () + return (ModuleSetting(_RESCALE_AFTER_MASKING_SETTING, rescale.choice),) + + +@dataclass(frozen=True, slots=True) +class _ClassHistogramMatchingBinding(SettingToKeywordBinding): + """The matching chosen by a block, with the template class it names.""" + + def bind( + self, + module: ModuleBlock, + kwargs: dict[str, CellProfilerSettingValue], + binder: SettingsBinder, + ) -> None: + del binder + kind = _chosen( + module, + _MATCHING_SETTING, + (NoClassHistogramMatching, *_CLASS_HISTOGRAM_MATCHINGS), + ) + kwargs[self.require_parameter_name()] = ( + kind( + parse_cellprofiler_int( + required_setting_value(module, _TEMPLATE_CLASS_SETTING) + ) + ) + if issubclass(kind, _TemplateClassMatching) + else kind() + ) + + def records_from_kwargs( + self, kwargs: Mapping[str, object] + ) -> tuple[ModuleSetting, ...]: + matching = kwargs.get(self.require_parameter_name()) + if not isinstance(matching, ClassHistogramMatching): + return () + return ( + ModuleSetting(_MATCHING_SETTING, matching.choice), + ModuleSetting( + _TEMPLATE_CLASS_SETTING, str(matching.template_class_number()) + ), + ) + + +class CompensateColorsModule(SingleObjectLabelInputPolicy, ObjectArtifactInputModule): + module_name = "CompensateColors" + function_name = "compensate_colors" + validated = True + label_kwarg = "labels" + + # Two rows: the number of image groups, then the number of object rows. + hidden_count_setting = "Hidden" + input_image_setting = "Select an image to measure" + class_setting = "What compensation class does this image belong to?" + output_image_setting = "Select an output image name" + input_image_binding = SettingToKeywordBinding.input( + input_image_setting, ImageArtifactType, repeated=True + ) + output_image_binding = SettingToKeywordBinding.output( + output_image_setting, ImageArtifactType, repeated=True + ) + # The plugin compensates within the first objects a block names. + objects_binding = SettingToKeywordBinding.input( + _OBJECTS_SETTING, ObjectLabelsArtifactType, runtime_parameter_name="labels" + ) + setting_bindings: ClassVar[tuple[SettingToKeywordBinding, ...]] = ( + input_image_binding, + SettingToKeywordBinding( + class_setting, "compensation_classes", parse_cellprofiler_int, repeated=True + ), + output_image_binding, + _WithinObjectsBinding(_WHERE_SETTING, "within_objects"), + objects_binding, + SettingToKeywordBinding( + "Should individual images be rescaled 0-1 before compensating " + "pre-masking or on unmasked images?", + "rescale_inputs", + parse_cellprofiler_bool, + ), + _RescaleAfterMaskingBinding( + _RESCALE_AFTER_MASKING_SETTING, "rescale_after_masking" + ), + _ClassHistogramMatchingBinding(_MATCHING_SETTING, "histogram_matching"), + SettingToKeywordBinding( + "Should images be rescaled 0-1 after compensating?", + "rescale_outputs", + parse_cellprofiler_bool, + ), + SettingToKeywordBinding( + "Should the images be divided by a scalar based on group percentiles", + "divide_by_class_percentile", + parse_cellprofiler_bool, + ), + SettingToKeywordBinding( + "What percentile should be used for multiplication", + "class_percentile", + parse_cellprofiler_float, + ), + SettingToKeywordBinding( + "Should the images have a tophat filter applied before correction?", + "tophat_filter", + parse_cellprofiler_bool, + ), + SettingToKeywordBinding( + "What size radius should be used for the tophat filter?", + "tophat_radius", + parse_cellprofiler_int, + ), + SettingToKeywordBinding( + "Should the images have a Laplacian of Gaussian filter applied " + "before correction?", + "laplacian_of_gaussian_filter", + parse_cellprofiler_bool, + ), + SettingToKeywordBinding( + "What size radius should be used for the LoG filter?", + "laplacian_of_gaussian_sigma", + parse_cellprofiler_int, + ), + SettingToKeywordBinding( + "Should the images have a Difference of Gaussians filter applied " + "before correction?", + "difference_of_gaussians_filter", + parse_cellprofiler_bool, + ), + SettingToKeywordBinding( + "What size sigma should be used for the DoG low sigma?", + "difference_of_gaussians_low_sigma", + parse_cellprofiler_int, + ), + SettingToKeywordBinding( + # The plugin's wording; this is the high sigma. + "What size radius should be used for the DoG low sigma?", + "difference_of_gaussians_high_sigma", + parse_cellprofiler_int, + ), + ) + + @classmethod + def postprocess_bound_settings( + cls, module: ModuleBlock, bound: BoundModuleSettings + ) -> BoundModuleSettings: + image_names = setting_values(module, cls.input_image_setting) + class_numbers = setting_values(module, cls.class_setting) + output_names = setting_values(module, cls.output_image_setting) + if not image_names or not ( + len(image_names) == len(class_numbers) == len(output_names) + ): + raise ValueError( + f"CompensateColors({module.module_num}) needs a class and an output " + f"name for each image, got {image_names!r}, {class_numbers!r} and " + f"{output_names!r}." + ) + named_outputs = cls.artifact_names_for_binding(module, cls.output_image_binding) + if len(named_outputs) != len(image_names) or len(set(named_outputs)) != len( + named_outputs + ): + # The function returns one image per input image. The plugin keeps + # one image per name, the last it makes, so names used twice (its + # default, "None", among them) leave it with fewer. + raise ValueError( + f"CompensateColors({module.module_num}) needs a different output " + f"image name for each input image, got {output_names!r}." + ) + if _compensates_within_objects(module) and not setting_values( + module, _OBJECTS_SETTING + ): + raise _refuse("no objects are named to compensate within") + counts = setting_values(module, cls.hidden_count_setting) + if counts and int(counts[0]) != len(image_names): + raise ValueError( + f"CompensateColors({module.module_num}) declares {counts[0]} image " + f"group(s) but has {len(image_names)}." + ) + return bound.with_kwargs( + { + "compensation_classes": tuple( + parse_cellprofiler_int(number) for number in class_numbers + ), + } + ).with_consumed_settings( + cls.hidden_count_setting, + cls.input_image_setting, + cls.class_setting, + cls.output_image_setting, + _TEMPLATE_CLASS_SETTING, + ) + + @classmethod + def active_artifact_bindings( + cls, + module: ModuleBlock | None = None, + *, + invocation_key: FunctionInvocationKey | None = None, + ) -> tuple[SettingToKeywordBinding, ...]: + """Objects are an input only when the block compensates within them.""" + bindings = super().active_artifact_bindings( + module, invocation_key=invocation_key + ) + if module is None or _compensates_within_objects(module): + return bindings + return tuple( + binding for binding in bindings if binding is not cls.objects_binding + ) + + @classmethod + def artifact_names_for_binding( + cls, module: ModuleBlock, binding: SettingToKeywordBinding + ) -> tuple[str, ...]: + names = super().artifact_names_for_binding(module, binding) + if binding is cls.objects_binding: + return names[:1] + return names + + @classmethod + def artifact_output_relations( + cls, + module: ModuleBlock, + *, + invocation_key: FunctionInvocationKey, + step_context: object, + binding: SettingToKeywordBinding, + name: str, + artifact_inputs: ArtifactSpecCollection, + output_position: int, + ) -> tuple[ArtifactSpecRelation, ...]: + """Every output depends on all input images; the first gives its scope.""" + del module, invocation_key, step_context, binding, name, output_position + image_inputs = artifact_inputs.for_artifact_type(ImageArtifactType).specs + if not image_inputs: + raise ValueError("CompensateColors requires at least one image input.") + return (GroupLineageSourceRelation(source=image_inputs[0].ref()),) diff --git a/openhcs/processing/backends/cellprofiler/declump_objects.py b/openhcs/processing/backends/cellprofiler/declump_objects.py new file mode 100644 index 000000000..d71752b99 --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/declump_objects.py @@ -0,0 +1,715 @@ +"""CellProfiler plugin DeclumpObjects. + +Upstream: CellProfiler-plugins ``active_plugins/declumpobjects.py``, as run by +CellProfiler 4.2.8.1 with scikit-image 0.18.3 and NumPy 1.24. + +The plugin splits objects with a seeded watershed. Seeds are the local maxima +of the distance of every object pixel to the background; they are dilated and +numbered, and the watershed floods either the smoothed inverted distance +("Shape") or a smoothed inverted reference image ("Intensity") from them, +within all object pixels. + +What the plugin does, reproduced here: + +* All input objects are one foreground. Distances are measured to the + background only (in pixels, whatever the voxel spacing), so touching objects + with different labels are one clump, a seed may flood several of them, and an + output object may lie on several input objects. +* The seed search is scikit-image 0.18.3's ``peak_local_max``, which differs + from later versions: candidates are the pixels equal to the maximum of their + ``2 * distance + 1`` window and above the threshold, visited from the highest + down; one is dropped when an earlier kept one is closer than the minimum + distance on every axis (so two exactly that far apart both stay). Pixels of + equal height are visited in the order NumPy 1.24's unstable sort leaves them + in, which decides which pixel of a plateau stays and which seeds a seed limit + keeps; that sort is repeated here step by step. An image in which every pixel + is such a maximum (no objects, or nothing but objects) has no seeds. +* "Minimum absolute internal distance" is a fraction of the largest distance, + not a distance. +* A seed limit of -1 means no limit; any other negative limit drops that many + of the last-visited seeds; 0 leaves none. +* An input object without a seed is absent from the output, and without any + seed the output is empty. +* Output labels count seeds backwards: the last seed in scan order is object 1. + Every seed has a pixel on an object, so the labels are 1..N without gaps and + the object count is the number of dilated seeds. +* Smoothing treats a volume whose last axis has length 3 as a colour image and + does not smooth along that axis. +* Labels are stored as 16-bit, so they wrap past 65535; with fewer than 32767 + input objects the seed numbers are held as 16-bit signed and wrap past 32767. + +The functions return the labels and each output object's parent (the input +object it overlaps most). Counts, locations and the parent and child-count +measurements are made from them by the module family when the objects are +recorded; because the labels have no gaps, they are CellProfiler's +``Count_``, ``Location_Center_``, ``Parent_`` and ``Children_..._Count``. + +Refused, because the CellProfiler plugin fails: a structuring element whose +dimensions differ from the objects' (a disk for a volume, a ball for a plane), +a reference image shaped unlike the objects, a negative smoothing factor. + +CellProfiler keeps a number setting's default when a pipeline gives it a value +outside its range, so a block with a negative minimum distance, internal +distance or border width, or a connectivity other than 1 to 3, runs with 1, 0, +0 and 1; such blocks are bound to those values. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar, TypeVar + +import numpy as np + +from openhcs.core.artifacts.kinds import ImageArtifactType +from openhcs.core.measurement_row_materialization import ( + MeasurementSparseColumnarRows, +) +from openhcs.core.memory.decorators import numpy +from openhcs.core.pipeline.function_contracts import ( + FullStackLabels, + object_label_input_execution_mode, + special_inputs, +) +from openhcs.core.processing_contracts import Pure2DContract, Pure3DContract +from openhcs.core.runtime_object_label_domains import ( + PresentObjectLabelIdsDomainDeclaration, +) +from openhcs.core.runtime_object_labels import ( + ObjectLabelValue, + object_label_dense_array, + object_label_value_with_dense_labels, +) +from openhcs.core.runtime_plane_projection import RuntimeSliceInvariantValue +from openhcs.core.runtime_relationships import ( + DirectedObjectRelationshipPayload, + object_label_parent_child_payload, +) +from openhcs.interop.cellprofiler.module_structuring_element_settings import ( + StructuringElementSettingBinding, + StructuringElementSettingsModule, +) +from openhcs.interop.cellprofiler.parser import ModuleSetting +from openhcs.interop.cellprofiler.runtime.object_input_policies import ( + LabelsObjectInputPolicy, +) +from openhcs.interop.cellprofiler.runtime.primary_image_input_policies import ( + ObjectLabelDrivenPrimaryImageInputPolicy, +) +from openhcs.interop.cellprofiler.setting_names import required_setting_value +from openhcs.interop.cellprofiler.settings_binder import ( + SettingToKeywordBinding, + parse_cellprofiler_float, + parse_cellprofiler_int, +) +from openhcs.processing.backends.cellprofiler.morphology import ( + ObjectLineageTransformModule, + ZStackFunctionVariantModule, +) +from openhcs.processing.backends.cellprofiler.structuring_elements import ( + StructuringElement, + StructuringElementInput, + StructuringElementSize, + build_structuring_element, +) + +if TYPE_CHECKING: + from openhcs.core.function_patterns import FunctionInvocationKey + from openhcs.interop.cellprofiler.parser import ModuleBlock + from openhcs.interop.cellprofiler.settings_binder import ( + CellProfilerSettingValue, + SettingsBinder, + ) + +_Number = TypeVar("_Number", int, float) + +_PLUGIN_FAILS = "the CellProfiler plugin fails" +_METHOD_SETTING = "Declump method" +_NO_SEED_LIMIT = -1 +# NumPy sorts runs spanning at most this many steps by insertion. +_INSERTION_SORTED_LENGTH = 15 + + +def _refuse(reason: str) -> ValueError: + return ValueError(f"DeclumpObjects cannot run: {reason}; {_PLUGIN_FAILS} here.") + + +class DeclumpMethod(RuntimeSliceInvariantValue, ABC): + """What the watershed floods to find the lines between objects.""" + + choice: ClassVar[str] + """The wording of this choice in CellProfiler.""" + + @abstractmethod + def heights(self, distance: np.ndarray, reference: np.ndarray | None) -> np.ndarray: + """The unsmoothed surface: low where an object's middle is.""" + + +@dataclass(frozen=True, slots=True) +class DeclumpByShape(DeclumpMethod): + """Objects part where they are narrow: the surface is the inverted distance.""" + + choice: ClassVar[str] = "Shape" + + def heights(self, distance: np.ndarray, reference: np.ndarray | None) -> np.ndarray: + heights = -distance + heights -= heights.min() + return heights + + +@dataclass(frozen=True, slots=True) +class DeclumpByIntensity(DeclumpMethod): + """Objects part where a reference image is dim: the surface is its inverse.""" + + choice: ClassVar[str] = "Intensity" + + def heights(self, distance: np.ndarray, reference: np.ndarray | None) -> np.ndarray: + from skimage.util import img_as_float + + if reference is None: + raise ValueError("DeclumpObjects by intensity requires a reference image.") + if reference.shape != distance.shape: + raise _refuse( + f"the reference image has shape {reference.shape} and the objects " + f"{distance.shape}" + ) + heights = img_as_float(reference, force_copy=True) + heights -= heights.min() + return 1 - heights + + +_DECLUMP_METHODS: tuple[type[DeclumpMethod], ...] = (DeclumpByShape, DeclumpByIntensity) + + +def _heapsorted_order(values: list[float], order: list[int]) -> list[int]: + """``order`` sorted by value as NumPy's heap sort leaves it.""" + count = len(order) + heap = [0, *order] + + def sift(start: int, moved: int, end: int) -> None: + parent, child = start, 2 * start + while child <= end: + if child < end and values[heap[child]] < values[heap[child + 1]]: + child += 1 + if not values[moved] < values[heap[child]]: + break + heap[parent] = heap[child] + parent, child = child, 2 * child + heap[parent] = moved + + for start in range(count >> 1, 0, -1): + sift(start, heap[start], count) + while count > 1: + moved = heap[count] + heap[count] = heap[1] + count -= 1 + sift(1, moved, count) + return heap[1:] + + +def _numpy_1_24_argsort(values: list[float]) -> list[int]: + """The order ``numpy.argsort`` gives in NumPy 1.24, equal values included. + + That sort (median-of-three quicksort, insertion sort for short runs, heap + sort when the recursion gets too deep) is not stable, and later NumPy + versions order equal values differently. + """ + order = list(range(len(values))) + if len(order) < 2: + return order + low, high = 0, len(order) - 1 + pending: list[tuple[int, int, int]] = [] + depth = 2 * (len(order).bit_length() - 1) + while True: + if depth < 0: + order[low : high + 1] = _heapsorted_order(values, order[low : high + 1]) + else: + while high - low > _INSERTION_SORTED_LENGTH: + middle = low + ((high - low) >> 1) + if values[order[middle]] < values[order[low]]: + order[middle], order[low] = order[low], order[middle] + if values[order[high]] < values[order[middle]]: + order[high], order[middle] = order[middle], order[high] + if values[order[middle]] < values[order[low]]: + order[middle], order[low] = order[low], order[middle] + pivot = values[order[middle]] + left, right = low, high - 1 + order[middle], order[right] = order[right], order[middle] + while True: + left += 1 + while values[order[left]] < pivot: + left += 1 + right -= 1 + while pivot < values[order[right]]: + right -= 1 + if left >= right: + break + order[left], order[right] = order[right], order[left] + order[left], order[high - 1] = order[high - 1], order[left] + depth -= 1 + # The larger part waits; the smaller is sorted first. + if left - low < high - left: + pending.append((left + 1, high, depth)) + high = left - 1 + else: + pending.append((low, left - 1, depth)) + low = left + 1 + for index in range(low + 1, high + 1): + moved = order[index] + value = values[moved] + position = index + while position > low and value < values[order[position - 1]]: + order[position] = order[position - 1] + position -= 1 + order[position] = moved + if not pending: + return order + low, high, depth = pending.pop() + + +def _seeds( + distance: np.ndarray, + min_distance: int, + threshold_rel: float, + border_width: int, + max_seeds: int, +) -> np.ndarray: + """Seed pixels as scikit-image 0.18.3's ``peak_local_max`` picks them.""" + from scipy.ndimage import maximum_filter + + threshold = max(distance.min(), threshold_rel * distance.max()) + if min_distance < 1 or distance.size == 1: + candidates = distance > threshold + else: + window = np.ones((2 * min_distance + 1,) * distance.ndim, dtype=bool) + candidates = distance == maximum_filter( + distance, footprint=window, mode="constant" + ) + if candidates.all(): + candidates[:] = False + candidates &= distance > threshold + if border_width: + for axis in range(distance.ndim): + edge = [slice(None)] * distance.ndim + edge[axis] = slice(None, border_width) + candidates[tuple(edge)] = False + edge[axis] = slice(-border_width, None) + candidates[tuple(edge)] = False + + positions = np.transpose(np.nonzero(candidates)) + spaced = min_distance > 0 + limited = max_seeds != _NO_SEED_LIMIT and len(positions) > max_seeds + if not (spaced or limited) or not len(positions): + return candidates + order = _numpy_1_24_argsort((-distance[candidates]).tolist()) + positions = positions[order] + if spaced: + # A kept seed shuts out later ones nearer than the minimum distance. + shut_out = np.zeros(distance.shape, dtype=bool) + kept = [] + reach = min_distance - 1 + for index, position in enumerate(positions.tolist()): + if shut_out[tuple(position)]: + continue + kept.append(index) + shut_out[ + tuple(slice(max(at - reach, 0), at + reach + 1) for at in position) + ] = True + positions = positions[kept] + if max_seeds != _NO_SEED_LIMIT and len(positions) > max_seeds: + positions = positions[:max_seeds] + seeds = np.zeros(distance.shape, dtype=bool) + seeds[tuple(positions.T)] = True + return seeds + + +def _declumped( + labels: np.ndarray, + reference: np.ndarray | None, + *, + method: DeclumpMethod, + smoothing_sigma: float, + min_seed_distance: int, + min_relative_distance: float, + seed_border_exclusion: int, + max_seeds: int, + seed_structuring_element: StructuringElement, + seed_structuring_element_size: int, + watershed_connectivity: int, +) -> np.ndarray: + """The labels the plugin stores for one plane or one volume of objects.""" + from scipy.ndimage import ( + binary_dilation, + distance_transform_edt, + gaussian_filter, + label, + ) + from skimage.segmentation import watershed + + if seed_structuring_element is StructuringElement.OCTAGON: + # CellProfiler does not offer it and cannot build it from one size. + raise _refuse("an octagon is not a structuring element of CellProfiler") + footprint = build_structuring_element( + seed_structuring_element, seed_structuring_element_size + ) + if footprint.ndim != labels.ndim: + raise _refuse( + f"the structuring element has {footprint.ndim} dimensions and the " + f"objects {labels.ndim}" + ) + if smoothing_sigma < 0: + raise _refuse("the smoothing factor is negative") + if min_seed_distance < 0 or seed_border_exclusion < 0: + raise ValueError( + "DeclumpObjects needs a minimum seed distance and a border width of 0 " + f"or more, got {min_seed_distance!r} and {seed_border_exclusion!r}." + ) + if not 1 <= watershed_connectivity <= 3: + raise ValueError( + "DeclumpObjects watershed connectivity is 1, 2 or 3, got " + f"{watershed_connectivity!r}." + ) + + foreground = labels != 0 + distance = distance_transform_edt(foreground) + heights = method.heights(distance, reference) + if heights.dtype == np.float16: + heights = heights.astype(np.float32) + # scikit-image 0.18 takes a last axis of length 3 for colour channels. + sigmas = ( + (smoothing_sigma, smoothing_sigma, 0) + if heights.ndim == 3 and heights.shape[-1] == 3 + else smoothing_sigma + ) + heights = gaussian_filter(heights, sigmas, mode="nearest", truncate=4.0) + + seeds = _seeds( + distance, + min_seed_distance, + min_relative_distance, + seed_border_exclusion, + max_seeds, + ) + numbered_seeds = label(binary_dilation(seeds, structure=footprint))[0] + object_count = len(np.unique(labels[foreground])) + markers = np.zeros( + labels.shape, + dtype=np.int16 if object_count < np.iinfo(np.int16).max else np.int32, + ) + markers[numbered_seeds > 0] = -numbered_seeds[numbered_seeds > 0] + flooded = watershed( + heights, + markers=markers, + connectivity=watershed_connectivity, + mask=foreground, + ).astype(np.int32) + unreached = flooded == 0 + # Markers are negative: the last seed, the most negative, becomes 1. + flooded += np.abs(flooded.min()) + 1 + flooded[unreached] = 0 + return flooded.astype(np.uint16) + + +def _children_of( + image: np.ndarray, labels: ObjectLabelValue, declumped: np.ndarray +) -> tuple[ + np.ndarray, + MeasurementSparseColumnarRows, + ObjectLabelValue, + DirectedObjectRelationshipPayload, +]: + """The split labels as objects that are children of the objects they lie on.""" + children = object_label_value_with_dense_labels( + labels, + declumped.astype(np.int32), + domain_declaration=PresentObjectLabelIdsDomainDeclaration(), + ) + return ( + image, + # Counts, locations and parents are measured from the labels when the + # objects are recorded; the function has no measurements of its own. + MeasurementSparseColumnarRows.from_rows((), fields=()), + children, + object_label_parent_child_payload(labels, children), + ) + + +@numpy(contract=Pure2DContract) +@special_inputs("labels", "reference") +def declump_objects( + image: np.ndarray, + labels: ObjectLabelValue, + reference: np.ndarray | None = None, + method: DeclumpMethod = DeclumpByShape(), + smoothing_sigma: float = 1.0, + min_seed_distance: int = 1, + min_relative_distance: float = 0.0, + seed_border_exclusion: int = 0, + max_seeds: int = _NO_SEED_LIMIT, + seed_structuring_element: StructuringElementInput = StructuringElement.DISK, + seed_structuring_element_size: StructuringElementSize = 1, + watershed_connectivity: int = 1, +) -> tuple[ + np.ndarray, + MeasurementSparseColumnarRows, + ObjectLabelValue, + DirectedObjectRelationshipPayload, +]: + """Split touching objects of a plane with a seeded watershed. + + Seeds are the pixels farthest from the background within their + surroundings. They are dilated, and a watershed grows one output object + from each dilated seed over all object pixels. All input objects are + treated as one foreground, so an output object can cover parts of several + touching input objects, and an input object without a seed disappears. + The new objects are children of the input objects they lie on. + + Reproduces the CellProfiler plugin DeclumpObjects under scikit-image + 0.18.3, including which pixel of a plateau becomes the seed and the + numbering of the output (the last seed in scan order is object 1). + + Args: + labels: Objects to split. + reference: Image whose dim valleys separate objects; used by + ``DeclumpByIntensity`` only and shaped like the objects. + method: ``DeclumpByShape()`` floods the inverted distance to the + background, ``DeclumpByIntensity()`` the inverted reference image. + smoothing_sigma: Sigma in pixels of the Gaussian that smooths the + flooded surface before the watershed. 0 leaves it as it is. + min_seed_distance: A seed is the maximum of the square window reaching + this many pixels to each side, and no two seeds are nearer than + this on every axis. 0 makes every object pixel above the threshold + a seed. + min_relative_distance: Seeds are farther from the background than this + fraction of the largest distance in the image (CellProfiler calls + it "Minimum absolute internal distance"). + seed_border_exclusion: No seed within this many pixels of the image + border. + max_seeds: Keep at most this many seeds, those farthest from the + background first; -1 keeps all. + seed_structuring_element: Shape the seeds are dilated with before they + are numbered; seeds that touch afterwards are one seed. A plane + needs a flat shape (disk, square, diamond, star). + seed_structuring_element_size: Radius or extent of that shape in pixels. + watershed_connectivity: Neighbours the watershed floods to: 1 for + those sharing an edge, 2 also diagonal ones. + """ + return _children_of( + image, + labels, + _declumped( + object_label_dense_array(labels, dtype=np.int32), + reference, + method=method, + smoothing_sigma=smoothing_sigma, + min_seed_distance=min_seed_distance, + min_relative_distance=min_relative_distance, + seed_border_exclusion=seed_border_exclusion, + max_seeds=max_seeds, + seed_structuring_element=seed_structuring_element, + seed_structuring_element_size=seed_structuring_element_size, + watershed_connectivity=watershed_connectivity, + ), + ) + + +@numpy(contract=Pure3DContract) +@object_label_input_execution_mode(FullStackLabels) +@special_inputs("labels", "reference") +def declump_objects_3d( + image: np.ndarray, + labels: ObjectLabelValue, + reference: np.ndarray | None = None, + method: DeclumpMethod = DeclumpByShape(), + smoothing_sigma: float = 1.0, + min_seed_distance: int = 1, + min_relative_distance: float = 0.0, + seed_border_exclusion: int = 0, + max_seeds: int = _NO_SEED_LIMIT, + seed_structuring_element: StructuringElementInput = StructuringElement.BALL, + seed_structuring_element_size: StructuringElementSize = 1, + watershed_connectivity: int = 1, +) -> tuple[ + np.ndarray, + MeasurementSparseColumnarRows, + ObjectLabelValue, + DirectedObjectRelationshipPayload, +]: + """Split touching objects of a volume with a seeded watershed. + + The volume counterpart of ``declump_objects``: distances, seed windows, + smoothing and the watershed span all three axes, counted in voxels + whatever the voxel spacing. + + Args: + labels: Volume of objects to split. + reference: Volume whose dim valleys separate objects; used by + ``DeclumpByIntensity`` only and shaped like the objects. + method: ``DeclumpByShape()`` floods the inverted distance to the + background, ``DeclumpByIntensity()`` the inverted reference volume. + smoothing_sigma: Sigma in voxels of the Gaussian that smooths the + flooded surface before the watershed. A volume whose last axis has + length 3 is not smoothed along that axis. + min_seed_distance: A seed is the maximum of the cubic window reaching + this many voxels to each side, and no two seeds are nearer than + this on every axis. + min_relative_distance: Seeds are farther from the background than this + fraction of the largest distance in the volume. + seed_border_exclusion: No seed within this many voxels of the volume's + faces, the first and last planes included. + max_seeds: Keep at most this many seeds, those farthest from the + background first; -1 keeps all. + seed_structuring_element: Shape the seeds are dilated with before they + are numbered. A volume needs a solid shape (ball, cube, octahedron). + seed_structuring_element_size: Radius or extent of that shape in voxels. + watershed_connectivity: Neighbours the watershed floods to: 1 for + those sharing a face, 2 also an edge, 3 also a corner. + """ + return _children_of( + image, + labels, + _declumped( + object_label_dense_array(labels, dtype=np.int32), + reference, + method=method, + smoothing_sigma=smoothing_sigma, + min_seed_distance=min_seed_distance, + min_relative_distance=min_relative_distance, + seed_border_exclusion=seed_border_exclusion, + max_seeds=max_seeds, + seed_structuring_element=seed_structuring_element, + seed_structuring_element_size=seed_structuring_element_size, + watershed_connectivity=watershed_connectivity, + ), + ) + + +def _kept_at_default_outside_range( + parse: Callable[[str], _Number], + default: _Number, + minimum: _Number, + maximum: _Number | None = None, +) -> Callable[[str], _Number]: + """A number setting as CellProfiler reads it from a pipeline. + + CellProfiler leaves the setting at its default when the pipeline's value is + below the minimum or above the maximum the plugin declares. + """ + + def parsed(text: str) -> _Number: + value = parse(text) + if value < minimum or (maximum is not None and value > maximum): + return default + return value + + return parsed + + +def _chosen_method(module: ModuleBlock) -> type[DeclumpMethod]: + choice = required_setting_value(module, _METHOD_SETTING) + for method in _DECLUMP_METHODS: + if method.choice == choice: + return method + raise ValueError( + f"DeclumpObjects({module.module_num}) setting {_METHOD_SETTING!r} is one " + f"of {[method.choice for method in _DECLUMP_METHODS]}, got {choice!r}." + ) + + +@dataclass(frozen=True, slots=True) +class _DeclumpMethodBinding(SettingToKeywordBinding): + def bind( + self, + module: ModuleBlock, + kwargs: dict[str, CellProfilerSettingValue], + binder: SettingsBinder, + ) -> None: + del binder + kwargs[self.require_parameter_name()] = _chosen_method(module)() + + def records_from_kwargs( + self, kwargs: Mapping[str, object] + ) -> tuple[ModuleSetting, ...]: + method = kwargs.get(self.require_parameter_name()) + if not isinstance(method, DeclumpMethod): + return () + return (ModuleSetting(_METHOD_SETTING, method.choice),) + + +class DeclumpObjectsModule( + ZStackFunctionVariantModule, + ObjectLabelDrivenPrimaryImageInputPolicy, + LabelsObjectInputPolicy, + ObjectLineageTransformModule, + StructuringElementSettingsModule, +): + module_name = "DeclumpObjects" + function_name = "declump_objects" + function_variants = ("declump_objects_3d",) + validated = True + structuring_element_binding = StructuringElementSettingBinding( + setting_name="Structuring element for seed dilation", + legacy_size_setting_name=None, + default_value="disk,1", + shape_keyword="seed_structuring_element", + size_keyword="seed_structuring_element_size", + ) + reference_binding = SettingToKeywordBinding.input( + "Reference Image", ImageArtifactType, runtime_parameter_name="reference" + ) + setting_bindings = ( + _DeclumpMethodBinding(_METHOD_SETTING, "method"), + reference_binding, + SettingToKeywordBinding( + "Segmentation distance transform smoothing factor", + "smoothing_sigma", + parse_cellprofiler_float, + ), + SettingToKeywordBinding( + "Minimum distance between seeds", + "min_seed_distance", + _kept_at_default_outside_range(parse_cellprofiler_int, 1, 0), + ), + # The plugin passes this as the relative threshold of the seed search. + SettingToKeywordBinding( + "Minimum absolute internal distance", + "min_relative_distance", + _kept_at_default_outside_range(parse_cellprofiler_float, 0.0, 0.0), + ), + SettingToKeywordBinding( + "Pixels from border to exclude", + "seed_border_exclusion", + _kept_at_default_outside_range(parse_cellprofiler_int, 0, 0), + ), + SettingToKeywordBinding( + "Maximum number of seeds", "max_seeds", parse_cellprofiler_int + ), + SettingToKeywordBinding( + "Watershed connectivity", + "watershed_connectivity", + _kept_at_default_outside_range(parse_cellprofiler_int, 1, 1, 3), + ), + ) + + @classmethod + def active_artifact_bindings( + cls, + module: ModuleBlock | None = None, + *, + invocation_key: FunctionInvocationKey | None = None, + ) -> tuple[SettingToKeywordBinding, ...]: + """The reference image is an input only when it is what gets flooded. + + The plugin reads it for no other method, and its setting then holds + whatever name was last shown, often "None". + """ + bindings = super().active_artifact_bindings( + module, invocation_key=invocation_key + ) + if module is None or _chosen_method(module) is DeclumpByIntensity: + return bindings + return tuple( + binding for binding in bindings if binding is not cls.reference_binding + ) diff --git a/openhcs/processing/backends/cellprofiler/enforce_objects_one_to_one.py b/openhcs/processing/backends/cellprofiler/enforce_objects_one_to_one.py new file mode 100644 index 000000000..496797935 --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/enforce_objects_one_to_one.py @@ -0,0 +1,423 @@ +"""CellProfiler plugin EnforceObjectsOneToOne. + +Upstream: CellProfiler-plugins ``active_plugins/enforceobjectsonetoone.py``, as +run by CellProfiler 4.2.8.1 with NumPy 1.24. + +The plugin takes two independently made object sets, the pre-primary objects +(nuclei, say) and the pre-secondary objects (cells), and keeps the pairs in +which a pre-primary object is the best one on a pre-secondary object. The kept +pre-primary objects, cut down to their partner, are the primary objects; the +kept pre-secondary objects, whole, are the secondary objects. + +What the plugin does, reproduced here: + +* An object's candidates are the partners it shares pixels with, the largest + shared area first, equal areas in label order. It takes the first candidate + on which it is the best object. +* The best object on a partner is the one with the largest share of its + partnered area there: the shared area divided by the object's area on any + partner, not by the object's whole area. An object that touches one partner + only therefore always scores 1, however little of it lies on the partner. + Equal scores go to the larger shared area; when that is equal too, the + partner has no best object. (The plugin's shortcut for a partner touched by + one object compares a shape with a number, is never taken, and would give the + same result.) +* Pre-primary objects are matched first. One without a match is removed; a + matched one loses every pixel outside its partner. The remaining labels are + renumbered 1..N in ascending order of the old ones. +* Pre-secondary objects are then matched in the same way against the new + primary objects and keep their whole shape. Both sets are numbered in the + order of their own old labels, so primary object 3 need not lie in secondary + object 3; the parent measurements say which belongs to which. +* The plugin takes the smallest value of a label image for the background. In + an object set that covers every pixel, the object with the lowest number is + therefore never visited: it is neither matched nor removed, and it is made + background by the renumbering unless a removed object left background before + it. +* When either set has no objects, both outputs are that empty set. + +The functions return the two object sets and three parent-child relationships: +pre-primary to primary, pre-secondary to secondary, and primary to secondary +(the parent is the object a child overlaps most, the lowest number among +equals). Counts, locations and the ``Parent_`` and ``Children_..._Count`` +measurements are made from them by the module family when the objects are +recorded; because the labels have no gaps, they are CellProfiler's. + +Refused, because the CellProfiler plugin fails: + +* neither set is empty and no object finds a partner (the plugin takes the + maximum of an empty array). This includes sets that do not overlap, and sets + in which every partner is contested by objects with equal scores and areas; +* the two outputs do not hold the same numbers (the plugin's own final check), + which happens only with a set that covers every pixel; +* two non-empty sets of different shapes. + +The block's display option and the image named for it only choose what the +plugin's window shows. With the option on, the plugin still fetches that image +after it has stored its results and fails when the image is missing or colour; +that is not reproduced: the image is not an input here. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from openhcs.core.artifacts.kinds import ObjectLabelsArtifactType +from openhcs.core.artifacts.relations import SourceStackLineageSourceRelation +from openhcs.core.artifacts.specs import ArtifactSpecCollection +from openhcs.core.measurement_row_materialization import ( + MeasurementSparseColumnarRows, +) +from openhcs.core.memory.decorators import numpy +from openhcs.core.pipeline.function_contracts import ( + FullStackLabels, + object_label_input_execution_mode, + special_inputs, +) +from openhcs.core.processing_contracts import Pure2DContract, Pure3DContract +from openhcs.core.runtime_object_label_domains import ( + PresentObjectLabelIdsDomainDeclaration, +) +from openhcs.core.runtime_object_labels import ( + ObjectLabelValue, + object_label_dense_array, + object_label_value_with_dense_labels, +) +from openhcs.core.runtime_relationships import ( + DirectedObjectRelationshipPayload, + object_label_parent_child_payload, +) +from openhcs.interop.cellprofiler.module_artifact_declarations import ( + MeasurementArtifactOutputModule, + ObjectArtifactInputModule, + ObjectArtifactOutputModule, + ParentChildLineageArtifactOutputModule, + PlaneRuntimeArtifactModule, +) +from openhcs.interop.cellprofiler.runtime.primary_image_input_policies import ( + ObjectLabelDrivenPrimaryImageInputPolicy, +) +from openhcs.interop.cellprofiler.settings_binder import SettingToKeywordBinding +from openhcs.processing.backends.cellprofiler.morphology import ( + ZStackFunctionVariantModule, +) + +if TYPE_CHECKING: + from openhcs.core.artifacts.relations import ArtifactSpecRelation + from openhcs.core.artifacts.specs import ArtifactSpec + from openhcs.core.function_patterns import FunctionInvocationKey + from openhcs.core.invocation_artifacts import ArtifactDeclarationStepContext + from openhcs.interop.cellprofiler.parser import ModuleBlock + +_PLUGIN_FAILS = "the CellProfiler plugin fails" + +OneToOneObjects = tuple[ + np.ndarray, + MeasurementSparseColumnarRows, + ObjectLabelValue, + ObjectLabelValue, + DirectedObjectRelationshipPayload, + DirectedObjectRelationshipPayload, + DirectedObjectRelationshipPayload, +] + + +def _refuse(reason: str) -> ValueError: + return ValueError( + f"EnforceObjectsOneToOne cannot run: {reason}; {_PLUGIN_FAILS} here." + ) + + +def _visited_labels(labels: np.ndarray) -> np.ndarray: + """The labels the plugin takes for objects: all values but the smallest. + + The smallest is the background, 0, unless the objects cover every pixel. + """ + return np.unique(labels)[1:] + + +def _partner_of_each(objects: np.ndarray, partners: np.ndarray) -> np.ndarray: + """The partner of every object label (index), 0 where it has none.""" + partner_columns = int(partners.max()) + 1 + shared = np.bincount( + objects.ravel().astype(np.int64) * partner_columns + partners.ravel(), + minlength=(int(objects.max()) + 1) * partner_columns, + ).reshape(-1, partner_columns)[1:, 1:] + touching = shared > 0 + area_on_partners = shared.sum(axis=1, keepdims=True) + score = np.where(touching, shared / np.maximum(area_on_partners, 1), -1.0) + best = touching & (score == score.max(axis=0, initial=-1.0)) + # Among equal scores the larger shared area wins, and nobody when equal. + area_of_best = np.where(best, shared, 0) + winning = best & (area_of_best == area_of_best.max(axis=0, initial=0)) + won = winning & (winning.sum(axis=0) == 1) + # The largest shared area first; argmax takes the lowest label of equals. + area_won = np.where(won, shared, 0) + partner_of = np.zeros(shared.shape[0] + 1, dtype=np.int64) + if area_won.size: + partner_of[1:] = np.where(area_won.any(axis=1), area_won.argmax(axis=1) + 1, 0) + return partner_of + + +def _renumbered(labels: np.ndarray) -> np.ndarray: + """Labels 1..N in ascending order of the old ones.""" + present = _visited_labels(labels) + new_label = np.zeros(int(labels.max()) + 1, dtype=np.int64) + new_label[present] = np.arange(1, len(present) + 1) + return new_label[labels] + + +def _matched(objects: np.ndarray, partners: np.ndarray, *, cut: bool) -> np.ndarray: + """The objects that found a partner, renumbered; cut down to it on request.""" + partner_of = _partner_of_each(objects, partners) + visited = np.zeros(len(partner_of), dtype=bool) + visited[_visited_labels(objects)] = True + if not partner_of[visited].any(): + raise _refuse("no object is matched") + partner = partner_of[objects] + removed = partner == 0 + if cut: + removed |= partners != partner + return _renumbered(np.where(visited[objects] & removed, 0, objects)) + + +def _one_to_one( + pre_primary: np.ndarray, pre_secondary: np.ndarray +) -> tuple[np.ndarray, np.ndarray]: + """The primary and secondary labels the plugin stores.""" + if not pre_secondary.any(): + return pre_secondary, pre_secondary + if not pre_primary.any(): + return pre_primary, pre_primary + if pre_primary.shape != pre_secondary.shape: + raise _refuse( + f"the pre-primary objects have shape {pre_primary.shape} and the " + f"pre-secondary objects {pre_secondary.shape}" + ) + primary = _matched(pre_primary, pre_secondary, cut=True) + secondary = _matched(pre_secondary, primary, cut=False) + if not np.array_equal(np.unique(primary), np.unique(secondary)): + raise _refuse( + f"{len(np.unique(primary)) - 1} primary and " + f"{len(np.unique(secondary)) - 1} secondary objects are left" + ) + return primary, secondary + + +def _as_parents_and_children( + image: np.ndarray, + pre_primary_labels: ObjectLabelValue, + pre_secondary_labels: ObjectLabelValue, +) -> OneToOneObjects: + primary_array, secondary_array = _one_to_one( + object_label_dense_array(pre_primary_labels, dtype=np.int32), + object_label_dense_array(pre_secondary_labels, dtype=np.int32), + ) + primary = object_label_value_with_dense_labels( + pre_primary_labels, + primary_array.astype(np.int32), + domain_declaration=PresentObjectLabelIdsDomainDeclaration(), + ) + secondary = object_label_value_with_dense_labels( + pre_secondary_labels, + secondary_array.astype(np.int32), + domain_declaration=PresentObjectLabelIdsDomainDeclaration(), + ) + return ( + image, + # Counts, locations and parents are measured from the labels when the + # objects are recorded; the function has no measurements of its own. + MeasurementSparseColumnarRows.from_rows((), fields=()), + primary, + secondary, + object_label_parent_child_payload(pre_primary_labels, primary), + object_label_parent_child_payload(pre_secondary_labels, secondary), + object_label_parent_child_payload(primary, secondary), + ) + + +@numpy(contract=Pure2DContract) +@special_inputs("pre_primary_labels", "pre_secondary_labels") +def enforce_objects_one_to_one( + image: np.ndarray, + pre_primary_labels: ObjectLabelValue, + pre_secondary_labels: ObjectLabelValue, +) -> OneToOneObjects: + """Pair two object sets of a plane one to one, as nuclei with their cells. + + Keeps the pairs in which a pre-primary object is the best one on a + pre-secondary object it overlaps: the one with the largest share of its + partnered area there, the larger overlap among equal shares. Unpaired + objects of both sets are dropped. The primary objects are the paired + pre-primary objects without their pixels outside the partner; the secondary + objects are the paired pre-secondary objects, unchanged in shape. Both are + renumbered from 1 in the order of their old numbers. + + The primary objects are children of the pre-primary objects, the secondary + objects children of the pre-secondary and of the primary objects. + + Reproduces the CellProfiler plugin EnforceObjectsOneToOne and fails where + it does: when neither set is empty and nothing can be paired. + + Args: + pre_primary_labels: The inner objects (nuclei), made on their own. + pre_secondary_labels: The outer objects (cells), made on their own. + """ + return _as_parents_and_children(image, pre_primary_labels, pre_secondary_labels) + + +@numpy(contract=Pure3DContract) +@object_label_input_execution_mode(FullStackLabels) +@special_inputs("pre_primary_labels", "pre_secondary_labels") +def enforce_objects_one_to_one_3d( + image: np.ndarray, + pre_primary_labels: ObjectLabelValue, + pre_secondary_labels: ObjectLabelValue, +) -> OneToOneObjects: + """Pair two object sets of a volume one to one, as nuclei with their cells. + + The volume counterpart of ``enforce_objects_one_to_one``: overlaps are + counted in voxels over all planes. + + Args: + pre_primary_labels: Volume of the inner objects (nuclei). + pre_secondary_labels: Volume of the outer objects (cells). + """ + return _as_parents_and_children(image, pre_primary_labels, pre_secondary_labels) + + +class EnforceObjectsOneToOneModule( + ZStackFunctionVariantModule, + ObjectLabelDrivenPrimaryImageInputPolicy, + PlaneRuntimeArtifactModule, + ParentChildLineageArtifactOutputModule, + MeasurementArtifactOutputModule, + ObjectArtifactInputModule, + ObjectArtifactOutputModule, +): + module_name = "EnforceObjectsOneToOne" + function_name = "enforce_objects_one_to_one" + function_variants = ("enforce_objects_one_to_one_3d",) + validated = True + pre_primary_binding = SettingToKeywordBinding.input( + "Pre-primary objects", + ObjectLabelsArtifactType, + runtime_parameter_name="pre_primary_labels", + ) + pre_secondary_binding = SettingToKeywordBinding.input( + "Pre-secondary objects", + ObjectLabelsArtifactType, + runtime_parameter_name="pre_secondary_labels", + ) + primary_binding = SettingToKeywordBinding.output( + "Name the output primary object", ObjectLabelsArtifactType + ) + secondary_binding = SettingToKeywordBinding.output( + "Name the output secondary object", ObjectLabelsArtifactType + ) + setting_bindings = ( + pre_primary_binding, + pre_secondary_binding, + primary_binding, + secondary_binding, + ) + # Both only choose what the plugin's window shows. + ignored_settings = ( + "In module display, show enforced objects on a selected image?", + "Select the image for visualization", + ) + + @classmethod + def primary_image_domain_input_binding(cls) -> SettingToKeywordBinding: + return cls.pre_primary_binding + + @classmethod + def _input_objects( + cls, + module: ModuleBlock, + binding: SettingToKeywordBinding, + artifact_inputs: ArtifactSpecCollection, + ) -> ArtifactSpec: + (name,) = cls.artifact_names_for_binding(module, binding) + return artifact_inputs.require_by_name_and_artifact_type( + name, ObjectLabelsArtifactType + ) + + @classmethod + def artifact_output_relations( + cls, + module: ModuleBlock, + *, + binding: SettingToKeywordBinding, + name: str, + invocation_key: FunctionInvocationKey, + step_context: ArtifactDeclarationStepContext, + artifact_inputs: ArtifactSpecCollection, + output_position: int, + ) -> tuple[ArtifactSpecRelation, ...]: + """Each output set comes from the input set it is a selection of.""" + made_from = { + cls.primary_binding: cls.pre_primary_binding, + cls.secondary_binding: cls.pre_secondary_binding, + } + if binding not in made_from: + return super().artifact_output_relations( + module, + binding=binding, + name=name, + invocation_key=invocation_key, + step_context=step_context, + artifact_inputs=artifact_inputs, + output_position=output_position, + ) + source = cls._input_objects(module, made_from[binding], artifact_inputs) + return (SourceStackLineageSourceRelation(source=source.ref()),) + + @classmethod + def artifact_contract_outputs( + cls, + module: ModuleBlock, + *, + invocation_key: FunctionInvocationKey, + step_context: ArtifactDeclarationStepContext, + artifact_inputs: ArtifactSpecCollection, + ) -> tuple[ArtifactSpec, ...]: + """The declared outputs, then the three relationships the plugin measures.""" + outputs = ArtifactSpecCollection( + super().artifact_contract_outputs( + module, + invocation_key=invocation_key, + step_context=step_context, + artifact_inputs=artifact_inputs, + ) + ) + pre_primary = cls._input_objects( + module, cls.pre_primary_binding, artifact_inputs + ) + pre_secondary = cls._input_objects( + module, cls.pre_secondary_binding, artifact_inputs + ) + primary, secondary = ( + outputs.require_by_name_and_artifact_type(name, ObjectLabelsArtifactType) + for binding in (cls.primary_binding, cls.secondary_binding) + for name in cls.artifact_names_for_binding(module, binding) + ) + return ( + *outputs.specs, + *( + cls.parent_child_relationship_output_artifact( + module, + step_context=step_context, + parent=parent, + child=child, + lineage_source=pre_primary, + ) + for parent, child in ( + (pre_primary, primary), + (pre_secondary, secondary), + (primary, secondary), + ) + ), + ) diff --git a/openhcs/processing/backends/cellprofiler/measure_radial_entropy.py b/openhcs/processing/backends/cellprofiler/measure_radial_entropy.py new file mode 100644 index 000000000..82aeea954 --- /dev/null +++ b/openhcs/processing/backends/cellprofiler/measure_radial_entropy.py @@ -0,0 +1,418 @@ +"""CellProfiler plugin MeasureRadialEntropy. + +Upstream: CellProfiler-plugins +``unmaintained_plugins/CellProfiler4_autoconverted/measureradialentropy.py``. +CellProfiler 4.2.8.1 cannot load that file under its upstream name: its class +is called ``MeasurementTemplate`` and CellProfiler only takes the class whose +lower-cased name is the file name ("Could not find +cellprofiler_core.module.Module class"). Copied unchanged to +``measurementtemplate.py`` it loads and registers as ``MeasureRadialEntropy``; +that copy made the reference outputs this module is tested against. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar + +import numpy as np +from scipy.special import entr + +from openhcs.core.artifacts.kinds import ImageArtifactType, ObjectLabelsArtifactType +from openhcs.core.measurement_row_materialization import MeasurementSparseColumnarRows +from openhcs.core.memory.decorators import numpy +from openhcs.core.pipeline.function_contracts import ( + runtime_bound_parameters, + special_inputs, +) +from openhcs.core.processing_contracts import Pure2DContract +from openhcs.core.runtime_batch_contracts import SliceIndexRuntimeParameter +from openhcs.core.runtime_image_values import ImagePayload +from openhcs.core.runtime_measurements import MeasurementRowAxisField +from openhcs.core.runtime_object_labels import ( + ObjectLabelValue, + object_label_dense_array, +) +from openhcs.core.runtime_plane_projection import RuntimeSliceInvariantValue +from openhcs.core.runtime_tabular_values import FieldSpec +from openhcs.core.source_spatial_domain import VolumeSourceSpatialDomain +from openhcs.interop.cellprofiler.module_artifact_declarations import ( + ObjectArtifactInputModule, + ObjectMeasurementArtifactOutputModule, +) +from openhcs.interop.cellprofiler.parser import ModuleSetting +from openhcs.interop.cellprofiler.runtime.measurement_recording import ( + NoObjectNameMeasurementRecordMixin, +) +from openhcs.interop.cellprofiler.runtime.measurement_rows import ( + ModuleOwnedResultMeasurementRows, +) +from openhcs.interop.cellprofiler.runtime.object_input_policies import ( + LabelsObjectInputPolicy, +) +from openhcs.interop.cellprofiler.setting_names import required_setting_value +from openhcs.interop.cellprofiler.settings_binder import SettingToKeywordBinding + +if TYPE_CHECKING: + from openhcs.interop.cellprofiler.parser import ModuleBlock + from openhcs.interop.cellprofiler.runtime.output_record_request import ( + CellProfilerOutputRecordRequest, + ) + from openhcs.interop.cellprofiler.settings_binder import ( + CellProfilerSettingValue, + SettingsBinder, + ) + +_PLUGIN_FAILS = "the CellProfiler plugin fails" +_OBJECT_NAME = MeasurementRowAxisField.OBJECT_NAME.value +_OBJECT_LABEL = MeasurementRowAxisField.OBJECT_LABEL.value +_SLICE_INDEX = MeasurementRowAxisField.SLICE_INDEX.value +_CATEGORY = "Entropy" +_METRIC_SETTING = "Which intensity measurement should be used?" +_DEFAULT_BINS = 6 + + +def _refuse(reason: str) -> ValueError: + return ValueError( + f"MeasureRadialEntropy cannot run: {reason}; {_PLUGIN_FAILS} here." + ) + + +class WedgeIntensity(RuntimeSliceInvariantValue, ABC): + """The one number that stands for the pixels of a wedge.""" + + choice: ClassVar[str] + """The wording of this choice in CellProfiler, also part of the measurement names.""" + + @abstractmethod + def of(self, pixels: np.ndarray) -> float: + """The wedge's value from its pixels, taken in the pixels' own number type.""" + + +@dataclass(frozen=True, slots=True) +class MeanIntensity(WedgeIntensity): + """A wedge counts with the mean of its pixels.""" + + choice: ClassVar[str] = "Mean" + + def of(self, pixels: np.ndarray) -> float: + return float(np.mean(pixels)) + + +@dataclass(frozen=True, slots=True) +class MedianIntensity(WedgeIntensity): + """A wedge counts with the median of its pixels.""" + + choice: ClassVar[str] = "Median" + + def of(self, pixels: np.ndarray) -> float: + return float(np.median(pixels)) + + +@dataclass(frozen=True, slots=True) +class IntegratedIntensity(WedgeIntensity): + """A wedge counts with the sum of its pixels.""" + + choice: ClassVar[str] = "Integrated" + + def of(self, pixels: np.ndarray) -> float: + return float(np.sum(pixels)) + + +_WEDGE_INTENSITIES: tuple[type[WedgeIntensity], ...] = ( + MeanIntensity, + MedianIntensity, + IntegratedIntensity, +) + + +def _entropy_feature(metric: WedgeIntensity, bins: int) -> str: + """Name of an object's entropy, without CellProfiler's category and image.""" + return f"{metric.choice}_{bins}" + + +def _wedge_feature(metric: WedgeIntensity, bins: int, position: int) -> str: + """Name of the ``position``-th wedge value (from 1), likewise.""" + return f"{metric.choice}_Bin{position}_of_{bins}" + + +def _wedge_values( + pixels: np.ndarray, + inside: np.ndarray, + centre: tuple[float, float], + metric: WedgeIntensity, + bins: int, +) -> list[float]: + """One value per wedge, in the order the plugin lists them. + + Wedges come in the order their first pixel is met reading the image row by + row; wedges without a pixel follow as NaN, by number. + """ + rows, columns = np.nonzero(inside) + numbers = np.ceil( + (np.pi + np.arctan2(rows - centre[0], columns - centre[1])) + * (bins / (2 * np.pi)) + ).astype(np.int64) + values = pixels[rows, columns] + met, first_met = np.unique(numbers, return_index=True) + measured = [ + metric.of(values[numbers == number]) for number in met[np.argsort(first_met)] + ] + empty = [number for number in range(1, bins + 1) if number not in met] + return [*measured, *([float("nan")] * len(empty))] + + +def _entropy(values: np.ndarray) -> float: + """Shannon entropy of the values' shares of their sum, NaN values left out.""" + counted = values[~np.isnan(values)] + with np.errstate(invalid="ignore", divide="ignore"): + return float(np.sum(entr(1.0 * counted / np.sum(counted)))) + + +@numpy(contract=Pure2DContract) +@special_inputs("labels") +@runtime_bound_parameters(SliceIndexRuntimeParameter) +def measure_radial_entropy( + image: ImagePayload, + labels: ObjectLabelValue, + metric: WedgeIntensity = MeanIntensity(), + bins: int = _DEFAULT_BINS, + slice_index: int = 0, +) -> tuple[ImagePayload, MeasurementSparseColumnarRows]: + """Measure how unevenly an image's intensity is spread around each object. + + Each object is cut into ``bins`` pie-shaped wedges around its centroid. + Every wedge gets one value from its pixels (``metric``), and the object's + measurement is the Shannon entropy (natural logarithm) of the wedge values' + shares of their sum: ``log(bins)`` when all wedges are alike, lower the more + the intensity sits in few wedges. + + Per object, with ```` being ``Mean``, ``Median`` or ``Integrated``: + ``_`` (the entropy) and ``_Bin_of_`` for k + from 1 to ``bins`` (wedge values). A CellProfiler pipeline records them as + ``Entropy___`` and + ``Entropy___Bin_of_`` of the objects. + + The plugin's behaviour is kept, including: + + - ``Bin`` is not the k-th wedge by angle. The wedge values are listed in + the order the wedges' first pixels are met reading the image row by row; + wedges without a pixel follow as NaN. A small object therefore has its + values first and NaN after them. + - Wedges without a pixel are left out of the entropy. + - A pixel directly left of the centroid can fall into a wedge beyond the + numbered ones, which gives the object one value more. All values enter + the entropy; only the first ``bins`` are reported. + - Pixels with intensity -1 are taken to lie outside their object. + - Wedge values are computed in the image's number type (32-bit means and + sums for a 32-bit image); a yes/no image counts as 0 and 1. + - Wedge values that sum to 0 give NaN, a negative share gives minus + infinity. + - The plugin fails, and this function refuses: no objects; object numbers + with gaps (the plugin then pairs objects with other objects' centroids + and runs out of centroids); objects with different numbers of wedge + values (see above); an image and objects of different sizes; a volume. + + Args: + labels: The objects to measure. + metric: How a wedge's pixels become its value: ``MeanIntensity``, + ``MedianIntensity`` or ``IntegratedIntensity`` (their sum). + bins: Number of wedges. CellProfiler allows 3 to 60. + """ + if bins < 1: + raise ValueError(f"MeasureRadialEntropy needs at least one wedge, got {bins}.") + pixels = np.asarray(image.data) + if pixels.dtype.kind != "f": + # CellProfiler hands the plugin a yes/no image as 64-bit numbers. + pixels = pixels.astype(np.float64) + label_plane = object_label_dense_array(labels, dtype=np.int32) + if pixels.ndim != 2 or isinstance( + image.metadata.source_spatial_domain, VolumeSourceSpatialDomain + ): + raise _refuse("it works on single planes, not on volumes") + if label_plane.shape != pixels.shape: + raise _refuse("the image and the objects differ in size") + object_count = int(label_plane.max(initial=0)) + if object_count == 0: + raise _refuse("there are no objects") + # The plugin has one centroid per object present, in object-number order, + # and takes the n-th of them for object number n. + centroids = [ + np.argwhere(label_plane == number).mean(axis=0) + for number in np.unique(label_plane[label_plane > 0]) + ] + outside = np.full(pixels.shape, -1).astype(pixels.dtype) + per_object: list[np.ndarray] = [] + for number in range(1, object_count + 1): + of_object = label_plane == number + kept = outside.copy() + kept[of_object] = pixels[of_object] + inside = kept != np.float64(-1) + if not inside.any(): + per_object.append(np.full(bins, np.nan)) + continue + if number > len(centroids): + raise _refuse("the objects are not numbered 1 to their count") + centre_row, centre_column = centroids[number - 1] + per_object.append( + np.array( + _wedge_values(kept, inside, (centre_row, centre_column), metric, bins), + dtype=float, + ) + ) + if len({len(values) for values in per_object}) > 1: + raise _refuse( + "a pixel directly left of an object's centroid gives that object one " + "wedge value more than the others have" + ) + names = ( + _entropy_feature(metric, bins), + *(_wedge_feature(metric, bins, position) for position in range(1, bins + 1)), + ) + rows = [ + { + _OBJECT_LABEL: number, + _SLICE_INDEX: slice_index, + **dict(zip(names, (_entropy(values), *map(float, values[:bins])))), + } + for number, values in enumerate(per_object, start=1) + ] + return ( + image, + MeasurementSparseColumnarRows.from_rows( + rows, + fields=( + FieldSpec(_SLICE_INDEX, int, required=False), + FieldSpec(_OBJECT_LABEL, int, required=False), + *(FieldSpec(name, float, required=False) for name in names), + ), + ), + ) + + +def _parse_bins(text: str) -> int: + """CellProfiler's reading of the bin count. + + A count that is no whole number from 3 to 60 leaves the setting at the + plugin's default. + """ + try: + bins = int(text) + except ValueError: + return _DEFAULT_BINS + return bins if 3 <= bins <= 60 else _DEFAULT_BINS + + +@dataclass(frozen=True, slots=True) +class _WedgeIntensityBinding(SettingToKeywordBinding): + def bind( + self, + module: ModuleBlock, + kwargs: dict[str, CellProfilerSettingValue], + binder: SettingsBinder, + ) -> None: + del binder + choice = required_setting_value(module, self.setting_name) + for kind in _WEDGE_INTENSITIES: + if kind.choice == choice: + kwargs[self.require_parameter_name()] = kind() + return + raise ValueError( + f"MeasureRadialEntropy({module.module_num}) setting " + f"{_METRIC_SETTING!r} is one of " + f"{[kind.choice for kind in _WEDGE_INTENSITIES]}, got {choice!r}." + ) + + def records_from_kwargs( + self, kwargs: Mapping[str, object] + ) -> tuple[ModuleSetting, ...]: + metric = kwargs.get(self.require_parameter_name()) + if not isinstance(metric, WedgeIntensity): + return () + return (ModuleSetting(_METRIC_SETTING, metric.choice),) + + +class MeasureRadialEntropyModule( + NoObjectNameMeasurementRecordMixin, + LabelsObjectInputPolicy, + ObjectArtifactInputModule, + ObjectMeasurementArtifactOutputModule, +): + module_name = "MeasureRadialEntropy" + function_name = "measure_radial_entropy" + validated = True + measurement_category_prefixes = (("entropy",),) + setting_bindings: ClassVar[tuple[SettingToKeywordBinding, ...]] = ( + SettingToKeywordBinding.input("Select an image to measure", ImageArtifactType), + SettingToKeywordBinding.input( + "Select objects to measure", + ObjectLabelsArtifactType, + runtime_parameter_name="labels", + ), + _WedgeIntensityBinding(_METRIC_SETTING, "metric"), + SettingToKeywordBinding("Input number of bins", "bins", _parse_bins), + ) + + @dataclass(frozen=True, slots=True) + class MeasurementRows(ModuleOwnedResultMeasurementRows): + """The function's rows under CellProfiler's names. + + CellProfiler names each measurement after the image it was made on and + files it under the objects' name; the block knows both, the function + neither. + """ + + image_name: str + object_name: str + + @classmethod + def for_request( + cls, + module_type: type[object], + request: CellProfilerOutputRecordRequest, + ) -> MeasureRadialEntropyModule.MeasurementRows: + inputs = request.callable_contract.artifact_inputs.specs + (image_name,) = ( + spec.name for spec in inputs if spec.artifact_type is ImageArtifactType + ) + (object_name,) = ( + spec.name + for spec in inputs + if spec.artifact_type is ObjectLabelsArtifactType + ) + return cls( + request.output_value, + module_type=module_type, + image_name=image_name, + object_name=object_name, + ) + + def rows(self) -> MeasurementSparseColumnarRows: + measured = self.source_rows() + axes = (_SLICE_INDEX, _OBJECT_LABEL) + named = { + field.name: ( + field.name + if field.name in axes + else f"{_CATEGORY}_{self.image_name}_{field.name}" + ) + for field in measured.fields + } + return MeasurementSparseColumnarRows.from_rows( + [ + { + _OBJECT_NAME: self.object_name, + **{named[name]: value for name, value in row.items()}, + } + for row in measured.iter_row_mappings() + ], + fields=( + FieldSpec(_OBJECT_NAME, str, required=False), + *( + FieldSpec(named[field.name], field.dtype, required=False) + for field in measured.fields + ), + ), + ) diff --git a/tests/fixtures/cellprofiler_plugins/Barcodes.csv b/tests/fixtures/cellprofiler_plugins/Barcodes.csv new file mode 100644 index 000000000..852ece45e --- /dev/null +++ b/tests/fixtures/cellprofiler_plugins/Barcodes.csv @@ -0,0 +1,9 @@ +sgRNA,gene_symbol,plate +ACGTT,GeneA,1 +TTACC,GeneB,1 +,NoBarcode,1 +GATAA,GeneC,1 +GATCC,GeneD,2 +ACGTT,GeneE,2 +CCTAA,GeneF,2 +GCGAA,GeneG,2 diff --git a/tests/fixtures/cellprofiler_plugins/cases.cppipe b/tests/fixtures/cellprofiler_plugins/cases.cppipe index 5c3a69ca4..b8f158322 100644 --- a/tests/fixtures/cellprofiler_plugins/cases.cppipe +++ b/tests/fixtures/cellprofiler_plugins/cases.cppipe @@ -2,10 +2,10 @@ CellProfiler Pipeline: http://www.cellprofiler.org Version:5 DateRevision:428 GitHash: -ModuleCount:20 +ModuleCount:72 HasImagePlaneDetails:False -HistogramEqualization:[module_num:1|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramEqualization:[module_num:1|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['EqualizedPlane']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Plane Name the output image:EqualizedPlane Bins:256 @@ -17,7 +17,7 @@ HistogramEqualization:[module_num:1|svn_version:'Unknown'|variable_revision_numb Do framewise calculation?:No Tile Size (Z):2 -HistogramEqualization:[module_num:2|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramEqualization:[module_num:2|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['EqualizedPlaneWithinMask']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Plane Name the output image:EqualizedPlaneWithinMask Bins:64 @@ -29,7 +29,7 @@ HistogramEqualization:[module_num:2|svn_version:'Unknown'|variable_revision_numb Do framewise calculation?:No Tile Size (Z):2 -HistogramEqualization:[module_num:3|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramEqualization:[module_num:3|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['LocallyEqualizedPlane']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Plane Name the output image:LocallyEqualizedPlane Bins:128 @@ -41,7 +41,7 @@ HistogramEqualization:[module_num:3|svn_version:'Unknown'|variable_revision_numb Do framewise calculation?:No Tile Size (Z):2 -HistogramEqualization:[module_num:4|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramEqualization:[module_num:4|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['LocallyEqualizedPlaneMaskUnused']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Plane Name the output image:LocallyEqualizedPlaneMaskUnused Bins:128 @@ -53,7 +53,7 @@ HistogramEqualization:[module_num:4|svn_version:'Unknown'|variable_revision_numb Do framewise calculation?:No Tile Size (Z):2 -HistogramEqualization:[module_num:5|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramEqualization:[module_num:5|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['EqualizedVolume']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Volume Name the output image:EqualizedVolume Bins:256 @@ -65,7 +65,7 @@ HistogramEqualization:[module_num:5|svn_version:'Unknown'|variable_revision_numb Do framewise calculation?:No Tile Size (Z):2 -HistogramEqualization:[module_num:6|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramEqualization:[module_num:6|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['EqualizedVolumeWithinMask']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Volume Name the output image:EqualizedVolumeWithinMask Bins:256 @@ -77,7 +77,7 @@ HistogramEqualization:[module_num:6|svn_version:'Unknown'|variable_revision_numb Do framewise calculation?:No Tile Size (Z):2 -HistogramEqualization:[module_num:7|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramEqualization:[module_num:7|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['EqualizedVolumeFramewise']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Volume Name the output image:EqualizedVolumeFramewise Bins:256 @@ -89,7 +89,7 @@ HistogramEqualization:[module_num:7|svn_version:'Unknown'|variable_revision_numb Do framewise calculation?:Yes Tile Size (Z):2 -HistogramEqualization:[module_num:8|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramEqualization:[module_num:8|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['EqualizedVolumeFramewiseWithinMask']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Volume Name the output image:EqualizedVolumeFramewiseWithinMask Bins:256 @@ -101,7 +101,7 @@ HistogramEqualization:[module_num:8|svn_version:'Unknown'|variable_revision_numb Do framewise calculation?:Yes Tile Size (Z):2 -HistogramEqualization:[module_num:9|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramEqualization:[module_num:9|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['LocallyEqualizedVolume']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Volume Name the output image:LocallyEqualizedVolume Bins:128 @@ -113,7 +113,7 @@ HistogramEqualization:[module_num:9|svn_version:'Unknown'|variable_revision_numb Do framewise calculation?:No Tile Size (Z):2 -HistogramEqualization:[module_num:10|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramEqualization:[module_num:10|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['LocallyEqualizedVolumeFramewise']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Volume Name the output image:LocallyEqualizedVolumeFramewise Bins:128 @@ -125,7 +125,7 @@ HistogramEqualization:[module_num:10|svn_version:'Unknown'|variable_revision_num Do framewise calculation?:Yes Tile Size (Z):2 -HistogramEqualization:[module_num:11|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramEqualization:[module_num:11|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['LocallyEqualizedVolumeNotDeclared3D']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Volume Name the output image:LocallyEqualizedVolumeNotDeclared3D Bins:128 @@ -137,27 +137,27 @@ HistogramEqualization:[module_num:11|svn_version:'Unknown'|variable_revision_num Do framewise calculation?:Yes Tile Size (Z):2 -DistanceTransform:[module_num:12|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +DistanceTransform:[module_num:12|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['RescaledPlaneDistance']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:BinaryPlane Name the output image:RescaledPlaneDistance Rescale values from 0 to 1?:Yes -DistanceTransform:[module_num:13|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +DistanceTransform:[module_num:13|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['PlaneDistance']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:BinaryPlane Name the output image:PlaneDistance Rescale values from 0 to 1?:No -DistanceTransform:[module_num:14|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +DistanceTransform:[module_num:14|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['RescaledVolumeDistance']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:BinaryVolume Name the output image:RescaledVolumeDistance Rescale values from 0 to 1?:Yes -DistanceTransform:[module_num:15|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +DistanceTransform:[module_num:15|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['VolumeDistance']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:BinaryVolume Name the output image:VolumeDistance Rescale values from 0 to 1?:No -HistogramMatching:[module_num:16|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramMatching:[module_num:16|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['PlaneMatchedToReference']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Plane Name the output image:PlaneMatchedToReference Is your image 3D?:No @@ -165,7 +165,7 @@ HistogramMatching:[module_num:16|svn_version:'Unknown'|variable_revision_number: Image to use as reference :ReferencePlane Frame number:5 -HistogramMatching:[module_num:17|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramMatching:[module_num:17|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['PlaneMatchedToReferenceFrameSettingOn']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Plane Name the output image:PlaneMatchedToReferenceFrameSettingOn Is your image 3D?:No @@ -173,7 +173,7 @@ HistogramMatching:[module_num:17|svn_version:'Unknown'|variable_revision_number: Image to use as reference :ReferencePlane Frame number:2 -HistogramMatching:[module_num:18|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramMatching:[module_num:18|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['VolumeMatchedToFrame']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Volume Name the output image:VolumeMatchedToFrame Is your image 3D?:Yes @@ -181,7 +181,7 @@ HistogramMatching:[module_num:18|svn_version:'Unknown'|variable_revision_number: Image to use as reference :None Frame number:2 -HistogramMatching:[module_num:19|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramMatching:[module_num:19|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['VolumeMatchedToFrameNotDeclared3D']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Volume Name the output image:VolumeMatchedToFrameNotDeclared3D Is your image 3D?:No @@ -189,10 +189,916 @@ HistogramMatching:[module_num:19|svn_version:'Unknown'|variable_revision_number: Image to use as reference :None Frame number:1 -HistogramMatching:[module_num:20|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] +HistogramMatching:[module_num:20|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['VolumeMatchedToReference']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] Select the input image:Volume Name the output image:VolumeMatchedToReference Is your image 3D?:Yes Use a frame within image as reference?:No Image to use as reference :ReferencePlane Frame number:2 + +CallBarcodes:[module_num:21|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['BarcodesMatchedToLibrary']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Number of cycles:3 + Input object name:Foci + Select one of the measures from Cycle 1 to use for calling:Intensity_MaxIntensity_CorrCycle01_A + Input data file location:Default Input Folder| + Name of the file:Barcodes.csv + Select the column of barcodes to match against:sgRNA + Select the column with gene/transcript barcode names:gene_symbol + Retain an image of the barcodes color coded by call?:Yes + Enter the called barcode image name:BarcodeCalls + Retain an image of the barcodes color coded by score match?:Yes + Enter the barcode score image name:BarcodeScores + Do you have an empty vector barcode you would like to add to the barcode list?:No + What is the empty vector sequence?:AAAAAAAAAAAAAAA + +CallBarcodes:[module_num:22|svn_version:'Unknown'|variable_revision_number:2|show_window:False|notes:['BarcodesWithEmptyVector']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Number of cycles:3 + Input object name:Foci + Select one of the measures from Cycle 1 to use for calling:Intensity_MaxIntensity_CorrCycle01_A + Input data file location:Default Input Folder| + Name of the file:Barcodes.csv + Select the column containing barcodes to match against:sgRNA + Select the column containing gene/transcript barcode names:gene_symbol + Retain an image of the barcodes color coded by call?:Yes + Enter the called barcode image name:BarcodeCalls + Retain an image of the barcodes color coded by score match?:No + Enter the barcode score image name:BarcodeScores + Do you have an empty vector barcode you would like to add to the barcode list?:Yes + What is the empty vector sequence?:TAAGG + What kind of encoding is used here?:One Hot Exponentially Multiplexed (ie 4-color SBS/ISS) + Base we are currently calling:A + Select the first measurement indicating that A is encoded:AreaShape_Area + Does a positive value here mean the base SHOULD be called A?:Yes + Base we are currently calling:C + Select the first measurement indicating that C is encoded:AreaShape_Area + Does a positive value here mean the base SHOULD be called C?:Yes + Base we are currently calling:G + Select the first measurement indicating that G is encoded:AreaShape_Area + Does a positive value here mean the base SHOULD be called G?:Yes + Base we are currently calling:T + Select the first measurement indicating that T is encoded:AreaShape_Area + Does a positive value here mean the base SHOULD be called T?:Yes + +CallBarcodes:[module_num:23|svn_version:'Unknown'|variable_revision_number:4|show_window:False|notes:['BarcodesWithoutLibrary']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Number of cycles:3 + Input object name:Foci + Select one of the measures from Cycle 1 to use for calling:Intensity_MaxIntensity_CorrCycle01_A + Input data file location:Elsewhere...| + Name of the file:None + Select the column containing barcodes to match against:No CSV file + Select the column containing gene/transcript barcode names:No CSV file + Retain an image of the barcodes color coded by match?:Yes + Enter the called barcode image name:BarcodeCalls + Retain an image of the barcodes color coded by score match?:Yes + Enter the barcode score image name:BarcodeScores + Do you have an empty vector barcode you would like to add to the barcode list?:No + What is the empty vector sequence?:AAAAAAAAAAAAAAA + What kind of encoding is used here?:One Hot Exponentially Multiplexed (ie 4-color SBS/ISS) + Do you want to match to an external library of barcodes?:No + Base we are currently calling:A + Select the first measurement indicating that A is encoded:AreaShape_Area + Does a positive value here mean the base SHOULD be called A?:Yes + Base we are currently calling:C + Select the first measurement indicating that C is encoded:AreaShape_Area + Does a positive value here mean the base SHOULD be called C?:Yes + Base we are currently calling:G + Select the first measurement indicating that G is encoded:AreaShape_Area + Does a positive value here mean the base SHOULD be called G?:Yes + Base we are currently calling:T + Select the first measurement indicating that T is encoded:AreaShape_Area + Does a positive value here mean the base SHOULD be called T?:Yes + Minimum value to be considered positive in a channel:1.0 + +CallBarcodes:[module_num:24|svn_version:'Unknown'|variable_revision_number:4|show_window:False|notes:['BarcodesCalledByThreshold']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Number of cycles:3 + Input object name:Foci + Select one of the measures from Cycle 1 to use for calling:AreaShape_Area + Input data file location:Default Input Folder| + Name of the file:Barcodes.csv + Select the column containing barcodes to match against:sgRNA + Select the column containing gene/transcript barcode names:gene_symbol + Retain an image of the barcodes color coded by match?:No + Enter the called barcode image name:BarcodeCalls + Retain an image of the barcodes color coded by score match?:Yes + Enter the barcode score image name:BarcodeScores + Do you have an empty vector barcode you would like to add to the barcode list?:No + What is the empty vector sequence?:AAAAAAAAAAAAAAA + What kind of encoding is used here?:Exponentially Multiplexed (SBS/ISS only) + Do you want to match to an external library of barcodes?:Yes + Base we are currently calling:A + Select the first measurement indicating that A is encoded:Intensity_MaxIntensity_CorrCycle01_A + Does a positive value here mean the base SHOULD be called A?:Yes + Base we are currently calling:A + Select the next measurement indicating that A is encoded:Intensity_MeanIntensity_CorrCycle01_A + Does a positive value here mean the base SHOULD be called A?:No + Base we are currently calling:C + Select the first measurement indicating that C is encoded:Intensity_MaxIntensity_CorrCycle01_C + Does a positive value here mean the base SHOULD be called C?:Yes + Base we are currently calling:G + Select the first measurement indicating that G is encoded:Intensity_MaxIntensity_CorrCycle01_G + Does a positive value here mean the base SHOULD be called G?:Yes + Base we are currently calling:T + Select the first measurement indicating that T is encoded:Intensity_MaxIntensity_CorrCycle01_T + Does a positive value here mean the base SHOULD be called T?:Yes + Minimum value to be considered positive in a channel:0.5 + +CallBarcodes:[module_num:25|svn_version:'Unknown'|variable_revision_number:4|show_window:False|notes:['BarcodesCalledByThresholdWithoutLibrary']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Number of cycles:3 + Input object name:Foci + Select one of the measures from Cycle 1 to use for calling:AreaShape_Area + Input data file location:Elsewhere...| + Name of the file:None + Select the column containing barcodes to match against:No CSV file + Select the column containing gene/transcript barcode names:No CSV file + Retain an image of the barcodes color coded by match?:No + Enter the called barcode image name:None + Retain an image of the barcodes color coded by score match?:No + Enter the barcode score image name:None + Do you have an empty vector barcode you would like to add to the barcode list?:No + What is the empty vector sequence?:AAAAAAAAAAAAAAA + What kind of encoding is used here?:Exponentially Multiplexed (SBS/ISS only) + Do you want to match to an external library of barcodes?:No + Base we are currently calling:A + Select the first measurement indicating that A is encoded:Intensity_MaxIntensity_CorrCycle01_A + Does a positive value here mean the base SHOULD be called A?:Yes + Base we are currently calling:C + Select the first measurement indicating that C is encoded:Intensity_MaxIntensity_CorrCycle01_C + Does a positive value here mean the base SHOULD be called C?:Yes + Base we are currently calling:G + Select the first measurement indicating that G is encoded:Intensity_MaxIntensity_CorrCycle01_G + Does a positive value here mean the base SHOULD be called G?:Yes + Base we are currently calling:T + Select the first measurement indicating that T is encoded:Intensity_MaxIntensity_CorrCycle01_T + Does a positive value here mean the base SHOULD be called T?:Yes + Minimum value to be considered positive in a channel:1.25 + +CallBarcodes:[module_num:26|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['BarcodesOfNoObjects']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Number of cycles:3 + Input object name:NoFoci + Select one of the measures from Cycle 1 to use for calling:Intensity_MaxIntensity_CorrCycle01_A + Input data file location:Default Input Folder| + Name of the file:Barcodes.csv + Select the column of barcodes to match against:sgRNA + Select the column with gene/transcript barcode names:gene_symbol + Retain an image of the barcodes color coded by call?:Yes + Enter the called barcode image name:BarcodeCalls + Retain an image of the barcodes color coded by score match?:Yes + Enter the barcode score image name:BarcodeScores + Do you have an empty vector barcode you would like to add to the barcode list?:No + What is the empty vector sequence?:AAAAAAAAAAAAAAA + +CallBarcodes:[module_num:27|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['BarcodesLongerThanTheLibrary']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Number of cycles:4 + Input object name:Foci + Select one of the measures from Cycle 1 to use for calling:Intensity_MaxIntensity_CorrCycle01_A + Input data file location:Default Input Folder| + Name of the file:Barcodes.csv + Select the column of barcodes to match against:plate + Select the column with gene/transcript barcode names:gene_symbol + Retain an image of the barcodes color coded by call?:No + Enter the called barcode image name:BarcodeCalls + Retain an image of the barcodes color coded by score match?:No + Enter the barcode score image name:BarcodeScores + Do you have an empty vector barcode you would like to add to the barcode list?:No + What is the empty vector sequence?:AAAAAAAAAAAAAAA + +CompensateColors:[module_num:28|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['CompensatedAcrossImage']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:4 + Hidden:1 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:CompensatedAcrossImage_Cycle01_A + Select an image to measure:Cycle02_A + What compensation class does this image belong to?:1 + Select an output image name:CompensatedAcrossImage_Cycle02_A + Select an image to measure:Cycle01_C + What compensation class does this image belong to?:2 + Select an output image name:CompensatedAcrossImage_Cycle01_C + Select an image to measure:Cycle02_C + What compensation class does this image belong to?:2 + Select an output image name:CompensatedAcrossImage_Cycle02_C + Select where to perform color compensation:Across entire image + Select an object to perform compensation within:None + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:No + Should histogram matching be performed between the image groups?:No + What compensation class should serve as the template histogram?:1 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.0 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:No + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 + +CompensateColors:[module_num:29|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['CompensatedSpotsWithinFoci']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:4 + Hidden:1 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:CompensatedSpotsWithinFoci_Cycle01_A + Select an image to measure:Cycle02_A + What compensation class does this image belong to?:1 + Select an output image name:CompensatedSpotsWithinFoci_Cycle02_A + Select an image to measure:Cycle01_C + What compensation class does this image belong to?:2 + Select an output image name:CompensatedSpotsWithinFoci_Cycle01_C + Select an image to measure:Cycle02_C + What compensation class does this image belong to?:2 + Select an output image name:CompensatedSpotsWithinFoci_Cycle02_C + Select where to perform color compensation:Within objects + Select an object to perform compensation within:Foci + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:No + Should histogram matching be performed between the image groups?:Yes, post-masking to objects + What compensation class should serve as the template histogram?:2 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.9 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:Yes + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 + +CompensateColors:[module_num:30|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['CompensatedEachImageRescaledWithinCells']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:2 + Hidden:1 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:CompensatedEachImageRescaledWithinCells_Cycle01_A + Select an image to measure:Cycle01_C + What compensation class does this image belong to?:2 + Select an output image name:CompensatedEachImageRescaledWithinCells_Cycle01_C + Select where to perform color compensation:Within objects + Select an object to perform compensation within:Cells + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:Yes + Should images be rescaled 0-1 before compensating but after masking to objects?:Yes, per image + Should histogram matching be performed between the image groups?:No + What compensation class should serve as the template histogram?:1 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.0 + Should the images have a tophat filter applied before correction?:Yes + What size radius should be used for the tophat filter?:2 + Should the images have a Laplacian of Gaussian filter applied before correction?:No + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 + +CompensateColors:[module_num:31|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['CompensatedEachClassRescaled']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:4 + Hidden:1 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:CompensatedEachClassRescaled_Cycle01_A + Select an image to measure:Cycle02_A + What compensation class does this image belong to?:1 + Select an output image name:CompensatedEachClassRescaled_Cycle02_A + Select an image to measure:Cycle01_C + What compensation class does this image belong to?:2 + Select an output image name:CompensatedEachClassRescaled_Cycle01_C + Select an image to measure:Cycle02_C + What compensation class does this image belong to?:2 + Select an output image name:CompensatedEachClassRescaled_Cycle02_C + Select where to perform color compensation:Across entire image + Select an object to perform compensation within:None + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:Yes, per group + Should histogram matching be performed between the image groups?:Yes, pre-masking or on unmasked images + What compensation class should serve as the template histogram?:2 + Should images be rescaled 0-1 after compensating?:Yes + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.0 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:No + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 + +CompensateColors:[module_num:32|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['CompensatedDividedByPercentile']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:4 + Hidden:1 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:CompensatedDividedByPercentile_Cycle01_A + Select an image to measure:Cycle02_A + What compensation class does this image belong to?:1 + Select an output image name:CompensatedDividedByPercentile_Cycle02_A + Select an image to measure:Cycle01_C + What compensation class does this image belong to?:2 + Select an output image name:CompensatedDividedByPercentile_Cycle01_C + Select an image to measure:Cycle02_C + What compensation class does this image belong to?:2 + Select an output image name:CompensatedDividedByPercentile_Cycle02_C + Select where to perform color compensation:Within objects + Select an object to perform compensation within:Cells + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:No + Should histogram matching be performed between the image groups?:Yes, pre-masking or on unmasked images + What compensation class should serve as the template histogram?:1 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:Yes + What percentile should be used for multiplication:90.5 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:No + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:Yes + What size sigma should be used for the DoG low sigma?:1 + What size radius should be used for the DoG low sigma?:2 + +CompensateColors:[module_num:33|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['CompensatedAfterEveryFilter']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:4 + Hidden:1 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:CompensatedAfterEveryFilter_Cycle01_A + Select an image to measure:Cycle02_A + What compensation class does this image belong to?:1 + Select an output image name:CompensatedAfterEveryFilter_Cycle02_A + Select an image to measure:Cycle01_C + What compensation class does this image belong to?:2 + Select an output image name:CompensatedAfterEveryFilter_Cycle01_C + Select an image to measure:Cycle02_C + What compensation class does this image belong to?:2 + Select an output image name:CompensatedAfterEveryFilter_Cycle02_C + Select where to perform color compensation:Within objects + Select an object to perform compensation within:Cells + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:Yes + Should images be rescaled 0-1 before compensating but after masking to objects?:Yes, per group + Should histogram matching be performed between the image groups?:Yes, post-masking to objects + What compensation class should serve as the template histogram?:2 + Should images be rescaled 0-1 after compensating?:Yes + Should the images be divided by a scalar based on group percentiles:Yes + What percentile should be used for multiplication:99.0 + Should the images have a tophat filter applied before correction?:Yes + What size radius should be used for the tophat filter?:2 + Should the images have a Laplacian of Gaussian filter applied before correction?:Yes + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:Yes + What size sigma should be used for the DoG low sigma?:1 + What size radius should be used for the DoG low sigma?:2 + +CompensateColors:[module_num:34|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['CompensatedWithinFirstOfTwoObjectSets']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:2 + Hidden:2 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:CompensatedWithinFirstOfTwoObjectSets_Cycle01_A + Select an image to measure:Cycle01_C + What compensation class does this image belong to?:2 + Select an output image name:CompensatedWithinFirstOfTwoObjectSets_Cycle01_C + Select where to perform color compensation:Within objects + Select an object to perform compensation within:Cells + Select an object to perform compensation within:Foci + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:No + Should histogram matching be performed between the image groups?:No + What compensation class should serve as the template histogram?:1 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.0 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:No + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 + +CompensateColors:[module_num:35|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['CompensatedThreeClasses']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:3 + Hidden:1 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:FirstClass + Select an image to measure:Cycle01_C + What compensation class does this image belong to?:5 + Select an output image name:LastClass + Select an image to measure:Plane + What compensation class does this image belong to?:3 + Select an output image name:MiddleClass + Select where to perform color compensation:Across entire image + Select an object to perform compensation within:Foci + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:No + Should histogram matching be performed between the image groups?:No + What compensation class should serve as the template histogram?:1 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.0 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:No + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 + +CompensateColors:[module_num:36|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['SpotsAcrossImageAreBlack']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:2 + Hidden:1 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:SpotsAcrossImageAreBlack_Cycle01_A + Select an image to measure:Cycle01_C + What compensation class does this image belong to?:2 + Select an output image name:SpotsAcrossImageAreBlack_Cycle01_C + Select where to perform color compensation:Across entire image + Select an object to perform compensation within:None + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:No + Should histogram matching be performed between the image groups?:No + What compensation class should serve as the template histogram?:1 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.0 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:Yes + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 + +CompensateColors:[module_num:37|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['ClassNowhereBrightestIsBlack']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:2 + Hidden:1 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:BrightestEverywhere + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:2 + Select an output image name:BrightestNowhere + Select where to perform color compensation:Across entire image + Select an object to perform compensation within:None + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:No + Should histogram matching be performed between the image groups?:No + What compensation class should serve as the template histogram?:1 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.0 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:No + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 + +CompensateColors:[module_num:38|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['EachImageRescaledWithoutObjects']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:2 + Hidden:1 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:EachImageRescaledWithoutObjects_Cycle01_A + Select an image to measure:Cycle01_C + What compensation class does this image belong to?:2 + Select an output image name:EachImageRescaledWithoutObjects_Cycle01_C + Select where to perform color compensation:Across entire image + Select an object to perform compensation within:None + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:Yes, per image + Should histogram matching be performed between the image groups?:No + What compensation class should serve as the template histogram?:1 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.0 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:No + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 + +CompensateColors:[module_num:39|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['MatchedAfterMaskingWithoutObjects']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:2 + Hidden:1 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:MatchedAfterMaskingWithoutObjects_Cycle01_A + Select an image to measure:Cycle01_C + What compensation class does this image belong to?:2 + Select an output image name:MatchedAfterMaskingWithoutObjects_Cycle01_C + Select where to perform color compensation:Across entire image + Select an object to perform compensation within:None + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:No + Should histogram matching be performed between the image groups?:Yes, post-masking to objects + What compensation class should serve as the template histogram?:1 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.0 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:No + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 + +CompensateColors:[module_num:40|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['MatchedToClassWithoutImages']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:2 + Hidden:1 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:MatchedToClassWithoutImages_Cycle01_A + Select an image to measure:Cycle01_C + What compensation class does this image belong to?:2 + Select an output image name:MatchedToClassWithoutImages_Cycle01_C + Select where to perform color compensation:Across entire image + Select an object to perform compensation within:None + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:No + Should histogram matching be performed between the image groups?:Yes, pre-masking or on unmasked images + What compensation class should serve as the template histogram?:3 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.0 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:No + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 + +CompensateColors:[module_num:41|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['ClassesOfDifferentSizes']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:3 + Hidden:1 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:ClassesOfDifferentSizes_Cycle01_A + Select an image to measure:Cycle02_A + What compensation class does this image belong to?:1 + Select an output image name:ClassesOfDifferentSizes_Cycle02_A + Select an image to measure:Cycle01_C + What compensation class does this image belong to?:2 + Select an output image name:ClassesOfDifferentSizes_Cycle01_C + Select where to perform color compensation:Across entire image + Select an object to perform compensation within:None + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:No + Should histogram matching be performed between the image groups?:No + What compensation class should serve as the template histogram?:1 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.0 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:No + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 + +CompensateColors:[module_num:42|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['EachClassRescaledWithinNoObjects']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:2 + Hidden:1 + Select an image to measure:Cycle01_A + What compensation class does this image belong to?:1 + Select an output image name:EachClassRescaledWithinNoObjects_Cycle01_A + Select an image to measure:Cycle01_C + What compensation class does this image belong to?:2 + Select an output image name:EachClassRescaledWithinNoObjects_Cycle01_C + Select where to perform color compensation:Within objects + Select an object to perform compensation within:NoFoci + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:Yes, per group + Should histogram matching be performed between the image groups?:No + What compensation class should serve as the template histogram?:1 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.0 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:No + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 + +DeclumpObjects:[module_num:43|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedByShape']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedByShape + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:3 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:44|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedByIntensity']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedByIntensity + Declump method:Intensity + Reference Image:Plane + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:3 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:45|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedUnsmoothed']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedUnsmoothed + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:0.0 + Minimum distance between seeds:2 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:46|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedWithDistantSeeds']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedWithDistantSeeds + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:2 + Minimum distance between seeds:7 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:47|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedFromEveryPixel']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedFromEveryPixel + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:0 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:48|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedFromDeepSeeds']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedFromDeepSeeds + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:1 + Minimum absolute internal distance:0.7 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:49|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedAwayFromBorder']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedAwayFromBorder + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:2 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:5 + Maximum number of seeds:-1 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:50|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedFromTwoSeeds']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedFromTwoSeeds + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:3 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:2 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:51|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedWithoutLastThreeSeeds']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedWithoutLastThreeSeeds + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:3 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-3 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:52|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedFromNoSeeds']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedFromNoSeeds + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:1 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:0 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:53|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedFromWideSeedsAcrossDiagonals']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedFromWideSeedsAcrossDiagonals + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:2 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:Square,5 + Watershed connectivity:2 + +DeclumpObjects:[module_num:54|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedWithNumbersOutOfRange']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedWithNumbersOutOfRange + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:-1 + Minimum absolute internal distance:-0.5 + Pixels from border to exclude:-2 + Maximum number of seeds:-1 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:5 + +DeclumpObjects:[module_num:55|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedNoObjects']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:NoFoci + Name the output object:DeclumpedNoObjects + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:1 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:56|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedVolume']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:ClumpVolume + Name the output object:DeclumpedVolume + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:2 + Minimum distance between seeds:7 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:Ball,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:57|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedVolumeByIntensity']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:ClumpVolume + Name the output object:DeclumpedVolumeByIntensity + Declump method:Intensity + Reference Image:Volume + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:2 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:1 + Maximum number of seeds:-1 + Structuring element for seed dilation:Cube,2 + Watershed connectivity:3 + +DeclumpObjects:[module_num:58|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedPlaneWithBall']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedPlaneWithBall + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:1 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:Ball,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:59|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedVolumeWithDisk']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:ClumpVolume + Name the output object:DeclumpedVolumeWithDisk + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:1 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:60|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedByIntensityOfAnotherShape']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedByIntensityOfAnotherShape + Declump method:Intensity + Reference Image:ReferencePlane + Segmentation distance transform smoothing factor:1.0 + Minimum distance between seeds:1 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:1 + +DeclumpObjects:[module_num:61|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DeclumpedWithNegativeSmoothing']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Clumps + Name the output object:DeclumpedWithNegativeSmoothing + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:-1.0 + Minimum distance between seeds:1 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:Disk,1 + Watershed connectivity:1 + +MeasureRadialEntropy:[module_num:62|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['RadialEntropyOfMeans']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select an image to measure:Plane + Select objects to measure:Clumps + Which intensity measurement should be used?:Mean + Input number of bins:6 + +MeasureRadialEntropy:[module_num:63|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['RadialEntropyOfMedians']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select an image to measure:Plane + Select objects to measure:Cells + Which intensity measurement should be used?:Median + Input number of bins:12 + +MeasureRadialEntropy:[module_num:64|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['RadialEntropyOfSums']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select an image to measure:Plane + Select objects to measure:Clumps + Which intensity measurement should be used?:Integrated + Input number of bins:3 + +MeasureRadialEntropy:[module_num:65|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['RadialEntropyOfSmallObjects']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select an image to measure:Plane + Select objects to measure:Foci + Which intensity measurement should be used?:Mean + Input number of bins:12 + +MeasureRadialEntropy:[module_num:66|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['RadialEntropyWithTooFewBins']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select an image to measure:Plane + Select objects to measure:Cells + Which intensity measurement should be used?:Integrated + Input number of bins:2 + +MeasureRadialEntropy:[module_num:67|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['RadialEntropyWithTooManyBins']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select an image to measure:Plane + Select objects to measure:Foci + Which intensity measurement should be used?:Median + Input number of bins:61 + +MeasureRadialEntropy:[module_num:68|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['RadialEntropyOfNoObjects']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select an image to measure:Plane + Select objects to measure:NoFoci + Which intensity measurement should be used?:Mean + Input number of bins:6 + +MeasureRadialEntropy:[module_num:69|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['RadialEntropyOfVolume']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select an image to measure:Volume + Select objects to measure:ClumpVolume + Which intensity measurement should be used?:Mean + Input number of bins:6 + +MeasureRadialEntropy:[module_num:70|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['RadialEntropyOfImageOfAnotherShape']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select an image to measure:ReferencePlane + Select objects to measure:Clumps + Which intensity measurement should be used?:Mean + Input number of bins:6 + +MeasureRadialEntropy:[module_num:71|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['RadialEntropyOfObjectsNumberedWithGap']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select an image to measure:Plane + Select objects to measure:CellsNumberedWithGap + Which intensity measurement should be used?:Mean + Input number of bins:6 + +MeasureRadialEntropy:[module_num:72|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['RadialEntropyWithUnevenWedgeCounts']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select an image to measure:Plane + Select objects to measure:Clumps + Which intensity measurement should be used?:Mean + Input number of bins:7 diff --git a/tests/fixtures/cellprofiler_plugins/make_reference.py b/tests/fixtures/cellprofiler_plugins/make_reference.py index f48092379..498a184dd 100644 --- a/tests/fixtures/cellprofiler_plugins/make_reference.py +++ b/tests/fixtures/cellprofiler_plugins/make_reference.py @@ -4,16 +4,33 @@ nice -n 19 /.venv-cellprofiler39/bin/python -I \ tests/fixtures/cellprofiler_plugins/make_reference.py \ - \ + \ \ tests/fixtures/cellprofiler_plugins +Plugin files (CellProfiler-plugins ``active_plugins/`` unless a path is given): +histogramequalization.py, distancetransform.py, histogrammatching.py, +callbarcodes.py, compensatecolors.py, declumpobjects.py, and +measurementtemplate.py: an unchanged copy of +``unmaintained_plugins/CellProfiler4_autoconverted/measureradialentropy.py``. +CellProfiler 4.2.8.1 does not load that file under its own name ("Could not +find cellprofiler_core.module.Module class"), because its class is called +``MeasurementTemplate`` and CellProfiler takes the class named like the file. +Under the name ``measurementtemplate.py`` it loads and registers as +``MeasureRadialEntropy``. + CellProfiler's own pipeline loader reads ``cases.cppipe`` (so the plugins parse -their own settings), then each module's ``run`` is called on a workspace whose -images are the arrays below. No image is read from or written to disk and Java -is not started. The script writes ``reference.npz`` (inputs and the pixels each -plugin stored) and ``reference.json`` (versions, voxel spacing, and the cases -in which the plugin itself raised). +their own settings), then each module's ``run`` is called on a workspace holding +the images, objects and measurements below. No image is read from or written to +disk and Java is not started. A block's first note is its case name. The script +writes ``reference.npz`` (the inputs, and every image, object set and measurement +each plugin added to its workspace) and ``reference.json`` (versions, voxel +spacing, and the cases in which the plugin itself raised). + +Keys of ``reference.npz``: ``input/image/``, ``input/objects/``, +``input/measurement//``, and per case +``/image/``, ``/objects/``, +``/measurement//``. """ import json @@ -23,6 +40,8 @@ import numpy VOXEL_SPACING_ZYX = (5.0, 2.0, 2.0) +BARCODE_CYCLES = 3 +BARCODE_BASES = "ACGT" def input_images(): @@ -57,6 +76,140 @@ def input_images(): "MaskVolume": mask_volume.astype(numpy.float32), "BinaryPlane": binary_plane, "BinaryVolume": binary_volume, + **sequencing_cycle_images(), + } + + +def sequencing_cycle_images(): + """Two sequencing cycles, each imaged in the channels of bases A and C. + + Ten spots, the first six on the foci. A spot is bright in its base's channel + and bleeds into the other one; the C channel is the dimmer of the two. + """ + random = numpy.random.RandomState(20261015) + rows, columns = numpy.mgrid[0:24, 0:32] + centres = ( + (2.5, 4), + (2.5, 13), + (9.5, 21), + (15.5, 7), + (18.5, 27), + (21.5, 16), + (6, 27), + (12, 3), + (13, 14), + (20, 21), + ) + images = {} + for cycle, bases in ((1, "AACCACACCA"), (2, "CAACCAACAC")): + signal = {"A": numpy.zeros((24, 32)), "C": numpy.zeros((24, 32))} + for (row, column), base in zip(centres, bases): + signal[base] += (0.35 + 0.4 * random.rand()) * numpy.exp( + -((rows - row) ** 2 + (columns - column) ** 2) / 3.0 + ) + channels = { + "A": signal["A"] + 0.2 * signal["C"], + "C": 0.8 * signal["C"] + 0.4 * signal["A"], + } + for base, pixels in channels.items(): + background = 0.02 + 0.01 * columns / 31.0 + 0.03 * random.rand(24, 32) + images["Cycle%02d_%s" % (cycle, base)] = (pixels + background).astype( + numpy.float32 + ) + return images + + +def input_objects(): + foci = numpy.zeros((24, 32), dtype=numpy.int32) + for label, (row, column) in enumerate( + ((2, 3), (2, 12), (9, 20), (15, 6), (18, 26), (21, 15)), start=1 + ): + foci[row : row + 2, column : column + 3] = label + # Two cells that leave only a border of the image uncovered. + cells = numpy.zeros((24, 32), dtype=numpy.int32) + cells[1:23, 1:16] = 1 + cells[1:23, 16:31] = 2 + # The second cell under the number 3, so that no object has the number 2. + cells_numbered_with_gap = numpy.where(cells == 2, 3, cells) + return { + "Foci": foci, + "NoFoci": numpy.zeros((24, 32), dtype=numpy.int32), + "Cells": cells, + "CellsNumberedWithGap": cells_numbered_with_gap, + **clumped_objects(), + } + + +def clumped_objects(): + """Objects made of overlapping discs, for the modules that split them. + + In the plane: object 1 is two discs, object 2 three, object 3 one small + disc, and objects 4 and 5 are the two halves of one rectangle, so they touch. + In the volume: object 1 is two overlapping columns through all four planes + and object 2 a small column in the two middle planes. + """ + rows, columns = numpy.mgrid[0:24, 0:32] + clumps = numpy.zeros((24, 32), dtype=numpy.int32) + for label, row, column, radius in ( + (1, 6, 6, 4.5), + (1, 6, 13, 4.5), + (2, 17, 5, 3.6), + (2, 17, 11, 3.6), + (2, 18, 17, 3.6), + (3, 4, 27, 2.0), + ): + clumps[(rows - row) ** 2 + (columns - column) ** 2 < radius**2] = label + clumps[10:21, 22:26] = 4 + clumps[10:21, 26:31] = 5 + volume_rows, volume_columns = numpy.mgrid[0:20, 0:24] + clump_volume = numpy.zeros((4, 20, 24), dtype=numpy.int32) + for row, column in ((7, 6), (7, 13)): + column_of_discs = (volume_rows - row) ** 2 + (volume_columns - column) ** 2 + clump_volume[:, column_of_discs < 4.5**2] = 1 + clump_volume[1:3, (volume_rows - 15) ** 2 + (volume_columns - 19) ** 2 < 9] = 2 + return {"Clumps": clumps, "ClumpVolume": clump_volume} + + +def input_measurements(objects): + """Per-object values keyed by (object name, feature name). + + Each focus has one intensity per sequencing cycle and base: 1.0 for the base + of its barcode, at most 0.375 for the others. Focus 4 has two equal maxima + (C and T) in cycle 2 and focus 5 has no signal in cycle 3. A fourth cycle is + measured that the cases, which call three, must leave out. The mean + intensities are a second measurement family, used by the cases that call a + base from several measurements. + """ + random = numpy.random.RandomState(20261013) + barcodes = ("ACGT", "TTAC", "GATG", "GCAA", "TAAC", "CCAT") + assert len(barcodes) == int(objects["Foci"].max()) + measurements = {} + for cycle in range(1, BARCODE_CYCLES + 2): + for base in BARCODE_BASES: + values = random.randint(0, 4, size=len(barcodes)) / 8.0 + for focus, barcode in enumerate(barcodes): + if barcode[cycle - 1] == base: + values[focus] = 1.0 + if cycle == 2 and base in "CT": + values[3] = 1.5 + if cycle == 3: + values[4] = 0.0 + feature = "Intensity_MaxIntensity_CorrCycle%02d_%s" % (cycle, base) + measurements["Foci", feature] = values + measurements["NoFoci", feature] = numpy.zeros(0) + measurements["Foci", "Intensity_MeanIntensity_CorrCycle%02d_A" % cycle] = ( + numpy.arange(len(barcodes)) == cycle + ) * 0.75 + return measurements + + +def measurement_values(measurements, object_names): + return { + (object_name, feature): numpy.asarray( + measurements.get_current_measurement(object_name, feature) + ) + for object_name in object_names + for feature in measurements.get_feature_names(object_name) } @@ -66,9 +219,13 @@ def main(plugin_directory, plugin_commit, fixture_directory): import skimage from cellprofiler_core.image import Image, ImageSetList from cellprofiler_core.measurement import Measurements - from cellprofiler_core.object import ObjectSet + from cellprofiler_core.object import Objects, ObjectSet from cellprofiler_core.pipeline import Pipeline - from cellprofiler_core.preferences import set_headless, set_plugin_directory + from cellprofiler_core.preferences import ( + set_default_image_directory, + set_headless, + set_plugin_directory, + ) from cellprofiler_core.utilities.core.modules import fill_modules from cellprofiler_core.workspace import Workspace @@ -76,15 +233,26 @@ def main(plugin_directory, plugin_commit, fixture_directory): set_plugin_directory(plugin_directory, globally=False) fill_modules() fixture_directory = Path(fixture_directory) + # Files a block names (the barcode library) sit beside the pipeline. + set_default_image_directory(str(fixture_directory)) pipeline = Pipeline() pipeline.load(str(fixture_directory / "cases.cppipe")) - inputs = input_images() - outputs = {} + images = input_images() + objects = input_objects() + object_measurements = input_measurements(objects) + stored = {} + for name, pixels in images.items(): + stored["input/image/" + name] = pixels + for name, labels in objects.items(): + stored["input/objects/" + name] = labels + for (object_name, feature), values in object_measurements.items(): + stored["input/measurement/%s/%s" % (object_name, feature)] = values failures = {} for module in pipeline.modules(): + (case,) = module.notes image_set_list = ImageSetList() image_set = image_set_list.get_image_set(0) - for name, pixels in inputs.items(): + for name, pixels in images.items(): image_set.add( name, Image( @@ -93,23 +261,45 @@ def main(plugin_directory, plugin_commit, fixture_directory): spacing=VOXEL_SPACING_ZYX if pixels.ndim == 3 else None, ), ) + object_set = ObjectSet() measurements = Measurements() + for name, labels in objects.items(): + one_object_set = Objects() + one_object_set.segmented = labels + object_set.add_objects(one_object_set, name) + measurements.add_measurement("Image", "Count_" + name, int(labels.max())) + for (object_name, feature), values in object_measurements.items(): + measurements.add_measurement(object_name, feature, values) + measured_before = measurement_values( + measurements, measurements.get_object_names() + ) workspace = Workspace( - pipeline, module, image_set, ObjectSet(), measurements, image_set_list + pipeline, module, image_set, object_set, measurements, image_set_list ) - output_name = module.y_name.value try: module.run(workspace) - outputs[output_name] = image_set.get_image(output_name).pixel_data except Exception as error: # noqa: BLE001 - whatever the plugin raises is the record - failures[output_name] = f"{type(error).__name__}: {error}" + failures[case] = f"{type(error).__name__}: {error}" + else: + for name in image_set.names: + if name not in images: + stored[f"{case}/image/{name}"] = image_set.get_image( + name + ).pixel_data + for name in object_set.get_object_names(): + if name not in objects: + stored[f"{case}/objects/{name}"] = object_set.get_objects( + name + ).segmented + measured = measurement_values( + measurements, measurements.get_object_names() + ) + for (object_name, feature), values in measured.items(): + if (object_name, feature) not in measured_before: + stored[f"{case}/measurement/{object_name}/{feature}"] = values finally: measurements.close() - numpy.savez_compressed( - str(fixture_directory / "reference.npz"), - **{"input_" + name: pixels for name, pixels in inputs.items()}, - **{"output_" + name: pixels for name, pixels in outputs.items()}, - ) + numpy.savez_compressed(str(fixture_directory / "reference.npz"), **stored) (fixture_directory / "reference.json").write_text( json.dumps( { diff --git a/tests/fixtures/cellprofiler_plugins/reference.json b/tests/fixtures/cellprofiler_plugins/reference.json new file mode 100644 index 000000000..bc029fbf8 --- /dev/null +++ b/tests/fixtures/cellprofiler_plugins/reference.json @@ -0,0 +1,32 @@ +{ + "cellprofiler": "4.2.8.1", + "cellprofiler_plugins_commit": "5aaa4e54513173438b50b8e4b33ad6a592b35df0", + "numpy": "1.24.4", + "plugin_raised": { + "BarcodesLongerThanTheLibrary": "IndexError: string index out of range", + "ClassesOfDifferentSizes": "ValueError: setting an array element with a sequence. The requested array has an inhomogeneous shape after 1 dimensions. The detected shape was (2,) + inhomogeneous part.", + "DeclumpedByIntensityOfAnotherShape": "ValueError: `mask` (shape (24, 32)) must have same shape as `image` (shape (20, 28))", + "DeclumpedPlaneWithBall": "ValueError: Structuring element does not match object dimensions: 3 != 2", + "DeclumpedVolumeWithDisk": "ValueError: Structuring element does not match object dimensions: 2 != 3", + "DeclumpedWithNegativeSmoothing": "ValueError: Sigma values less than zero are not valid", + "EachClassRescaledWithinNoObjects": "ValueError: zero-size array to reduction operation minimum which has no identity", + "EachImageRescaledWithoutObjects": "UnboundLocalError: local variable 'object_mask' referenced before assignment", + "EqualizedVolumeFramewiseWithinMask": "IndexError: too many indices for array: array is 2-dimensional, but 3 were indexed", + "MatchedAfterMaskingWithoutObjects": "UnboundLocalError: local variable 'object_mask' referenced before assignment", + "MatchedToClassWithoutImages": "KeyError: 3", + "RadialEntropyOfImageOfAnotherShape": "IndexError: boolean index did not match indexed array along dimension 0; dimension is 20 but corresponding boolean dimension is 24", + "RadialEntropyOfNoObjects": "IndexError: too many indices for array: array is 1-dimensional, but 2 were indexed", + "RadialEntropyOfObjectsNumberedWithGap": "IndexError: index 2 is out of bounds for axis 0 with size 2", + "RadialEntropyOfVolume": "ValueError: too many values to unpack (expected 2)", + "RadialEntropyWithUnevenWedgeCounts": "ValueError: setting an array element with a sequence. The requested array has an inhomogeneous shape after 1 dimensions. The detected shape was (5,) + inhomogeneous part.", + "VolumeMatchedToReference": "AttributeError: 'Image' object has no attribute 'ndim'" + }, + "python": "3.9.25", + "scikit-image": "0.18.3", + "scipy": "1.9.0", + "voxel_spacing_zyx_of_volumes": [ + 5.0, + 2.0, + 2.0 + ] +} diff --git a/tests/fixtures/cellprofiler_plugins/reference.npz b/tests/fixtures/cellprofiler_plugins/reference.npz index fa7bdd6e116d46c95e6a9d676d9d82f4009a67fa..d8642de711515dcf92d30f664ecfd45501664838 100644 GIT binary patch delta 129759 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�cLc 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 z zK@$O>0fP-KC0a+S0XkaWv*)i!;K~55kq%OzM(f7NXk7^>eBR7;b(;XSWf)O_OiXyB!Fd7&<<0qFJ z>P1&w7zX-|B1}Ix8YHTPQ{I3jLpUJAWb`YR)x$&J+A_@=$N)q1W8w}Cn+NK;Ke_<1LEuNleK57bK_= zAa!9FwSV7!VgE9G67T*A32FpLBXMIsfrErtn`4i%i3cZ{Q4o0UiL4OvI!M2=-9go0 zRV=UxTNH~Erg0bRIj_aY|*c2G2;pc~Reyli@6L!crj{fU4&wyntAe_>JjSc#aBCNfDg98rf z!B{lTiG$d&s-10}L>&17A@Dslu^h0JB!49DYjyn_5!74J@3Z2kT=B z81-Oc3fSwOU!wS6)fbtOGwNx>WH67(5$a7~Ngv1=`rU(rmzV*DC`Aw5X>T~dgeW~$ zKoJwLQ2AQ@aFzmGi0FK2UuFUp^}Jq689Il)HN(#rcQF&NsORibu=GPNaNy_bH8ZfN zhvQPPx^k{e!pFLBg$enho@z_MIu)|>Grk?hTx9|l^(R4iQ+9*GuN$rLnXo9Tr<{vCECE0F{xq-8nWFS0&T`Url28P&LPlVGY1XXc6y-CPDj?;nfsW$fq9!z zo}^B0LIcnzp(C-Ps+oobx1^LIfB^K#=15Gt2h7AIs|pnpeNs6Rb3qL=F%ez%0QAY@ zNX$vK%)~@=-viJmi6b%Fn2L$$$_JoN21jC+)G;%kh;F?rGG;*!Bkiuo$HbnUjXZ-I z=Ll+w-S{9IT8qJ~LG43A15fsfxvm*!B5J3pr1wo-!=ZT&~ zX0HZ|wBVT?gp{O-k$zcw+COb0J|;r-9G0}H!5J$l%TVuKU9b{Qn&_X$F0F91$4dfC z)$T|8-09aqT`rR0;{vv5y|EH6@iP{p3)l@+^Vp&mj@q?P%Z(CuE4541-k4pFZ{a5;I4T>OA z%;Etp;T8Jr^=KC%RT(n)wjhVTdyC<%VxImcgxU*8Ia7ctBJ5kndGOqeoHsA-M+IPu zVUL_IuoXte?|{EOkvSF)40O4cqNoB{`U&)v&lhX z)LzqrAYy dict[str | None, tuple[str, ...]]: - """Image names a block declares, keyed by the function parameter they feed.""" + """Names a block declares of one kind, keyed by the function parameter they feed.""" module_type = CellProfilerModule.require_module(block.name) - declared = {} + declared: dict[str | None, tuple[str, ...]] = {} for binding in module_type.artifact_bindings_for(block, plan_type=plan_type): - assert binding.require_artifact_type() is ImageArtifactType - declared[binding.runtime_parameter_name] = ( - module_type.artifact_names_for_binding(block, binding) - ) + if binding.require_artifact_type() is artifact_type: + declared[binding.runtime_parameter_name] = ( + *declared.get(binding.runtime_parameter_name, ()), + *module_type.artifact_names_for_binding(block, binding), + ) return declared -def _output_name(block: ModuleBlock) -> str: - ((name,),) = _artifact_names(block, ArtifactOutputPlan).values() - return name - - -_CASES = { - _output_name(block): block - for block in CPPipeParser().parse(_FIXTURES / "cases.cppipe") -} - - def _image(pixels: np.ndarray, *, volume: bool, stacked: bool = False) -> ImagePayload: return ImagePayloadMetadata( source_voxel_spacing=SourceVoxelSpacing( @@ -83,56 +125,282 @@ def _image(pixels: np.ndarray, *, volume: bool, stacked: bool = False) -> ImageP ).payload_with(pixels, None) -def _execute(block: ModuleBlock, image: ImagePayload) -> np.ndarray: - """Bind the block's settings and run its function the way the runtime does.""" +def _run(block: ModuleBlock, image: ImagePayload) -> object: + """Bind the block's settings and run its function the way the runtime does. + + This stands in for the runtime's input binding: named images and objects + come from the reference inputs, and a function that reads prior measurements + of its objects gets ``object_name`` and one ``measurement_values`` array per + measurement its module declares. A module with a function for volumes runs + that one on a volume. + """ module_type = CellProfilerModule.require_module(block.name) + volume = isinstance(image.metadata.source_spatial_domain, VolumeSourceSpatialDomain) + function = module_type.resolve_function( + block, + contract=CallableContract.from_callable(module_type.require_callable()), + source_bindings=StepSourceBindingsConfig( + source_stack_components=(Microscopy.ZIndex,) if volume else () + ), + ) contract = replace( - CallableContract.from_callable( - module_type.require_callable() - ).with_prepared_signature(), + CallableContract.from_callable(function).with_prepared_signature(), runtime_adapter=CellProfilerRuntimeAdapter.runtime_adapter_spec(), ) - image_names = module_type.compile_time_parameter_names(contract) + named_at_compile_time = module_type.compile_time_parameter_names(contract) kwargs = { name: value for name, value in module_type.bind_settings( - block, binder=SettingsBinder() + block, binder=SettingsBinder(source_root=_FIXTURES) ).kwargs.items() - if name not in image_names + if name not in named_at_compile_time } for parameter, names in _artifact_names(block, ArtifactInputPlan).items(): if parameter is not None and names: (name,) = names - kwargs[parameter] = _REFERENCE[f"input_{name}"] + kwargs[parameter] = _REFERENCE[f"input/image/{name}"] + object_names = _artifact_names(block, ArtifactInputPlan, ObjectLabelsArtifactType) + for parameter, names in object_names.items(): + (name,) = names + kwargs[parameter] = ObjectLabelPayload( + variant_data=ObjectLabelVariantData( + labels=_REFERENCE[f"input/objects/{name}"] + ) + ) + if issubclass(module_type, PriorMeasurementArtifactInputModule): + ((object_name,),) = object_names.values() + kwargs["object_name"] = object_name + kwargs["measurement_values"] = tuple( + _REFERENCE[f"input/measurement/{object_name}/{feature}"] + for feature in module_type.prior_measurement_feature_names(block) + ) + assert set(kwargs) <= set(inspect.signature(function).parameters) return CellProfilerFunctionContractExecutor().execute( contract, contract.resolve_canonical_raw_callable(), image, kwargs, - execution_mode=contract.runtime_image_execution_mode, - ).data + execution_mode=contract.runtime_image_execution_mode or NaturalExecution, + ) -def _input_pixels(block: ModuleBlock) -> np.ndarray: - (name,) = _artifact_names(block, ArtifactInputPlan)[None] - return _REFERENCE[f"input_{name}"] +def _main_image(block: ModuleBlock) -> np.ndarray: + """The block's unnamed input image; a blank plane where it has objects only. + Several unnamed input images are stacked in the order the block names them. + """ + names = _artifact_names(block, ArtifactInputPlan).get(None) + if names: + images = [_REFERENCE[f"input/image/{name}"] for name in names] + return images[0] if len(images) == 1 else np.stack(images) + ((name,), *_) = _artifact_names( + block, ArtifactInputPlan, ObjectLabelsArtifactType + ).values() + return np.zeros(_REFERENCE[f"input/objects/{name}"].shape, dtype=np.float32) -@pytest.mark.parametrize("output_name", sorted(_CASES)) -def test_output_equals_what_the_plugin_stored(output_name: str) -> None: - block = _CASES[output_name] - pixels = _input_pixels(block) - image = _image(pixels, volume=pixels.ndim == 3) - if output_name in _PLUGIN_RAISED: +def _main_images_are_volumes(block: ModuleBlock) -> bool: + """Whether the unnamed input images, or else the objects, are volumes.""" + names = _artifact_names(block, ArtifactInputPlan).get(None, ()) + if not names: + return _main_image(block).ndim == 3 + return any(_REFERENCE[f"input/image/{name}"].ndim == 3 for name in names) + + +def _measurements(rows: ColumnarRows) -> dict[str, np.ndarray]: + """Measurement rows as CellProfiler stores them: ``/`` arrays. + + Rows naming an object give one value per object in label order; rows naming + none are the image's measurements. + """ + axes = (_OBJECT_NAME, _OBJECT_LABEL, _SLICE_INDEX) + per_object: dict[str, dict[int, object]] = {} + stored: dict[str, np.ndarray] = {} + for row in rows.row_mappings(): + present = { + name: value + for name, value in row.items() + if value is not getattr(rows, "missing_cell", None) + } + for feature, value in present.items(): + if feature in axes: + continue + if _OBJECT_NAME in present: + per_object.setdefault(f"{present[_OBJECT_NAME]}/{feature}", {})[ + present[_OBJECT_LABEL] + ] = value + else: + assert f"Image/{feature}" not in stored + stored[f"Image/{feature}"] = np.asarray(value) + for key, by_label in per_object.items(): + assert sorted(by_label) == list(range(1, len(by_label) + 1)) + stored[key] = np.asarray([by_label[label] for label in sorted(by_label)]) + return stored + + +def _by_object_label(values: dict[int, object]) -> np.ndarray: + """One value per object in label order; the labels must be 1 to their count.""" + assert sorted(values) == list(range(1, len(values) + 1)) + return np.asarray([values[label] for label in sorted(values)]) + + +def _measured_when_recorded( + module_type: type[ObjectArtifactOutputModule], name: str, labels: ObjectLabelValue +) -> dict[str, np.ndarray]: + """The count and locations a module's family measures of an object set. + + The runtime measures them from the labels a function returned when it + records them (``ObjectArtifactOutputModule.measurement_record_rows``); the + function itself returns none. This makes the same rows for one object set. + """ + location_rows = module_type.object_location_measurement_row_type()( + labels, object_name=name + ) + count = sum(len(ids) for _plane, ids in location_rows.label_plane_domains()) + stored = { + f"Image/{ObjectCountFeatureName.feature(name).name}": np.asarray(float(count)) + } + by_feature: dict[str, dict[int, object]] = {} + for row in location_rows.rows().row_mappings(): + by_feature.setdefault(row[_FEATURE_NAME], {})[row[_OBJECT_LABEL]] = row[ + _RESULT_VALUE + ] + for feature, values in by_feature.items(): + stored[f"{name}/{feature}"] = _by_object_label(values) + return stored + + +def _parents_and_children( + relationship: DirectedObjectRelationshipPayload, + parent_name: str, + parent_labels: np.ndarray, + child_name: str, +) -> dict[str, np.ndarray]: + """A returned parent-child relationship as CellProfiler's two columns. + + Each child's parent, and each parent's number of children (0 where none). + """ + stored: dict[str, np.ndarray] = {} + if relationship.target_ids: + stored[f"{child_name}/Parent_{parent_name}"] = _by_object_label( + dict(zip(relationship.target_ids, relationship.source_ids)) + ) + if parent_labels.max(initial=0): + stored[f"{parent_name}/Children_{child_name}_Count"] = np.bincount( + relationship.source_ids, minlength=int(parent_labels.max()) + 1 + )[1:] + return stored + + +def _rows_as_recorded(block: ModuleBlock, rows: ColumnarRows) -> ColumnarRows: + """A function's rows under the names its module records them with. + + A module may rename its function's rows when they are recorded + (``measurement_row_projection_types``), from what the record request says + about the block: here its input images and objects. This stands in for the + request with the inputs the block declares. + """ + module_type = CellProfilerModule.require_module(block.name) + request = SimpleNamespace( + callable_contract=SimpleNamespace( + artifact_inputs=ArtifactSpecCollection( + ArtifactSpec(name, artifact_type, parameter_name=parameter) + for artifact_type in (ImageArtifactType, ObjectLabelsArtifactType) + for parameter, names in _artifact_names( + block, ArtifactInputPlan, artifact_type + ).items() + for name in names + ) + ) + ) + for projection_type in module_type.measurement_row_projection_types(): + request.output_value = rows + rows = projection_type.for_request(module_type, request).rows() + return rows + + +def _outputs(block: ModuleBlock, result: object) -> dict[str, np.ndarray]: + """What a block's function returned, keyed like the reference's case entries. + + For a module of the family that measures the objects it makes, the family's + measurements of the returned objects are included. + """ + module_type = CellProfilerModule.require_module(block.name) + image_names = _artifact_names(block, ArtifactOutputPlan).get(None, ()) + object_names = _artifact_names( + block, ArtifactOutputPlan, ObjectLabelsArtifactType + ).get(None, ()) + main, *others = result if isinstance(result, tuple) else (result,) + stored: dict[str, np.ndarray] = {} + if image_names: + images = main.slices if isinstance(main, AlignedImageStack) else (main,) + assert len(images) == len(image_names) + for name, image in zip(image_names, images): + stored[f"image/{name}"] = np.asarray(image.data) + object_sets = [value for value in others if isinstance(value, ObjectLabelValue)] + assert len(object_sets) == len(object_names) + for name, labels in zip(object_names, object_sets): + stored[f"objects/{name}"] = object_label_dense_array(labels, dtype=np.int32) + if issubclass(module_type, ObjectArtifactOutputModule): + for key, values in _measured_when_recorded( + module_type, name, labels + ).items(): + stored[f"measurement/{key}"] = values + for relationship in others: + if isinstance(relationship, DirectedObjectRelationshipPayload): + ((parent_name,),) = _artifact_names( + block, ArtifactInputPlan, ObjectLabelsArtifactType + ).values() + (child_name,) = object_names + for key, values in _parents_and_children( + relationship, + parent_name, + _REFERENCE[f"input/objects/{parent_name}"], + child_name, + ).items(): + stored[f"measurement/{key}"] = values + for rows in (value for value in others if isinstance(value, ColumnarRows)): + for key, values in _measurements(_rows_as_recorded(block, rows)).items(): + stored[f"measurement/{key}"] = values + return stored + + +def _execute(block: ModuleBlock, image: ImagePayload) -> np.ndarray: + """The pixels of a block with one output image.""" + ((name,),) = _artifact_names(block, ArtifactOutputPlan).values() + return _outputs(block, _run(block, image))[f"image/{name}"] + + +@pytest.mark.parametrize("case", sorted(_CASES)) +def test_outputs_equal_what_the_plugin_stored(case: str) -> None: + block = _CASES[case] + image = _image(_main_image(block), volume=_main_images_are_volumes(block)) + + if case in _PLUGIN_RAISED: with pytest.raises(ValueError, match="the CellProfiler plugin fails"): - _execute(block, image) + _run(block, image) return - expected = _REFERENCE[f"output_{output_name}"] - actual = _execute(block, image) - assert actual.dtype == expected.dtype == np.float32 - np.testing.assert_allclose(actual, expected, rtol=0, atol=_TOLERANCE) + # The plugin writes an empty column per object measurement when there are + # no objects; here no object rows exist then. + expected = { + key.removeprefix(f"{case}/"): _REFERENCE[key] + for key in _REFERENCE.files + if key.startswith(f"{case}/") + and _REFERENCE[key].shape != (0,) + and not key.endswith(f"/{_OBJECT_NUMBER_FEATURE}") + } + actual = _outputs(block, _run(block, image)) + assert sorted(actual) == sorted(expected) + for key, stored in expected.items(): + if key.startswith("image/") and case not in _IMAGE_TYPE_NOT_COMPARED: + assert actual[key].dtype == stored.dtype + if stored.dtype.kind in "US": + np.testing.assert_array_equal(actual[key], stored) + else: + np.testing.assert_allclose( + actual[key], stored, rtol=0, atol=_TOLERANCE, err_msg=key + ) @pytest.mark.parametrize( @@ -141,7 +409,7 @@ def test_output_equals_what_the_plugin_stored(output_name: str) -> None: ) def test_each_image_of_a_stack_is_processed_on_its_own(output_name: str) -> None: block = _CASES[output_name] - pixels = _input_pixels(block) + pixels = _main_image(block) other = np.ascontiguousarray(pixels[::-1, ::-1]) stacked = _execute( @@ -157,29 +425,118 @@ def test_each_image_of_a_stack_is_processed_on_its_own(output_name: str) -> None @pytest.mark.parametrize( - ("output_name", "inputs"), + ("case", "inputs", "outputs"), ( - ("EqualizedPlane", {None: ("Plane",), "mask_image": ()}), + ("EqualizedPlane", {None: ("Plane",), "mask_image": ()}, ("EqualizedPlane",)), ( "EqualizedPlaneWithinMask", {None: ("Plane",), "mask_image": ("MaskPlane",)}, + ("EqualizedPlaneWithinMask",), ), - ("PlaneDistance", {None: ("BinaryPlane",)}), + ("PlaneDistance", {None: ("BinaryPlane",)}, ("PlaneDistance",)), ( "PlaneMatchedToReference", {None: ("Plane",), "reference": ("ReferencePlane",)}, + ("PlaneMatchedToReference",), ), # CellProfiler's blank value names no image. - ("VolumeMatchedToFrame", {None: ("Volume",), "reference": ()}), + ( + "VolumeMatchedToFrame", + {None: ("Volume",), "reference": ()}, + ("VolumeMatchedToFrame",), + ), + # An image is an output only when the block retains it. + ( + "BarcodesMatchedToLibrary", + {"labels": ("Foci",)}, + ("BarcodeCalls", "BarcodeScores"), + ), + ("BarcodesWithEmptyVector", {"labels": ("Foci",)}, ("BarcodeCalls",)), + ("BarcodesCalledByThreshold", {"labels": ("Foci",)}, ("BarcodeScores",)), + # Without a library the plugin makes no image, whatever the block retains. + ("BarcodesWithoutLibrary", {"labels": ("Foci",)}, ()), + # Objects are an input only where the block compensates within them. + ( + "CompensatedThreeClasses", + {None: ("Cycle01_A", "Cycle01_C", "Plane")}, + ("FirstClass", "LastClass", "MiddleClass"), + ), + ( + "CompensatedSpotsWithinFoci", + { + None: ("Cycle01_A", "Cycle02_A", "Cycle01_C", "Cycle02_C"), + "labels": ("Foci",), + }, + tuple( + f"CompensatedSpotsWithinFoci_{name}" + for name in ("Cycle01_A", "Cycle02_A", "Cycle01_C", "Cycle02_C") + ), + ), + # The plugin uses the first of the object sets a block names. + ( + "CompensatedWithinFirstOfTwoObjectSets", + {None: ("Cycle01_A", "Cycle01_C"), "labels": ("Cells",)}, + tuple( + f"CompensatedWithinFirstOfTwoObjectSets_{name}" + for name in ("Cycle01_A", "Cycle01_C") + ), + ), + ("DeclumpedByShape", {"labels": ("Clumps",)}, ("DeclumpedByShape",)), + # The reference image is an input only where the block floods it. + ( + "DeclumpedByIntensity", + {"reference": ("Plane",), "labels": ("Clumps",)}, + ("DeclumpedByIntensity",), + ), + ("RadialEntropyOfMeans", {None: ("Plane",), "labels": ("Clumps",)}, ()), ), ) -def test_block_declares_its_input_images_and_one_output_image( - output_name: str, inputs: dict[str | None, tuple[str, ...]] +def test_block_declares_its_input_and_output_images_and_objects( + case: str, + inputs: dict[str | None, tuple[str, ...]], + outputs: tuple[str, ...], ) -> None: - block = _CASES[output_name] + block = _CASES[case] + + assert { + **_artifact_names(block, ArtifactInputPlan), + **_artifact_names(block, ArtifactInputPlan, ObjectLabelsArtifactType), + } == inputs + assert ( + *_artifact_names(block, ArtifactOutputPlan).get(None, ()), + *_artifact_names(block, ArtifactOutputPlan, ObjectLabelsArtifactType).get( + None, () + ), + ) == outputs + + +def test_block_declares_the_measurements_of_every_cycle_as_its_inputs() -> None: + """One measurement of the first cycle stands for those of all cycles.""" + declared = { + case: CellProfilerModule.require_module( + _CASES[case].name + ).prior_measurement_feature_names(_CASES[case]) + for case in ("BarcodesMatchedToLibrary", "BarcodesCalledByThreshold") + } - assert _artifact_names(block, ArtifactInputPlan) == inputs - assert _artifact_names(block, ArtifactOutputPlan) == {None: (output_name,)} + assert declared == { + "BarcodesMatchedToLibrary": tuple( + f"Intensity_MaxIntensity_CorrCycle{cycle:02d}_{base}" + for cycle in (1, 2, 3) + for base in "ACGT" + ), + "BarcodesCalledByThreshold": tuple( + f"Intensity_{measurement}_CorrCycle{cycle:02d}_{base}" + for measurement, base in ( + ("MaxIntensity", "A"), + ("MeanIntensity", "A"), + ("MaxIntensity", "C"), + ("MaxIntensity", "G"), + ("MaxIntensity", "T"), + ) + for cycle in (1, 2, 3) + ), + } _SOURCE = """CellProfiler Pipeline: http://www.cellprofiler.org @@ -272,6 +629,271 @@ def test_block_declares_its_input_images_and_one_output_image( """ ) +_CYCLE_IMAGES = tuple( + f"CorrCycle{cycle:02d}_{base}" for cycle in range(1, 9) for base in "ACGT" +) + +# broadinstitute/pooled-cell-painting-image-processing qc/QC_EightCycle.cppipe at +# 47067a480bebc55b8247652c38f5d645db83ef85, module 36. The folder of the barcode +# file is the author's (Elsewhere...|/Users/eweisbar/Documents/projects/PERISCOPE) +# there and the default input folder here; the notes are left out. The blocks +# before it stand for the pipeline's 34 modules that make the foci and measure +# them in every cycle's four images. +_POOLED_SCREEN_PIPELINE = ( + "CellProfiler Pipeline: http://www.cellprofiler.org\n" + "NamesAndTypes:[module_num:1|enabled:True]\n" + f" Assignments count:{len(_CYCLE_IMAGES)}\n" + + "".join( + " Select the image type:Grayscale image\n" + f" Name to assign these images:{name}\n" + f' Select the rule criteria:and (file does contain "{name}")\n' + for name in _CYCLE_IMAGES + ) + + f"""IdentifyPrimaryObjects:[module_num:2|enabled:True] + Select the input image:CorrCycle01_A + Name the primary objects to be identified:Foci +MeasureObjectIntensity:[module_num:3|enabled:True] + Select images to measure:{", ".join(_CYCLE_IMAGES)} + Select object sets to measure:Foci +CallBarcodes:[module_num:36|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Number of cycles:8 + Input object name:Foci + Select one of the measures from Cycle 1 to use for calling:Intensity_MaxIntensity_CorrCycle01_A + Input data file location:Default Input Folder| + Name of the file:Barcodes.csv + Select the column of barcodes to match against:sgRNA + Select the column with gene/transcript barcode names:gene_symbol + Retain an image of the barcodes color coded by call?:Yes + Enter the called barcode image name:Barcodes_IntValues + Retain an image of the barcodes color coded by score match?:Yes + Enter the barcode score image name:BarcodeScores_IntValues + Do you have an empty vector barcode you would like to add to the barcode list?:No + What is the empty vector sequence?:AAAAAAAAAAAAAAA +""" +) + +_BACKGROUND_SUBTRACTED_IMAGES = tuple( + f"Cycle{cycle:02d}_{base}_BackSub" for base in "ACGT" for cycle in range(1, 9) +) + +# broadinstitute/pooled-cell-painting-image-processing qc/QC_EightCycle.cppipe at +# 47067a480bebc55b8247652c38f5d645db83ef85, module 34, unchanged. The blocks +# before it stand for the pipeline's modules that make the background-subtracted +# images of every cycle and the foci. +_POOLED_SCREEN_COMPENSATION_PIPELINE = ( + "CellProfiler Pipeline: http://www.cellprofiler.org\n" + "NamesAndTypes:[module_num:1|enabled:True]\n" + f" Assignments count:{len(_BACKGROUND_SUBTRACTED_IMAGES)}\n" + + "".join( + " Select the image type:Grayscale image\n" + f" Name to assign these images:{name}\n" + f' Select the rule criteria:and (file does contain "{name}")\n' + for name in _BACKGROUND_SUBTRACTED_IMAGES + ) + + """IdentifyPrimaryObjects:[module_num:2|enabled:True] + Select the input image:Cycle01_A_BackSub + Name the primary objects to be identified:Foci +""" + + """CompensateColors:[module_num:34|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['DO NOT EDIT', 'Uses parameters that have been optimized across many batches. Troubleshooting alrnative compensation parameters is outside the scope of this pipeline.']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:32 + Hidden:1 + Select an image to measure:Cycle01_A_BackSub + What compensation class does this image belong to?:1 + Select an output image name:CorrCycle01_A + Select an image to measure:Cycle02_A_BackSub + What compensation class does this image belong to?:1 + Select an output image name:CorrCycle02_A + Select an image to measure:Cycle03_A_BackSub + What compensation class does this image belong to?:1 + Select an output image name:CorrCycle03_A + Select an image to measure:Cycle04_A_BackSub + What compensation class does this image belong to?:1 + Select an output image name:CorrCycle04_A + Select an image to measure:Cycle05_A_BackSub + What compensation class does this image belong to?:1 + Select an output image name:CorrCycle05_A + Select an image to measure:Cycle06_A_BackSub + What compensation class does this image belong to?:1 + Select an output image name:CorrCycle06_A + Select an image to measure:Cycle07_A_BackSub + What compensation class does this image belong to?:1 + Select an output image name:CorrCycle07_A + Select an image to measure:Cycle08_A_BackSub + What compensation class does this image belong to?:1 + Select an output image name:CorrCycle08_A + Select an image to measure:Cycle01_C_BackSub + What compensation class does this image belong to?:2 + Select an output image name:CorrCycle01_C + Select an image to measure:Cycle02_C_BackSub + What compensation class does this image belong to?:2 + Select an output image name:CorrCycle02_C + Select an image to measure:Cycle03_C_BackSub + What compensation class does this image belong to?:2 + Select an output image name:CorrCycle03_C + Select an image to measure:Cycle04_C_BackSub + What compensation class does this image belong to?:2 + Select an output image name:CorrCycle04_C + Select an image to measure:Cycle05_C_BackSub + What compensation class does this image belong to?:2 + Select an output image name:CorrCycle05_C + Select an image to measure:Cycle06_C_BackSub + What compensation class does this image belong to?:2 + Select an output image name:CorrCycle06_C + Select an image to measure:Cycle07_C_BackSub + What compensation class does this image belong to?:2 + Select an output image name:CorrCycle07_C + Select an image to measure:Cycle08_C_BackSub + What compensation class does this image belong to?:2 + Select an output image name:CorrCycle08_C + Select an image to measure:Cycle01_G_BackSub + What compensation class does this image belong to?:3 + Select an output image name:CorrCycle01_G + Select an image to measure:Cycle02_G_BackSub + What compensation class does this image belong to?:3 + Select an output image name:CorrCycle02_G + Select an image to measure:Cycle03_G_BackSub + What compensation class does this image belong to?:3 + Select an output image name:CorrCycle03_G + Select an image to measure:Cycle04_G_BackSub + What compensation class does this image belong to?:3 + Select an output image name:CorrCycle04_G + Select an image to measure:Cycle05_G_BackSub + What compensation class does this image belong to?:3 + Select an output image name:CorrCycle05_G + Select an image to measure:Cycle06_G_BackSub + What compensation class does this image belong to?:3 + Select an output image name:CorrCycle06_G + Select an image to measure:Cycle07_G_BackSub + What compensation class does this image belong to?:3 + Select an output image name:CorrCycle07_G + Select an image to measure:Cycle08_G_BackSub + What compensation class does this image belong to?:3 + Select an output image name:CorrCycle08_G + Select an image to measure:Cycle01_T_BackSub + What compensation class does this image belong to?:4 + Select an output image name:CorrCycle01_T + Select an image to measure:Cycle02_T_BackSub + What compensation class does this image belong to?:4 + Select an output image name:CorrCycle02_T + Select an image to measure:Cycle03_T_BackSub + What compensation class does this image belong to?:4 + Select an output image name:CorrCycle03_T + Select an image to measure:Cycle04_T_BackSub + What compensation class does this image belong to?:4 + Select an output image name:CorrCycle04_T + Select an image to measure:Cycle05_T_BackSub + What compensation class does this image belong to?:4 + Select an output image name:CorrCycle05_T + Select an image to measure:Cycle06_T_BackSub + What compensation class does this image belong to?:4 + Select an output image name:CorrCycle06_T + Select an image to measure:Cycle07_T_BackSub + What compensation class does this image belong to?:4 + Select an output image name:CorrCycle07_T + Select an image to measure:Cycle08_T_BackSub + What compensation class does this image belong to?:4 + Select an output image name:CorrCycle08_T + Select where to perform color compensation:Within objects + Select an object to perform compensation within:Foci + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:No + Should histogram matching be performed between the image groups?:Yes, post-masking to objects + What compensation class should serve as the template histogram?:4 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.9 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:Yes + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 +""" +) + +# CellProfiler/CellProfiler-plugins +# tests/headless_test/4.2.5_plugins_test_pipeline_BASIC.cppipe at +# e962b48c39f7b191ebf3785fa2af390f3d34e081, module 6, unchanged. +_PLUGINS_TEST_COMPENSATION_PIPELINE = ( + _SOURCE.format(image="DNA") + + """CompensateColors:[module_num:6|svn_version:'Unknown'|variable_revision_number:1|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Hidden:2 + Hidden:1 + Select an image to measure:DNA + What compensation class does this image belong to?:1 + Select an output image name:None + Select an image to measure:DNA + What compensation class does this image belong to?:1 + Select an output image name:None + Select where to perform color compensation:Across entire image + Select an object to perform compensation within:None + Should individual images be rescaled 0-1 before compensating pre-masking or on unmasked images?:No + Should images be rescaled 0-1 before compensating but after masking to objects?:No + Should histogram matching be performed between the image groups?:No + What compensation class should serve as the template histogram?:1 + Should images be rescaled 0-1 after compensating?:No + Should the images be divided by a scalar based on group percentiles:No + What percentile should be used for multiplication:99.0 + Should the images have a tophat filter applied before correction?:No + What size radius should be used for the tophat filter?:3 + Should the images have a Laplacian of Gaussian filter applied before correction?:No + What size radius should be used for the LoG filter?:1 + Should the images have a Difference of Gaussians filter applied before correction?:No + What size sigma should be used for the DoG low sigma?:3 + What size radius should be used for the DoG low sigma?:5 +""" +) + +# COBA-NIH/Newmark 3DPlanarianOrgans/testes/CP_Testes_4ch.cppipe at +# df202568caa4b98383f1d462d589cc359295bda6, module 8, unchanged. The pipeline +# processes volumes; the block before it stands for the modules that make the +# objects it splits. +_PLANARIAN_TESTES_PIPELINE = ( + """CellProfiler Pipeline: http://www.cellprofiler.org +NamesAndTypes:[module_num:1|enabled:True] + Process as 3D?:Yes + Assignments count:1 + Select the image type:Grayscale image + Name to assign these images:DNA + Select the rule criteria:and (file does contain "DNA") +ConvertImageToObjects:[module_num:2|enabled:True] + Select the input image:DNA + Name the output objects:PreWatershed +""" + + """DeclumpObjects:[module_num:8|svn_version:'Unknown'|variable_revision_number:1|show_window:False|notes:['Separate objects - separate nuclei that are two closed together that previous module identify as one object']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:PreWatershed + Name the output object:Watershed + Declump method:Shape + Reference Image:None + Segmentation distance transform smoothing factor:2 + Minimum distance between seeds:7 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:Ball,1 + Watershed connectivity:1 +""" +) + +# carpenter-singh-lab/2025_Haghighi_Mito pipelines/radialdistribution.cppipe at +# 1b92661f0a5b55c606756b0b859bc318f7d4f4cc, module 22, unchanged (that file +# escapes its header lines). The block before it stands for the pipeline's +# modules that make the cells. +_MITO_RADIAL_DISTRIBUTION_PIPELINE = ( + _SOURCE.format(image="actin") + + """IdentifyPrimaryObjects:[module_num:2|enabled:True] + Select the input image:actin + Name the primary objects to be identified:Cells +""" + + r"""MeasureRadialEntropy:[module_num:22|svn_version:\'Unknown\'|variable_revision_number:1|show_window:True|notes:\x5B\x5D|batch_state:array(\x5B\x5D, dtype=uint8)|enabled:True|wants_pause:False] + Select an image to measure:actin + Select objects to measure:Cells + Which intensity measurement should be used?:Integrated + Input number of bins:12 +""" +) + @pytest.mark.parametrize( ("pipeline", "steps"), @@ -303,17 +925,85 @@ def test_block_declares_its_input_images_and_one_output_image( _TGLOW_PIPELINE, (("gaussian_filter", {}), ("histogram_matching", {})), ), + ( + _POOLED_SCREEN_PIPELINE, + ( + ("identify_primary_objects", {}), + ("measure_object_intensity", {}), + ( + "call_barcodes", + { + "calling": BrightestBase("Intensity_MaxIntensity_CorrCycle01_A"), + "barcode_library": Path("Barcodes.csv"), + "barcode_column": "sgRNA", + "gene_column": "gene_symbol", + "retain_call_image": True, + "retain_score_image": True, + }, + ), + ), + ), + ( + _POOLED_SCREEN_COMPENSATION_PIPELINE, + ( + ("identify_primary_objects", {}), + ( + "compensate_colors", + { + "compensation_classes": (1,) * 8 + (2,) * 8 + (3,) * 8 + (4,) * 8, + "within_objects": True, + "histogram_matching": MatchAfterMasking(template_class=4), + "class_percentile": 99.9, + "laplacian_of_gaussian_filter": True, + }, + ), + ), + ), + ( + _PLANARIAN_TESTES_PIPELINE, + ( + ("convert_image_to_objects", {}), + # The function for volumes; a ball of radius 1 is its default. + ( + "declump_objects_3d", + {"smoothing_sigma": 2.0, "min_seed_distance": 7}, + ), + ), + ), + ( + _MITO_RADIAL_DISTRIBUTION_PIPELINE, + ( + ("identify_primary_objects", {}), + ( + "measure_radial_entropy", + {"metric": IntegratedIntensity(), "bins": 12}, + ), + ), + ), + ), + ids=( + "plugins_test_pipeline", + "kuo_segmentation", + "kuo_tracking", + "tglow", + "pooled_screen", + "pooled_screen_compensation", + "planarian_testes", + "mito_radial_distribution", ), - ids=("plugins_test_pipeline", "kuo_segmentation", "kuo_tracking", "tglow"), ) def test_real_pipeline_blocks_import_as_steps_with_the_plugin_settings( tmp_path: Path, pipeline: str, steps: tuple[tuple[str, dict[str, object]], ...], ) -> None: - """Imported steps keep only the settings that differ from the plugin defaults.""" + """Imported steps keep only the settings that differ from the plugin defaults. + + A file a block names is found beside the pipeline and shown relative to it. + """ cppipe_path = tmp_path / "pipeline.cppipe" cppipe_path.write_text(pipeline, encoding="utf-8") + shutil.copy(_FIXTURES / "Barcodes.csv", tmp_path) imported, _pipeline_config = import_cellprofiler_pipeline(cppipe_path) @@ -325,10 +1015,28 @@ def test_real_pipeline_blocks_import_as_steps_with_the_plugin_settings( ( invocation.contract.function_name, { - name: value + name: ( + value.relative_to(tmp_path) + if isinstance(value, Path) + else value + ) for name, value in invocation.kwargs_dict.items() if name in inspect.signature(function).parameters }, ) ) assert tuple(settings_of_steps) == steps + + +def test_block_naming_one_output_image_for_several_inputs_is_refused( + tmp_path: Path, +) -> None: + """The plugins' own test pipeline keeps the default name of both outputs. + + The plugin then leaves one image, named ``None``, for its two inputs. + """ + cppipe_path = tmp_path / "pipeline.cppipe" + cppipe_path.write_text(_PLUGINS_TEST_COMPENSATION_PIPELINE, encoding="utf-8") + + with pytest.raises(ValueError, match="a different output image name for each"): + import_cellprofiler_pipeline(cppipe_path)