"""DAG ↔ :class:`~phenotypic.ImagePipeline` conversion for the new builder.
This module is the conversion seam between the DAG-shaped
:class:`~phenotypic.gui.builder._state.BuilderState` (block + edge model
introduced in Phase 1 of the builder redesign — see spec §5.4) and the
runtime :class:`~phenotypic.ImagePipeline`.
The two public functions mirror today's :func:`to_pipeline` /
:func:`from_pipeline` but operate on the DAG schema:
* :func:`to_pipeline_dag` runs validation first and **raises**
:class:`ValueError` on any blocking issue. On a clean state it
topologically walks the image-flow edges from the ``InputImage``
sentinel block, folds aux-typed edges into each consumer's ``params``
dict via :func:`_block_to_marker`, partitions the result by
:class:`MeasureFeatures` / :class:`PostMeasurement` membership, and
hands ``ops`` / ``meas`` / ``post`` lists to a fresh
:class:`ImagePipeline`.
* :func:`from_pipeline_dag` walks an existing pipeline's ops / meas /
post lists, mints one :class:`BlockNode` per entry, draws image-flow
edges between consecutive entries, and recursively extracts
op-typed parameters as aux edges + embedded aux blocks. Shared
operation instances (the same Python ``id(op)`` in both ``_ops`` and a
consumer's aux param — the legacy popover model permitted this) are
deep-copied into a fresh :class:`BlockNode` and a single info toast is
queued summarising how many were cloned.
Both functions are stateless and side-effect-free apart from the toast
queue mutation in :func:`from_pipeline_dag`.
Examples:
Round-trip a tiny linear chain through the DAG conversion:
>>> from phenotypic.gui.builder._conversion_dag import (
... to_pipeline_dag, from_pipeline_dag,
... )
>>> # state is a BuilderState authored against the spec schema
>>> # pipeline = to_pipeline_dag(state)
>>> # state2 = from_pipeline_dag(pipeline)
"""
from __future__ import annotations
import copy
import logging
from collections import defaultdict
from typing import Any, Dict, List, Optional, Tuple
from phenotypic import ImagePipeline
from phenotypic.abc_ import ImageOperation, MeasureFeatures, PostMeasurement
from phenotypic.gui._operation_registry import OperationRegistry, get_registry
from phenotypic.gui.builder._state import (
INPUT_IMAGE_CLASS_NAME,
PIPELINE_CLASS_NAME,
BlockNode,
Edge,
_DagBuilderScope as BuilderScope,
_DagBuilderState as BuilderState,
_new_block_id,
_seed_input_image,
)
from phenotypic.gui.builder._validation import Issue, validate
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# to_pipeline_dag — DAG → ImagePipeline
# ---------------------------------------------------------------------------
[docs]
def to_pipeline_dag(state: BuilderState) -> ImagePipeline:
"""Convert a DAG-shaped :class:`BuilderState` to an :class:`ImagePipeline`.
Steps (spec §5.4):
1. Validate the state; raise :class:`ValueError` on any blocking
issue (``severity == "error"``).
2. Walk image-flow edges from the ``InputImage`` block in the root
scope to produce a topological order of non-input blocks.
3. For each block, fold any aux edges targeting it into its
``params`` dict — scalar aux at ``params[port]``, list-aux at
``params[port][slot]`` with empty slots set to ``None``.
4. Partition the resulting instances by
:class:`~phenotypic.abc_.MeasureFeatures` /
:class:`~phenotypic.abc_.PostMeasurement` membership into
``ops`` / ``meas`` / ``post`` lists.
5. Construct a fresh :class:`ImagePipeline` with the root scope's
``name``, ``desc``, ``nrows``, ``ncols``.
Aux-only blocks (those without an outgoing image-flow edge) are
never returned as top-level entries — they are folded into the
consumer's ``params`` via :func:`_block_to_marker` recursion.
Args:
state: The :class:`BuilderState` to materialise.
Returns:
A fresh :class:`ImagePipeline`.
Raises:
ValueError: If :func:`validate` returns any issue with
``severity == "error"``. The message lists each rule kind
and the offending block's label so the GUI toast can
surface a deterministic explanation.
Examples:
Build a one-op DAG (Input Image → GaussianBlur) and materialise
it as an :class:`~phenotypic.ImagePipeline`. The synth-yeast
fixture is loaded only to anchor the example in the project's
microbiology context — the conversion does not touch image
data:
>>> from phenotypic.data import load_synth_yeast_plate
>>> from phenotypic.gui.builder._state import (
... BuilderScope, BuilderState, BlockNode, Edge,
... _new_block_id,
... )
>>> from phenotypic.gui.builder._conversion_dag import to_pipeline_dag
>>> scope = BuilderScope(name="demo", desc="one-op chain")
>>> input_block = scope.blocks[0]
>>> blur = BlockNode(
... block_id=_new_block_id(),
... class_name="GaussianBlur",
... params={},
... label="blur",
... )
>>> scope.blocks.append(blur)
>>> scope.edges.append(Edge(
... edge_id=_new_block_id(),
... source_block_id=input_block.block_id,
... target_block_id=blur.block_id,
... target_port="in",
... kind="image",
... ))
>>> state = BuilderState(root=scope)
>>> pipeline = to_pipeline_dag(state)
>>> len(pipeline.get_ops())
1
>>> _ = load_synth_yeast_plate() # microbiology context anchor
"""
errors = [iss for iss in validate(state) if iss.severity == "error"]
if errors:
_raise_validation_error(state.root, errors)
registry = get_registry()
instances = _materialise_scope(state.root, registry)
ops_list: List[Any] = []
meas_list: List[MeasureFeatures] = []
post_list: List[PostMeasurement] = []
for inst in instances:
if isinstance(inst, MeasureFeatures):
meas_list.append(inst)
elif isinstance(inst, PostMeasurement):
post_list.append(inst)
else:
ops_list.append(inst)
return ImagePipeline(
ops=ops_list,
meas=meas_list,
post=post_list,
name=state.root.name,
desc=state.root.desc,
nrows=state.root.nrows,
ncols=state.root.ncols,
)
def _raise_validation_error(scope: BuilderScope, errors: List[Issue]) -> None:
"""Raise a :class:`ValueError` summarising every blocking issue.
The message format is fixed by spec §5.4: a count, an em-dash, and a
comma-separated list of ``kind(label)`` pairs so the toast surface
can show a deterministic phrase regardless of issue ordering.
Args:
scope: Root :class:`BuilderScope` (used to resolve block labels
for friendlier error messages).
errors: Issues filtered to ``severity == "error"``.
Raises:
ValueError: Always; constructed from the supplied issues.
"""
labels = _collect_block_labels(scope)
def _label_for(block_id: Optional[str]) -> str:
if block_id is None:
return "scope"
return labels.get(block_id, block_id[:8])
summary = ", ".join(f"{iss.kind}({_label_for(iss.block_id)})" for iss in errors)
raise ValueError(
f"Cannot materialise pipeline: {len(errors)} validation error(s) — "
f"{summary}"
)
def _collect_block_labels(scope: BuilderScope) -> Dict[str, str]:
"""Walk every block (including nested containers) to map ``block_id`` → label.
Used by :func:`_raise_validation_error` to render friendly names in
the exception message.
Args:
scope: :class:`BuilderScope` to walk.
Returns:
Dict mapping every block's id to its label / class name.
"""
labels: Dict[str, str] = {}
stack: List[BuilderScope] = [scope]
while stack:
current = stack.pop()
for block in current.blocks:
labels[block.block_id] = block.label or block.class_name
if block.nested is not None:
stack.append(block.nested)
return labels
def _materialise_scope(
scope: BuilderScope, registry: OperationRegistry
) -> List[Any]:
"""Materialise a scope's blocks into runtime instances in topological order.
Walks image-flow edges starting from the ``InputImage`` sentinel to
produce an order; folds aux edges into each block's params; and
instantiates each non-input block via the registry (or recursively
via :func:`_materialise_scope` for nested containers).
Args:
scope: The :class:`BuilderScope` to walk.
registry: Operation registry used for class lookup +
``create_instance`` calls.
Returns:
A list of runtime instances in execution order, **excluding**
the ``InputImage`` sentinel.
Raises:
ValueError: If the topological walk reveals a structural
invariant violation that validation didn't catch (defensive
— should never fire on a validated state).
"""
input_block = _find_input_block(scope)
order = _topological_image_order(scope, input_block)
aux_edges_by_target = _index_aux_edges_by_target(scope.edges)
blocks_by_id = {b.block_id: b for b in scope.blocks}
instances: List[Any] = []
aux_only_block_ids = _aux_only_block_ids(scope)
for block in order:
if block.block_id == input_block.block_id:
continue
if block.block_id in aux_only_block_ids:
# Defensive: aux-only blocks must not appear in the
# image-flow order; the topological walk that produced
# ``order`` only emits blocks reachable via outgoing image
# edges, so this branch is unreachable. Keep the guard so a
# future refactor that broadens the walk does not silently
# ship aux-only blocks as top-level ops.
raise ValueError(
f"aux-only block {block.block_id!r} surfaced in topological "
"order; this indicates a bug in the image-flow walk"
)
params = _build_params_with_aux(
block=block,
aux_edges=aux_edges_by_target.get(block.block_id, []),
blocks_by_id=blocks_by_id,
aux_edges_by_target=aux_edges_by_target,
registry=registry,
)
instances.append(_instantiate_block(block, params, registry))
return instances
def _find_input_block(scope: BuilderScope) -> BlockNode:
"""Return the ``InputImage`` block of *scope* or raise.
Validation rule 6 catches missing inputs before
:func:`to_pipeline_dag` reaches this helper, but the conversion
layer keeps a defensive guard so callers that bypass
:func:`to_pipeline_dag` (e.g. internal recursion) still get a clear
error.
Args:
scope: The :class:`BuilderScope` to search.
Returns:
The first ``InputImage`` block in scope order.
Raises:
ValueError: If no ``InputImage`` block exists.
"""
for block in scope.blocks:
if block.class_name == INPUT_IMAGE_CLASS_NAME:
return block
raise ValueError("scope is missing an InputImage block (Rule 6)")
def _topological_image_order(
scope: BuilderScope, input_block: BlockNode
) -> List[BlockNode]:
"""Walk image-flow edges from ``InputImage`` to produce execution order.
Validation rule 1 guarantees no forks (each output has ≤1 outgoing
image wire), so the image-flow subgraph is a chain (or several
chains, but only the one rooted at ``InputImage`` is honoured here
— orphans are caught by Rule 2). Rule 4 guarantees acyclicity.
Args:
scope: The :class:`BuilderScope` whose edges drive the walk.
input_block: The starting ``InputImage`` block.
Returns:
Ordered list of :class:`BlockNode` objects starting with
``input_block`` and proceeding via image-flow edges.
"""
next_by_source: Dict[str, str] = {}
for edge in scope.edges:
if edge.kind == "image":
# Rule 1 (fork) already validated: at most one outgoing
# image edge per source. Last-write-wins matches the
# behaviour of a downstream renderer that picked the most
# recently created edge.
next_by_source[edge.source_block_id] = edge.target_block_id
blocks_by_id = {b.block_id: b for b in scope.blocks}
order: List[BlockNode] = []
visited: set[str] = set()
current_id: Optional[str] = input_block.block_id
while current_id is not None:
if current_id in visited:
# Cycle detection already runs in validate(); guard here so
# an unvalidated direct call doesn't infinite-loop.
break
visited.add(current_id)
block = blocks_by_id.get(current_id)
if block is None:
break
order.append(block)
current_id = next_by_source.get(current_id)
return order
def _index_aux_edges_by_target(edges: List[Edge]) -> Dict[str, List[Edge]]:
"""Group aux edges by target block id for O(1) lookup.
Args:
edges: All edges in the scope.
Returns:
Dict mapping ``target_block_id`` to the list of aux edges that
terminate there. Image edges are excluded.
"""
grouped: Dict[str, List[Edge]] = defaultdict(list)
for edge in edges:
if edge.kind == "aux":
grouped[edge.target_block_id].append(edge)
return grouped
def _aux_only_block_ids(scope: BuilderScope) -> set[str]:
"""Return the set of block ids that have no outgoing image-flow edge.
Aux-only blocks are sources of aux wires only; they are folded into
consumer params rather than appearing as top-level ops.
Args:
scope: The :class:`BuilderScope` to scan.
Returns:
Set of ``block_id`` strings for aux-only blocks (excluding the
``InputImage`` sentinel, which has no outgoing image edge by
construction but is never an aux source).
"""
sources_with_image_out: set[str] = set()
for edge in scope.edges:
if edge.kind == "image":
sources_with_image_out.add(edge.source_block_id)
aux_only: set[str] = set()
for block in scope.blocks:
if block.class_name == INPUT_IMAGE_CLASS_NAME:
continue
if block.block_id in sources_with_image_out:
continue
# No outgoing image edge → aux source or terminal node.
aux_only.add(block.block_id)
# Terminal nodes (the final consumer in the chain) also have no
# outgoing image edge. Remove them: terminal nodes have an
# **incoming** image edge.
image_in_targets: set[str] = set()
for edge in scope.edges:
if edge.kind == "image":
image_in_targets.add(edge.target_block_id)
return {bid for bid in aux_only if bid not in image_in_targets}
def _build_params_with_aux(
*,
block: BlockNode,
aux_edges: List[Edge],
blocks_by_id: Dict[str, BlockNode],
aux_edges_by_target: Dict[str, List[Edge]],
registry: OperationRegistry,
) -> Dict[str, Any]:
"""Fold aux edges + scalar params into a runtime params dict for *block*.
Each aux edge resolves its source block to a marker via
:func:`_block_to_marker`. Scalar aux ports collapse to a single
marker (or ``None``); list-aux ports collapse to a list with
``None`` filling any empty slots ``[0, count)`` not covered by an
edge.
Args:
block: Consumer :class:`BlockNode`.
aux_edges: Aux edges targeting ``block``.
blocks_by_id: Block-id lookup for the current scope (so edges
can resolve their sources).
aux_edges_by_target: Pre-computed index of all aux edges in the
current scope (needed so :func:`_block_to_marker` recursion
can fold aux-of-aux on its own source block).
registry: Registry for class lookup (needed by
:func:`_block_to_marker` recursion and to detect list-typed
params).
Returns:
Params dict ready for ``registry.create_instance(class_name,
**params)``.
"""
params: Dict[str, Any] = dict(block.params)
info = registry.get(block.class_name)
# Group aux edges by target port so list-aux slots assemble.
edges_by_port: Dict[str, List[Edge]] = defaultdict(list)
for edge in aux_edges:
edges_by_port[edge.target_port].append(edge)
for port_name, port_edges in edges_by_port.items():
param_info = info.parameters.get(port_name) if info is not None else None
is_list = bool(param_info and param_info.is_list)
if is_list:
slot_count = block.list_slot_counts.get(port_name, 0)
# Edges' explicit slot indices may exceed the recorded
# ``list_slot_counts`` if the state was hand-edited; grow
# to fit so we never drop a wire.
for edge in port_edges:
if edge.target_slot is not None:
slot_count = max(slot_count, edge.target_slot + 1)
slots: List[Any] = [None] * slot_count
for edge in port_edges:
slot_index = edge.target_slot if edge.target_slot is not None else 0
source_block = blocks_by_id.get(edge.source_block_id)
if source_block is None:
continue
marker = _block_to_marker(
source_block, blocks_by_id, aux_edges_by_target, registry
)
if slot_index < len(slots):
slots[slot_index] = marker
params[port_name] = slots
else:
# Scalar aux: take the first edge's source.
edge = port_edges[0]
source_block = blocks_by_id.get(edge.source_block_id)
if source_block is not None:
params[port_name] = _block_to_marker(
source_block, blocks_by_id, aux_edges_by_target, registry
)
# Honour explicit ``list_slot_counts`` for ports that have empty
# slots but no wired edges — surface as an empty list with ``None``
# entries so the runtime sees a deterministic placeholder.
for port_name, slot_count in block.list_slot_counts.items():
if port_name in edges_by_port:
continue
param_info = info.parameters.get(port_name) if info is not None else None
if param_info is None or not param_info.is_list:
continue
params[port_name] = [None] * slot_count
return params
def _block_to_marker(
block: BlockNode,
blocks_by_id: Dict[str, BlockNode],
aux_edges_by_target: Dict[str, List[Edge]],
registry: OperationRegistry,
) -> Dict[str, Any]:
"""Serialise an aux-source block into a runtime marker dict.
Marker shape matches the legacy
:func:`phenotypic.gui.builder._state._fold_aux_ports_for_node`
convention:
* Container source: a ``pipeline_instance`` marker carrying a
pre-materialised :class:`ImagePipeline` (the container is
recursively converted via :func:`_materialise_scope`).
* Operation source: ``{"__type__": "operation", "class_name":
<name>, "params": {...}}`` where ``params`` is the source
block's scalar params merged with any aux edges that target it
(aux-of-aux recursion via the same edge index).
Args:
block: Source :class:`BlockNode`.
blocks_by_id: Block-id lookup for the source's enclosing scope.
aux_edges_by_target: Pre-computed index of every aux edge in
the enclosing scope, keyed by ``target_block_id``. Used to
fold aux-of-aux on the source block.
registry: Registry for class lookup.
Returns:
Marker dict consumable by ``_resolve_marker``.
"""
if block.class_name == PIPELINE_CLASS_NAME:
# Nested container as aux source: materialise its inner scope
# to an :class:`ImagePipeline` via recursive
# :func:`_materialise_scope`.
inner = block.nested or BuilderScope()
inner_instances = _materialise_scope(inner, registry)
inner_pipeline = _split_and_build_pipeline(inner_instances, inner)
return {
"__type__": "pipeline_instance",
"instance": inner_pipeline,
}
# Build the source block's effective params (its scalar params +
# aux folds from aux-of-aux edges in the same scope).
aux_edges_for_block = aux_edges_by_target.get(block.block_id, [])
params = _build_params_with_aux(
block=block,
aux_edges=aux_edges_for_block,
blocks_by_id=blocks_by_id,
aux_edges_by_target=aux_edges_by_target,
registry=registry,
)
return {
"__type__": "operation",
"class_name": block.class_name,
"params": params,
}
def _instantiate_block(
block: BlockNode, params: Dict[str, Any], registry: OperationRegistry
) -> Any:
"""Convert a block + folded params into a runtime instance.
Container blocks recurse through :func:`_materialise_scope` to
produce an inner :class:`ImagePipeline`; ordinary blocks dispatch
to ``registry.create_instance``.
Args:
block: The :class:`BlockNode` to instantiate.
params: Folded params (scalar + aux markers).
registry: Operation registry.
Returns:
Runtime instance (``ImageOperation`` or ``ImagePipeline``).
Raises:
ValueError: If the block has an unknown class name (validation
should have flagged this with an advisory, but we still
raise if asked to materialise the unknown class).
"""
if block.class_name == PIPELINE_CLASS_NAME:
inner_scope = block.nested or BuilderScope()
inner_instances = _materialise_scope(inner_scope, registry)
return _split_and_build_pipeline(inner_instances, inner_scope)
info = registry.get(block.class_name)
if info is None:
raise ValueError(
f"class {block.class_name!r} is not in the operation registry "
"(advisory issue should have surfaced this before to_pipeline_dag)"
)
resolved_params = {
name: _resolve_marker(value, registry) for name, value in params.items()
}
return registry.create_instance(block.class_name, **resolved_params)
def _resolve_marker(value: Any, registry: OperationRegistry) -> Any:
"""Walk a folded param value and resolve marker dicts to instances.
Markers minted by :func:`_block_to_marker` are dicts shaped like
``{"__type__": "operation", ...}`` (or ``"pipeline_instance"`` for
pre-materialised containers). Scalars / lists pass through; nested
markers recurse.
Args:
value: Folded param value (scalar, list, or marker).
registry: Registry for class lookup.
Returns:
Runtime value (scalar or :class:`ImageOperation` /
:class:`ImagePipeline` instance).
"""
if isinstance(value, dict) and value.get("__type__") == "pipeline_instance":
# Already a materialised ImagePipeline instance handed in by
# :func:`_block_to_marker` for container sources.
return value["instance"]
if isinstance(value, dict) and value.get("__type__") == "operation":
class_name = value["class_name"]
inner = value.get("params") or {}
resolved_inner = {k: _resolve_marker(v, registry) for k, v in inner.items()}
return registry.create_instance(class_name, **resolved_inner)
if isinstance(value, list):
return [_resolve_marker(v, registry) for v in value]
return value
def _split_and_build_pipeline(
instances: List[Any], scope: BuilderScope
) -> ImagePipeline:
"""Partition *instances* into ops / meas / post and construct a pipeline.
Used both at the top level (by :func:`to_pipeline_dag`) and for
container sub-pipelines. Container scopes leave ``nrows``/``ncols``
as ``None`` per spec §4.5.
Args:
instances: Materialised runtime instances in execution order.
scope: The scope they came from (carries ``name`` / ``desc`` /
``nrows`` / ``ncols``).
Returns:
A fresh :class:`ImagePipeline`.
"""
ops_list: List[Any] = []
meas_list: List[MeasureFeatures] = []
post_list: List[PostMeasurement] = []
for inst in instances:
if isinstance(inst, MeasureFeatures):
meas_list.append(inst)
elif isinstance(inst, PostMeasurement):
post_list.append(inst)
else:
ops_list.append(inst)
return ImagePipeline(
ops=ops_list,
meas=meas_list,
post=post_list,
name=scope.name,
desc=scope.desc,
nrows=scope.nrows,
ncols=scope.ncols,
)
# ---------------------------------------------------------------------------
# from_pipeline_dag — ImagePipeline → DAG
# ---------------------------------------------------------------------------
[docs]
def from_pipeline_dag(pipeline: ImagePipeline) -> BuilderState:
"""Convert an :class:`ImagePipeline` to a DAG-shaped :class:`BuilderState`.
Steps (spec §5.4):
1. Construct an empty :class:`BuilderState` (the root
:class:`BuilderScope` auto-seeds an ``InputImage`` block via
its ``__post_init__``).
2. Walk ``pipeline.get_ops() + get_meas() + get_post()`` minting
one :class:`BlockNode` per entry and adding ``"image"`` edges
between consecutive entries (plus from ``InputImage`` to the
first entry).
3. For each block, extract op-typed parameters via the registry's
:class:`ParamInfo` metadata into embedded aux blocks + aux
edges. Recursive walks handle aux-of-aux + container aux.
4. Track an ``id(op) → block_id`` map: when the same Python
instance appears in both ``_ops`` and a consumer's aux param
(legacy shared-instance edge case), deep-copy the aux source
into a fresh block and append an info toast to
``state.toast_queue``.
5. Copy ``pipeline.name``, ``pipeline._desc``, ``pipeline.nrows``,
and ``pipeline.ncols`` to the **root** scope; container scopes
leave ``nrows``/``ncols`` as ``None``.
Args:
pipeline: The :class:`ImagePipeline` to mirror.
Returns:
A :class:`BuilderState` whose root scope reproduces the
pipeline.
Examples:
Mirror a tiny :class:`~phenotypic.ImagePipeline` back into a
DAG :class:`BuilderState`. The auto-seeded ``InputImage``
block sits at index 0 of the root scope's ``blocks`` list and
the mirrored op follows at index 1:
>>> from phenotypic import ImagePipeline
>>> from phenotypic.enhance import GaussianBlur
>>> from phenotypic.data import load_synth_yeast_plate
>>> from phenotypic.gui.builder._conversion_dag import (
... from_pipeline_dag,
... )
>>> pipeline = ImagePipeline(ops=[GaussianBlur()], name="demo")
>>> state = from_pipeline_dag(pipeline)
>>> [b.class_name for b in state.root.blocks]
['InputImage', 'GaussianBlur']
>>> _ = load_synth_yeast_plate() # microbiology context anchor
"""
state = BuilderState()
registry = get_registry()
dedup: Dict[int, str] = {}
clone_counter = _CloneCounter()
_populate_scope_from_pipeline(
scope=state.root,
pipeline=pipeline,
registry=registry,
dedup=dedup,
clone_counter=clone_counter,
is_root=True,
)
if clone_counter.count > 0:
state.toast_queue.append(
{
"kind": "info",
"text": (
f"Loaded {clone_counter.count} shared operation "
"instance(s) as independent copies. The GUI does "
"not support sharing the same operation between "
"two consumers."
),
}
)
return state
class _CloneCounter:
"""Mutable counter passed through the recursive walk.
Plain ``int`` would force every helper to return its incremented
value; a small object keeps the call sites readable.
"""
__slots__ = ("count",)
def __init__(self) -> None:
self.count = 0
def bump(self) -> None:
self.count += 1
def _populate_scope_from_pipeline(
scope: BuilderScope,
pipeline: ImagePipeline,
registry: OperationRegistry,
dedup: Dict[int, str],
clone_counter: _CloneCounter,
is_root: bool,
) -> None:
"""Populate *scope* in place with blocks + edges from *pipeline*.
The root scope's ``__post_init__`` already seeded an
``InputImage`` block; this helper appends the rest.
Args:
scope: Target :class:`BuilderScope` (already constructed +
auto-seeded with ``InputImage``).
pipeline: Source :class:`ImagePipeline`.
registry: Operation registry.
dedup: ``id(op) → block_id`` map for shared-instance detection.
Maintained across the whole recursion so a deepcopy in one
sub-pipeline doesn't fool a sibling.
clone_counter: Mutable clone counter for the info toast.
is_root: ``True`` for the top-level scope; ``False`` for
recursive container sub-scopes. Only the root copies
``nrows`` / ``ncols`` from the pipeline (spec §4.5).
"""
# Copy scope-level metadata.
scope.name = pipeline.name
scope.desc = pipeline._desc if pipeline._desc is not None else ""
if is_root:
scope.nrows = pipeline.nrows
scope.ncols = pipeline.ncols
# else: leave nrows/ncols as None per spec §4.5
# The root's __post_init__ already seeded an InputImage. Ensure it
# exists (the dataclass may have been constructed without
# __post_init__ running in some edge cases).
_seed_input_image(scope)
input_block = _find_input_block(scope)
pairs: List[Tuple[str, Any]] = []
pairs.extend(pipeline.get_ops().items())
pairs.extend(pipeline.get_meas().items())
pairs.extend(pipeline.get_post().items())
prev_block_id = input_block.block_id
for label, op in pairs:
block = _block_from_op(op, label, registry, dedup, clone_counter, scope)
dedup[id(op)] = block.block_id
scope.blocks.append(block)
scope.edges.append(
Edge(
edge_id=_new_block_id(),
source_block_id=prev_block_id,
source_port="out",
target_block_id=block.block_id,
target_port="in",
target_slot=None,
kind="image",
)
)
prev_block_id = block.block_id
def _block_from_op(
op: Any,
label: str,
registry: OperationRegistry,
dedup: Dict[int, str],
clone_counter: _CloneCounter,
scope: BuilderScope,
) -> BlockNode:
"""Build a :class:`BlockNode` for *op* and append its aux edges to *scope*.
Walks ``op``'s op-typed parameters via the registry's
:class:`ParamInfo` and mints embedded aux blocks + aux edges
targeting the new block. Shared-instance clones are detected here.
Args:
op: Source runtime instance (``ImageOperation`` or
``ImagePipeline``).
label: Pipeline-level label for the block (becomes
``block.label``).
registry: Operation registry.
dedup: Shared-instance map.
clone_counter: Mutable clone counter.
scope: The target scope (aux blocks + edges land here).
Returns:
Newly-constructed :class:`BlockNode` (already populated; the
caller appends it to ``scope.blocks``).
"""
block_id = _new_block_id()
if isinstance(op, ImagePipeline):
nested = BuilderScope()
_populate_scope_from_pipeline(
scope=nested,
pipeline=op,
registry=registry,
dedup=dedup,
clone_counter=clone_counter,
is_root=False,
)
return BlockNode(
block_id=block_id,
class_name=PIPELINE_CLASS_NAME,
params={},
label=label,
nested=nested,
)
class_name = type(op).__name__
info = registry.get(class_name)
params: Dict[str, Any] = {}
list_slot_counts: Dict[str, int] = {}
block = BlockNode(
block_id=block_id,
class_name=class_name,
params=params,
label=label,
list_slot_counts=list_slot_counts,
)
if info is None:
# Unknown class: best-effort serialisation from ``vars``.
for attr_name, value in vars(op).items():
if attr_name.startswith("_"):
continue
if not isinstance(value, (ImageOperation, ImagePipeline)):
params[attr_name] = value
return block
for param_name, param_info in info.parameters.items():
if not hasattr(op, param_name):
continue
current = getattr(op, param_name)
aux_eligible = param_info.is_operation or param_info.is_pipeline
if not aux_eligible:
params[param_name] = current
continue
if current is None:
continue
if param_info.is_list:
_attach_list_aux_param(
consumer_block_id=block_id,
port_name=param_name,
sequence=current,
scope=scope,
registry=registry,
dedup=dedup,
clone_counter=clone_counter,
list_slot_counts=list_slot_counts,
)
else:
_attach_scalar_aux_param(
consumer_block_id=block_id,
port_name=param_name,
source_op=current,
scope=scope,
registry=registry,
dedup=dedup,
clone_counter=clone_counter,
)
return block
def _attach_scalar_aux_param(
consumer_block_id: str,
port_name: str,
source_op: Any,
scope: BuilderScope,
registry: OperationRegistry,
dedup: Dict[int, str],
clone_counter: _CloneCounter,
) -> None:
"""Mint an aux source block + edge for a scalar op-typed param.
Args:
consumer_block_id: ``block_id`` of the consumer block.
port_name: Param name (becomes ``target_port`` on the edge).
source_op: The op-typed value pulled from the consumer.
scope: Target scope (aux block + edge land here).
registry: Operation registry.
dedup: Shared-instance map.
clone_counter: Mutable clone counter.
"""
cloned, source_op = _maybe_clone(source_op, dedup, clone_counter)
aux_block = _block_from_op(
source_op,
type(source_op).__name__,
registry,
dedup,
clone_counter,
scope,
)
if not cloned:
dedup[id(source_op)] = aux_block.block_id
scope.blocks.append(aux_block)
scope.edges.append(
Edge(
edge_id=_new_block_id(),
source_block_id=aux_block.block_id,
source_port="out",
target_block_id=consumer_block_id,
target_port=port_name,
target_slot=None,
kind="aux",
)
)
def _attach_list_aux_param(
consumer_block_id: str,
port_name: str,
sequence: Any,
scope: BuilderScope,
registry: OperationRegistry,
dedup: Dict[int, str],
clone_counter: _CloneCounter,
list_slot_counts: Dict[str, int],
) -> None:
"""Mint aux blocks + edges for a list-typed op-typed param.
Empty (``None``) entries reserve slot positions without minting
edges. ``list_slot_counts[port_name]`` records the total slot
count so empty slots interspersed in the original list survive
round-trip.
Args:
consumer_block_id: ``block_id`` of the consumer block.
port_name: Param name (becomes ``target_port`` on each edge).
sequence: The list-shaped value pulled from the consumer.
scope: Target scope (aux blocks + edges land here).
registry: Operation registry.
dedup: Shared-instance map.
clone_counter: Mutable clone counter.
list_slot_counts: Mutable map for the consumer's
``list_slot_counts`` (port → total slot count).
"""
items = list(sequence) if isinstance(sequence, (list, tuple)) else []
list_slot_counts[port_name] = len(items)
for slot_index, source_op in enumerate(items):
if source_op is None:
continue
cloned, source_op = _maybe_clone(source_op, dedup, clone_counter)
aux_block = _block_from_op(
source_op,
type(source_op).__name__,
registry,
dedup,
clone_counter,
scope,
)
if not cloned:
dedup[id(source_op)] = aux_block.block_id
scope.blocks.append(aux_block)
scope.edges.append(
Edge(
edge_id=_new_block_id(),
source_block_id=aux_block.block_id,
source_port="out",
target_block_id=consumer_block_id,
target_port=port_name,
target_slot=slot_index,
kind="aux",
)
)
def _maybe_clone(
source_op: Any,
dedup: Dict[int, str],
clone_counter: _CloneCounter,
) -> Tuple[bool, Any]:
"""Deep-copy *source_op* if its Python id has already been seen.
Args:
source_op: Candidate aux source op.
dedup: Shared-instance map (``id(op) → block_id``).
clone_counter: Mutable counter; incremented on each clone.
Returns:
Tuple ``(cloned, op)`` where ``op`` is the (possibly cloned)
source. ``cloned=True`` means a fresh instance is returned
and the caller should NOT register its id in ``dedup``
(because the consumer downstream of this clone owns it
independently).
"""
if source_op is None:
return False, source_op
if id(source_op) in dedup:
clone_counter.bump()
return True, copy.deepcopy(source_op)
return False, source_op