Files
DumiFlex 7fc5280c55 feat(image-filter): labels output and painted mask preview (#174)
Every image gets a label ("#3 · red · pencil · 2" by default) that the
picker can rename under its Grid tile or its Detail title. The new labels
output sends them in step with images, one line per image of an item, and
sits last so saved workflows keep their links. The Mask tab previews a
painted mask over the image or on its own.
2026-10-28 14:21:46 +01:00

451 lines
16 KiB
Python

"""Pure logic behind ``WP_ImageFilter`` — the pause-and-pick node.
The node sits between two passes (first KSampler → upscaler / hires pass) and
lets the user choose which images go on. Everything wired through it follows
the same picks so its outputs stay lined up for the next sampler: the latents
of the picked images, the conditionings and prompt strings of the frames they
came from, the masks, the pipeline context.
Shapes. With ``is_input_list`` the node receives every input as a list. The
``images`` list sets the frames: ``images[f]`` is one IMAGE batch, so a plain
batch is one frame of B images and a Context Loop of N iterations is N frames.
A pick is ``(frame, image)``. Each other slot is aligned to the frames the way
ComfyUI aligns lists (one item per frame, or its last item repeated) and then:
- a value batched like its frame's images (latent ``samples``, a mask, a
conditioning tensor whose first dimension equals the frame's batch) is
sliced to the picked images;
- anything else (a string, a context, a batch-1 conditioning shared by the
whole batch, a model) is kept or dropped with its frame.
Duck-typed on ``.shape`` + list indexing (torch tensors and numpy arrays both
qualify) so this module stays free of torch and ComfyUI imports and is tested
with plain pytest.
The waiting side lives here too: ``PendingAnswers`` is the thread-safe hand-off
between the HTTP route (aiohttp's loop) and the node (ComfyUI runs prompts in a
worker thread with its own event loop, so an ``asyncio.Event`` from one loop
can't be awaited from the other).
"""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, field
from typing import Any
Pick = tuple[int, int]
#: One output frame: the source frame and the image indexes it keeps.
PlanItem = tuple[int, list[int]]
MODES = ("pause", "reuse", "pass_all")
SEND_AS = ("same_shape", "per_image")
ON_TIMEOUT = ("keep_all", "stop", "keep_first")
DEFAULT_CONFIG: dict[str, Any] = {
"mode": "pause",
"send_as": "same_shape",
# Seconds; 0 waits until answered (or the run is cancelled).
"timeout": 600,
"on_timeout": "keep_all",
}
#: Upper bound for the timeout widget (a day). Guards against a typo turning
#: into a node that can never time out by accident.
MAX_TIMEOUT = 86_400
def parse_config(raw: Any) -> dict[str, Any]:
"""Decode the node's JSON config widget, falling back per key.
Unknown keys are ignored and bad values collapse to their default, so a
hand-edited or older workflow still runs (same recovery rule as the
Context Loop's ``_parse_config``).
"""
out = dict(DEFAULT_CONFIG)
if isinstance(raw, str):
try:
raw = json.loads(raw) if raw.strip() else {}
except ValueError:
raw = {}
if not isinstance(raw, dict):
return out
for key, allowed in (
("mode", MODES),
("send_as", SEND_AS),
("on_timeout", ON_TIMEOUT),
):
if raw.get(key) in allowed:
out[key] = raw[key]
timeout = raw.get("timeout")
if isinstance(timeout, (int, float)) and not isinstance(timeout, bool):
out["timeout"] = max(0, min(MAX_TIMEOUT, int(timeout)))
return out
# ------------------------------------------------------------------ picks ---
def batch_len(value: Any) -> int | None:
"""First dimension of a tensor-like, else None."""
shape = getattr(value, "shape", None)
if shape is None:
return None
try:
return int(shape[0]) if len(shape) >= 1 else None
except (TypeError, ValueError):
return None
def all_picks(batch_sizes: list[int]) -> list[Pick]:
return [(f, i) for f, b in enumerate(batch_sizes) for i in range(b)]
def normalize_picks(raw: Any, batch_sizes: list[int]) -> list[Pick]:
"""Keep the in-range ``[frame, image]`` pairs, deduplicated, in order."""
if not isinstance(raw, (list, tuple)):
return []
seen: set[Pick] = set()
for item in raw:
if not isinstance(item, (list, tuple)) or len(item) != 2:
continue
f, i = item
if isinstance(f, bool) or isinstance(i, bool):
continue
if not isinstance(f, int) or not isinstance(i, int):
continue
if 0 <= f < len(batch_sizes) and 0 <= i < batch_sizes[f]:
seen.add((f, i))
return sorted(seen)
def plan_output(picks: list[Pick], send_as: str = "same_shape") -> list[PlanItem]:
"""Group picks into output frames.
``same_shape``: one output frame per source frame that has a pick, holding
its picked images. ``per_image``: one output frame per picked image.
"""
ordered = sorted(set(picks))
if send_as == "per_image":
return [(f, [i]) for f, i in ordered]
plan: list[PlanItem] = []
for f, i in ordered:
if plan and plan[-1][0] == f:
plan[-1][1].append(i)
else:
plan.append((f, [i]))
return plan
def format_picks(picks: list[Pick]) -> str:
"""1-based ``frame:image`` list for the ``picks`` output, e.g. ``1:2, 3:1``."""
return ", ".join(f"{f + 1}:{i + 1}" for f, i in sorted(picks))
# ------------------------------------------------------------- following ---
def align(values: list[Any] | None, frames: int) -> list[Any]:
"""Line a slot's list up with the frames, ComfyUI-style.
A slot with fewer items than frames repeats its last item (that is how
ComfyUI pairs lists of different lengths); an unconnected slot is None for
every frame.
"""
if not values:
return [None] * frames
return [values[f] if f < len(values) else values[-1] for f in range(frames)]
def _take(value: Any, idx: list[int], batch: int) -> Any:
n = batch_len(value)
if n is None or n != batch or idx == list(range(batch)):
return value
return value[idx]
def _is_conditioning(value: Any) -> bool:
return (
isinstance(value, list)
and len(value) > 0
and all(
isinstance(entry, (list, tuple))
and len(entry) == 2
and batch_len(entry[0]) is not None
and isinstance(entry[1], dict)
for entry in value
)
)
def take_images(value: Any, idx: list[int], batch: int) -> Any:
"""Slice one frame's value down to the picked images, when it is batched.
Values whose batch doesn't match the frame's image batch pass through: a
batch-1 conditioning broadcast over 4 images, a single mask for the whole
batch, a string.
"""
if value is None:
return None
if isinstance(value, dict) and "samples" in value:
# LATENT: slice samples and whatever travels batched beside them.
out = dict(value)
out["samples"] = _take(value["samples"], idx, batch)
if "noise_mask" in value:
out["noise_mask"] = _take(value["noise_mask"], idx, batch)
bi = value.get("batch_index")
if isinstance(bi, list) and len(bi) == batch:
out["batch_index"] = [bi[i] for i in idx]
return out
if _is_conditioning(value):
sliced = []
for tensor, extras in value:
new_extras = {
k: (_take(v, idx, batch) if batch_len(v) is not None else v)
for k, v in extras.items()
}
sliced.append([_take(tensor, idx, batch), new_extras])
return sliced
return _take(value, idx, batch)
def follow_slot(
values: list[Any] | None,
plan: list[PlanItem],
batch_sizes: list[int],
) -> list[Any]:
"""The output list for one slot under ``plan``."""
aligned = align(values, len(batch_sizes))
return [take_images(aligned[f], idx, batch_sizes[f]) for f, idx in plan]
# ---------------------------------------------------------------- answers ---
#: Per-image edits from the picker: ``positive`` / ``negative`` replacement
#: text and ``mask``, a PNG data URL (white = masked) at any size.
Edit = dict[str, str]
#: Longest prompt edit accepted (characters) and mask data URL (bytes).
MAX_EDIT_TEXT = 20_000
MAX_MASK_URL = 16 * 1024 * 1024
MASK_PREFIX = "data:image/png;base64,"
@dataclass(frozen=True)
class Answer:
"""What the picker sent back: ``picks``, ``keep_all`` or ``stop``, plus
any per-image ``edits`` made in the picker and each image's ``labels``."""
action: str
picks: tuple[Pick, ...] = ()
edits: dict[Pick, Edit] = field(default_factory=dict)
labels: dict[Pick, str] = field(default_factory=dict)
# ---------------------------------------------------------------- labels ---
#: Longest label kept (characters); labels are one line.
MAX_LABEL = 200
#: Between a label's parts: "#3 · red · pencil · 2".
LABEL_SEP = " · "
def clean_label(text: Any) -> str | None:
"""One line, trimmed, at most ``MAX_LABEL`` characters; None when not text."""
if not isinstance(text, str):
return None
return " ".join(text.split())[:MAX_LABEL]
def default_label(frame: int, image: int, frame_size: int, loop_index: Any = None) -> str:
"""``#N`` (loop index + 1, else the frame number), then ``· K`` when the
frame has more than one image. The picker adds the sweep option names
between the two; here (no picker) they aren't known."""
looped = isinstance(loop_index, int) and not isinstance(loop_index, bool)
n = loop_index + 1 if looped else frame + 1
parts = [f"#{n}"]
if frame_size > 1:
parts.append(str(image + 1))
return LABEL_SEP.join(parts)
def parse_labels(raw: Any, batch_sizes: list[int]) -> dict[Pick, str]:
"""Validate ``{"f:i": "label"}``; bad entries drop."""
out: dict[Pick, str] = {}
if not isinstance(raw, dict):
return out
for key, text in raw.items():
pick = _parse_key(key, batch_sizes)
label = clean_label(text)
if pick is not None and label is not None:
out[pick] = label
return out
def item_labels(
plan: list[PlanItem],
labels: dict[Pick, str],
batch_sizes: list[int],
loop_indexes: list[Any] | None = None,
) -> list[str]:
"""The ``labels`` output: per output item one line per image (newline
joined), each the picker's label or the default."""
out: list[str] = []
for f, idx in plan:
size = batch_sizes[f] if f < len(batch_sizes) else 1
loop = loop_indexes[f] if loop_indexes and f < len(loop_indexes) else None
out.append("\n".join(labels.get((f, i)) or default_label(f, i, size, loop) for i in idx))
return out
def _parse_key(key: Any, batch_sizes: list[int]) -> Pick | None:
"""``"f:i"`` as a pick that exists in the frames, else None."""
if not isinstance(key, str):
return None
parts = key.split(":")
if len(parts) != 2 or not all(p.isdigit() for p in parts):
return None
f, i = int(parts[0]), int(parts[1])
if not (0 <= f < len(batch_sizes) and 0 <= i < batch_sizes[f]):
return None
return (f, i)
def parse_edits(raw: Any, batch_sizes: list[int]) -> dict[Pick, Edit]:
"""Validate ``{"f:i": {positive?, negative?, mask?}}``; bad entries drop."""
out: dict[Pick, Edit] = {}
if not isinstance(raw, dict):
return out
for key, entry in raw.items():
pick = _parse_key(key, batch_sizes)
if pick is None or not isinstance(entry, dict):
continue
f, i = pick
edit: Edit = {}
for name in ("positive", "negative"):
text = entry.get(name)
if isinstance(text, str) and len(text) <= MAX_EDIT_TEXT:
edit[name] = text
mask = entry.get("mask")
if isinstance(mask, str) and mask.startswith(MASK_PREFIX) and len(mask) <= MAX_MASK_URL:
edit["mask"] = mask
if edit:
out[(f, i)] = edit
return out
def parse_answer(payload: Any, batch_sizes: list[int]) -> Answer | None:
"""Validate a picker response body; None when it is unusable."""
if not isinstance(payload, dict):
return None
action = payload.get("action")
edits = parse_edits(payload.get("edits"), batch_sizes)
labels = parse_labels(payload.get("labels"), batch_sizes)
if action == "keep_all":
return Answer("keep_all", tuple(all_picks(batch_sizes)), edits, labels)
if action == "stop":
return Answer("stop")
if action == "picks":
picks = tuple(normalize_picks(payload.get("picks"), batch_sizes))
kept = set(picks)
return Answer(
"picks", picks,
{p: e for p, e in edits.items() if p in kept},
{p: t for p, t in labels.items() if p in kept},
)
return None
def item_text_edits(
plan: list[PlanItem], edits: dict[Pick, Edit], key: str,
) -> list[str | None]:
"""Edited ``positive`` / ``negative`` text per output item, or None.
An item with several images (Same shape) has one prompt, so the first
edited image in it decides (the picker writes the same text to all of a
frame's images in that mode).
"""
out: list[str | None] = []
for f, idx in plan:
text = next((edits[(f, i)][key] for i in idx if key in edits.get((f, i), {})), None)
out.append(text)
return out
def item_mask_edits(plan: list[PlanItem], edits: dict[Pick, Edit]) -> list[list[str] | None]:
"""Per output item, the painted mask URL of each image ("" when that image
has none), or None when no image of the item was painted."""
out: list[list[str] | None] = []
for f, idx in plan:
urls = [edits.get((f, i), {}).get("mask", "") for i in idx]
out.append(urls if any(urls) else None)
return out
def resolve_timeout(on_timeout: str, batch_sizes: list[int]) -> Answer:
if on_timeout == "stop":
return Answer("stop")
if on_timeout == "keep_first" and batch_sizes:
return Answer("picks", ((0, 0),))
return Answer("keep_all", tuple(all_picks(batch_sizes)))
class PendingAnswers:
"""Thread-safe registry of nodes waiting for a pick.
``open`` registers a request (and keeps its payload so a reloaded page can
show it again), ``answer`` is called from the HTTP route, ``take`` is polled
by the waiting node, ``close`` drops the request whatever happened.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._requests: dict[str, dict[str, Any]] = {}
self._answers: dict[str, Any] = {}
def open(self, token: str, request: dict[str, Any]) -> None:
with self._lock:
self._requests[token] = request
self._answers.pop(token, None)
def answer(self, token: str, payload: Any) -> bool:
"""Store a response; False when nothing is waiting on ``token``."""
with self._lock:
if token not in self._requests:
return False
self._answers[token] = payload
return True
def take(self, token: str) -> Any:
with self._lock:
return self._answers.pop(token, None)
def close(self, token: str) -> None:
with self._lock:
self._requests.pop(token, None)
self._answers.pop(token, None)
def pending(self) -> list[dict[str, Any]]:
with self._lock:
return list(self._requests.values())
#: Process-wide registry shared by the node and the route.
PENDING = PendingAnswers()
#: Last picks per node (``unique_id``), for the "Reuse last picks" mode.
#: In memory only: a restart forgets them and the node asks again.
LAST_PICKS: dict[str, list[Pick]] = {}
#: The labels that went with them, so "Reuse last picks" keeps the names.
LAST_LABELS: dict[str, dict[Pick, str]] = {}
def reusable_picks(stored: list[Pick] | None, batch_sizes: list[int]) -> list[Pick] | None:
"""Stored picks when they still fit the incoming frames, else None."""
if not stored:
return None
picks = normalize_picks([list(p) for p in stored], batch_sizes)
return picks if len(picks) == len(stored) else None