Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the

## Unreleased

- **`make_clean_run(description=...)` builds a described run for the bricks' Stage 2 tests (Stage 2
design PR 1).** `description` is a published experiment description (bytes, text or a
`Description`). The helper writes the declared tree (`image_count` images per member, RGB colour
for an `rgb` unit), writes the declared masks named in `mask_types` (now mask IDs in that case),
publishes the bytes as `morphobricks.toml`, runs preprocessing and the mask step, and returns a
`CleanRun` whose new `description` field holds the model (None otherwise).
`testing.make_described_run(folder, description, masks=..., mask_step=...)` is the same with
the mask step optional (masks left `not yet checked`). `axes`, `shape`, `pixel_size`, `design`
and `seed` raise `ValueError` with a description. Tested on the Spec fixtures `v03 v08 v13 v28
v30 v33 v34`: each builds, opens with `RunInputs`, and `fovs()` gives its condition IDs, repeat
groups, technical repeat IDs, channel IDs and image count. Without `description`, behaviour is
unchanged.
- **A described run writes manifest version `"4"` (Stage 2 design question 9, owner answer 9).**
When the run manifest carries `experiment_description`, `manifest_version` is `"4"`; an
undescribed run keeps `"2"` or `"3"`, byte for byte (the v0.4.0 parity suite is unchanged).
Expand Down
6 changes: 6 additions & 0 deletions docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,12 @@ A `RunInputs` is picklable, so worker processes can use it.
`morphobricks_preprocessing.testing.make_clean_run(folder, ...)` writes a tiny experiment of
named-axis OME-TIFFs (optionally with masks and a confirmed design) and its clean run, for
the bricks' tests.
`make_clean_run(folder, description=<bytes, text or Description>)` (also `testing.make_described_run`)
writes instead the tree a published experiment description declares (`image_count` tiny images per
member, `rgb` units as RGB colour, one mask per image for each declared mask ID in `mask_types`),
publishes the bytes as `morphobricks.toml`, runs preprocessing and the mask step, and returns
the same `CleanRun` with `description` set; `axes`, `shape`, `pixel_size`, `design` and `seed` then
raise `ValueError`. Tested for the Spec fixtures `v03 v08 v13 v28 v30 v33 v34`.

### 9.2 Modes, recorded inputs, lock and report (v0.4.0, design v0.4 §1, §4)

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ baseline = "tools/code_rules_baseline.json"
"morphobricks_preprocessing.run_inputs_text" = "pure"
"morphobricks_preprocessing.unrecorded" = "io"
"morphobricks_preprocessing.testing" = "orchestration"
"morphobricks_preprocessing.testing_described" = "orchestration"
"morphobricks_preprocessing.mask_scan" = "io"
"morphobricks_preprocessing.mask_pairs" = "pure"
"morphobricks_preprocessing.masks" = "io"
Expand Down
55 changes: 28 additions & 27 deletions src/morphobricks_preprocessing/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,50 +8,34 @@
from __future__ import annotations

import os
from dataclasses import dataclass
from pathlib import Path

import numpy as np
import tifffile

from .axes import prefill_mapping
from .dataset import biological_repeats
from .description import Description
from .design import ExperimentDesign, default_design
from .discovery import discover_experiment
from .run import RunResult, run_experiment
from .run import run_experiment
from .testing_described import CleanRun as CleanRun
from .testing_described import make_described_run as make_described_run


@dataclass(frozen=True)
class CleanRun:
"""A clean run made by :func:`make_clean_run`.

Attributes
----------
experiment
The experiment folder.
output
The run's output folder.
masks
The masks folder, or None.
result
What :func:`.run.run_experiment` returned.
"""

experiment: Path
output: Path
masks: Path | None
result: RunResult
DEFAULTS = {"axes": "CYX", "shape": (2, 16, 20), "pixel_size": 0.5, "design": False}
"""The defaults of the arguments a ``description`` replaces (changing one then raises)."""


def make_clean_run(
folder: str | os.PathLike,
*,
axes: str = "CYX",
shape: tuple[int, ...] = (2, 16, 20),
axes: str = DEFAULTS["axes"],
shape: tuple[int, ...] = DEFAULTS["shape"],
mask_types: tuple[str, ...] = (),
pixel_size: float | None = 0.5,
design: bool = False,
pixel_size: float | None = DEFAULTS["pixel_size"],
design: bool = DEFAULTS["design"],
seed: int = 0,
description: Description | bytes | str | None = None,
) -> CleanRun:
"""Write a two-condition experiment of named-axis TIFFs and run preprocessing on it.

Expand All @@ -74,16 +58,33 @@ def make_clean_run(
repeat group), so the run writes ``experiment_design.csv``.
seed
The random seed of the pixels.
description
A published experiment description (its bytes or text, published unchanged, or a
:class:`.description.Description`): the run is then a described run built by
:func:`.testing_described.make_described_run`, the tree is the one the description
declares (``image_count`` images per member) and ``mask_types`` are IDs of the
description's masks, written and checked by the mask step. ``axes``, ``shape``,
``pixel_size``, ``design`` and ``seed`` come from the description or are not used, and
changing one raises ``ValueError``. For masks left unchecked, call
:func:`.testing_described.make_described_run` with ``mask_step=False``.

Returns
-------
CleanRun

Raises
------
ValueError
If a ``description`` is given with an argument it replaces, or with an unknown mask ID.
RuntimeError
If the run does not write its outputs (its issues are in the message).
"""
if description is not None:
given = {"axes": axes, "shape": shape, "pixel_size": pixel_size, "design": design}
changed = sorted(k for k, v in given.items() if v != DEFAULTS[k]) + (["seed"] * bool(seed))
if changed:
raise ValueError(f"{changed} cannot be set together with a description")
return make_described_run(folder, description, masks=tuple(mask_types))
root = Path(folder)
experiment, masks = root / "experiment", (root / "masks" if mask_types else None)
rng = np.random.default_rng(seed)
Expand Down
210 changes: 210 additions & 0 deletions src/morphobricks_preprocessing/testing_described.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"""A small described preprocessing run for the other bricks' tests (Stage 2 design, PR 1).

:func:`make_described_run` writes the experiment folder a published experiment description
(``morphobricks.toml``, experiment-v1) declares: one folder per condition, repeat and
technical repeat, ``image_count`` tiny images per member (as many channels as the unit
declares, as RGB colour for an ``rgb`` unit), and, when asked, one mask per image for each
declared mask. It publishes the description beside the output, runs preprocessing on it and,
unless told not to, runs the mask step, so a brick's tests read their inputs through
:class:`.run_inputs.RunInputs` exactly as a user's described run gives them.

:func:`.testing.make_clean_run` calls it for ``make_clean_run(description=...)``.
"""

from __future__ import annotations

import os
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path

import numpy as np
import tifffile

from .axes import AxisMapping, prefill_mapping
from .description import Description, Unit, description_from_bytes
from .description_write import canonical_bytes
from .image_type import RGB
from .run import RunResult, run_experiment
from .run.mask_step import describe_masks, prepare_mask_step


@dataclass(frozen=True)
class CleanRun:
"""A clean run made by :func:`.testing.make_clean_run` or :func:`make_described_run`.

Attributes
----------
experiment
The experiment folder.
output
The run's output folder.
masks
The masks folder, or None.
result
What :func:`.run.run_experiment` returned.
description
The experiment description of a described run, else None.
"""

experiment: Path
output: Path
masks: Path | None
result: RunResult
description: Description | None = None


PUBLISHED_NAME = "morphobricks.toml"
"""The published description's file name in the output folder."""
IMAGE_SIZE = 8
"""The side of every image and mask, in pixels."""


def description_of(description: Description | bytes | str) -> tuple[Description, bytes]:
"""Return the description and the exact bytes to publish.

Bytes and text are published as given (the run records their SHA-256); a
:class:`.description.Description` is written canonically.

Raises
------
morphobricks_preprocessing.description.DescriptionError
If bytes or text are not a valid description.
"""
if isinstance(description, Description):
return description, canonical_bytes(description)
data = description.encode("utf-8") if isinstance(description, str) else bytes(description)
return description_from_bytes(data), data


def unit_folder(description: Description, repeat_id: str, condition_id: str, unit: Unit) -> Path:
"""Return the unit's folder relative to the experiment root (no level for ``single``)."""
member = description.repeats[repeat_id].members[condition_id]
parts = [description.conditions[condition_id].source_folder, member.source_repeat]
if unit.source_folder != "single":
parts.append(unit.source_folder)
return Path(*parts)


def _units(description: Description) -> Iterator[tuple[Path, Unit, int]]:
"""Yield ``(folder, unit, image count)`` of every declared unit."""
for repeat_id, repeat in description.repeats.items():
for condition_id, member in repeat.members.items():
for unit in member.technical_repeats.values():
folder = unit_folder(description, repeat_id, condition_id, unit)
yield folder, unit, member.image_count


def _mapping(description: Description) -> AxisMapping:
"""Return the axes of the images this description's units are written as."""
kinds = {unit.image_type == RGB for _, unit, _ in _units(description)}
if len(kinds) != 1:
raise ValueError("a run cannot mix rgb and fluorescence units")
return prefill_mapping("YXS" if kinds == {True} else "CYX")


def _write_image(path: Path, unit: Unit, seed: int) -> None:
"""Write a tiny image of the unit's channels (RGB colour for an ``rgb`` unit)."""
path.parent.mkdir(parents=True, exist_ok=True)
rng = np.random.default_rng(seed)
if unit.image_type == RGB:
pixels = rng.integers(0, 200, (IMAGE_SIZE, IMAGE_SIZE, 3), dtype=np.uint8)
tifffile.imwrite(path, pixels, photometric="rgb", metadata={"axes": "YXS"})
return
pixels = rng.integers(0, 200, (len(unit.channels), IMAGE_SIZE, IMAGE_SIZE), dtype=np.uint8)
tifffile.imwrite(path, pixels, photometric="minisblack", metadata={"axes": "CYX"})


def _write_mask(path: Path, representation: str) -> None:
"""Write a 2-D mask: two labelled squares (``instances``), else one region of 1."""
labels = np.zeros((IMAGE_SIZE, IMAGE_SIZE), np.uint8)
labels[1:3, 1:3] = 1
if representation == "instances":
labels[-3:-1, -3:-1] = 2
path.parent.mkdir(parents=True, exist_ok=True)
tifffile.imwrite(path, labels, photometric="minisblack", metadata={"axes": "YX"})


def write_described_tree(
root: Path, masks_root: Path | None, description: Description, mask_ids: tuple[str, ...] = ()
) -> list[str]:
"""Write the images (and the masks of ``mask_ids``) of a description; return image paths.

Raises
------
ValueError
If a mask ID is not declared by the description, or masks are asked without a folder.
"""
unknown = [m for m in mask_ids if m not in description.masks]
if unknown or (mask_ids and masks_root is None):
raise ValueError(
f"mask IDs {unknown} are not declared by the description "
f"(declared: {sorted(description.masks)})"
)
found: list[str] = []
for folder, unit, count in _units(description):
for index in range(count):
rel = (folder / f"img{index}.tif").as_posix()
_write_image(root / rel, unit, seed=len(found) + 1)
for mask_id in mask_ids:
mask = description.masks[mask_id]
_write_mask(masks_root / mask.source_type / rel, mask.representation)
found.append(rel)
return sorted(found)


def make_described_run(
folder: str | os.PathLike,
description: Description | bytes | str,
*,
masks: tuple[str, ...] = (),
mask_step: bool = True,
) -> CleanRun:
"""Write the experiment a description declares, publish it and run preprocessing.

The folder holds ``experiment`` (the images), ``masks`` (when asked), and ``output``
(the run, with the published ``morphobricks.toml`` as given).

Parameters
----------
folder
An empty folder.
description
The description: its bytes or text (published unchanged) or a ``Description``.
masks
IDs of the description's masks to write, one file per image and ID (instances: two
labelled squares; binary: one region of 1s). None by default.
mask_step
Run the mask step (:func:`.run.mask_step.describe_masks`) after the run, so the masks
are ``checked``; False leaves them ``not yet checked``. Ignored without ``masks``.

Returns
-------
CleanRun
Where everything is, and the run's result.

Raises
------
ValueError
For an unknown mask ID, or a description that mixes rgb and fluorescence units.
RuntimeError
If the run or the mask step does not write its outputs (its issues are in the message).
"""
root = Path(folder)
parsed, data = description_of(description)
experiment, output = root / "experiment", root / "output"
masks_root = root / "masks" if masks else None
mapping = _mapping(parsed)
write_described_tree(experiment, masks_root, parsed, tuple(masks))
output.mkdir(parents=True, exist_ok=True)
(output / PUBLISHED_NAME).write_bytes(data)
result = run_experiment(experiment, mapping, output_folder=output, masks_folder=masks_root)
if not result.written:
raise RuntimeError(
"The described run was not written: " + "; ".join(map(str, result.issues))
)
if masks and mask_step:
step = describe_masks(prepare_mask_step(None, output))
if step.issues:
raise RuntimeError("The mask step failed: " + "; ".join(map(str, step.issues)))
return CleanRun(experiment, output, masks_root, result, parsed)
10 changes: 1 addition & 9 deletions tests/description_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,7 @@
import tifffile

from morphobricks_preprocessing import prefill_mapping


def unit_folder(description, repeat_id, condition_id, unit):
"""Return the unit's folder relative to the experiment root."""
member = description.repeats[repeat_id].members[condition_id]
parts = [description.conditions[condition_id].source_folder, member.source_repeat]
if unit.source_folder != "single":
parts.append(unit.source_folder)
return Path(*parts)
from morphobricks_preprocessing.testing_described import unit_folder


def write_image(path, channels, seed=1):
Expand Down
Loading
Loading