forked from xiangwang25/whale-town-end-v2
Initial WhaleTown V2 backend
This commit is contained in:
1439
scripts/skin_generation/generate_skin_from_prompt.py
Normal file
1439
scripts/skin_generation/generate_skin_from_prompt.py
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
scripts/skin_generation/references/human_whale_reference_up.png
Normal file
BIN
scripts/skin_generation/references/human_whale_reference_up.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
BIN
scripts/skin_generation/references/whaleboy_reference_down.png
Normal file
BIN
scripts/skin_generation/references/whaleboy_reference_down.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
275
scripts/skin_generation/tools/assemble_direction_strips.py
Executable file
275
scripts/skin_generation/tools/assemble_direction_strips.py
Executable file
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
ROWS = ["down", "up", "right", "left"]
|
||||
|
||||
|
||||
def resize_rgba_alpha_aware(image: Image.Image, size: tuple[int, int]) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
arr = np.asarray(rgba).astype(np.float32)
|
||||
alpha = arr[:, :, 3:4] / 255.0
|
||||
premultiplied = arr[:, :, :3] * alpha
|
||||
premul_image = Image.fromarray(np.clip(premultiplied, 0, 255).astype(np.uint8), "RGB")
|
||||
alpha_image = Image.fromarray(arr[:, :, 3].astype(np.uint8), "L")
|
||||
resized_premul = np.asarray(premul_image.resize(size, Image.Resampling.LANCZOS)).astype(np.float32)
|
||||
resized_alpha = np.asarray(alpha_image.resize(size, Image.Resampling.LANCZOS)).astype(np.float32)
|
||||
alpha_fraction = resized_alpha[:, :, None] / 255.0
|
||||
rgb = np.zeros_like(resized_premul)
|
||||
np.divide(resized_premul, alpha_fraction, out=rgb, where=alpha_fraction > 0.001)
|
||||
output = np.dstack([np.clip(rgb, 0, 255), resized_alpha])
|
||||
return Image.fromarray(np.clip(output, 0, 255).astype(np.uint8), "RGBA")
|
||||
|
||||
|
||||
def largest_component_mask(alpha: np.ndarray, threshold: int = 8) -> np.ndarray:
|
||||
foreground = alpha > threshold
|
||||
visited = np.zeros(foreground.shape, dtype=bool)
|
||||
best: list[tuple[int, int]] = []
|
||||
height, width = foreground.shape
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
if not foreground[y, x] or visited[y, x]:
|
||||
continue
|
||||
component: list[tuple[int, int]] = []
|
||||
queue: deque[tuple[int, int]] = deque([(y, x)])
|
||||
visited[y, x] = True
|
||||
while queue:
|
||||
cy, cx = queue.popleft()
|
||||
component.append((cy, cx))
|
||||
for ny, nx in ((cy - 1, cx), (cy + 1, cx), (cy, cx - 1), (cy, cx + 1)):
|
||||
if 0 <= ny < height and 0 <= nx < width and foreground[ny, nx] and not visited[ny, nx]:
|
||||
visited[ny, nx] = True
|
||||
queue.append((ny, nx))
|
||||
if len(component) > len(best):
|
||||
best = component
|
||||
mask = np.zeros(foreground.shape, dtype=bool)
|
||||
for y, x in best:
|
||||
mask[y, x] = True
|
||||
return mask
|
||||
|
||||
|
||||
def keep_largest_component(cell: Image.Image, dilate: int) -> Image.Image:
|
||||
arr = np.asarray(cell.convert("RGBA")).copy()
|
||||
keep = largest_component_mask(arr[:, :, 3], threshold=10)
|
||||
for _ in range(dilate):
|
||||
expanded = keep.copy()
|
||||
expanded[:-1, :] |= keep[1:, :]
|
||||
expanded[1:, :] |= keep[:-1, :]
|
||||
expanded[:, :-1] |= keep[:, 1:]
|
||||
expanded[:, 1:] |= keep[:, :-1]
|
||||
keep = expanded
|
||||
arr[:, :, 3] = np.where(keep, arr[:, :, 3], 0).astype(np.uint8)
|
||||
return Image.fromarray(arr, "RGBA")
|
||||
|
||||
|
||||
def remove_magenta_contamination(cell: Image.Image) -> Image.Image:
|
||||
arr = np.asarray(cell.convert("RGBA")).copy()
|
||||
rgb = arr[:, :, :3].astype(np.int16)
|
||||
alpha = arr[:, :, 3]
|
||||
magenta = (alpha > 0) & (rgb[:, :, 0] > 120) & (rgb[:, :, 1] < 80) & (rgb[:, :, 2] > 120)
|
||||
if magenta.any():
|
||||
arr[:, :, 3] = np.where(magenta, 0, alpha).astype(np.uint8)
|
||||
return Image.fromarray(arr, "RGBA")
|
||||
|
||||
|
||||
def fit_cell(
|
||||
cell: Image.Image,
|
||||
*,
|
||||
frame_size: int,
|
||||
target_body_height: int,
|
||||
target_foot_y: int,
|
||||
component_dilate: int,
|
||||
) -> Image.Image:
|
||||
cell = remove_magenta_contamination(keep_largest_component(cell, component_dilate))
|
||||
bbox = cell.getchannel("A").getbbox()
|
||||
if bbox is None:
|
||||
return Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0))
|
||||
trimmed = cell.crop(bbox)
|
||||
scale = min(target_body_height / trimmed.height, (frame_size - 8) / trimmed.width, 1.0)
|
||||
next_size = (max(1, round(trimmed.width * scale)), max(1, round(trimmed.height * scale)))
|
||||
resized = resize_rgba_alpha_aware(trimmed, next_size)
|
||||
output = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0))
|
||||
x = (frame_size - resized.width) // 2
|
||||
y = target_foot_y - resized.height
|
||||
y = max(0, min(frame_size - resized.height, y))
|
||||
output.alpha_composite(resized, (x, y))
|
||||
return output
|
||||
|
||||
|
||||
def extract_row(path: Path, args: argparse.Namespace) -> list[Image.Image]:
|
||||
image = Image.open(path).convert("RGBA")
|
||||
width, height = image.size
|
||||
x_edges = [round(i * width / args.columns) for i in range(args.columns + 1)]
|
||||
frames: list[Image.Image] = []
|
||||
for column in range(args.columns):
|
||||
cell = image.crop((x_edges[column], 0, x_edges[column + 1], height))
|
||||
frames.append(
|
||||
fit_cell(
|
||||
cell,
|
||||
frame_size=args.frame_size,
|
||||
target_body_height=args.target_body_height,
|
||||
target_foot_y=args.target_foot_y,
|
||||
component_dilate=args.component_dilate,
|
||||
)
|
||||
)
|
||||
return align_frames_to_neutral_upper_body(frames)
|
||||
|
||||
|
||||
def shift_frame_horizontally(frame: Image.Image, offset: int) -> Image.Image:
|
||||
if offset == 0:
|
||||
return frame
|
||||
shifted = Image.new("RGBA", frame.size, (0, 0, 0, 0))
|
||||
shifted.alpha_composite(frame, (offset, 0))
|
||||
return shifted
|
||||
|
||||
|
||||
def align_frames_to_neutral_upper_body(frames: list[Image.Image], max_shift: int = 8) -> list[Image.Image]:
|
||||
"""Undo per-frame recentering caused by wider walking-leg silhouettes."""
|
||||
if not frames:
|
||||
return frames
|
||||
upper_end = round(frames[0].height * 0.60)
|
||||
neutral = np.asarray(frames[0].convert("RGBA"))[:upper_end, :, 3] > 20
|
||||
aligned = [frames[0]]
|
||||
for frame in frames[1:]:
|
||||
candidate = np.asarray(frame.convert("RGBA"))[:upper_end, :, 3] > 20
|
||||
best_score = float("inf")
|
||||
best_shift = 0
|
||||
for offset in range(-max_shift, max_shift + 1):
|
||||
shifted = np.zeros_like(candidate)
|
||||
if offset < 0:
|
||||
shifted[:, :offset] = candidate[:, -offset:]
|
||||
elif offset > 0:
|
||||
shifted[:, offset:] = candidate[:, :-offset]
|
||||
else:
|
||||
shifted = candidate
|
||||
union = np.logical_or(neutral, shifted)
|
||||
score = float(np.logical_xor(neutral, shifted).sum() / union.sum()) if union.any() else 0.0
|
||||
if score < best_score:
|
||||
best_score = score
|
||||
best_shift = offset
|
||||
aligned.append(shift_frame_horizontally(frame, best_shift if abs(best_shift) >= 2 else 0))
|
||||
return aligned
|
||||
|
||||
|
||||
def checkerboard(width: int, height: int, tile: int = 8) -> Image.Image:
|
||||
image = Image.new("RGBA", (width, height), (226, 226, 226, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
for y in range(0, height, tile):
|
||||
for x in range(0, width, tile):
|
||||
if (x // tile + y // tile) % 2 == 0:
|
||||
draw.rectangle((x, y, x + tile - 1, y + tile - 1), fill=(248, 248, 248, 255))
|
||||
return image
|
||||
|
||||
|
||||
def save_review(sheet: Image.Image, path: Path, *, frame_size: int, columns: int) -> None:
|
||||
scale = 2
|
||||
label_width = 68
|
||||
strip_height = frame_size * scale
|
||||
canvas = Image.new("RGB", (label_width + sheet.width * scale, strip_height * 4), (246, 247, 250))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
font = ImageFont.load_default()
|
||||
for row, name in enumerate(ROWS):
|
||||
y = row * strip_height
|
||||
draw.text((8, y + strip_height // 2 - 5), name, fill=(28, 32, 36), font=font)
|
||||
row_sheet = sheet.crop((0, row * frame_size, sheet.width, (row + 1) * frame_size))
|
||||
row_preview = row_sheet.resize((sheet.width * scale, strip_height), Image.Resampling.NEAREST)
|
||||
bg = checkerboard(row_preview.width, row_preview.height, 16)
|
||||
bg.alpha_composite(row_preview)
|
||||
canvas.paste(bg.convert("RGB"), (label_width, y))
|
||||
for index in range(columns + 1):
|
||||
x = label_width + index * frame_size * scale
|
||||
draw.line((x, y, x, y + strip_height), fill=(205, 60, 60), width=1)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
canvas.save(path)
|
||||
|
||||
|
||||
def save_feet_zoom(sheet: Image.Image, path: Path, *, frame_size: int, columns: int) -> None:
|
||||
scale = 4
|
||||
crop_y0 = round(frame_size * 0.49)
|
||||
crop_h = frame_size - crop_y0
|
||||
label_w = 64
|
||||
label_h = 22
|
||||
cell_w = frame_size * scale
|
||||
cell_h = crop_h * scale
|
||||
canvas = Image.new("RGB", (label_w + cell_w * columns, label_h + cell_h * 4), (246, 247, 250))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
font = ImageFont.load_default()
|
||||
for column in range(columns):
|
||||
draw.text((label_w + column * cell_w + 4, 5), f"F{column + 1}", fill=(72, 76, 82), font=font)
|
||||
for row, row_name in enumerate(ROWS):
|
||||
y = label_h + row * cell_h
|
||||
draw.text((6, y + cell_h // 2 - 5), row_name, fill=(28, 32, 36), font=font)
|
||||
for column in range(columns):
|
||||
cell = sheet.crop(
|
||||
(
|
||||
column * frame_size,
|
||||
row * frame_size + crop_y0,
|
||||
(column + 1) * frame_size,
|
||||
(row + 1) * frame_size,
|
||||
)
|
||||
).resize((cell_w, cell_h), Image.Resampling.NEAREST)
|
||||
bg = checkerboard(cell_w, cell_h, 20)
|
||||
bg.alpha_composite(cell)
|
||||
x = label_w + column * cell_w
|
||||
canvas.paste(bg.convert("RGB"), (x, y))
|
||||
draw.rectangle((x, y, x + cell_w - 1, y + cell_h - 1), outline=(188, 194, 202), width=1)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
canvas.save(path)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Assemble four WhaleTown direction strips into an 8x4 spritesheet.")
|
||||
parser.add_argument("--down", type=Path, required=True)
|
||||
parser.add_argument("--up", type=Path, required=True)
|
||||
parser.add_argument("--right", type=Path, required=True)
|
||||
parser.add_argument("--left", type=Path, required=True)
|
||||
parser.add_argument("--name", required=True)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
parser.add_argument("--frame-size", type=int, default=160)
|
||||
parser.add_argument("--columns", type=int, default=8)
|
||||
parser.add_argument("--target-body-height", type=int, default=116)
|
||||
parser.add_argument("--target-foot-y", type=int, default=137)
|
||||
parser.add_argument("--component-dilate", type=int, default=2)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
inputs = {"down": args.down, "up": args.up, "right": args.right, "left": args.left}
|
||||
for name, path in inputs.items():
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"{name} strip not found: {path}")
|
||||
|
||||
sheet = Image.new("RGBA", (args.frame_size * args.columns, args.frame_size * 4), (0, 0, 0, 0))
|
||||
for row, direction in enumerate(ROWS):
|
||||
frames = extract_row(inputs[direction], args)
|
||||
for column, frame in enumerate(frames):
|
||||
sheet.alpha_composite(frame, (column * args.frame_size, row * args.frame_size))
|
||||
|
||||
processed_dir = args.output_dir / "processed"
|
||||
review_dir = args.output_dir / "review"
|
||||
processed_dir.mkdir(parents=True, exist_ok=True)
|
||||
review_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sheet_path = processed_dir / f"{args.name}_spritesheet.png"
|
||||
review_path = review_dir / f"{args.name}_review.png"
|
||||
feet_path = review_dir / f"{args.name}_feet_zoom.png"
|
||||
|
||||
sheet.save(sheet_path)
|
||||
save_review(sheet, review_path, frame_size=args.frame_size, columns=args.columns)
|
||||
save_feet_zoom(sheet, feet_path, frame_size=args.frame_size, columns=args.columns)
|
||||
|
||||
print(sheet_path)
|
||||
print(review_path)
|
||||
print(feet_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
293
scripts/skin_generation/tools/birefnet_cutout.py
Normal file
293
scripts/skin_generation/tools/birefnet_cutout.py
Normal file
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create clean transparent game-asset cutouts with BiRefNet.
|
||||
|
||||
This tool is intended for Novamailio-generated WhaleTown assets that come back
|
||||
on a plain matte background. It uses BiRefNet for the alpha mask, then fills
|
||||
transparent/semitransparent edge RGB from confident foreground pixels so Godot
|
||||
texture filtering cannot reveal the original matte color.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import deque
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Iterable, Tuple
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
DEFAULT_MODEL = "ZhengPeng7/BiRefNet"
|
||||
DEFAULT_HF_ENDPOINT = "https://hf-mirror.com"
|
||||
DEFAULT_SIZE = 1024
|
||||
|
||||
|
||||
def _die(message: str, code: int = 1) -> None:
|
||||
print(f"Error: {message}", file=sys.stderr)
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
def _warn(message: str) -> None:
|
||||
print(f"Warning: {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def _import_ml_deps() -> Tuple[object, object, object]:
|
||||
try:
|
||||
import torch
|
||||
from torchvision import transforms
|
||||
from transformers import AutoModelForImageSegmentation
|
||||
except ImportError as exc:
|
||||
_die(
|
||||
"Missing BiRefNet dependencies. Install them with:\n"
|
||||
" python3 -m pip install torch torchvision transformers timm einops kornia scipy\n"
|
||||
f"Original import error: {exc}"
|
||||
)
|
||||
return torch, transforms, AutoModelForImageSegmentation
|
||||
|
||||
|
||||
def _select_device(torch: object, requested: str) -> str:
|
||||
if requested != "auto":
|
||||
return requested
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _load_birefnet(model_name: str, device: str, torch: object, auto_model: object) -> object:
|
||||
model = auto_model.from_pretrained(model_name, trust_remote_code=True)
|
||||
model.to(device)
|
||||
model.eval()
|
||||
return model
|
||||
|
||||
|
||||
def _run_birefnet(
|
||||
image: Image.Image,
|
||||
*,
|
||||
model_name: str,
|
||||
device: str,
|
||||
input_size: int,
|
||||
) -> Image.Image:
|
||||
torch, transforms, auto_model = _import_ml_deps()
|
||||
selected_device = _select_device(torch, device)
|
||||
print(f"BiRefNet device: {selected_device}", file=sys.stderr)
|
||||
print(f"BiRefNet model: {model_name}", file=sys.stderr)
|
||||
model = _load_birefnet(model_name, selected_device, torch, auto_model)
|
||||
|
||||
transform = transforms.Compose(
|
||||
[
|
||||
transforms.Resize(
|
||||
(input_size, input_size),
|
||||
interpolation=transforms.InterpolationMode.BILINEAR,
|
||||
),
|
||||
transforms.ToTensor(),
|
||||
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
|
||||
]
|
||||
)
|
||||
input_tensor = transform(image).unsqueeze(0).to(selected_device)
|
||||
with torch.no_grad():
|
||||
output = model(input_tensor)
|
||||
if isinstance(output, (list, tuple)):
|
||||
prediction = output[-1]
|
||||
elif hasattr(output, "logits"):
|
||||
prediction = output.logits
|
||||
else:
|
||||
prediction = output
|
||||
prediction = prediction.sigmoid().detach().float().cpu()[0]
|
||||
if prediction.ndim == 3:
|
||||
prediction = prediction.squeeze(0)
|
||||
mask = transforms.ToPILImage()(prediction)
|
||||
return mask.resize(image.size, Image.Resampling.LANCZOS).convert("L")
|
||||
|
||||
|
||||
def _stabilize_mask(
|
||||
mask: Image.Image,
|
||||
*,
|
||||
low_cut: float,
|
||||
high_span: float,
|
||||
hard_low: float,
|
||||
hard_high: float,
|
||||
) -> Image.Image:
|
||||
mask_arr = np.asarray(mask).astype(np.float32) / 255.0
|
||||
mask_arr = np.clip((mask_arr - low_cut) / high_span, 0.0, 1.0)
|
||||
mask_arr = np.where(mask_arr > hard_high, 1.0, mask_arr)
|
||||
mask_arr = np.where(mask_arr < hard_low, 0.0, mask_arr)
|
||||
return Image.fromarray((mask_arr * 255).astype(np.uint8))
|
||||
|
||||
|
||||
def _connected_matte_mask(image: Image.Image, threshold: float) -> np.ndarray:
|
||||
"""Fallback mask for comparing or rescuing BiRefNet failures."""
|
||||
rgb = np.asarray(image.convert("RGB"))
|
||||
arr = rgb.astype(np.int32)
|
||||
h, w = arr.shape[:2]
|
||||
strips = np.concatenate(
|
||||
[
|
||||
arr[:80, :, :].reshape(-1, 3),
|
||||
arr[max(0, h - 80) : h, :, :].reshape(-1, 3),
|
||||
arr[:, :80, :].reshape(-1, 3),
|
||||
arr[:, max(0, w - 80) : w, :].reshape(-1, 3),
|
||||
],
|
||||
axis=0,
|
||||
)
|
||||
background = np.median(strips, axis=0).astype(np.int32)
|
||||
color_dist = np.sqrt(((arr - background) ** 2).sum(axis=2))
|
||||
candidate = color_dist <= threshold
|
||||
visited = np.zeros((h, w), dtype=bool)
|
||||
queue: deque[Tuple[int, int]] = deque()
|
||||
for x in range(w):
|
||||
for y in (0, h - 1):
|
||||
if candidate[y, x] and not visited[y, x]:
|
||||
visited[y, x] = True
|
||||
queue.append((y, x))
|
||||
for y in range(h):
|
||||
for x in (0, w - 1):
|
||||
if candidate[y, x] and not visited[y, x]:
|
||||
visited[y, x] = True
|
||||
queue.append((y, x))
|
||||
while queue:
|
||||
y, x = queue.popleft()
|
||||
for ny, nx in ((y - 1, x), (y + 1, x), (y, x - 1), (y, x + 1)):
|
||||
if 0 <= ny < h and 0 <= nx < w and (not visited[ny, nx]) and candidate[ny, nx]:
|
||||
visited[ny, nx] = True
|
||||
queue.append((ny, nx))
|
||||
return ~visited
|
||||
|
||||
|
||||
def _decontaminate_edge_rgb(rgb: np.ndarray, alpha: np.ndarray, confidence: int) -> np.ndarray:
|
||||
foreground = alpha > confidence
|
||||
if not foreground.any():
|
||||
_warn("Mask has no confident foreground pixels; edge decontamination skipped.")
|
||||
return rgb
|
||||
try:
|
||||
from scipy import ndimage
|
||||
except ImportError:
|
||||
_warn("scipy is missing; install scipy for edge RGB decontamination.")
|
||||
return rgb
|
||||
|
||||
_, indices = ndimage.distance_transform_edt(~foreground, return_indices=True)
|
||||
nearest_rgb = rgb[indices[0], indices[1]]
|
||||
alpha_f = alpha.astype(np.float32) / 255.0
|
||||
edge_mix = np.clip((0.98 - alpha_f) / 0.98, 0.0, 1.0)[..., None]
|
||||
replace_strength = np.where(alpha_f[..., None] < 0.98, edge_mix, 0.0)
|
||||
cleaned = rgb * (1.0 - replace_strength) + nearest_rgb * replace_strength
|
||||
return np.clip(cleaned, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def _compose_cutout(image: Image.Image, mask: Image.Image, confidence: int) -> Image.Image:
|
||||
rgb = np.asarray(image.convert("RGB")).astype(np.float32)
|
||||
alpha = np.asarray(mask).astype(np.uint8)
|
||||
cleaned_rgb = _decontaminate_edge_rgb(rgb, alpha, confidence)
|
||||
return Image.fromarray(np.dstack([cleaned_rgb, alpha]))
|
||||
|
||||
|
||||
def _make_preview(cutout: Image.Image, output: Path, scale: float) -> None:
|
||||
w, h = cutout.size
|
||||
small = cutout.resize((int(w * scale), int(h * scale)), Image.Resampling.LANCZOS)
|
||||
canvas = Image.new("RGB", (small.width * 2 + 48, small.height + 48), (238, 238, 238))
|
||||
|
||||
magenta_plate = Image.new("RGB", small.size, (198, 76, 190))
|
||||
magenta_plate.paste(small, (0, 0), small)
|
||||
|
||||
checker = Image.new("RGB", small.size, (230, 230, 230))
|
||||
draw = ImageDraw.Draw(checker)
|
||||
step = 32
|
||||
for y in range(0, small.height, step):
|
||||
for x in range(0, small.width, step):
|
||||
if ((x // step) + (y // step)) % 2 == 0:
|
||||
draw.rectangle([x, y, x + step - 1, y + step - 1], fill=(190, 206, 224))
|
||||
checker.paste(small, (0, 0), small)
|
||||
|
||||
canvas.paste(magenta_plate, (16, 32))
|
||||
canvas.paste(checker, (small.width + 32, 32))
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
canvas.save(output)
|
||||
|
||||
|
||||
def _magenta_stats(image: Image.Image) -> Tuple[int, int, int]:
|
||||
arr = np.asarray(image.convert("RGBA")).astype(np.int16)
|
||||
rgb = arr[:, :, :3]
|
||||
alpha = arr[:, :, 3]
|
||||
semi = (alpha > 0) & (alpha < 255)
|
||||
magenta = (alpha > 0) & (rgb[:, :, 0] > 120) & (rgb[:, :, 1] < 60) & (rgb[:, :, 2] > 90)
|
||||
semi_magenta = semi & magenta
|
||||
return int(semi.sum()), int(magenta.sum()), int(semi_magenta.sum())
|
||||
|
||||
|
||||
def parse_args(argv: Iterable[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--input", required=True, help="Source RGB/RGBA image with matte background.")
|
||||
parser.add_argument("--output", required=True, help="Transparent PNG output path.")
|
||||
parser.add_argument("--mask-out", help="Optional alpha mask output path.")
|
||||
parser.add_argument("--preview-out", help="Optional preview sheet output path.")
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help=f"HF model id. Default: {DEFAULT_MODEL}")
|
||||
parser.add_argument("--hf-endpoint", default=DEFAULT_HF_ENDPOINT, help="Hugging Face endpoint/mirror.")
|
||||
parser.add_argument("--device", choices=["auto", "cpu", "mps", "cuda"], default="auto")
|
||||
parser.add_argument("--input-size", type=int, default=DEFAULT_SIZE, help="Square BiRefNet input size.")
|
||||
parser.add_argument("--low-cut", type=float, default=0.025, help="Low alpha normalization cut.")
|
||||
parser.add_argument("--high-span", type=float, default=0.94, help="Alpha normalization span.")
|
||||
parser.add_argument("--hard-low", type=float, default=0.015, help="Values below this become transparent.")
|
||||
parser.add_argument("--hard-high", type=float, default=0.985, help="Values above this become opaque.")
|
||||
parser.add_argument("--edge-confidence", type=int, default=245, help="Confident foreground alpha for RGB fill.")
|
||||
parser.add_argument("--preview-scale", type=float, default=0.52)
|
||||
parser.add_argument("--fallback-connected-matte", action="store_true", help="Use simple connected matte mask instead of BiRefNet.")
|
||||
parser.add_argument("--fallback-threshold", type=float, default=28.0)
|
||||
return parser.parse_args(list(argv))
|
||||
|
||||
|
||||
def main(argv: Iterable[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
input_path = Path(args.input)
|
||||
output_path = Path(args.output)
|
||||
if not input_path.exists():
|
||||
_die(f"Input image not found: {input_path}")
|
||||
|
||||
os.environ.setdefault("HF_ENDPOINT", args.hf_endpoint)
|
||||
image = Image.open(input_path).convert("RGB")
|
||||
if args.fallback_connected_matte:
|
||||
foreground = _connected_matte_mask(image, args.fallback_threshold)
|
||||
mask = Image.fromarray(foreground.astype(np.uint8) * 255)
|
||||
else:
|
||||
mask = _run_birefnet(
|
||||
image,
|
||||
model_name=args.model,
|
||||
device=args.device,
|
||||
input_size=args.input_size,
|
||||
)
|
||||
mask = _stabilize_mask(
|
||||
mask,
|
||||
low_cut=args.low_cut,
|
||||
high_span=args.high_span,
|
||||
hard_low=args.hard_low,
|
||||
hard_high=args.hard_high,
|
||||
)
|
||||
|
||||
cutout = _compose_cutout(image, mask, args.edge_confidence)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cutout.save(output_path)
|
||||
|
||||
if args.mask_out:
|
||||
mask_path = Path(args.mask_out)
|
||||
mask_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
mask.save(mask_path)
|
||||
if args.preview_out:
|
||||
_make_preview(cutout, Path(args.preview_out), args.preview_scale)
|
||||
|
||||
semi, magenta, semi_magenta = _magenta_stats(cutout)
|
||||
print(f"wrote {output_path}")
|
||||
if args.mask_out:
|
||||
print(f"wrote {args.mask_out}")
|
||||
if args.preview_out:
|
||||
print(f"wrote {args.preview_out}")
|
||||
print(f"alpha_bbox={cutout.getchannel('A').getbbox()}")
|
||||
print(f"semi_transparent_pixels={semi}")
|
||||
print(f"visible_magenta_like_pixels={magenta}")
|
||||
print(f"semi_magenta_like_pixels={semi_magenta}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
75
scripts/skin_generation/tools/expand_pose_triplet.py
Normal file
75
scripts/skin_generation/tools/expand_pose_triplet.py
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Expand canonical A/B/C poses into the A/B/A/C/A/B/A/C walk cycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
POSE_SEQUENCE = (0, 1, 0, 2, 0, 1, 0, 2)
|
||||
|
||||
|
||||
def split_equal_columns(image: Image.Image, columns: int) -> list[Image.Image]:
|
||||
width, height = image.size
|
||||
edges = [round(index * width / columns) for index in range(columns + 1)]
|
||||
return [image.crop((edges[index], 0, edges[index + 1], height)) for index in range(columns)]
|
||||
|
||||
|
||||
def expand_pose_triplet(image: Image.Image) -> Image.Image:
|
||||
poses = split_equal_columns(image.convert("RGBA"), 3)
|
||||
cell_width = max(pose.width for pose in poses)
|
||||
cell_height = image.height
|
||||
output = Image.new("RGBA", (cell_width * len(POSE_SEQUENCE), cell_height), (0, 0, 0, 0))
|
||||
for output_index, pose_index in enumerate(POSE_SEQUENCE):
|
||||
pose = poses[pose_index]
|
||||
x = output_index * cell_width + (cell_width - pose.width) // 2
|
||||
output.alpha_composite(pose, (x, 0))
|
||||
return output
|
||||
|
||||
|
||||
def combine_pose_images(paths: list[Path]) -> Image.Image:
|
||||
poses = [Image.open(path).convert("RGBA") for path in paths]
|
||||
cell_width = max(pose.width for pose in poses)
|
||||
cell_height = max(pose.height for pose in poses)
|
||||
output = Image.new("RGBA", (cell_width * len(poses), cell_height), (0, 0, 0, 0))
|
||||
for index, pose in enumerate(poses):
|
||||
x = index * cell_width + (cell_width - pose.width) // 2
|
||||
y = (cell_height - pose.height) // 2
|
||||
output.alpha_composite(pose, (x, y))
|
||||
return output
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--input", type=Path)
|
||||
parser.add_argument("--pose", type=Path, action="append", default=[])
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
if bool(args.input) == bool(args.pose):
|
||||
raise ValueError("Use either --input triplet or exactly three --pose images")
|
||||
if args.pose:
|
||||
if len(args.pose) != 3:
|
||||
raise ValueError(f"Expected exactly three --pose images, got {len(args.pose)}")
|
||||
for path in args.pose:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Canonical pose not found: {path}")
|
||||
image = combine_pose_images(args.pose)
|
||||
else:
|
||||
if args.input is None or not args.input.exists():
|
||||
raise FileNotFoundError(f"Pose triplet not found: {args.input}")
|
||||
image = Image.open(args.input).convert("RGBA")
|
||||
if image.width < 3 or image.height < 1:
|
||||
raise ValueError(f"Invalid pose triplet size: {image.size}")
|
||||
|
||||
output = expand_pose_triplet(image)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.save(args.output)
|
||||
print(args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1156
scripts/skin_generation/tools/novamailio_image_gen.py
Normal file
1156
scripts/skin_generation/tools/novamailio_image_gen.py
Normal file
File diff suppressed because it is too large
Load Diff
82
scripts/skin_generation/tools/save_single_row_review.py
Normal file
82
scripts/skin_generation/tools/save_single_row_review.py
Normal file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Save single-row review images from an assembled WhaleTown spritesheet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
def checkerboard(width: int, height: int, tile: int) -> Image.Image:
|
||||
image = Image.new("RGBA", (width, height), (226, 226, 226, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
for y in range(0, height, tile):
|
||||
for x in range(0, width, tile):
|
||||
if (x // tile + y // tile) % 2 == 0:
|
||||
draw.rectangle((x, y, x + tile - 1, y + tile - 1), fill=(248, 248, 248, 255))
|
||||
return image
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--sheet", type=Path, required=True)
|
||||
parser.add_argument("--direction", default="down")
|
||||
parser.add_argument("--row", type=int, default=0)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--feet-output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
frame_size = 160
|
||||
columns = 8
|
||||
row = Image.open(args.sheet).convert("RGBA").crop(
|
||||
(0, args.row * frame_size, columns * frame_size, (args.row + 1) * frame_size)
|
||||
)
|
||||
font = ImageFont.load_default()
|
||||
|
||||
scale = 2
|
||||
label_width = 68
|
||||
preview = row.resize((row.width * scale, row.height * scale), Image.Resampling.NEAREST)
|
||||
canvas = Image.new("RGB", (label_width + preview.width, preview.height), (246, 247, 250))
|
||||
plate = checkerboard(preview.width, preview.height, 16)
|
||||
plate.alpha_composite(preview)
|
||||
canvas.paste(plate.convert("RGB"), (label_width, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
draw.text((8, preview.height // 2 - 5), args.direction, fill=(28, 32, 36), font=font)
|
||||
for index in range(columns + 1):
|
||||
x = label_width + index * frame_size * scale
|
||||
draw.line((x, 0, x, preview.height), fill=(205, 60, 60), width=1)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
canvas.save(args.output)
|
||||
|
||||
feet_y = round(frame_size * 0.49)
|
||||
feet_scale = 4
|
||||
feet_height = frame_size - feet_y
|
||||
cell_width = frame_size * feet_scale
|
||||
cell_height = feet_height * feet_scale
|
||||
label_height = 22
|
||||
feet_canvas = Image.new(
|
||||
"RGB", (label_width + columns * cell_width, label_height + cell_height), (246, 247, 250)
|
||||
)
|
||||
feet_draw = ImageDraw.Draw(feet_canvas)
|
||||
feet_draw.text((7, label_height + cell_height // 2 - 5), args.direction, fill=(28, 32, 36), font=font)
|
||||
for index in range(columns):
|
||||
feet_draw.text((label_width + index * cell_width + 4, 5), f"F{index + 1}", fill=(72, 76, 82), font=font)
|
||||
cell = row.crop((index * frame_size, feet_y, (index + 1) * frame_size, frame_size)).resize(
|
||||
(cell_width, cell_height), Image.Resampling.NEAREST
|
||||
)
|
||||
plate = checkerboard(cell_width, cell_height, 20)
|
||||
plate.alpha_composite(cell)
|
||||
x = label_width + index * cell_width
|
||||
feet_canvas.paste(plate.convert("RGB"), (x, label_height))
|
||||
feet_draw.rectangle(
|
||||
(x, label_height, x + cell_width - 1, label_height + cell_height - 1),
|
||||
outline=(188, 194, 202),
|
||||
width=1,
|
||||
)
|
||||
feet_canvas.save(args.feet_output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user