From c97190fe2eda630f2fccdd4c9730754d1ff9a139 Mon Sep 17 00:00:00 2001 From: tack Date: Thu, 23 Apr 2026 12:41:56 +0800 Subject: [PATCH] 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 --- tk_comfyui_batch_image/nodes/page_composer.py | 78 +++++++++++++++++++ .../tests/test_page_composer.py | 65 ++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 tk_comfyui_batch_image/nodes/page_composer.py create mode 100644 tk_comfyui_batch_image/tests/test_page_composer.py diff --git a/tk_comfyui_batch_image/nodes/page_composer.py b/tk_comfyui_batch_image/nodes/page_composer.py new file mode 100644 index 0000000..0da42b2 --- /dev/null +++ b/tk_comfyui_batch_image/nodes/page_composer.py @@ -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 diff --git a/tk_comfyui_batch_image/tests/test_page_composer.py b/tk_comfyui_batch_image/tests/test_page_composer.py new file mode 100644 index 0000000..8cf6e73 --- /dev/null +++ b/tk_comfyui_batch_image/tests/test_page_composer.py @@ -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)