Files
hermes-agent/agent/pet/generate/atlas.py
T
Teknium 2ad284b473 refactor(agent/runtime): pet — shared flood/placement helpers in atlas, provider resolution loop, dead helpers removed
- atlas: _border_flood/_unvisited_components/_place/_clear_region/_remove_masked
  replace 6 hand-rolled flood + canvas-composite loops; _sever_expected_gutters
  uses channel ops instead of per-pixel writes; dead _color_distance,
  _has_slot_padding, _slot_bounds, atlas_to_webp_bytes, FRAME_COUNTS (0 refs).
- render/store: dead _open_sheet/_png_bytes/_union_alpha_bbox/_thumbs_dir removed.
- imagegen: _available() unifies 3 provider-availability checks; forced/preferred
  provider resolution collapsed into one ordered loop (same precedence).
- state: derive_pet_state as a ranked table (same priority order).
- Comment/docstring compaction keeping the pipeline invariants.
2026-09-02 13:29:47 -07:00

910 lines
35 KiB
Python

"""Deterministic spritesheet assembly — generated row strips → Hermes atlas.
Image models draw a row of poses well but can't do exact grid geometry, so the
model never owns the layout: it emits one loose horizontal strip per state and
these ops slice it into centered transparent ``192x208`` cells packed into the
petdex/Codex atlas (8 columns x 9 rows, ``1536x1872``) that
:mod:`agent.pet.render` reads via :data:`agent.pet.constants.CODEX_STATE_ROWS`.
``running`` is the in-place *working* state; ``running-right``/``-left`` are the
directional walk cycles. Segmentation/fit/residue logic is adapted from
OpenAI's ``hatch-pet`` skill (openai/skills, Apache-2.0).
"""
from __future__ import annotations
import logging
import math
from collections import deque
from pathlib import Path
from agent.pet.constants import FRAME_H, FRAME_W
logger = logging.getLogger(__name__)
CELL_WIDTH = FRAME_W
CELL_HEIGHT = FRAME_H
# (state, row index, frame count). Order/row indices MUST match
# ``constants.CODEX_STATE_ROWS``; frame counts mirror the petdex ``hatch-pet``
# spec. Rows shorter than 8 leave their tail transparent (renderer trims it).
ROW_SPECS: list[tuple[str, int, int]] = [
("idle", 0, 6),
("running-right", 1, 8),
("running-left", 2, 8),
("waving", 3, 4),
("jumping", 4, 5),
("failed", 5, 8),
("waiting", 6, 6),
("running", 7, 6),
("review", 8, 6),
]
ROWS = len(ROW_SPECS)
COLUMNS = max(count for _, _, count in ROW_SPECS)
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
_ALPHA_FLOOR = 16 # alpha at/below which a pixel is "background"
_CELL_PAD = 10 # padding kept around a fitted sprite
# Small margin for the normalized pass so cells fill like real petdex pets
# (~5px from the edges); the width clamp, not the pad, prevents clipping.
_NORMALIZE_PAD = 14
# Side-lobe cutoff: adjacent-pose bleed shows as a small separated lobe; keep
# sizeable lobes so a legitimate wide pose isn't punished.
_SIDE_LOBE_RATIO = 0.18
_NEIGHBOURS = ((1, 0), (-1, 0), (0, 1), (0, -1))
def _median(values) -> int:
ordered = sorted(values)
return ordered[len(ordered) // 2]
def _blank(size=(CELL_WIDTH, CELL_HEIGHT)):
from PIL import Image
return Image.new("RGBA", size, (0, 0, 0, 0))
def _place(sprite, size: tuple[int, int], offset: tuple[int, int] = (0, 0)):
"""*sprite* alpha-composited at *offset* onto a new transparent canvas of *size*."""
canvas = _blank(size)
canvas.alpha_composite(sprite, offset)
return canvas
def _clear_region(image, box: tuple[int, int, int, int]) -> None:
"""Make the ``(left, top, right, bottom)`` region of *image* fully transparent, in place."""
image.paste(_blank((box[2] - box[0], box[3] - box[1])), (box[0], box[1]))
def _load_rgba(image):
"""Open a path (or take an image) as RGBA."""
from PIL import Image
if isinstance(image, (str, Path)):
with Image.open(image) as opened:
return opened.convert("RGBA")
return image.convert("RGBA")
def _border_coords(w: int, h: int):
"""Every border pixel: top/bottom rows column-wise, then left/right columns."""
for x in range(w):
for y in (0, h - 1):
yield x, y
for y in range(h):
for x in (0, w - 1):
yield x, y
def _flood(w: int, h: int, visited: bytearray, seeds, accept) -> list[tuple[int, int]]:
"""4-connected BFS from *seeds* over pixels passing *accept*; returns the visited pixels."""
queue = deque(seeds)
pixels: list[tuple[int, int]] = []
while queue:
x, y = queue.popleft()
pixels.append((x, y))
for dx, dy in _NEIGHBOURS:
nx, ny = x + dx, y + dy
if 0 <= nx < w and 0 <= ny < h:
idx = ny * w + nx
if not visited[idx]:
visited[idx] = 1
if accept(nx, ny):
queue.append((nx, ny))
return pixels
def _border_flood(w: int, h: int, visited: bytearray, accept) -> list[tuple[int, int]]:
"""Flood from every border pixel passing *accept* (edge-connected region only)."""
seeds: list[tuple[int, int]] = []
for x, y in _border_coords(w, h):
if not visited[y * w + x] and accept(x, y):
visited[y * w + x] = 1
seeds.append((x, y))
return _flood(w, h, visited, seeds, accept)
def _unvisited_components(w: int, h: int, visited: bytearray, accept):
"""Yield each 4-connected component of not-yet-visited pixels passing *accept*, in scan order."""
for start in range(w * h):
if visited[start]:
continue
visited[start] = 1
x, y = start % w, start // w
if accept(x, y):
yield _flood(w, h, visited, [(x, y)], accept)
# ───────────────────────── background removal ─────────────────────────
def _has_transparency(image) -> bool:
"""True if the strip already carries a real alpha background."""
if image.getchannel("A").getextrema()[0] > _ALPHA_FLOOR:
return False
transparent = sum(image.getchannel("A").histogram()[: _ALPHA_FLOOR + 1])
return transparent > image.width * image.height * 0.05
def _dominant_corner_color(image) -> tuple[int, int, int]:
"""Most common opaque color among the four corners."""
from collections import Counter
w, h = image.width, image.height
px = image.load()
counter: Counter = Counter()
for x, y in ((0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)):
r, g, b, a = px[x, y]
if a > _ALPHA_FLOOR:
counter[(r, g, b)] += 1
return counter.most_common(1)[0][0] if counter else (0, 255, 0)
def _near_key_mask(image, key: tuple[int, int, int], tol: int = 48):
"""``L`` mask, 255 where a pixel is within *tol* per-channel of *key*.
Tight on purpose: marks only near-pure backdrop so trapped chroma pockets
seed the flood while chroma-tinted character pixels stay outside it.
"""
from PIL import ImageChops
r, g, b, _a = image.split()
kr, kg, kb = key
return ImageChops.darker(
ImageChops.darker(
r.point(lambda v: 255 if abs(v - kr) <= tol else 0),
g.point(lambda v: 255 if abs(v - kg) <= tol else 0),
),
b.point(lambda v: 255 if abs(v - kb) <= tol else 0),
)
def _remove_masked(rgba, mask):
"""Clear the pixels *mask* (``L``, 255 = remove) selects, then erode alpha by 1px (3x3 min).
The erosion drops the antialiased key/sprite blend ring (too far from the key to
match); the sprite's own thick outline keeps the silhouette intact.
"""
from PIL import Image, ImageFilter
out = Image.composite(_blank(rgba.size), rgba, mask)
out.putalpha(out.getchannel("A").filter(ImageFilter.MinFilter(3)))
return out
def remove_background(image, *, chroma_key: tuple[int, int, int] | None = None, threshold: float = 90.0):
"""Return *image* (RGBA) with its flat background keyed out to transparent.
Already-transparent strips are left alone (holes repaired). Otherwise key out
*chroma_key* (or the dominant corner color) via a border flood-fill: a global
color match punched holes wherever an interior highlight matched the backdrop.
"""
from PIL import Image, ImageChops
rgba = image.convert("RGBA")
if _has_transparency(rgba):
return _repair_internal_alpha_holes(rgba)
key = chroma_key or _dominant_corner_color(rgba)
w, h = rgba.width, rgba.height
px = rgba.load()
def _is_bg(x: int, y: int) -> bool:
r, g, b, a = px[x, y]
return a > _ALPHA_FLOOR and math.sqrt((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2) <= threshold
# Fast path for saturated chroma keys (our prompts use hot magenta): C-level
# channel ops clear border backdrop and enclosed pockets alike, no Python flood.
if max(key) - min(key) >= 120:
opaque = rgba.getchannel("A").point(lambda a: 255 if a > _ALPHA_FLOOR else 0)
return _remove_masked(rgba, ImageChops.darker(_near_key_mask(rgba, key), opaque))
# Border-only flood on purpose: a desaturated near-white/gray key must never
# seed from the character's interior (that is the hole-punching case).
# Mark removals in a flat mask and composite once in C — per-pixel writes
# were ~3M PixelAccess calls and stalled the gateway.
remove = bytearray(w * h)
for x, y in _border_flood(w, h, bytearray(w * h), _is_bg):
remove[y * w + x] = 1
return _remove_masked(rgba, Image.frombytes("L", (w, h), bytes(remove)).point(lambda v: 255 if v else 0))
def _repair_internal_alpha_holes(image):
"""Fill transparent islands fully enclosed by opaque sprite pixels.
Some providers return "transparent" PNGs with swiss-cheese alpha inside the
character. Edge-connected transparent components stay background; enclosed
ones are filled with the average color of their opaque neighbours.
"""
rgba = image.convert("RGBA")
w, h = rgba.size
px = rgba.load()
visited = bytearray(w * h)
def _is_transparent(x: int, y: int) -> bool:
return px[x, y][3] <= _ALPHA_FLOOR
_border_flood(w, h, visited, _is_transparent) # edge-connected transparency = background
def _fill_color(hole: list[tuple[int, int]]) -> tuple[int, int, int, int]:
samples: list[tuple[int, int, int]] = []
seen = set(hole)
for x, y in hole:
for dx, dy in _NEIGHBOURS:
nx, ny = x + dx, y + dy
if 0 <= nx < w and 0 <= ny < h and (nx, ny) not in seen:
r, g, b, a = px[nx, ny]
if a > _ALPHA_FLOOR:
samples.append((r, g, b))
if not samples:
return (0, 0, 0, 255)
r, g, b = (round(sum(c[i] for c in samples) / len(samples)) for i in range(3))
return (r, g, b, 255)
for hole in _unvisited_components(w, h, visited, _is_transparent):
color = _fill_color(hole)
for hx, hy in hole:
px[hx, hy] = color
return rgba
# ───────────────────────── frame extraction ─────────────────────────
def _fit_to_cell(image):
"""Crop to content, scale to fit a padded cell, and center on transparent."""
from PIL import Image
image = _drop_side_bleed(image)
bbox = image.getbbox()
if bbox is None:
return _blank()
sprite = image.crop(bbox)
scale = min((CELL_WIDTH - _CELL_PAD) / sprite.width, (CELL_HEIGHT - _CELL_PAD) / sprite.height, 1.0)
if scale != 1.0:
# NEAREST: interpolating resamples blur the hard pixel-art edges.
sprite = sprite.resize(
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
Image.Resampling.NEAREST,
)
return _place(sprite, (CELL_WIDTH, CELL_HEIGHT), ((CELL_WIDTH - sprite.width) // 2, (CELL_HEIGHT - sprite.height) // 2))
def _drop_side_bleed(image):
"""Remove tiny separated left/right lobes (neighbour-pose slivers) before fitting.
Component extraction may already have grouped a near sliver with the subject;
a horizontal alpha projection still reveals it as a low-mass side lobe. Only
those are dropped so wide poses and real limbs survive.
"""
rgba = image.convert("RGBA")
w, h = rgba.size
runs = _content_runs(_column_profile(rgba))
if len(runs) < 2:
return rgba
keep_mass = max(m for _run, m in runs) * _SIDE_LOBE_RATIO
keep = [run for run, m in runs if m >= keep_mass]
if len(keep) == len(runs):
return rgba
rgba = rgba.copy()
prev = 0
for left, right in keep:
if left > prev:
_clear_region(rgba, (prev, 0, left, h))
prev = right
if prev < w:
_clear_region(rgba, (prev, 0, w, h))
return rgba
def _erase_long_axis_lines(image):
"""Remove thin slot-spanning guide/floor/divider lines.
Models sometimes draw literal floors or panel dividers; they survive keying
and connect otherwise clean poses. Only *thin* near-full-span rows/columns go.
"""
rgba = image.convert("RGBA").copy()
w, h = rgba.size
alpha = rgba.getchannel("A")
def _thin_groups(indices: list[int]) -> list[tuple[int, int]]:
groups: list[tuple[int, int]] = []
start = prev = -2
for idx in [*indices, -2]: # -2 sentinel flushes the last run
if start >= 0 and idx == prev + 1:
prev = idx
continue
if start >= 0 and prev - start + 1 <= 4:
groups.append((start, prev + 1))
start = prev = idx
return groups
wide_rows = [y for y in range(h) if sum(1 for x in range(w) if alpha.getpixel((x, y)) > _ALPHA_FLOOR) >= w * 0.85]
tall_cols = [x for x in range(w) if sum(1 for y in range(h) if alpha.getpixel((x, y)) > _ALPHA_FLOOR) >= h * 0.85]
for top, bottom in _thin_groups(wide_rows):
_clear_region(rgba, (0, top, w, bottom))
for left, right in _thin_groups(tall_cols):
_clear_region(rgba, (left, 0, right, h))
return rgba
def _component_boxes(image) -> list[tuple[tuple[int, int, int, int], int]]:
"""Connected opaque components as ``[(bbox, mass)]``."""
rgba = image.convert("RGBA")
bbox = rgba.getbbox()
if bbox is None:
return []
l0, t0, r0, b0 = bbox
w, h = r0 - l0, b0 - t0
alpha = rgba.getchannel("A").load()
out: list[tuple[tuple[int, int, int, int], int]] = []
def _opaque(x: int, y: int) -> bool:
return alpha[l0 + x, t0 + y] > _ALPHA_FLOOR
for pixels in _unvisited_components(w, h, bytearray(w * h), _opaque):
xs = [x for x, _ in pixels]
ys = [y for _, y in pixels]
out.append(((l0 + min(xs), t0 + min(ys), l0 + max(xs) + 1, t0 + max(ys) + 1), len(pixels)))
return out
def _isolate_slot_subject(image):
"""Keep the slot's real subject; drop detached effects/noise."""
rgba = _erase_long_axis_lines(image)
comps = _component_boxes(rgba)
if not comps:
return rgba
main_box, main_mass = max(comps, key=lambda item: item[1])
ml, mt, mr, mb = main_box
mw = max(1, mr - ml)
keep: list[tuple[int, int, int, int]] = []
for box, mass in comps:
if box == main_box:
keep.append(box)
continue
left, _top, right, _bottom = box
overlap = max(0, min(right, mr) - max(left, ml))
center_x = (left + right) / 2
near_main = (ml - mw * 0.25) <= center_x <= (mr + mw * 0.25)
# Keep attached-looking accessories (halos); drop sparkles/tears/noise.
if mass >= max(24, main_mass * 0.035) and (overlap >= mw * 0.3 or near_main):
keep.append(box)
out = _blank(rgba.size)
for box in keep:
out.alpha_composite(rgba.crop(box), (box[0], box[1]))
return out
def _has_margin(size: tuple[int, int], box: tuple[int, int, int, int], fx: float, fy: float) -> bool:
"""True when *box* leaves empty room on all four edges of an image of *size* (≥4px, ≤12/16px)."""
w, h = size
left, top, right, bottom = box
min_x = max(4, min(12, round(w * fx)))
min_y = max(4, min(16, round(h * fy)))
return left >= min_x and top >= min_y and w - right >= min_x and h - bottom >= min_y
def _group_component_rows(boxes: list[tuple[int, int, int, int]]) -> list[list[tuple[int, int, int, int]]]:
"""Group component boxes into visual rows, then sort left→right."""
if not boxes:
return []
row_tol = max(12, _median(max(1, b[3] - b[1]) for b in boxes) * 0.55)
rows: list[list[tuple[int, int, int, int]]] = []
centers: list[float] = []
for box in sorted(boxes, key=lambda b: (b[1] + b[3]) / 2):
cy = (box[1] + box[3]) / 2
for i, center in enumerate(centers):
if abs(cy - center) <= row_tol:
rows[i].append(box)
centers[i] = sum((b[1] + b[3]) / 2 for b in rows[i]) / len(rows[i])
break
else:
rows.append([box])
centers.append(cy)
ordered = [row for _center, row in sorted(zip(centers, rows, strict=False), key=lambda item: item[0])]
for row in ordered:
row.sort(key=lambda b: (b[0] + b[2]) / 2)
return ordered
def _merge_related_boxes(boxes: list[tuple[int, int, int, int]]) -> list[tuple[int, int, int, int]]:
"""Merge disconnected parts of one subject (capes, tails, props) on the same row.
Merges when vertical spans overlap and the horizontal gap is tiny relative to
the component size; never bridges the larger gaps between separate poses.
"""
boxes = list(boxes)
changed = True
while changed:
changed = False
merged: list[tuple[int, int, int, int]] = []
used = [False] * len(boxes)
for i, a in enumerate(boxes):
if used[i]:
continue
al, at, ar, ab = a
used[i] = True
for j in range(i + 1, len(boxes)):
if used[j]:
continue
bl, bt, br, bb = boxes[j]
v_overlap = max(0, min(ab, bb) - max(at, bt))
min_h = max(1, min(ab - at, bb - bt))
gap = max(0, max(al, bl) - min(ar, br))
min_w = max(1, min(ar - al, br - bl))
if v_overlap >= min_h * 0.45 and gap <= max(14, min_w * 0.22):
al, at, ar, ab = min(al, bl), min(at, bt), max(ar, br), max(ab, bb)
used[j] = True
changed = True
merged.append((al, at, ar, ab))
boxes = merged
return boxes
def _component_crops(strip, frame_count: int, *, require_padding: bool = False) -> list | None:
"""Extract frame subjects as connected non-background objects.
Robust path for models that emit a 2D grid instead of one row: count real
subject components, discard tiny effects, sort in reading order, return
exactly *frame_count* frames (or ``None`` when the contract can't be met).
"""
def attempt(source) -> list | None:
subjects = _significant_subject_boxes(source, min_mass=64)
if len(subjects) < frame_count:
return None
rows = _group_component_rows(subjects)
ordered = [box for row in rows for box in row][:frame_count]
if len(ordered) < frame_count:
return None
if require_padding and not all(_has_margin(source.size, box, 0.01, 0.015) for box in ordered):
return None
multirow = len(rows) > 1
frames = []
for left, top, right, bottom in ordered:
pad_x = max(8, round((right - left) * 0.08))
pad_y = max(8, round((bottom - top) * 0.08))
if multirow:
crop_box = (
max(0, left - pad_x),
max(0, top - pad_y),
min(source.width, right + pad_x),
min(source.height, bottom + pad_y),
)
elif frame_count == 1:
crop_box = (0, 0, source.width, source.height)
else:
# Keep full height for true one-row strips so vertical motion survives.
crop_box = (max(0, left - pad_x), 0, min(source.width, right + pad_x), source.height)
# No second component filter here: capes/tails can be legitimate
# disconnected lobes inside the chosen subject box.
frames.append(
_place(
source.crop((left, top, right, bottom)),
(crop_box[2] - crop_box[0], crop_box[3] - crop_box[1]),
(left - crop_box[0], top - crop_box[1]),
)
)
return frames
return attempt(strip) or attempt(_erase_long_axis_lines(strip))
def _sever_expected_gutters(strip, frame_count: int):
"""Cut narrow transparent bands at expected frame boundaries before labeling.
Shared shadows/smears/1px bridges connect neighbouring poses into one blob;
losing a few boundary pixels beats exporting merged frames.
"""
if frame_count <= 1:
return strip
out = strip.copy()
alpha = out.getchannel("A") # zero alpha only; RGB is left untouched
slot = out.width / frame_count
half = max(3, min(18, round(slot * 0.06)))
for i in range(1, frame_count):
x = round(i * slot)
alpha.paste(0, (max(0, x - half), 0, min(out.width, x + half + 1), out.height))
out.putalpha(alpha)
return out
def _clean_slot(image):
return _drop_side_bleed(_isolate_slot_subject(image))
def _slot_crops(strip, frame_count: int, *, require_padding: bool = False) -> list | None:
"""Slice *strip* into *frame_count* uniform, independently cleaned columns.
Equal-width columns keep every frame in one shared coordinate frame so
:func:`normalize_cells` preserves the row's real motion without sliding.
"""
w, h = strip.size
frames = []
for i in range(frame_count):
slot = _clean_slot(strip.crop((round(i * w / frame_count), 0, round((i + 1) * w / frame_count), h)))
bbox = slot.getbbox()
if require_padding and (bbox is None or not _has_margin(slot.size, bbox, 0.025, 0.02)):
return None
frames.append(slot)
return frames
def _content_runs(profile: list[int], *, threshold: int = 2) -> list[tuple[tuple[int, int], int]]:
"""``[((left, right), mass)]`` column spans whose alpha exceeds *threshold* (candidate frames)."""
runs: list[tuple[tuple[int, int], int]] = []
start: int | None = None
for x, v in enumerate(list(profile) + [0]):
if v > threshold:
if start is None:
start = x
elif start is not None:
runs.append(((start, x), sum(profile[start:x])))
start = None
return runs
def _frame_x_ranges(strip, frame_count: int) -> list[tuple[int, int]] | None:
"""Per-frame ``(left, right)`` column ranges from the row's empty gutters.
spans == frames → one per frame; spans > frames → merge across the smallest
gaps (a detached halo sits a tiny gap from its body, the inter-pose gutter is
the big gap that survives); spans < frames → ``None`` (poses touching).
Ranges span X only; the caller crops full height so tall ears/halos survive.
"""
runs = _content_runs(_column_profile(strip))
if not runs:
return None
floor = max(m for _run, m in runs) * 0.02
groups = [[l, r] for (l, r), m in runs if m >= floor]
if len(groups) < frame_count:
return None
while len(groups) > frame_count:
gi = min(range(len(groups) - 1), key=lambda i: groups[i + 1][0] - groups[i][1])
groups[gi][1] = groups[gi + 1][1]
del groups[gi + 1]
return [tuple(g) for g in groups]
def _significant_subject_boxes(image, *, min_mass: int = 32) -> list[tuple[int, int, int, int]]:
"""Merged boxes of components carrying meaningful mass (≥12% of the largest)."""
comps = _component_boxes(image)
if not comps:
return []
max_mass = max(mass for _box, mass in comps)
return _merge_related_boxes([box for box, mass in comps if mass >= max(min_mass, max_mass * 0.12)])
def _is_multi_pose_outlier(width: int, height: int, med_w: int, med_h: int) -> bool:
"""A frame several times wider than the median but not proportionally taller."""
return width > max(med_w * 3.0, med_w + 96) and height <= med_h * 1.6
def _validate_extracted_frames(frames: list, frame_count: int) -> None:
"""Reject rows where one "frame" is really multiple poses.
A collapsed strip of tiny repeated poses would make normalization shrink the
whole pet to postage-stamp size; catching it here lets hatch regenerate.
"""
if len(frames) != frame_count:
raise ValueError(f"expected {frame_count} frames, got {len(frames)}")
boxes = []
for i, frame in enumerate(frames):
bbox = frame.getbbox()
if bbox is None:
raise ValueError(f"frame {i} is empty")
if len(_significant_subject_boxes(frame)) >= 3:
raise ValueError(f"frame {i} contains multiple separated subjects")
boxes.append(bbox)
if frame_count <= 1:
return
med_w = max(1, _median(b[2] - b[0] for b in boxes))
med_h = max(1, _median(b[3] - b[1] for b in boxes))
for i, (left, top, right, bottom) in enumerate(boxes):
if _is_multi_pose_outlier(right - left, bottom - top, med_w, med_h):
raise ValueError(f"frame {i} is a multi-pose width outlier")
def extract_strip_frames(
strip,
frame_count: int,
*,
chroma_key: tuple[int, int, int] | None = None,
method: str = "auto",
fit: bool = True,
) -> list:
"""Turn one generated row strip into *frame_count* frames.
Keys out the background, then treats the frame count as source of truth:
isolate padded subjects (component pass, then equal slots). When that fails,
``components`` raises and ``auto`` falls back to lenient salvage (gutters →
severed gutters → raw slots). *fit* centers each frame into a 192x208 cell;
hatching passes ``fit=False`` so :func:`normalize_cells` can register the
whole pet with one shared scale + baseline.
"""
strip = remove_background(_load_rgba(strip), chroma_key=chroma_key)
frames = _component_crops(strip, frame_count, require_padding=True)
if frames is None:
frames = _slot_crops(strip, frame_count, require_padding=True)
if frames is None:
if method == "components":
raise ValueError(f"could not segment {frame_count} padded sprites from strip")
frames = _component_crops(strip, frame_count, require_padding=False)
if frames is None:
source = strip
ranges = _frame_x_ranges(source, frame_count)
if ranges is None:
source = _sever_expected_gutters(strip, frame_count)
ranges = _frame_x_ranges(source, frame_count)
if ranges is None:
frames = _slot_crops(source, frame_count, require_padding=False) or []
else:
h = source.height
pad = max(2, min(16, round((source.width / max(1, frame_count)) * 0.04)))
frames = [
_clean_slot(source.crop((max(0, left - pad), 0, min(source.width, right + pad), h)))
for left, right in ranges
]
_validate_extracted_frames(frames, frame_count)
return [_fit_to_cell(f) for f in frames] if fit else frames
def _column_profile(image) -> list[int]:
"""Per-column alpha mass — collapse to a 1px-tall strip (fast in C)."""
from PIL import Image
return list(image.getchannel("A").resize((image.width, 1), Image.BILINEAR).getdata())
def _best_shift(ref: list[int], prof: list[int], window: int) -> int:
"""Integer dx that best aligns *prof* onto *ref* (1-D cross-correlation).
The body dominates the column profile, so the peak locks onto the body and a
flipping arm/cape barely moves the match (~9px drift → ~1px).
"""
n = len(ref)
def score(d: int) -> int:
return sum(ref[x] * prof[x - d] for x in range(max(0, d), min(n, n + d)))
return max(range(-window, window + 1), key=score) # ties → smallest dx, as before
def normalize_cells(frames_by_state: dict[str, list], *, pad: int = _NORMALIZE_PAD) -> dict[str, list]:
"""Register every frame into a 192x208 cell — the deterministic anti-jitter math.
Per-frame crop→scale→center jitters (bbox shifts with a limb, per-frame scale
pulses). Instead: cross-correlate each frame's column profile against the
state's median profile to lock the body, union-crop through one shared state
window, then scale every state by a single global factor keyed to its median
pose height so the character is the same size in every row.
"""
from PIL import Image
out: dict[str, list] = {}
prepared: dict[str, tuple[list, tuple[int, int, int, int], tuple[int, int]]] = {}
target_w = CELL_WIDTH - pad
target_h = CELL_HEIGHT - pad
for state, frames in frames_by_state.items():
rgba = [f.convert("RGBA") for f in frames]
if not any(f.getbbox() for f in rgba):
out[state] = [_blank() for _ in frames]
continue
# Pad every frame to a common canvas so column profiles are comparable.
w0 = max(f.width for f in rgba)
h0 = max(f.height for f in rgba)
canvas = [f if f.size == (w0, h0) else _place(f, (w0, h0)) for f in rgba]
profiles = [_column_profile(f) for f in canvas]
ref = [_median(p[x] for p in profiles) for x in range(w0)]
window = max(8, w0 // 5)
margin = window
aligned = [
_place(f, (w0 + 2 * margin, h0), (margin + _best_shift(ref, prof, window), 0))
for f, prof in zip(canvas, profiles)
]
boxes = [b for b in (a.getbbox() for a in aligned) if b]
prepared[state] = (
aligned,
(min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes)),
(_median(b[2] - b[0] for b in boxes), _median(b[3] - b[1] for b in boxes)),
)
if not prepared:
return out
# K is the one global cap keeping the tallest/widest motion envelope (a
# jump's lift) inside the cell; a still row's union ≈ pose so it fills fully.
K = target_h
for _aligned, (left, top, right, bottom), (_pose_w, pose_h) in prepared.values():
uw, uh = right - left, bottom - top
K = min(K, target_h * pose_h / max(1, uh), target_w * pose_h / max(1, uw))
for state, (aligned, (left, top, right, bottom), (_pose_w, pose_h)) in prepared.items():
uw, uh = right - left, bottom - top
scale = K / max(1, pose_h)
sw, sh = max(1, round(uw * scale)), max(1, round(uh * scale))
px, py = round((CELL_WIDTH - sw) / 2), round((CELL_HEIGHT - pad // 2) - sh)
cells = []
for a in aligned:
crop = a.crop((left, top, right, bottom))
if crop.size != (sw, sh):
crop = crop.resize((sw, sh), Image.Resampling.NEAREST) # keep pixel edges crisp
cells.append(_place(crop, (CELL_WIDTH, CELL_HEIGHT), (px, py)))
out[state] = cells
return out
# ───────────────────────── atlas composition ─────────────────────────
def single_frame(image, *, fit: bool = True):
"""One frame from a standalone image (idle fallback so a pet always renders).
*fit* yields a finished cell; ``fit=False`` the raw keyed sprite for
:func:`normalize_cells`.
"""
keyed = remove_background(_load_rgba(image))
return _fit_to_cell(keyed) if fit else _drop_side_bleed(keyed)
def _clear_transparent_rgb(image):
"""Zero the RGB of fully-transparent pixels (no colored-halo residue)."""
from PIL import Image
rgba = image.convert("RGBA")
data = bytearray(rgba.tobytes())
for i in range(0, len(data), 4):
if data[i + 3] == 0:
data[i] = data[i + 1] = data[i + 2] = 0
return Image.frombytes("RGBA", rgba.size, bytes(data))
def mirror_frames(frames: list) -> list:
"""Flip each frame horizontally (per-frame, so order/timing is preserved).
Derives ``running-left`` from ``running-right``; NOT a strip reverse.
"""
from PIL import Image
flip = getattr(Image, "Transpose", Image).FLIP_LEFT_RIGHT
return [frame.convert("RGBA").transpose(flip) for frame in frames]
def compose_atlas(frames_by_state: dict[str, list]):
"""Pack per-state frame lists into the atlas; short states leave trailing cells transparent."""
atlas = _blank((ATLAS_WIDTH, ATLAS_HEIGHT))
for state, row, count in ROW_SPECS:
frames = frames_by_state.get(state) or []
for col, frame in enumerate(frames[:count]):
cell = frame.convert("RGBA")
if cell.size != (CELL_WIDTH, CELL_HEIGHT):
cell = _fit_to_cell(cell)
atlas.alpha_composite(cell, (col * CELL_WIDTH, row * CELL_HEIGHT))
return _clear_transparent_rgb(atlas)
def validate_atlas(atlas) -> dict:
"""Check geometry, per-cell occupancy, and transparency invariants.
Returns ``{ok, width, height, errors, warnings, filled_states}``; errors are
blockers, warnings soft (a whole state row blank).
"""
atlas = _load_rgba(atlas)
if atlas.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
errors: list[str] = [f"expected {ATLAS_WIDTH}x{ATLAS_HEIGHT}, got {atlas.width}x{atlas.height}"]
warnings: list[str] = []
filled_states: list[str] = []
else:
errors, warnings, filled_states = _check_atlas_cells(atlas)
return {
"ok": not errors,
"width": atlas.width,
"height": atlas.height,
"errors": errors,
"warnings": warnings,
"filled_states": filled_states,
}
def _check_atlas_cells(atlas) -> tuple[list[str], list[str], list[str]]:
"""Occupancy/collapse/residue checks for a correctly-sized atlas → ``(errors, warnings, filled_states)``."""
errors: list[str] = []
warnings: list[str] = []
filled_states: list[str] = []
cell_boxes_by_state: dict[str, list[tuple[int, int, int, int]]] = {}
for state, row, count in ROW_SPECS:
row_pixels = 0
boxes: list[tuple[int, int, int, int]] = []
for col in range(count):
left, top = col * CELL_WIDTH, row * CELL_HEIGHT
cell = atlas.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
row_pixels += sum(cell.getchannel("A").histogram()[1:])
bbox = cell.getbbox()
if bbox is not None:
boxes.append(bbox)
if row_pixels > 0:
filled_states.append(state)
cell_boxes_by_state[state] = boxes
else:
warnings.append(f"state '{state}' has no frames")
if not filled_states:
errors.append("atlas is empty — no state produced any frames")
# A valid pet must occupy the cell: one bad row can poison global
# normalization and shrink every state while still passing "non-empty".
all_boxes = [b for boxes in cell_boxes_by_state.values() for b in boxes]
global_med_w = global_med_h = 0
if all_boxes:
global_med_w = _median(r - l for l, _t, r, _b in all_boxes)
global_med_h = _median(b - t for _l, t, _r, b in all_boxes)
if global_med_h < max(56, round(CELL_HEIGHT * 0.28)):
errors.append(f"atlas sprites are too small after normalization (median frame height {global_med_h}px)")
for state, boxes in cell_boxes_by_state.items():
if len(boxes) <= 1:
continue
widths = [right - left for left, _top, right, _bottom in boxes]
heights = [bottom - top for _left, top, _right, bottom in boxes]
med_w, med_h = max(1, _median(widths)), max(1, _median(heights))
if _is_multi_pose_outlier(max(widths), max(heights), med_w, med_h):
errors.append(f"state '{state}' contains a multi-pose frame outlier")
# Per-state collapse guard: one malformed row must not pass on the
# strength of the healthy ones.
if (global_med_w and global_med_h) and (
med_w < max(32, round(global_med_w * 0.42)) or med_h < max(40, round(global_med_h * 0.50))
):
errors.append(
f"state '{state}' appears collapsed (median {med_w}x{med_h}px, global median {global_med_w}x{global_med_h}px)"
)
data = atlas.tobytes()
residue = sum(1 for i in range(0, len(data), 4) if data[i + 3] == 0 and (data[i] or data[i + 1] or data[i + 2]))
if residue:
errors.append(f"{residue} transparent pixels retain RGB residue")
return errors, warnings, filled_states