| from __future__ import annotations |
|
|
| import json |
| import math |
| import random |
| import time |
| from collections import deque |
| from pathlib import Path |
| from typing import Any |
|
|
| from PIL import Image, ImageDraw, ImageFilter, ImageFont |
|
|
| from src.character_spike.schema import CANONICAL_STAGE_SIZE, EXPRESSIONS, default_character_package |
|
|
|
|
| def create_asset_package_from_probe_outputs( |
| *, |
| source_run_dir: str | Path, |
| candidate_id: str, |
| character_id: str, |
| display_name: str, |
| output_root: str | Path, |
| seed: int = 42, |
| remove_background: bool = True, |
| ) -> dict[str, Any]: |
| started = time.perf_counter() |
| source = Path(source_run_dir) |
| out_root = Path(output_root) |
| run_dir = out_root / character_id |
| character_dir = run_dir / "assets" / "characters" / character_id |
| background_dir = run_dir / "assets" / "backgrounds" |
| generated_dir = run_dir / "generated" |
| character_dir.mkdir(parents=True, exist_ok=True) |
| background_dir.mkdir(parents=True, exist_ok=True) |
| generated_dir.mkdir(parents=True, exist_ok=True) |
|
|
| package = default_character_package(character_id, display_name) |
| package["metadata"]["source"] = "probe_asset_package" |
| package["metadata"]["source_run_dir"] = str(source) |
| package["metadata"]["candidate_id"] = candidate_id |
| package_path = run_dir / "characters" / f"{character_id}.json" |
| package_path.parent.mkdir(parents=True, exist_ok=True) |
| package_path.write_text(json.dumps(package, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") |
|
|
| assets: list[dict[str, Any]] = [] |
| missing_slots: list[str] = [] |
| for expression in EXPRESSIONS: |
| source_path = source / "generated" / candidate_id / f"expression_{expression}" / "00.png" |
| if not source_path.exists(): |
| source_path = source / "generated" / candidate_id / "expression_idle" / "00.png" |
| missing_slots.append(expression) |
| target_path = character_dir / f"{expression}.png" |
| image = Image.open(source_path).convert("RGBA") |
| processed = remove_flat_background(image) if remove_background else image |
| normalized = normalize_to_stage_canvas(processed) |
| normalized.save(target_path) |
| assets.append( |
| { |
| "slot": expression, |
| "path": str(target_path.relative_to(run_dir)), |
| "source_path": str(source_path), |
| "bytes": target_path.stat().st_size, |
| "source": f"probe:{candidate_id}", |
| "usable": expression not in missing_slots, |
| "manual_score": None, |
| } |
| ) |
|
|
| background_path = background_dir / f"{character_id}_spike_background.png" |
| _draw_mock_background(display_name, random.Random(seed)).save(background_path) |
| grid_path = generated_dir / "asset_grid.png" |
| make_thumbnail_grid([character_dir / f"{slot}.png" for slot in EXPRESSIONS], grid_path) |
|
|
| manifest = { |
| "schema_version": 1, |
| "run_type": "probe_asset_package", |
| "character_id": character_id, |
| "display_name": display_name, |
| "seed": seed, |
| "created_at_unix": int(time.time()), |
| "duration_seconds": round(time.perf_counter() - started, 3), |
| "paths": { |
| "run_dir": str(run_dir), |
| "character_package": str(package_path.relative_to(run_dir)), |
| "character_assets": str(character_dir.relative_to(run_dir)), |
| "background": str(background_path.relative_to(run_dir)), |
| "thumbnail_grid": str(grid_path.relative_to(run_dir)), |
| }, |
| "assets": assets, |
| "model_results": [], |
| "qa": { |
| "usable_assets": len([asset for asset in assets if asset["usable"]]), |
| "total_assets": len(EXPRESSIONS), |
| "needs_manual_review": missing_slots, |
| "notes": [ |
| "Packaged from Modal probe outputs.", |
| "Background removal uses a simple flat-background alpha pass; manual QA is still required." |
| if remove_background |
| else "Background was intentionally preserved as a no-matting fallback.", |
| ], |
| }, |
| } |
| manifest_path = generated_dir / "manifest.json" |
| manifest["paths"]["manifest"] = str(manifest_path.relative_to(run_dir)) |
| manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") |
| write_report(manifest, generated_dir / "report.md") |
| return manifest |
|
|
|
|
| def create_mock_asset_package( |
| *, |
| character_id: str, |
| display_name: str, |
| output_root: str | Path, |
| seed: int = 42, |
| ) -> dict[str, Any]: |
| started = time.perf_counter() |
| rng = random.Random(seed) |
| out_root = Path(output_root) |
| run_dir = out_root / character_id |
| character_dir = run_dir / "assets" / "characters" / character_id |
| background_dir = run_dir / "assets" / "backgrounds" |
| generated_dir = run_dir / "generated" |
| character_dir.mkdir(parents=True, exist_ok=True) |
| background_dir.mkdir(parents=True, exist_ok=True) |
| generated_dir.mkdir(parents=True, exist_ok=True) |
|
|
| package = default_character_package(character_id, display_name) |
| package["metadata"]["source"] = "mock_asset_package" |
| package_path = run_dir / "characters" / f"{character_id}.json" |
| package_path.parent.mkdir(parents=True, exist_ok=True) |
| package_path.write_text(json.dumps(package, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") |
|
|
| assets: list[dict[str, Any]] = [] |
| for index, expression in enumerate(EXPRESSIONS): |
| path = character_dir / f"{expression}.png" |
| image = _draw_mock_character(display_name, expression, index, rng) |
| normalized = normalize_to_stage_canvas(image) |
| normalized.save(path) |
| assets.append( |
| { |
| "slot": expression, |
| "path": str(path.relative_to(run_dir)), |
| "bytes": path.stat().st_size, |
| "source": "mock_pillow", |
| "usable": True, |
| "manual_score": None, |
| } |
| ) |
|
|
| background_path = background_dir / f"{character_id}_spike_background.png" |
| _draw_mock_background(display_name, rng).save(background_path) |
| grid_path = generated_dir / "asset_grid.png" |
| make_thumbnail_grid([character_dir / f"{slot}.png" for slot in EXPRESSIONS], grid_path) |
|
|
| manifest = { |
| "schema_version": 1, |
| "run_type": "mock_asset_package", |
| "character_id": character_id, |
| "display_name": display_name, |
| "seed": seed, |
| "created_at_unix": int(time.time()), |
| "duration_seconds": round(time.perf_counter() - started, 3), |
| "paths": { |
| "run_dir": str(run_dir), |
| "character_package": str(package_path.relative_to(run_dir)), |
| "character_assets": str(character_dir.relative_to(run_dir)), |
| "background": str(background_path.relative_to(run_dir)), |
| "thumbnail_grid": str(grid_path.relative_to(run_dir)), |
| }, |
| "assets": assets, |
| "model_results": [], |
| "qa": { |
| "usable_assets": len(assets), |
| "total_assets": len(EXPRESSIONS), |
| "needs_manual_review": [], |
| "notes": ["Mock assets validate packaging, postprocessing, manifest, and reporting without GPU cost."], |
| }, |
| } |
| manifest_path = generated_dir / "manifest.json" |
| manifest["paths"]["manifest"] = str(manifest_path.relative_to(run_dir)) |
| manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") |
| write_report(manifest, generated_dir / "report.md") |
| return manifest |
|
|
|
|
| def normalize_to_stage_canvas(image: Image.Image, size: tuple[int, int] = CANONICAL_STAGE_SIZE) -> Image.Image: |
| image = image.convert("RGBA") |
| alpha = image.getchannel("A") |
| bbox = alpha.getbbox() |
| if bbox is None: |
| return Image.new("RGBA", size, (0, 0, 0, 0)) |
|
|
| crop = image.crop(bbox) |
| max_width = int(size[0] * 0.82) |
| max_height = int(size[1] * 0.92) |
| scale = min(max_width / crop.width, max_height / crop.height) |
| new_size = (max(1, round(crop.width * scale)), max(1, round(crop.height * scale))) |
| crop = crop.resize(new_size, Image.Resampling.LANCZOS) |
| canvas = Image.new("RGBA", size, (0, 0, 0, 0)) |
| x = (size[0] - new_size[0]) // 2 |
| y = size[1] - new_size[1] - 24 |
| canvas.alpha_composite(crop, (x, y)) |
| return canvas |
|
|
|
|
| def remove_flat_background( |
| image: Image.Image, |
| *, |
| tolerance: int = 42, |
| feather_radius: float = 1.25, |
| ) -> Image.Image: |
| image = image.convert("RGBA") |
| width, height = image.size |
| sample_points = [ |
| (0, 0), |
| (width - 1, 0), |
| (0, height - 1), |
| (width - 1, height - 1), |
| (width // 2, 0), |
| (width // 2, height - 1), |
| ] |
| pixels = image.load() |
| samples = [pixels[x, y][:3] for x, y in sample_points] |
| background = tuple(round(sum(channel) / len(samples)) for channel in zip(*samples)) |
|
|
| source_pixels = image.load() |
| visited = bytearray(width * height) |
| queue: deque[tuple[int, int]] = deque() |
|
|
| def is_background(x: int, y: int) -> bool: |
| rgb = source_pixels[x, y][:3] |
| distance = max(abs(rgb[index] - background[index]) for index in range(3)) |
| return distance <= tolerance |
|
|
| def push(x: int, y: int) -> None: |
| index = y * width + x |
| if visited[index] or not is_background(x, y): |
| return |
| visited[index] = 1 |
| queue.append((x, y)) |
|
|
| for x in range(width): |
| push(x, 0) |
| push(x, height - 1) |
| for y in range(height): |
| push(0, y) |
| push(width - 1, y) |
|
|
| while queue: |
| x, y = queue.popleft() |
| if x > 0: |
| push(x - 1, y) |
| if x < width - 1: |
| push(x + 1, y) |
| if y > 0: |
| push(x, y - 1) |
| if y < height - 1: |
| push(x, y + 1) |
|
|
| alpha = Image.new("L", image.size, 255) |
| alpha_pixels = alpha.load() |
| for y in range(height): |
| row = y * width |
| for x in range(width): |
| if visited[row + x]: |
| alpha_pixels[x, y] = 0 |
| if feather_radius > 0: |
| alpha = alpha.filter(ImageFilter.GaussianBlur(feather_radius)) |
| image.putalpha(alpha) |
| return image |
|
|
|
|
| def make_thumbnail_grid(paths: list[Path], output_path: str | Path, thumb_size: tuple[int, int] = (180, 240)) -> Path: |
| output = Path(output_path) |
| cols = 4 |
| rows = math.ceil(len(paths) / cols) |
| grid = Image.new("RGB", (cols * thumb_size[0], rows * (thumb_size[1] + 26)), (17, 24, 39)) |
| draw = ImageDraw.Draw(grid) |
| font = _font(16) |
| for index, path in enumerate(paths): |
| row, col = divmod(index, cols) |
| x = col * thumb_size[0] |
| y = row * (thumb_size[1] + 26) |
| image = Image.open(path).convert("RGBA") |
| image.thumbnail(thumb_size, Image.Resampling.LANCZOS) |
| cell = Image.new("RGBA", thumb_size, (15, 23, 42, 255)) |
| px = (thumb_size[0] - image.width) // 2 |
| py = (thumb_size[1] - image.height) // 2 |
| cell.alpha_composite(image, (px, py)) |
| grid.paste(cell.convert("RGB"), (x, y)) |
| draw.text((x + 8, y + thumb_size[1] + 4), path.stem, fill=(226, 232, 240), font=font) |
| output.parent.mkdir(parents=True, exist_ok=True) |
| grid.save(output) |
| return output |
|
|
|
|
| def write_report(manifest: dict[str, Any], path: str | Path) -> Path: |
| output = Path(path) |
| output.parent.mkdir(parents=True, exist_ok=True) |
| report = render_report_markdown(manifest) |
| output.write_text(report, encoding="utf-8") |
| return output |
|
|
|
|
| def render_report_markdown(manifest: dict[str, Any]) -> str: |
| qa = manifest.get("qa", {}) |
| model_results = manifest.get("model_results") or [] |
| assets = manifest.get("assets") or [] |
| lines = [ |
| f"# Character Generation Spike Report: {manifest.get('display_name', manifest.get('character_id', 'unknown'))}", |
| "", |
| "## Run", |
| "", |
| f"- Character ID: `{manifest.get('character_id')}`", |
| f"- Run type: `{manifest.get('run_type')}`", |
| f"- Duration: `{manifest.get('duration_seconds')}` seconds", |
| f"- Seed: `{manifest.get('seed')}`", |
| "", |
| "## Asset QA", |
| "", |
| f"- Usable assets: `{qa.get('usable_assets', 0)}/{qa.get('total_assets', len(assets))}`", |
| f"- Needs manual review: `{len(qa.get('needs_manual_review') or [])}`", |
| ] |
| for note in qa.get("notes") or []: |
| lines.append(f"- Note: {note}") |
| lines.extend(["", "## Model Results", ""]) |
| if model_results: |
| lines.append("| Candidate | Mode | Cold/Warm | Images | Seconds | GPU | Status |") |
| lines.append("| --- | --- | --- | ---: | ---: | --- | --- |") |
| for result in model_results: |
| lines.append( |
| "| {candidate} | {mode} | {temperature} | {images} | {seconds} | {gpu} | {status} |".format( |
| candidate=result.get("candidate_id", ""), |
| mode=result.get("mode", ""), |
| temperature="cold" if result.get("loaded_before") is False else "warm", |
| images=result.get("image_count", 0), |
| seconds=result.get("duration_seconds", ""), |
| gpu=result.get("gpu", ""), |
| status=result.get("status", ""), |
| ) |
| ) |
| else: |
| lines.append("No remote model probes recorded yet.") |
|
|
| lines.extend(["", "## Assets", ""]) |
| for asset in assets: |
| lines.append(f"- `{asset.get('slot')}`: `{asset.get('path')}` ({asset.get('bytes')} bytes)") |
| lines.append("") |
| return "\n".join(lines) |
|
|
|
|
| def _draw_mock_character(display_name: str, expression: str, index: int, rng: random.Random) -> Image.Image: |
| width, height = CANONICAL_STAGE_SIZE |
| image = Image.new("RGBA", (width, height), (0, 0, 0, 0)) |
| draw = ImageDraw.Draw(image) |
| accent = _palette(index) |
| skin = (255, 216, 204, 255) |
| coat = (31 + rng.randrange(20), 41 + rng.randrange(20), 55 + rng.randrange(30), 255) |
| hair = (190 + rng.randrange(45), 230 + rng.randrange(20), 245 + rng.randrange(10), 255) |
|
|
| draw.ellipse((265, 970, 635, 1060), fill=(0, 0, 0, 72)) |
| draw.polygon([(250, 1080), (345, 660), (555, 660), (650, 1080)], fill=coat) |
| draw.polygon([(345, 670), (450, 850), (555, 670), (595, 1080), (305, 1080)], fill=(12, 18, 31, 255)) |
| draw.rounded_rectangle((395, 600, 505, 730), radius=36, fill=(239, 184, 170, 255)) |
| draw.ellipse((255, 170, 645, 640), fill=skin) |
| draw.ellipse((210, 95, 690, 520), fill=hair) |
| draw.pieslice((210, 110, 690, 650), 185, 355, fill=(120, 190, 210, 255)) |
| draw.polygon([(300, 250), (370, 100), (420, 340)], fill=hair) |
| draw.polygon([(430, 230), (510, 80), (540, 345)], fill=hair) |
| draw.polygon([(535, 260), (610, 150), (625, 390)], fill=hair) |
| draw.rounded_rectangle((385, 795, 515, 835), radius=18, fill=accent) |
|
|
| _draw_expression(draw, expression, accent) |
| font = _font(28) |
| small = _font(22) |
| draw.text((34, 34), display_name, fill=(238, 242, 255, 220), font=font) |
| draw.text((34, 72), expression, fill=accent, font=small) |
| return image |
|
|
|
|
| def _draw_expression(draw: ImageDraw.ImageDraw, expression: str, accent: tuple[int, int, int, int]) -> None: |
| eye = (15, 23, 42, 255) |
| if expression in {"smile", "happy"}: |
| draw.arc((325, 385, 410, 445), 200, 340, fill=eye, width=8) |
| draw.arc((490, 385, 575, 445), 200, 340, fill=eye, width=8) |
| draw.arc((405, 505, 495, 570), 15, 165, fill=(159, 18, 57, 255), width=8) |
| else: |
| draw.ellipse((330, 380, 405, 455), fill=(248, 250, 252, 255)) |
| draw.ellipse((495, 380, 570, 455), fill=(248, 250, 252, 255)) |
| draw.ellipse((356, 398, 388, 438), fill=accent) |
| draw.ellipse((521, 398, 553, 438), fill=accent) |
| draw.ellipse((366, 410, 382, 433), fill=eye) |
| draw.ellipse((531, 410, 547, 433), fill=eye) |
| if expression == "worried": |
| draw.arc((410, 525, 490, 580), 200, 340, fill=(159, 18, 57, 255), width=8) |
| else: |
| draw.arc((410, 505, 490, 552), 25, 155, fill=(159, 18, 57, 255), width=7) |
| if expression == "thinking": |
| draw.line((315, 350, 405, 360), fill=eye, width=7) |
| draw.line((495, 360, 585, 350), fill=eye, width=7) |
| elif expression == "worried": |
| draw.line((315, 365, 405, 340), fill=eye, width=7) |
| draw.line((495, 340, 585, 365), fill=eye, width=7) |
| else: |
| draw.line((320, 360, 405, 350), fill=eye, width=7) |
| draw.line((495, 350, 580, 360), fill=eye, width=7) |
|
|
|
|
| def _draw_mock_background(display_name: str, rng: random.Random) -> Image.Image: |
| width, height = 1600, 900 |
| image = Image.new("RGB", (width, height), (12, 18, 31)) |
| draw = ImageDraw.Draw(image) |
| for y in range(height): |
| shade = int(20 + 40 * y / height) |
| draw.line((0, y, width, y), fill=(10, 16 + shade // 4, 26 + shade)) |
| for _ in range(70): |
| x = rng.randrange(width) |
| y = rng.randrange(height) |
| radius = rng.randrange(1, 4) |
| draw.ellipse((x, y, x + radius, y + radius), fill=(148, 163, 184)) |
| for x in range(0, width, 120): |
| draw.line((x, 0, x + 320, height), fill=(255, 255, 255, 16), width=1) |
| font = _font(44) |
| draw.text((56, 56), f"{display_name} / spike background", fill=(226, 232, 240), font=font) |
| return image |
|
|
|
|
| def _palette(index: int) -> tuple[int, int, int, int]: |
| colors = [ |
| (103, 232, 249, 255), |
| (125, 211, 252, 255), |
| (250, 204, 21, 255), |
| (244, 114, 182, 255), |
| (167, 243, 208, 255), |
| (196, 181, 253, 255), |
| (251, 146, 60, 255), |
| (129, 140, 248, 255), |
| ] |
| return colors[index % len(colors)] |
|
|
|
|
| def _font(size: int) -> ImageFont.ImageFont: |
| try: |
| return ImageFont.truetype("arial.ttf", size) |
| except OSError: |
| return ImageFont.load_default() |
|
|