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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ src/morphobricks_preprocessing/
notebook_run.py step 3: settings, what will happen, the run (_RunStep)
notebook_results.py step 4: pooled statistics per group and channel (_ResultsStep)
notebook_plots.py the PNG plots and IntensityPanel (the intensity checks card)
run_labels.py RunLabels: channel and replicate IDs of a described run's plots, from the
published description when it is the file the manifest recorded (else C<n>, folder labels)
notebook_render.py HTML helpers: issues, balance, labels, role effect, captions, tiles
notebook_options.py the steps' choices and fixed settings (ROLE_OPTIONS, ...)
notebook_values.py display_range (pure: no widgets, no files)
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the

## Unreleased

- **Notebook: Step 3 run-page labels (UX proposal 2026-11-09, Q4).** In a described run the intensity
plots name the channel tabs, titles and tab findings by channel ID (`DNA`, `ACTIN`, `PHASE`) instead
of `C0`/`C1`/`C2`, and the box-plot axis and curve legends use the replicate ID (`rep2`) instead of
the folder name (`day2`). The IDs come from the published `morphobricks.toml` only when its SHA-256
is the one the run's manifest recorded (`run_labels.recorded_labels`); an undescribed run, an edited
file, or a channel position whose units do not all declare one ID keeps `C<n>` and the folder label.
The run tile says "no problems found" with a tick, not the warning sign. "What will happen" is hidden
after a successful run (it planned that run) and returns when a setting changes. *Describe the masks*
shows the declared kind (objects or regions) beside the suggested kind and has one *Mask* column
instead of *Mask type* and *Mask ID* holding the same text.
- **Notebook: check and save, reopen, described runs (Stage 1 PR 16b).** `PreprocessingNotebook(describe=True)`
(the shipped notebook) saves the experiment as `morphobricks.toml` before the run. *Check and save*
restates it in sentences (what each condition is compared against, "compared against nothing" in
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ baseline = "tools/code_rules_baseline.json"
"morphobricks_preprocessing.mask_review" = "pure"
"morphobricks_preprocessing.mask_binding" = "io"
"morphobricks_preprocessing.mask_record" = "io"
"morphobricks_preprocessing.run_labels" = "io"
"morphobricks_preprocessing.notebook_axes" = "ui"
"morphobricks_preprocessing.notebook_channel_thumbs" = "ui"
"morphobricks_preprocessing.notebook_channels" = "ui"
Expand Down
11 changes: 11 additions & 0 deletions src/morphobricks_preprocessing/mask_answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ def mask_of_type(answers: DraftAnswers, source_type: str) -> tuple[str, MaskAnsw
return None


KIND_WORDS = {INSTANCES: "objects", BINARY: "regions"}
"""The words the notebook uses for the two answers: separate objects, or regions."""


def mask_kinds(answers: DraftAnswers) -> dict[str, str]:
"""Return what the user declared each answered mask type to be: objects or regions."""
return {mask.source_type: KIND_WORDS[mask.representation] for mask in answers.masks.values()}


def mask_ids(answers: DraftAnswers) -> dict[str, str]:
"""Return the mask ID of every answered mask type, by mask type."""
return {mask.source_type: key for key, mask in answers.masks.items()}
Expand Down Expand Up @@ -186,10 +195,12 @@ def drawn_on(answers: DraftAnswers, source_type: str, unit: UnitKey | None) -> i
__all__ = [
"BINARY",
"INSTANCES",
"KIND_WORDS",
"MaskAnswerRefused",
"drawn_on",
"dropped_channels",
"mask_ids",
"mask_kinds",
"mask_of_type",
"masks_unanswered",
"masks_without_files",
Expand Down
37 changes: 24 additions & 13 deletions src/morphobricks_preprocessing/mask_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,26 +91,37 @@ def mask_thresholds_used(report: MaskReport) -> str:


NOT_DESCRIBED = "not described yet"
"""What the mask ID column says for a mask type the experiment description does not declare."""
"""What the declared-kind column says for a mask type the description does not declare."""


def mask_type_rows(report: MaskReport, ids: Mapping[str, str] | None = None) -> list[list[str]]:
def _mask_cell(name: str, ids: Mapping[str, str] | None) -> str:
"""Return the mask's name: its ID, with the folder name only when that differs."""
mask_id = None if ids is None else ids.get(name)
return name if mask_id is None else mask_id if mask_id == name else f"{mask_id} ({name})"


def mask_type_rows(
report: MaskReport,
ids: Mapping[str, str] | None = None,
declared: Mapping[str, str] | None = None,
) -> list[list[str]]:
"""Return the mask types as table rows, header first: kind, images, objects, pieces.

With ``ids`` (mask type -> mask ID, from *Your masks* or the published description) a
*Mask ID* column follows the type: the masks are named there, never here.
The first column, *Mask*, is the mask's ID when ``ids`` (mask type -> mask ID, from
*Your masks* or the published description) gives one, followed by the folder name in
brackets only when the two differ; it is one column, not a type and an ID that repeat
each other. With ``declared`` (mask type -> ``objects`` or ``regions``, what the user
answered) a *Declared kind* column states that answer beside the suggested kind, which
stays a suggestion; a type the user has not answered says :data:`NOT_DESCRIBED`.
"""
named = ids is not None
rows = [
["Mask type", *(["Mask ID"] if named else []), "Kind (suggested)", "Images", "Objects"]
+ ["Median pieces per label"]
]
shown = declared is not None
header = ["Mask", *(["Declared kind"] if shown else []), "Kind (suggested)"]
rows = [[*header, "Images", "Objects", "Median pieces per label"]]
for name, summary in sorted(report.types.items()):
kind = _MASK_KINDS[summary.kind]
numbers = (summary.images, summary.objects)
mask_id = [ids.get(name, NOT_DESCRIBED)] if named else []
answer = [declared.get(name, NOT_DESCRIBED)] if declared is not None else []
rows.append(
[name, *mask_id, kind, *(f"{n:,}" for n in numbers)]
[_mask_cell(name, ids), *answer, _MASK_KINDS[summary.kind]]
+ [f"{summary.images:,}", f"{summary.objects:,}"]
+ [f"{summary.median_pieces_per_label:g}"]
)
return rows
Expand Down
6 changes: 5 additions & 1 deletion src/morphobricks_preprocessing/notebook_mask_step.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ def _show_mask_description(self, step: MaskStep, result: MaskStepResult) -> None
],
raw=True,
)
kinds = self._mask_kinds()
relationships = mask_relationship_rows(report)
same = "".join(
f"<li>{html.escape(first)} and {html.escape(second)}</li>"
Expand All @@ -250,7 +251,10 @@ def _show_mask_description(self, step: MaskStep, result: MaskStepResult) -> None
hierarchy = html.escape("\n".join(mask_hierarchy_lines(report)))
parts = [
_heading("Mask types (suggestions to check)"),
ui.table(mask_type_rows(report, self._mask_ids()), numeric_from=3),
ui.table(
mask_type_rows(report, self._mask_ids(), kinds),
numeric_from=2 if kinds is None else 3, # from the Images column
),
_heading("Relationships found (suggestions to check)"),
ui.table(relationships, numeric_from=3)
if len(relationships) > 1
Expand Down
6 changes: 6 additions & 0 deletions src/morphobricks_preprocessing/notebook_masks_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
drawn_on,
dropped_channels,
mask_ids,
mask_kinds,
masks_unanswered,
masks_without_files,
without_mask,
Expand Down Expand Up @@ -265,6 +266,11 @@ def _mask_ids(self) -> dict[str, str] | None:
answers = getattr(self, "draft_answers", None)
return None if answers is None else mask_ids(answers)

def _mask_kinds(self) -> dict[str, str] | None:
"""Return what the user declared each mask type to be (objects or regions), or None."""
answers = getattr(self, "draft_answers", None)
return None if answers is None else mask_kinds(answers)

def _masks_answered(self) -> None:
"""Take *Check and save* into account after a mask answer."""
self._refresh_check_save()
Expand Down
69 changes: 49 additions & 20 deletions src/morphobricks_preprocessing/notebook_plots.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from .notebook_render import _label
from .notebook_values import display_range
from .planes import Plane
from .run_labels import NO_LABELS, RunLabels
from .validation import plural


Expand All @@ -61,6 +62,7 @@ class IntensityPanel:
def __init__(self, title: str, explanation: str): # noqa: ANN204
self.records: list[PlaneRecord] = []
self.report: IntensityReport | None = None
self.labels: RunLabels = NO_LABELS
self._used: tuple[tuple, IntensityCheckSettings] | None = None
self._build_settings_box()
self.status = widgets.HTML()
Expand Down Expand Up @@ -152,7 +154,7 @@ def _build_limits(self) -> None:

def _build_plots(self) -> None:
"""Build the channel choice and the two plots of the chosen channel."""
self.channel = widgets.ToggleButtons(options=[("C0", 0)], style={"button_width": "64px"})
self.channel = widgets.ToggleButtons(options=[("C0", 0)], style={"button_width": "96px"})
self.channel_row = widgets.VBox(
[
widgets.HTML("<b>Channel</b>"),
Expand Down Expand Up @@ -207,20 +209,25 @@ def restore_defaults(self) -> None:
"""Set the default limits again."""
self.use(IntensityCheckSettings())

def show(self, records: Sequence[PlaneRecord]) -> None:
"""Check the intensities of ``records`` and show the warnings and plots."""
def show(self, records: Sequence[PlaneRecord], labels: RunLabels = NO_LABELS) -> None:
"""Check the intensities of ``records`` and show the warnings and plots.

``labels`` names the channels and biological repeats by the IDs of the run's
description; the default keeps the table's own names (``C0``, the folder labels).
"""
self.records = list(records)
self.labels = labels
channels = self.records[0].channel_count if self.records else 1
with self.channel.hold_trait_notifications():
self.channel.options = [(f"C{c}", c) for c in range(channels)]
self.channel.options = [(labels.channel(c), c) for c in range(channels)]
self.channel.value = 0
ui.set_visible(self.channel_row, channels > 1)
self._check()
ui.set_visible(self.card, True)

def clear(self) -> None:
"""Hide the panel and forget the table."""
self.records, self.report = [], None
self.records, self.report, self.labels = [], None, NO_LABELS
self.status.value = self.tiles.value = self.findings.value = ""
self.boxes.value = self.curves.value = b""
ui.set_visible(self.card, False)
Expand Down Expand Up @@ -269,7 +276,7 @@ def _check(self) -> None:
("❔", f"{counts['channel_without_finite_pixels']:,}", "channels not checked"),
]
)
self.findings.value = intensity_findings_html(report)
self.findings.value = intensity_findings_html(report, self.labels)
ui.set_visible(self.plots, bool(report.summaries))
self._draw()

Expand All @@ -281,21 +288,28 @@ def _draw(self) -> None:
return
channel = self.channel.value or 0
boxes = repeat_boxes(report, channel)
self.boxes.value = intensity_boxes_png(boxes, report.high_percentile, channel) or b""
curves = repeat_distributions(self.records, channel)
self.curves.value = (
repeat_curves_png(curves, report.shifted_repeats(channel), channel) or b""
labels = self.labels
self.boxes.value = (
intensity_boxes_png(boxes, report.high_percentile, channel, labels) or b""
)
curves = repeat_distributions(self.records, channel)
shifted = report.shifted_repeats(channel)
self.curves.value = repeat_curves_png(curves, shifted, channel, labels) or b""


def intensity_findings_html(report: IntensityReport) -> str:
def intensity_findings_html(report: IntensityReport, labels: RunLabels = NO_LABELS) -> str:
"""Render the intensity warnings as a table, then what to do about each kind."""
if not report.issues:
return ""
rows = [["Check", "Channel", "Image or biological repeat", "What was found"]]
for issue in report.issues:
rows.append(
[_CHECK_LABELS[issue.code], f"C{issue.channel}", ", ".join(issue.items), issue.message]
[
_CHECK_LABELS[issue.code],
labels.channel(issue.channel),
", ".join(issue.items),
issue.message,
]
)
actions = []
for code in INTENSITY_CODES:
Expand All @@ -308,12 +322,16 @@ def intensity_findings_html(report: IntensityReport) -> str:


def intensity_boxes_png(
boxes: Sequence[RepeatBox], high_percentile: float, channel: int
boxes: Sequence[RepeatBox],
high_percentile: float,
channel: int,
labels: RunLabels = NO_LABELS,
) -> bytes | None:
"""Box plots of the image medians and high percentiles per condition and biological repeat.

Images with a warning in the channel are red diamonds; a biological repeat
reported as shifted has an amber box.
reported as shifted has an amber box. With ``labels`` the channel is named by its ID and
each biological repeat by its replicate ID (after its condition).
"""
if not boxes:
return None
Expand Down Expand Up @@ -350,9 +368,13 @@ def intensity_boxes_png(
axes.set_ylabel(name, fontsize=9)
axes.grid(axis="y", alpha=0.3)
pair[1].set_xticks(
list(range(n)), [box.label for box in boxes], rotation=30, ha="right", fontsize=8
list(range(n)),
[f"{b.condition} / {labels.repeat(b.condition, b.biological_repeat)}" for b in boxes],
rotation=30,
ha="right",
fontsize=8,
)
pair[0].set_title(f"Channel C{channel}: one point per image", fontsize=10)
pair[0].set_title(f"Channel {labels.channel(channel)}: one point per image", fontsize=10)
pair[0].legend(
handles=[
Line2D([], [], color="#d62728", marker="D", linestyle="", label="image with a warning"),
Expand All @@ -366,11 +388,17 @@ def intensity_boxes_png(
return _png(figure)


def repeat_curves_png(curves: dict, shifted: set[tuple[str, str]], channel: int) -> bytes | None:
def repeat_curves_png(
curves: dict,
shifted: set[tuple[str, str]],
channel: int,
labels: RunLabels = NO_LABELS,
) -> bytes | None:
"""Overlay the cumulative intensity distribution of each biological repeat, per condition.

``curves`` is :func:`.intensity.repeat_distributions`; shifted repeats are
dashed. At most 12 conditions are drawn.
dashed. At most 12 conditions are drawn. With ``labels`` the channel and each curve are
named by the channel and replicate IDs.
"""
conditions = list(curves)[:_MAX_PLOTTED_GROUPS]
if not conditions:
Expand All @@ -385,13 +413,14 @@ def repeat_curves_png(curves: dict, shifted: set[tuple[str, str]], channel: int)
axes = figure.add_subplot(rows, columns, index + 1)
for repeat, histogram in curves[condition].items():
dashed = (condition, repeat) in shifted
name = labels.repeat(condition, repeat)
axes.step(
histogram.values,
cumulative_share(histogram),
where="post",
linewidth=1.4,
linestyle="--" if dashed else "-",
label=f"{repeat} (shifted)" if dashed else repeat,
label=f"{name} (shifted)" if dashed else name,
)
if high > low:
axes.set_xlim(low, high)
Expand All @@ -403,7 +432,7 @@ def repeat_curves_png(curves: dict, shifted: set[tuple[str, str]], channel: int)
axes.tick_params(labelsize=7)
axes.grid(alpha=0.3)
axes.legend(fontsize=7, frameon=False, loc="lower right")
title = f"Channel C{channel}: one curve per biological repeat"
title = f"Channel {labels.channel(channel)}: one curve per biological repeat"
if len(curves) > _MAX_PLOTTED_GROUPS:
title += f" (first {_MAX_PLOTTED_GROUPS} of {len(curves)} conditions)"
figure.suptitle(title, fontsize=10)
Expand Down
Loading
Loading