feat(nodes): add ComicPageComposer (rect + bbox metadata)

Assembles per-panel IMAGE tensors onto full page canvases via compose_page,
emits panel_bboxes_json metadata, and passes the original panel stack through.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
tack
2026-04-23 12:41:56 +08:00
co-authored by Claude Sonnet 4.6
parent 33c7eb5098
commit c97190fe2e
2 changed files with 143 additions and 0 deletions
@@ -0,0 +1,78 @@
"""ComicPageComposer — assemble panels into full page images."""
from __future__ import annotations
import json
import numpy as np
import torch
from ..core.compositor import compose_page
from ..core.types import COMIC_SCRIPT_TYPE, SolvedScript
def _pil_to_tensor(img) -> np.ndarray:
return np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0
def _bboxes_metadata(script: SolvedScript) -> dict:
return {
"job_id": script.job_id,
"reading_direction": script.reading_direction,
"pages": [
{
"page_index": page.page_index,
"page_width": page.page_width,
"page_height": page.page_height,
"panels": [
{
"panel_index": panel.panel_index,
"bbox_topleft": list(panel.bbox_topleft),
"bbox_size": list(panel.bbox_size),
"shape_type": panel.shape_type,
} for panel in page.panels
],
} for page in script.pages
],
}
class ComicPageComposer:
CATEGORY = "comic/io"
FUNCTION = "compose"
RETURN_TYPES = ("IMAGE", "STRING", "IMAGE")
RETURN_NAMES = ("composed_pages", "panel_bboxes_json", "individual_panels")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"generated_panels": ("IMAGE",),
"comic_script": (COMIC_SCRIPT_TYPE,),
"debug_overlay": ("BOOLEAN", {"default": False}),
}
}
def compose(self, generated_panels: torch.Tensor, comic_script: SolvedScript,
debug_overlay: bool):
panels_np = generated_panels.cpu().numpy() # (N, Hmax, Wmax, 3)
out_pages: list[np.ndarray] = []
idx = 0
for page in comic_script.pages:
panel_imgs: list[tuple] = []
for panel in page.panels:
raw = panels_np[idx]
w, h = panel.bbox_size
cropped = raw[:h, :w, :] # matching what BatchGenerator placed (top-left of padding)
panel_imgs.append((panel, cropped))
idx += 1
pil = compose_page(page, panel_imgs)
out_pages.append(_pil_to_tensor(pil))
# Pad composed pages to same shape if sizes differ (M1: all same)
max_h = max(p.shape[0] for p in out_pages)
max_w = max(p.shape[1] for p in out_pages)
padded = [np.pad(p, ((0, max_h - p.shape[0]), (0, max_w - p.shape[1]), (0, 0)),
constant_values=1.0) for p in out_pages]
pages_tensor = torch.from_numpy(np.stack(padded, axis=0)).float()
bboxes = json.dumps(_bboxes_metadata(comic_script), indent=2, ensure_ascii=False)
return pages_tensor, bboxes, generated_panels
@@ -0,0 +1,65 @@
# tests/test_page_composer.py
import json
from pathlib import Path
import numpy as np
import torch
from PIL import Image
from tk_comfyui_batch_image.core.normalizer import normalize_script
from tk_comfyui_batch_image.nodes.page_composer import ComicPageComposer
FIXTURES = Path(__file__).parent / "fixtures" / "scripts"
def _make_panel_stack(script) -> torch.Tensor:
"""Produce a solid-colour IMAGE batch matching each panel's bbox_size."""
imgs = []
for page in script.pages:
for panel in page.panels:
w, h = panel.bbox_size
arr = np.full((h, w, 3), 0.5, dtype=np.float32)
imgs.append(arr)
# Pad to same H/W by placing each into a max-sized canvas
max_h = max(a.shape[0] for a in imgs)
max_w = max(a.shape[1] for a in imgs)
padded = [np.pad(a, ((0, max_h - a.shape[0]), (0, max_w - a.shape[1]), (0, 0)),
constant_values=1.0) for a in imgs]
return torch.from_numpy(np.stack(padded, axis=0)).float()
def test_compose_basic_script_yields_n_pages():
data = json.loads((FIXTURES / "basic.json").read_text(encoding="utf-8"))
script = normalize_script(data)
stack = _make_panel_stack(script)
pages, bboxes_json, pass_panels = ComicPageComposer().compose(
generated_panels=stack, comic_script=script, debug_overlay=False,
)
assert isinstance(pages, torch.Tensor)
assert pages.shape[0] == 2 # 2 pages
assert pages.shape[-1] == 3
assert pages.shape[1] == script.pages[0].page_height
assert pages.shape[2] == script.pages[0].page_width
def test_bboxes_json_includes_all_panels():
data = json.loads((FIXTURES / "basic.json").read_text(encoding="utf-8"))
script = normalize_script(data)
stack = _make_panel_stack(script)
_, bboxes_json, _ = ComicPageComposer().compose(
generated_panels=stack, comic_script=script, debug_overlay=False,
)
parsed = json.loads(bboxes_json)
assert len(parsed["pages"]) == 2
assert len(parsed["pages"][0]["panels"]) == 3
p0 = parsed["pages"][0]["panels"][0]
assert "bbox_topleft" in p0 and "bbox_size" in p0
def test_individual_panels_passthrough_preserves_order():
data = json.loads((FIXTURES / "basic.json").read_text(encoding="utf-8"))
script = normalize_script(data)
stack = _make_panel_stack(script)
_, _, pass_panels = ComicPageComposer().compose(
generated_panels=stack, comic_script=script, debug_overlay=False,
)
assert torch.equal(pass_panels, stack)