Files
Dansori_Characters/tools/relax_leesori_v2_waist.py
2026-07-04 10:34:46 +09:00

103 lines
3.3 KiB
Python

from __future__ import annotations
from pathlib import Path
from PIL import Image
ROOT = Path(r"D:\Work_AI\Dansori")
PUPPET = ROOT / "DansoriEQ" / "src" / "DansoriEQ.App" / "Assets" / "Characters" / "Puppets" / "LeeSoriV2"
SOURCE = PUPPET / "leesori_v2_source.png"
BACKUP = PUPPET / "leesori_v2_source_pre_waist_fix.png"
AI_PATCH_BASE = PUPPET / "leesori_v2_source_ai_waist_patch_base.png"
QA = PUPPET / "qa_source_waist_relaxed_black.png"
WAIST_TARGET_TOP_WIDTH = 116
def smoothstep(edge0: float, edge1: float, value: float) -> float:
t = max(0.0, min(1.0, (value - edge0) / (edge1 - edge0)))
return t * t * (3.0 - 2.0 * t)
def is_skin(pixel: tuple[int, int, int, int]) -> bool:
r, g, b, a = pixel
return a > 180 and r > 145 and g > 85 and b > 55 and r > g > b and (r - b) > 45
def relax_waist(im: Image.Image) -> Image.Image:
src = im.convert("RGBA")
dst = src.copy()
w, h = src.size
layer = src.copy()
mask = Image.new("L", src.size, 0)
# Only clean the side notches directly below the crop-top hem. This leaves
# the belly, waistband, and pelvis pixels exactly as they are in the base.
# The layer starts as a full source copy so feathering never blends against
# transparent black.
for y in range(350, 378):
xs = [x for x in range(86, 280) if is_skin(src.getpixel((x, y)))]
if not xs:
continue
left, right = min(xs), max(xs)
width = right - left + 1
target_width = max(width, int(WAIST_TARGET_TOP_WIDTH + (y - 350) * 0.16))
extra = max(0, int(round((target_width - width) / 2)))
if extra <= 0:
continue
vertical = smoothstep(350, 358, y) * (1.0 - smoothstep(370, 378, y))
if vertical <= 0:
continue
sample_y = min(h - 1, y + 18)
for dx in range(1, extra + 1):
edge = 1.0 - dx / (extra + 1)
amount = int(205 * vertical * edge)
if amount <= 0:
continue
ldst = left - dx
if 0 <= ldst < w:
sample = src.getpixel((ldst, sample_y))
if not is_skin(sample):
sample = src.getpixel((min(left + 2, right), y))
layer.putpixel((ldst, y), sample)
mask.putpixel((ldst, y), max(mask.getpixel((ldst, y)), amount))
rdst = right + dx
if 0 <= rdst < w:
sample = src.getpixel((rdst, sample_y))
if not is_skin(sample):
sample = src.getpixel((max(right - 2, left), y))
layer.putpixel((rdst, y), sample)
mask.putpixel((rdst, y), max(mask.getpixel((rdst, y)), amount))
return Image.composite(layer, dst, mask)
def blend(base: tuple[int, int, int, int], overlay: tuple[int, int, int, int], amount: int) -> tuple[int, int, int, int]:
t = amount / 255.0
return tuple(int(round(base[i] * (1.0 - t) + overlay[i] * t)) for i in range(4))
def main() -> None:
if not BACKUP.exists():
BACKUP.write_bytes(SOURCE.read_bytes())
if not AI_PATCH_BASE.exists():
AI_PATCH_BASE.write_bytes(SOURCE.read_bytes())
fixed = relax_waist(Image.open(AI_PATCH_BASE))
fixed.save(SOURCE)
black = Image.new("RGBA", fixed.size, (16, 16, 18, 255))
black.alpha_composite(fixed)
black.convert("RGB").save(QA)
if __name__ == "__main__":
main()