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:
co-authored by
Claude Sonnet 4.6
parent
33c7eb5098
commit
c97190fe2e
@@ -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)
|
||||
Reference in New Issue
Block a user