Files
DumiFlex 4d852de35e fix(label-grid): mismatched counts keep the grid; vertical and over-image side labels (#424)
Images without a label line or part go to a "(no label)" row or column, the node warns when label lines or typed headers don't match, column headers sit on the cells, and left/right labels can be vertical or over the image.
2026-11-14 01:20:49 +01:00

276 lines
10 KiB
Python

"""WP_LabelGrid — draw labels on images and lay them out in a labelled grid.
Takes one image, a batch, a list, or a list of batches (``is_input_list``, so a
Context Loop's frames arrive in ONE call), with optional labels — the Image
Filter's ``labels`` output fits straight in. Gives back the labelled images in
the shape they came in, plus one grid image, and shows the result on the node.
The drawing lives in ``engine/label_grid.py``; this module converts tensors,
reads the one JSON config widget and tells the widget the run's shape.
"""
from __future__ import annotations
import os
import uuid
from typing import Any
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
from engine import label_grid as g
from wp_nodes.types import LabelGridWidgetInput
def _first(values: Any, default: Any = None) -> Any:
"""A widget's value under ``is_input_list`` (ComfyUI wraps it in a list)."""
if isinstance(values, list):
return values[0] if values else default
return default if values is None else values
def _wired_text(values: Any, sep: str) -> str | None:
"""A wired text input under ``is_input_list``: None when unwired, else
every entry joined (a list output's entries become header lines)."""
if values is None:
return None
items = values if isinstance(values, list) else [values]
return sep.join(str(v) for v in items if v is not None)
def _to_pil(batch: Any) -> list[Any]:
import numpy as np
from PIL import Image
out = []
for i in range(int(batch.shape[0])):
arr = np.clip(255.0 * batch[i].cpu().numpy(), 0, 255).astype(np.uint8)
if arr.ndim == 3 and arr.shape[2] == 1:
arr = arr[:, :, 0]
img = Image.fromarray(arr)
out.append(img.convert("RGB") if img.mode != "RGB" else img)
return out
def _to_tensor(images: list[Any]) -> Any:
import numpy as np
import torch
arrs = [np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0 for img in images]
return torch.from_numpy(np.stack(arrs))
def _save_previews(images: list[Any]) -> list[dict[str, str]]:
"""Write the result to ComfyUI's temp folder so the node shows it (the
same refs a Preview Image node sends)."""
import folder_paths # type: ignore[import-not-found]
out_dir = folder_paths.get_temp_directory()
os.makedirs(out_dir, exist_ok=True)
stem = f"wp_label_grid_{uuid.uuid4().hex[:10]}"
refs = []
for i, img in enumerate(images):
name = f"{stem}_{i:03d}.png"
img.save(os.path.join(out_dir, name), compress_level=1)
refs.append({"filename": name, "subfolder": "", "type": "temp"})
return refs
#: Previews shown on the node at most (a 200-frame loop shouldn't write 200 PNGs).
_MAX_PREVIEWS = 64
class WPLabelGrid(io.ComfyNode):
"""Label images and lay them out in a grid."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="WP_LabelGrid",
display_name="WP Label & Grid",
category="wildcard-pipeline",
description=(
"Draws labels on images and lays them out in a labelled grid. "
"Takes one image, a batch, a list or a list of batches; labels "
"are optional (the Image Filter's labels output fits straight in)."
),
inputs=[
io.Image.Input(
"images",
tooltip="One image, a batch, a list, or a list of batches (a Context Loop).",
),
io.String.Input(
"labels",
optional=True,
force_input=True,
tooltip=(
"One entry per image or batch, one line per image of the batch "
"(one line covers the whole batch). The Image Filter's labels "
"output fits here."
),
),
io.String.Input(
"label_text",
optional=True,
force_input=True,
tooltip=(
"Wire text here to set what each image shows from another node "
"(the tokens {label}, {index}, … still work). A list gives one "
"text per item; the last one covers any item past it."
),
),
io.String.Input(
"title",
optional=True,
force_input=True,
tooltip="Wire text here to set the grid title from another node.",
),
io.String.Input(
"row_headers",
optional=True,
force_input=True,
tooltip=(
"Row headers from another node, one per line (or one per list "
"entry). Wiring it sets Rows to typed headers."
),
),
io.String.Input(
"column_headers",
optional=True,
force_input=True,
tooltip=(
"Column headers from another node, one per line (or one per list "
"entry). Wiring it sets Columns to typed headers."
),
),
# Label text, style and grid settings all live in one DOM widget
# (src/components/label-grid/LabelGridWidget.vue).
LabelGridWidgetInput.Input(
"wp_label_grid",
socketless=True,
default="",
optional=True,
),
],
outputs=[
io.Image.Output("images", is_output_list=True),
io.Image.Output("grid"),
],
is_input_list=True,
is_output_node=True,
)
@classmethod
def execute(
cls,
images,
labels=None,
label_text=None,
title=None,
row_headers=None,
column_headers=None,
wp_label_grid=None,
):
cfg = g.parse_config(_first(wp_label_grid, ""))
# A wired text input beats what is typed in the panel.
wired_title = _wired_text(title, " ")
if wired_title is not None:
cfg["title"] = wired_title
for key, values in (("row_headers", row_headers), ("column_headers", column_headers)):
wired = _wired_text(values, "\n")
if wired is not None:
cfg[key] = wired
cfg["rows_from" if key == "row_headers" else "columns_from"] = "typed"
style = g.Style(
position=cfg["position"],
align=cfg["align"],
orientation=cfg["orientation"],
font=cfg["font"],
font_size=cfg["font_size"],
text_color=cfg["text_color"],
label_color=cfg["label_color"],
background=cfg["background"],
header_color=cfg["header_color"],
gap=cfg["gap"],
margin=cfg["margin"],
cell_width=cfg["cell_width"],
)
batches = [b for b in (images or []) if b is not None]
pil = [_to_pil(b) for b in batches]
sizes = [len(p) for p in pil]
incoming = g.item_labels(labels, sizes)
missing, extra = g.label_fit(labels, sizes)
count = sum(sizes)
# A wired label text beats the panel's: one per item, the last repeats.
wired_text = [
"" if v is None else str(v)
for v in (label_text if isinstance(label_text, list) else [label_text])
] if label_text is not None else []
texts: list[list[str]] = []
index = 0
for fi, item in enumerate(incoming):
template = wired_text[min(fi, len(wired_text) - 1)] if wired_text else cfg["label"]
row = []
for ii, raw in enumerate(item):
row.append(g.apply_template(template, raw, fi, ii, index, count))
index += 1
texts.append(row)
labelled = [g.label_batch(p, t, style) for p, t in zip(pil, texts, strict=True)]
out_images = [_to_tensor(batch) for batch in labelled]
grid_tensor = None
grid_size: list[int] | None = None
fit: dict[str, Any] = {}
preview = [img for batch in labelled for img in batch]
if cfg["grid"] and count:
rows_from = cfg["rows_from"]
cols_from = cfg["columns_from"]
spec = g.GridSpec(
arrange=cfg["arrange"],
columns=cfg["columns"],
title=cfg["title"],
column_headers=g.typed_headers(cfg["column_headers"])
if cols_from == "typed"
else [],
row_headers=g.typed_headers(cfg["row_headers"]) if rows_from == "typed" else [],
column_part=g.header_part(cols_from),
row_part=g.header_part(rows_from),
)
cells = [
g.Cell(img, texts[fi][ii], fi, ii)
for fi, batch in enumerate(pil)
for ii, img in enumerate(batch)
]
fit = g.grid_fit(cells, spec)
grid = g.build_grid(cells, style, spec)
grid_size = [grid.width, grid.height]
grid_tensor = _to_tensor([grid])
preview = [grid]
if grid_tensor is None:
import torch
grid_tensor = torch.zeros((1, 64, 64, 3))
flat = [text for item in texts for text in item]
run = {
"items": len(sizes),
"batch_sizes": sizes[:256],
"count": count,
"labelled": any(lab for item in incoming for lab in item),
"parts": g.part_examples(flat),
"auto": g.auto_arrange(sizes),
"grid": grid_size,
# Label lines vs images, and typed headers vs the grid's lanes,
# so the panel can say when the numbers don't match.
"missing": missing,
"extra": extra,
**fit,
}
ui: dict[str, Any] = {"wp_label_grid": [run]}
try:
ui["images"] = _save_previews(preview[:_MAX_PREVIEWS])
except ImportError: # outside ComfyUI (tests): no temp folder, no preview
pass
return io.NodeOutput(out_images, grid_tensor, ui=ui)