103 lines
3.3 KiB
Python
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()
|