ij's picture
Current public release
4c3e3fc
Raw
History Blame Contribute Delete
11.9 kB
import tempfile
from collections import deque
from pathlib import Path
import numpy as np
from perfect_pixel import get_perfect_pixel
from PIL import Image, ImageFilter
COLUMNS = 4
ROWS = 2
SHEET_TASKS = {"Propagate frame 1 appearance", "Dress 4x2 walk sheet"}
def remove_white_background(image, minimum_tolerance=48):
rgb = np.asarray(image.convert("RGB"), dtype=np.float32)
border = np.concatenate((rgb[0], rgb[-1], rgb[:, 0], rgb[:, -1]))
background = np.median(border, axis=0)
tolerance = max(
minimum_tolerance,
float(np.percentile(np.linalg.norm(border - background, axis=1), 50) + 8),
)
rough = np.linalg.norm(rgb - background, axis=2) > tolerance
rough = np.asarray(
Image.fromarray(rough.astype(np.uint8) * 255)
.filter(ImageFilter.MaxFilter(3))
.filter(ImageFilter.MinFilter(3))
) > 0
height, width = rough.shape
seen = np.zeros_like(rough)
components = []
for seed_y, seed_x in zip(*np.nonzero(rough)):
if seen[seed_y, seed_x]:
continue
seen[seed_y, seed_x] = True
queue = [(int(seed_y), int(seed_x))]
component = []
while queue:
y, x = queue.pop()
component.append((y, x))
for next_y, next_x in ((y - 1, x), (y + 1, x), (y, x - 1), (y, x + 1)):
if (
0 <= next_y < height
and 0 <= next_x < width
and rough[next_y, next_x]
and not seen[next_y, next_x]
):
seen[next_y, next_x] = True
queue.append((next_y, next_x))
components.append(component)
if not components:
return Image.new("RGBA", image.size)
minimum_area = max(4, round(max(map(len, components)) * 0.002))
solid = np.zeros_like(rough)
for component in components:
if len(component) >= minimum_area:
y, x = zip(*component)
solid[y, x] = True
outside = np.zeros_like(solid)
queue = deque()
for x in range(width):
queue.extend(((0, x), (height - 1, x)))
for y in range(height):
queue.extend(((y, 0), (y, width - 1)))
while queue:
y, x = queue.popleft()
if outside[y, x] or solid[y, x]:
continue
outside[y, x] = True
for next_y, next_x in ((y - 1, x), (y + 1, x), (y, x - 1), (y, x + 1)):
if 0 <= next_y < height and 0 <= next_x < width:
queue.append((next_y, next_x))
solid |= ~outside
alpha = solid.astype(np.uint8) * 255
return Image.fromarray(np.dstack((rgb.astype(np.uint8), alpha)), "RGBA")
def foot_anchor(image):
alpha = np.asarray(image.getchannel("A"), dtype=np.float64) / 255
y, x = np.nonzero(alpha > 0.25)
if not len(x):
raise ValueError("A 4x2 frame contains no foreground sprite.")
bottom = int(y.max())
band = y >= bottom - max(2, round(image.height * 0.06))
return float(np.average(x[band], weights=alpha[y[band], x[band]])), bottom
def align_4x2(source, reference):
if source.width % COLUMNS or source.height % ROWS:
raise ValueError("The generated sheet must be divisible into a 4x2 grid.")
frame_width = source.width // COLUMNS
frame_height = source.height // ROWS
reference = reference.resize(source.size, Image.Resampling.NEAREST)
result = Image.new("RGBA", source.size)
for index in range(COLUMNS * ROWS):
row, column = divmod(index, COLUMNS)
box = (
column * frame_width,
row * frame_height,
(column + 1) * frame_width,
(row + 1) * frame_height,
)
generated = remove_white_background(source.crop(box))
sprite_box = generated.getbbox()
if not sprite_box:
raise ValueError(f"Generated frame {index + 1} is empty.")
sprite = generated.crop(sprite_box)
generated_x, generated_y = foot_anchor(sprite)
reference_frame = remove_white_background(reference.crop(box), 4)
reference_x, reference_y = foot_anchor(reference_frame)
frame = Image.new("RGBA", (frame_width, frame_height))
frame.alpha_composite(
sprite,
(
round(reference_x - generated_x),
round(reference_y - generated_y),
),
)
result.alpha_composite(frame, (box[0], box[1]))
white = Image.new("RGBA", result.size, "white")
white.alpha_composite(result)
return white.convert("RGB")
def adaptive_palette(image, colors=32):
quantized = image.convert("RGB").quantize(
colors=colors, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE
)
palette = quantized.getpalette()
used = sorted(quantized.getcolors(), reverse=True)
result = [
tuple(palette[index * 3 : index * 3 + 3])
for _, index in used[:colors]
]
whitest = max(range(len(result)), key=lambda i: sum(result[i]))
result[whitest] = (255, 255, 255)
return list(dict.fromkeys(result))
def reference_palette(image, colors=32):
rgb = np.asarray(image.convert("RGB"), dtype=np.uint8).reshape(-1, 3)
unique, counts = np.unique(rgb, axis=0, return_counts=True)
if len(unique) <= colors:
order = np.argsort(counts)[::-1]
palette = [tuple(map(int, color)) for color in unique[order]]
else:
palette = adaptive_palette(image, colors)
if (255, 255, 255) not in palette:
palette = [(255, 255, 255), *palette[: colors - 1]]
return palette[:colors]
def indexed_image(image, palette):
palette = palette[:32]
pixels = np.asarray(image.convert("RGB"), dtype=np.int16)
colors = np.asarray(palette, dtype=np.int16)
flat = pixels.reshape(-1, 3)
indexes = np.empty(len(flat), dtype=np.uint8)
for start in range(0, len(flat), 65536):
chunk = flat[start : start + 65536].astype(np.int32)
delta = chunk[:, None] - colors[None].astype(np.int32)
distance = (delta**2).sum(axis=2)
indexes[start : start + len(chunk)] = distance.argmin(axis=1)
result = Image.fromarray(indexes.reshape(pixels.shape[:2]), "P")
padded = [channel for color in palette for channel in color]
padded.extend([channel for _ in range(32 - len(palette)) for channel in palette[-1]])
result.putpalette(padded + [0] * (768 - len(padded)))
return result
def native_size(task):
return (256, 128) if task in SHEET_TASKS else (64, 64)
def perfect_pixel_image(image, task):
expected = native_size(task)
if image.size == expected:
return image.convert("RGB")
width, height, refined = get_perfect_pixel(
np.asarray(image.convert("RGB")),
sample_method="median",
min_size=4.0,
peak_width=6,
refine_intensity=0.25,
fix_square=True,
)
if width is None or height is None:
raise ValueError("Perfect Pixel์ด ์ด๋ฏธ์ง€์˜ ํ”ฝ์…€ ๊ฒฉ์ž๋ฅผ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.")
if (width, height) != expected:
raise ValueError(
f"Perfect Pixel ๊ฒ€์ถœ ํฌ๊ธฐ๋Š” {width}ร—{height}์ด์ง€๋งŒ "
f"์ด ์ž‘์—…์—๋Š” {expected[0]}ร—{expected[1]} ๊ฒฉ์ž๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."
)
return Image.fromarray(np.asarray(refined, dtype=np.uint8), "RGB")
def shared_palette(paths, colors=32):
images = [Image.open(path).convert("RGB") for path in paths]
if not images:
raise ValueError("๊ณตํ†ต ํŒ”๋ ˆํŠธ๋ฅผ ๋งŒ๋“ค ์ด๋ฏธ์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
width = max(image.width for image in images)
height = sum(image.height for image in images)
combined = Image.new("RGB", (width, height), "white")
y = 0
for image in images:
combined.paste(image, (0, y))
y += image.height
return adaptive_palette(combined, colors)
def apply_shared_palette(paths, palette):
results = []
for path in paths:
image = indexed_image(Image.open(path).convert("RGB"), palette)
output = tempfile.NamedTemporaryFile(delete=False, suffix=".png").name
image.save(output, bits=5)
results.append(output)
return results
def save_gif(sheet, palette, fps):
frame_width = sheet.width // COLUMNS
frame_height = sheet.height // ROWS
frames = []
for row in range(ROWS):
for column in range(COLUMNS):
frame = sheet.crop(
(
column * frame_width,
row * frame_height,
(column + 1) * frame_width,
(row + 1) * frame_height,
)
)
frames.append(indexed_image(frame.convert("RGB"), palette))
path = tempfile.NamedTemporaryFile(delete=False, suffix=".gif").name
frames[0].save(
path,
save_all=True,
append_images=frames[1:],
duration=round(1000 / fps),
loop=0,
disposal=2,
)
return path
def process_output(
generated_path,
input_path,
task,
palette_reference_path,
palette_mode,
align_frames,
pixel_snap,
output_resolution,
output_format,
fps,
):
generated = Image.open(generated_path).convert("RGB")
reference = Image.open(input_path).convert("RGB")
sheet_task = task in SHEET_TASKS
if align_frames and sheet_task:
generated = align_4x2(generated, reference)
target_native = native_size(task)
if pixel_snap:
working = perfect_pixel_image(generated, task)
elif output_resolution == "Native LPC":
working = generated.resize(target_native, Image.Resampling.NEAREST)
else:
working = generated
if palette_mode == "Defer shared palette":
palette = None
elif palette_mode == "Lock reference palette":
palette_source = Image.open(palette_reference_path).convert("RGB") if palette_reference_path else reference
palette_source = palette_source.resize(target_native, Image.Resampling.NEAREST)
palette = reference_palette(palette_source)
else:
palette = adaptive_palette(working)
if palette:
working = indexed_image(working, palette)
if output_resolution == "Upscaled" and working.size != generated.size:
working = working.resize(generated.size, Image.Resampling.NEAREST)
if output_format == "GIF":
if not sheet_task:
raise ValueError("GIF output is available for 4x2 sheet tasks.")
if not palette:
raise ValueError("๊ณตํ†ต ํŒ”๋ ˆํŠธ ์ ์šฉ ์ „์—๋Š” GIF๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
return save_gif(working, palette, fps)
path = tempfile.NamedTemporaryFile(delete=False, suffix=".png").name
working.save(path, bits=5)
return path
def self_check():
sheet = Image.new("RGB", (256, 128), "white")
array = np.asarray(sheet).copy()
for index in range(8):
row, column = divmod(index, 4)
array[row * 64 + 20 : row * 64 + 60, column * 64 + 24 : column * 64 + 40] = (
index * 20,
80,
160,
)
palette = adaptive_palette(Image.fromarray(array))
indexed = indexed_image(Image.fromarray(array), palette)
assert len(indexed.getcolors()) <= 32
rng = np.random.default_rng(7)
test_colors = np.asarray(
[(255, 255, 255), (20, 30, 40), (50, 90, 160), (200, 120, 80)],
dtype=np.uint8,
)
test_grid = test_colors[rng.integers(0, len(test_colors), size=(128, 256))]
upscaled = Image.fromarray(test_grid).resize(
(2048, 1024), Image.Resampling.NEAREST
)
assert perfect_pixel_image(upscaled, "Propagate frame 1 appearance").size == (
256,
128,
)
gif = save_gif(indexed, palette, 8)
with Image.open(gif) as animation:
assert animation.n_frames == 8
Path(gif).unlink()
print("postprocess self-check passed")
if __name__ == "__main__":
self_check()