ShadowInk's picture
Deploy Virtual Characters for Build Small Hackathon
005e075 verified
Raw
History Blame Contribute Delete
17.9 kB
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()