Initial Dansori character workspace

This commit is contained in:
eKeerar
2026-07-04 10:34:46 +09:00
commit 5a419816ff
2480 changed files with 38692 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
from PIL import Image
ROOT = Path(r"D:\Work_AI\Dansori")
APP = ROOT / "DansoriEQ" / "src" / "DansoriEQ.App"
HOST = APP / "Assets" / "Live2DHost"
PUPPET = APP / "Assets" / "Characters" / "Puppets" / "LeeSoriV2"
PLAN = ROOT / "DansoriEQ" / "docs" / "LIVE2D_CHARACTER_INTEGRATION_PLAN.md"
def update_characters() -> None:
path = HOST / "characters.json"
data = json.loads(path.read_text(encoding="utf-8-sig"))
for character in data["characters"]:
if character.get("id") == "leesori":
character["puppet"] = {
"rig": "../Characters/Puppets/LeeSoriV2/rig.json",
"imageBase": "../Characters/Puppets/LeeSoriV2/Images/",
}
break
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def update_css() -> None:
path = HOST / "style.css"
css = path.read_text(encoding="utf-8")
old = """#puppet {
display: none;
right: 10%;
bottom: 1%;
width: 80%;
height: auto;
aspect-ratio: 1086 / 1448;
transform-origin: 50% 100%;
filter: drop-shadow(0 20px 24px rgba(0, 0, 0, .46));
animation: puppetRootIdle 3.8s ease-in-out infinite;
}"""
new = """#puppet {
display: none;
right: 17.5%;
bottom: -20%;
width: 65%;
height: auto;
aspect-ratio: 365 / 1058;
transform-origin: 50% 100%;
filter: drop-shadow(0 20px 24px rgba(0, 0, 0, .46));
animation: puppetRootIdle 3.8s ease-in-out infinite;
}"""
if old not in css:
raise RuntimeError("Expected #puppet CSS block was not found.")
path.write_text(css.replace(old, new), encoding="utf-8")
def make_css_qa() -> None:
src = Image.open(PUPPET / "leesori_v2_source.png").convert("RGBA")
width, height = 390, 600
stage = Image.new("RGBA", (width, height), (16, 16, 18, 255))
target_w = int(width * 0.65)
target_h = int(target_w * src.height / src.width)
resized = src.resize((target_w, target_h), Image.Resampling.LANCZOS)
right = int(width * 0.175)
bottom = int(height * -0.20)
x = width - target_w - right
y = height - target_h - bottom
stage.alpha_composite(resized, (x, y))
stage.convert("RGB").save(PUPPET / "qa_view_390x600_css_selected.png")
def update_plan() -> None:
entry = f"""
### {date.today().isoformat()} - New Sheet LeeSoriV2 Conversion
User correction:
- The previous LeeSoriExtended puppet solved clipping but used the old LeeSori design.
- The authoritative new design sheet is:
- `Characters_Build_Docs/LeeSori_Profile/03_Assets/Reference/sori_sheet.png`
Changed:
- Created `LeeSoriV2` from the new sheet's front full-body pose without AI identity drift.
- Extracted alpha-clean source and overlap puppet parts:
- base, legs, chest, left/right arms, left/right hands, head.
- Replaced the app preview image with the new sheet-based LeeSori.
- Switched `characters.json` to:
- `../Characters/Puppets/LeeSoriV2/rig.json`
- `../Characters/Puppets/LeeSoriV2/Images/`
- Updated WebView framing for the new tall sheet ratio:
- `right: 17.5%`
- `bottom: -20%`
- `width: 65%`
- Framing goal: upper-body biased view for idle/work states while keeping head and both hands visible. Full body remains available in the source/rig for later state-specific framing.
QA artifacts:
- `src/DansoriEQ.App/Assets/Characters/Puppets/LeeSoriV2/qa_source_black.png`
- `src/DansoriEQ.App/Assets/Characters/Puppets/LeeSoriV2/qa_view_390x600_upper_bias.png`
- `src/DansoriEQ.App/Assets/Characters/Puppets/LeeSoriV2/qa_view_390x600_css_selected.png`
Next:
- Build and run in WPF to verify real WebView framing.
- If this sheet-based puppet is accepted, add state-specific animation tuning and then generate Haruka/Isabel/Noeul with the same source-first process.
"""
text = PLAN.read_text(encoding="utf-8")
if "New Sheet LeeSoriV2 Conversion" not in text:
PLAN.write_text(text.rstrip() + entry + "\n", encoding="utf-8")
def main() -> None:
update_characters()
update_css()
make_css_qa()
update_plan()
if __name__ == "__main__":
main()
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from pathlib import Path
import numpy as np
from PIL import Image, ImageFilter
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"
QA = PUPPET / "qa_source_waist_patch_black.png"
AI_PATCH = Path(
r"C:\Users\eKeerar\.codex\generated_images\019f277b-18ad-7800-8b99-e77ae92de4bd"
r"\ig_0f4c5e4720cfe26e016a47e80d6f9c8191ba3b82a1c165cf39.png"
)
def alpha_bbox(im: Image.Image) -> tuple[int, int, int, int]:
return im.getchannel("A").getbbox() or (0, 0, im.width, im.height)
def subject_bbox_on_checker(im: Image.Image) -> tuple[int, int, int, int]:
arr = np.array(im.convert("RGB"))
mask = arr.max(axis=2) < 225
ys, xs = np.where(mask)
return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1)
def map_box(
src_box: tuple[int, int, int, int],
src_bbox: tuple[int, int, int, int],
ref_bbox: tuple[int, int, int, int],
) -> tuple[int, int, int, int]:
sx0, sy0, sx1, sy1 = src_bbox
rx0, ry0, rx1, ry1 = ref_bbox
sw, sh = sx1 - sx0, sy1 - sy0
rw, rh = rx1 - rx0, ry1 - ry0
x0, y0, x1, y1 = src_box
return (
int(rx0 + ((x0 - sx0) / sw) * rw),
int(ry0 + ((y0 - sy0) / sh) * rh),
int(rx0 + ((x1 - sx0) / sw) * rw),
int(ry0 + ((y1 - sy0) / sh) * rh),
)
def main() -> None:
base = Image.open(BACKUP if BACKUP.exists() else SOURCE).convert("RGBA")
ref = Image.open(AI_PATCH).convert("RGBA")
src_bbox = alpha_bbox(base)
ref_bbox = subject_bbox_on_checker(ref)
# Torso-only region: below crop-top chest, through waist and top waistband.
src_patch_box = (86, 306, 282, 505)
ref_patch_box = map_box(src_patch_box, src_bbox, ref_bbox)
patch = ref.crop(ref_patch_box).resize(
(src_patch_box[2] - src_patch_box[0], src_patch_box[3] - src_patch_box[1]),
Image.Resampling.LANCZOS,
)
mask = Image.new("L", patch.size, 0)
px = mask.load()
w, h = patch.size
for y in range(h):
for x in range(w):
nx = abs((x / max(1, w - 1)) * 2 - 1)
ny = abs((y / max(1, h - 1)) * 2 - 1)
edge = max(nx, ny)
value = int(max(0.0, min(1.0, (1.0 - edge) / 0.36)) * 210)
# Keep the upper chest and far lower pants transition conservative.
if y < 28:
value = int(value * (y / 28))
if y > h - 28:
value = int(value * ((h - y) / 28))
px[x, y] = value
mask = mask.filter(ImageFilter.GaussianBlur(5))
out = base.copy()
out.alpha_composite(Image.composite(patch, out.crop(src_patch_box), mask), (src_patch_box[0], src_patch_box[1]))
out.save(SOURCE)
black = Image.new("RGBA", out.size, (16, 16, 18, 255))
black.alpha_composite(out)
black.convert("RGB").save(QA)
if __name__ == "__main__":
main()
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import json
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(r"D:\Work_AI\Dansori\Characters_Build_Docs")
DANCE = ROOT / "LeeSori_Live2D" / "03_Assets" / "Dance" / "SoloDance3"
SRC = DANCE / "pose01_dance_ready.png"
RIG_DIR = DANCE / "Rig"
IMAGES = RIG_DIR / "Images"
def alpha_bbox(im: Image.Image) -> tuple[int, int, int, int]:
return im.getchannel("A").getbbox() or (0, 0, im.width, im.height)
def prepare_source() -> Image.Image:
src = Image.open(SRC).convert("RGBA")
src = src.crop(alpha_bbox(src))
target_h = 1320
target_w = int(round(src.width * target_h / src.height))
src = src.resize((target_w, target_h), Image.Resampling.LANCZOS)
margin_x = 96
margin_y = 64
out = Image.new("RGBA", (target_w + margin_x * 2, target_h + margin_y * 2), (0, 0, 0, 0))
out.alpha_composite(src, (margin_x, margin_y))
return out
def mask_polygon(size: tuple[int, int], points: list[tuple[float, float]], blur: float = 0.8) -> Image.Image:
w, h = size
pts = [(int(round(x * w)), int(round(y * h))) for x, y in points]
mask = Image.new("L", size, 0)
ImageDraw.Draw(mask).polygon(pts, fill=255)
if blur:
mask = mask.filter(ImageFilter.GaussianBlur(blur))
return mask
def save_masked(src: Image.Image, name: str, points: list[tuple[float, float]], blur: float = 0.8) -> None:
mask = mask_polygon(src.size, points, blur)
part = src.copy()
alpha = Image.composite(part.getchannel("A"), Image.new("L", src.size, 0), mask)
part.putalpha(alpha)
part.save(IMAGES / name)
def save_empty(size: tuple[int, int], name: str) -> None:
Image.new("RGBA", size, (0, 0, 0, 0)).save(IMAGES / name)
def composite(parts: list[str], dest: Path) -> None:
sample = Image.open(IMAGES / parts[0]).convert("RGBA")
canvas = Image.new("RGBA", sample.size, (18, 18, 20, 255))
for part_name in parts:
canvas.alpha_composite(Image.open(IMAGES / part_name).convert("RGBA"))
canvas.convert("RGB").save(dest)
def main() -> None:
IMAGES.mkdir(parents=True, exist_ok=True)
src = prepare_source()
w, h = src.size
src.save(RIG_DIR / "leesori_solo3_source.png")
save_empty(src.size, "solo3_base.png")
# Broad overlapping masks are intentional. They avoid visible cracks during CSS rotation.
save_masked(src, "solo3_head.png", [(0.31, 0.02), (0.69, 0.02), (0.72, 0.22), (0.61, 0.29), (0.39, 0.29), (0.28, 0.22)], 0.5)
save_masked(src, "solo3_chest.png", [(0.24, 0.20), (0.76, 0.20), (0.72, 0.48), (0.28, 0.48)], 0.5)
save_masked(src, "solo3_pelvis.png", [(0.28, 0.43), (0.72, 0.43), (0.74, 0.63), (0.26, 0.63)], 0.7)
save_masked(src, "solo3_upperarm_l.png", [(0.11, 0.25), (0.33, 0.23), (0.34, 0.45), (0.16, 0.51), (0.08, 0.40)], 1.0)
save_masked(src, "solo3_forearm_l.png", [(0.08, 0.43), (0.28, 0.39), (0.24, 0.61), (0.02, 0.64)], 1.0)
save_masked(src, "solo3_hand_l.png", [(0.00, 0.58), (0.21, 0.55), (0.23, 0.69), (0.02, 0.72)], 0.7)
save_masked(src, "solo3_upperarm_r.png", [(0.67, 0.23), (0.89, 0.25), (0.92, 0.41), (0.84, 0.51), (0.66, 0.45)], 1.0)
save_masked(src, "solo3_forearm_r.png", [(0.72, 0.39), (0.92, 0.43), (0.98, 0.64), (0.76, 0.61)], 1.0)
save_masked(src, "solo3_hand_r.png", [(0.78, 0.55), (1.00, 0.58), (0.98, 0.72), (0.77, 0.69)], 0.7)
save_masked(src, "solo3_thigh_l.png", [(0.28, 0.55), (0.50, 0.54), (0.49, 0.76), (0.29, 0.78)], 0.8)
save_masked(src, "solo3_shin_l.png", [(0.29, 0.73), (0.49, 0.72), (0.48, 0.93), (0.31, 0.94)], 0.8)
save_masked(src, "solo3_foot_l.png", [(0.27, 0.90), (0.50, 0.90), (0.49, 1.00), (0.25, 1.00)], 0.6)
save_masked(src, "solo3_thigh_r.png", [(0.48, 0.54), (0.74, 0.55), (0.81, 0.78), (0.56, 0.77)], 0.8)
save_masked(src, "solo3_shin_r.png", [(0.57, 0.72), (0.82, 0.73), (0.86, 0.94), (0.65, 0.94)], 0.8)
save_masked(src, "solo3_foot_r.png", [(0.64, 0.90), (0.88, 0.90), (0.90, 1.00), (0.62, 1.00)], 0.6)
bones = [
{"name": "base", "parent": None, "pivot": [w * 0.50, h * 0.58], "z": 0, "image": "solo3_base.png"},
{"name": "pelvis", "parent": "base", "pivot": [w * 0.50, h * 0.48], "z": 2, "image": "solo3_pelvis.png"},
{"name": "chest", "parent": "base", "pivot": [w * 0.50, h * 0.31], "z": 4, "image": "solo3_chest.png"},
{"name": "upperarm_l", "parent": "chest", "pivot": [w * 0.30, h * 0.27], "z": 5, "image": "solo3_upperarm_l.png"},
{"name": "forearm_l", "parent": "upperarm_l", "pivot": [w * 0.18, h * 0.43], "z": 6, "image": "solo3_forearm_l.png"},
{"name": "hand_l", "parent": "forearm_l", "pivot": [w * 0.10, h * 0.60], "z": 7, "image": "solo3_hand_l.png"},
{"name": "upperarm_r", "parent": "chest", "pivot": [w * 0.70, h * 0.27], "z": 5, "image": "solo3_upperarm_r.png"},
{"name": "forearm_r", "parent": "upperarm_r", "pivot": [w * 0.82, h * 0.43], "z": 6, "image": "solo3_forearm_r.png"},
{"name": "hand_r", "parent": "forearm_r", "pivot": [w * 0.90, h * 0.60], "z": 7, "image": "solo3_hand_r.png"},
{"name": "thigh_l", "parent": "pelvis", "pivot": [w * 0.40, h * 0.56], "z": 2, "image": "solo3_thigh_l.png"},
{"name": "shin_l", "parent": "thigh_l", "pivot": [w * 0.39, h * 0.74], "z": 2, "image": "solo3_shin_l.png"},
{"name": "foot_l", "parent": "shin_l", "pivot": [w * 0.39, h * 0.92], "z": 2, "image": "solo3_foot_l.png"},
{"name": "thigh_r", "parent": "pelvis", "pivot": [w * 0.60, h * 0.56], "z": 2, "image": "solo3_thigh_r.png"},
{"name": "shin_r", "parent": "thigh_r", "pivot": [w * 0.61, h * 0.74], "z": 2, "image": "solo3_shin_r.png"},
{"name": "foot_r", "parent": "shin_r", "pivot": [w * 0.61, h * 0.92], "z": 2, "image": "solo3_foot_r.png"},
{"name": "head", "parent": "chest", "pivot": [w * 0.50, h * 0.16], "z": 10, "image": "solo3_head.png"},
]
rig = {
"name": "LeeSoriDance",
"status": "leesori_solo3_dance_pose_parts_2026_07_04",
"canvas": {"width": w, "height": h},
"imageBase": "./Images/",
"note": "Solo Dance 3 inspired pose-part rig. Parts are broad overlapping masks for the CSS Live2DHost dance loop.",
"bones": bones,
}
(RIG_DIR / "rig.json").write_text(json.dumps(rig, ensure_ascii=False, indent=2), encoding="utf-8")
composite([bone["image"] for bone in bones], RIG_DIR / "qa_parts_composite.png")
if __name__ == "__main__":
main()
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import json
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(r"D:\Work_AI\Dansori")
SHEET = ROOT / "Characters_Build_Docs" / "LeeSori_Profile" / "03_Assets" / "Reference" / "sori_sheet.png"
APP_ASSETS = ROOT / "DansoriEQ" / "src" / "DansoriEQ.App" / "Assets"
PUPPET = APP_ASSETS / "Characters" / "Puppets" / "LeeSoriV2"
IMAGES = PUPPET / "Images"
PREVIEW = APP_ASSETS / "Characters" / "Live2DPreview" / "leesori.png"
def alpha_bbox(im: Image.Image) -> tuple[int, int, int, int]:
return im.getchannel("A").getbbox() or (0, 0, im.width, im.height)
def clean_alpha(im: Image.Image) -> Image.Image:
im = im.convert("RGBA")
r, g, b, a = im.split()
# Remove very dark residual antialias pixels from the black sheet background.
pixels = im.load()
for y in range(im.height):
for x in range(im.width):
rr, gg, bb, aa = pixels[x, y]
if aa == 0:
continue
if max(rr, gg, bb) < 18:
pixels[x, y] = (0, 0, 0, 0)
elif max(rr, gg, bb) < 44 and aa < 255:
pixels[x, y] = (rr, gg, bb, max(0, aa - 80))
return im
def crop_front_pose(sheet: Image.Image) -> Image.Image:
# The new sheet's front full-body pose is the leftmost character.
crop = sheet.crop((30, 30, 365, 1038))
crop = clean_alpha(crop)
crop = crop.crop(alpha_bbox(crop))
margin = 36
out = Image.new("RGBA", (crop.width + margin * 2, crop.height + margin * 2), (0, 0, 0, 0))
out.alpha_composite(crop, (margin, margin))
return out
def mask_from_polygon(size: tuple[int, int], points: list[tuple[float, float]], blur: float = 3.5) -> Image.Image:
w, h = size
pts = [(int(x * w), int(y * h)) for x, y in points]
mask = Image.new("L", size, 0)
ImageDraw.Draw(mask).polygon(pts, fill=255)
if blur:
mask = mask.filter(ImageFilter.GaussianBlur(blur))
return mask
def make_part(src: Image.Image, name: str, points: list[tuple[float, float]], blur: float = 3.5) -> None:
part = Image.new("RGBA", src.size, (0, 0, 0, 0))
mask = mask_from_polygon(src.size, points, blur)
part.alpha_composite(src)
alpha = Image.composite(part.getchannel("A"), Image.new("L", src.size, 0), mask)
part.putalpha(alpha)
part.save(IMAGES / name)
def composite_black(parts: list[str], dest: Path, width: int = 390, height: int = 600) -> None:
canvas = Image.new("RGBA", (width, height), (16, 16, 18, 255))
src_size = Image.open(IMAGES / parts[0]).size
scale = min(width * 0.83 / src_size[0], height * 1.08 / src_size[1])
target = (int(src_size[0] * scale), int(src_size[1] * scale))
x = (width - target[0]) // 2 + 4
y = int(height - target[1] + 78)
for part_name in parts:
part = Image.open(IMAGES / part_name).convert("RGBA").resize(target, Image.Resampling.LANCZOS)
canvas.alpha_composite(part, (x, y))
canvas.convert("RGB").save(dest)
def main() -> None:
IMAGES.mkdir(parents=True, exist_ok=True)
sheet = Image.open(SHEET).convert("RGBA")
source_path = PUPPET / "leesori_v2_source.png"
if source_path.exists():
src = Image.open(source_path).convert("RGBA")
else:
src = crop_front_pose(sheet)
src.save(source_path)
w, h = src.size
# Full source is retained underneath; overlays are intentionally separated
# enough for visible idle motion while staying close to the sheet artwork.
src.save(IMAGES / "leesori_v2_v3_base.png")
# LeeSoriV2 uses the full base for torso/waist. A moving chest overlay
# creates green/crop-hem ghosting around the exposed waist, so the active
# rig below omits chest and keeps only head/right-arm motion.
make_part(src, "leesori_v2_v3_arm_r.png", [(0.65, 0.24), (0.86, 0.24), (0.84, 0.49), (0.69, 0.49), (0.61, 0.34)], blur=2.0)
make_part(src, "leesori_v2_v3_hand_r.png", [(0.70, 0.47), (0.88, 0.47), (0.87, 0.61), (0.67, 0.60)], blur=1.5)
make_part(src, "leesori_v2_v3_head.png", [(0.19, 0.00), (0.81, 0.00), (0.82, 0.27), (0.65, 0.36), (0.35, 0.36), (0.18, 0.27)])
rig = {
"name": "LeeSoriV2",
"status": "new_sheet_overlap_puppet_v4_static_torso_clean_waist",
"canvas": {"width": w, "height": h},
"imageBase": "./Images/",
"note": "New LeeSori sheet-based overlap puppet. Torso, waist, lower body, and pocketed left hand stay in the base image to avoid doubled outlines, green waist ghosting, and awkward hand motion.",
"bones": [
{"name": "base", "parent": None, "pivot": [w * 0.50, h * 0.62], "z": 0, "image": "leesori_v2_v3_base.png"},
{"name": "upperarm_r", "parent": "base", "pivot": [w * 0.74, h * 0.28], "z": 4, "image": "leesori_v2_v3_arm_r.png"},
{"name": "hand_r", "parent": "upperarm_r", "pivot": [w * 0.78, h * 0.55], "z": 8, "image": "leesori_v2_v3_hand_r.png"},
{"name": "head", "parent": "base", "pivot": [w * 0.50, h * 0.20], "z": 10, "image": "leesori_v2_v3_head.png"},
],
}
(PUPPET / "rig.json").write_text(json.dumps(rig, ensure_ascii=False, indent=2), encoding="utf-8")
preview = Image.new("RGBA", (512, 768), (0, 0, 0, 0))
scale = min(512 * 0.76 / w, 768 * 1.02 / h)
target = (int(w * scale), int(h * scale))
resized = src.resize(target, Image.Resampling.LANCZOS)
preview.alpha_composite(resized, ((512 - target[0]) // 2, 768 - target[1] + 44))
preview.save(PREVIEW)
parts = [
"leesori_v2_v3_base.png",
"leesori_v2_v3_arm_r.png",
"leesori_v2_v3_hand_r.png",
"leesori_v2_v3_head.png",
]
composite_black(parts, PUPPET / "qa_view_390x600_upper_bias.png")
black = Image.new("RGBA", src.size, (16, 16, 18, 255))
black.alpha_composite(src)
black.convert("RGB").save(PUPPET / "qa_source_black.png")
if __name__ == "__main__":
main()
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
import json
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(r"D:\Work_AI\Dansori")
WORK = ROOT / "Characters_Build_Docs"
SRC = WORK / "tmp" / "imagegen" / "leesori_v3_source_alpha.png"
APP_ASSETS = ROOT / "DansoriEQ" / "src" / "DansoriEQ.App" / "Assets"
PUPPET = APP_ASSETS / "Characters" / "Puppets" / "LeeSoriV3"
IMAGES = PUPPET / "Images"
PREVIEW = APP_ASSETS / "Characters" / "Live2DPreview" / "leesori.png"
def alpha_bbox(im: Image.Image) -> tuple[int, int, int, int]:
return im.getchannel("A").getbbox() or (0, 0, im.width, im.height)
def prepare_source() -> Image.Image:
src = Image.open(SRC).convert("RGBA")
src = src.crop(alpha_bbox(src))
target_h = 1120
target_w = int(round(src.width * target_h / src.height))
src = src.resize((target_w, target_h), Image.Resampling.LANCZOS)
margin = 44
out = Image.new("RGBA", (target_w + margin * 2, target_h + margin * 2), (0, 0, 0, 0))
out.alpha_composite(src, (margin, margin))
return out
def mask_from_polygon(size: tuple[int, int], points: list[tuple[float, float]], blur: float = 1.0) -> Image.Image:
w, h = size
pts = [(int(x * w), int(y * h)) for x, y in points]
mask = Image.new("L", size, 0)
ImageDraw.Draw(mask).polygon(pts, fill=255)
if blur:
mask = mask.filter(ImageFilter.GaussianBlur(blur))
return mask
def make_part(src: Image.Image, name: str, points: list[tuple[float, float]], blur: float = 1.0) -> None:
mask = mask_from_polygon(src.size, points, blur)
part = src.copy()
alpha = Image.composite(part.getchannel("A"), Image.new("L", src.size, 0), mask)
part.putalpha(alpha)
part.save(IMAGES / name)
def composite_black(src: Image.Image, parts: list[str], dest: Path, width: int = 390, height: int = 600) -> None:
canvas = Image.new("RGBA", (width, height), (16, 16, 18, 255))
scale = min(width * 0.89 / src.width, height * 1.12 / src.height)
target = (int(src.width * scale), int(src.height * scale))
x = (width - target[0]) // 2
y = int(height - target[1] + 58)
for part_name in parts:
part = Image.open(IMAGES / part_name).convert("RGBA").resize(target, Image.Resampling.LANCZOS)
canvas.alpha_composite(part, (x, y))
canvas.convert("RGB").save(dest)
def save_preview(src: Image.Image) -> None:
preview = Image.new("RGBA", (512, 768), (0, 0, 0, 0))
scale = min(512 * 0.82 / src.width, 768 * 1.04 / src.height)
target = (int(src.width * scale), int(src.height * scale))
resized = src.resize(target, Image.Resampling.LANCZOS)
preview.alpha_composite(resized, ((512 - target[0]) // 2, 768 - target[1] + 36))
preview.save(PREVIEW)
def main() -> None:
IMAGES.mkdir(parents=True, exist_ok=True)
src = prepare_source()
w, h = src.size
(PUPPET / "leesori_v3_source.png").parent.mkdir(parents=True, exist_ok=True)
src.save(PUPPET / "leesori_v3_source.png")
src.save(IMAGES / "leesori_v3_base.png")
# Tight masks: moving parts avoid the crop-top hem, waist skin, and pelvis.
make_part(src, "leesori_v3_head.png", [(0.27, 0.00), (0.73, 0.00), (0.74, 0.19), (0.64, 0.225), (0.36, 0.225), (0.26, 0.19)], blur=0.45)
make_part(src, "leesori_v3_chest.png", [(0.27, 0.18), (0.73, 0.18), (0.70, 0.315), (0.30, 0.315)], blur=0.25)
make_part(src, "leesori_v3_pelvis.png", [(0.30, 0.38), (0.70, 0.38), (0.73, 0.56), (0.27, 0.56)], blur=0.8)
make_part(src, "leesori_v3_upperarm_l.png", [(0.15, 0.23), (0.35, 0.22), (0.31, 0.44), (0.14, 0.48), (0.09, 0.36)], blur=0.9)
make_part(src, "leesori_v3_forearm_l.png", [(0.12, 0.42), (0.31, 0.40), (0.24, 0.62), (0.05, 0.64)], blur=0.9)
make_part(src, "leesori_v3_hand_l.png", [(0.03, 0.58), (0.25, 0.57), (0.22, 0.72), (0.00, 0.72)], blur=0.7)
make_part(src, "leesori_v3_upperarm_r.png", [(0.65, 0.22), (0.85, 0.23), (0.91, 0.36), (0.86, 0.48), (0.69, 0.44)], blur=0.9)
make_part(src, "leesori_v3_forearm_r.png", [(0.69, 0.40), (0.88, 0.42), (0.95, 0.64), (0.76, 0.62)], blur=0.9)
make_part(src, "leesori_v3_hand_r.png", [(0.75, 0.57), (0.97, 0.58), (1.00, 0.72), (0.78, 0.72)], blur=0.7)
make_part(src, "leesori_v3_thigh_l.png", [(0.27, 0.51), (0.50, 0.51), (0.48, 0.76), (0.27, 0.78)], blur=0.8)
make_part(src, "leesori_v3_shin_l.png", [(0.27, 0.73), (0.48, 0.73), (0.47, 0.93), (0.30, 0.93)], blur=0.8)
make_part(src, "leesori_v3_foot_l.png", [(0.27, 0.90), (0.50, 0.90), (0.49, 1.00), (0.24, 1.00)], blur=0.6)
make_part(src, "leesori_v3_thigh_r.png", [(0.50, 0.51), (0.73, 0.51), (0.73, 0.78), (0.52, 0.76)], blur=0.8)
make_part(src, "leesori_v3_shin_r.png", [(0.52, 0.73), (0.73, 0.73), (0.70, 0.93), (0.53, 0.93)], blur=0.8)
make_part(src, "leesori_v3_foot_r.png", [(0.50, 0.90), (0.73, 0.90), (0.76, 1.00), (0.51, 1.00)], blur=0.6)
rig = {
"name": "LeeSoriV3",
"status": "leesori_v3_new_art_full_rig_v1",
"canvas": {"width": w, "height": h},
"imageBase": "./Images/",
"note": "New generated source art with ordinary waist line and restored full limb rig. Moving masks are kept away from crop-top hem and waist skin.",
"bones": [
{"name": "base", "parent": None, "pivot": [w * 0.50, h * 0.58], "z": 0, "image": "leesori_v3_base.png"},
{"name": "pelvis", "parent": "base", "pivot": [w * 0.50, h * 0.48], "z": 2, "image": "leesori_v3_pelvis.png"},
{"name": "chest", "parent": "base", "pivot": [w * 0.50, h * 0.30], "z": 3, "image": "leesori_v3_chest.png"},
{"name": "upperarm_l", "parent": "chest", "pivot": [w * 0.30, h * 0.25], "z": 4, "image": "leesori_v3_upperarm_l.png"},
{"name": "forearm_l", "parent": "upperarm_l", "pivot": [w * 0.20, h * 0.45], "z": 5, "image": "leesori_v3_forearm_l.png"},
{"name": "hand_l", "parent": "forearm_l", "pivot": [w * 0.13, h * 0.61], "z": 6, "image": "leesori_v3_hand_l.png"},
{"name": "upperarm_r", "parent": "chest", "pivot": [w * 0.70, h * 0.25], "z": 4, "image": "leesori_v3_upperarm_r.png"},
{"name": "forearm_r", "parent": "upperarm_r", "pivot": [w * 0.80, h * 0.45], "z": 5, "image": "leesori_v3_forearm_r.png"},
{"name": "hand_r", "parent": "forearm_r", "pivot": [w * 0.87, h * 0.61], "z": 6, "image": "leesori_v3_hand_r.png"},
{"name": "thigh_l", "parent": "pelvis", "pivot": [w * 0.40, h * 0.54], "z": 2, "image": "leesori_v3_thigh_l.png"},
{"name": "shin_l", "parent": "thigh_l", "pivot": [w * 0.39, h * 0.75], "z": 2, "image": "leesori_v3_shin_l.png"},
{"name": "foot_l", "parent": "shin_l", "pivot": [w * 0.39, h * 0.92], "z": 2, "image": "leesori_v3_foot_l.png"},
{"name": "thigh_r", "parent": "pelvis", "pivot": [w * 0.60, h * 0.54], "z": 2, "image": "leesori_v3_thigh_r.png"},
{"name": "shin_r", "parent": "thigh_r", "pivot": [w * 0.61, h * 0.75], "z": 2, "image": "leesori_v3_shin_r.png"},
{"name": "foot_r", "parent": "shin_r", "pivot": [w * 0.61, h * 0.92], "z": 2, "image": "leesori_v3_foot_r.png"},
{"name": "head", "parent": "chest", "pivot": [w * 0.50, h * 0.16], "z": 10, "image": "leesori_v3_head.png"},
],
}
(PUPPET / "rig.json").write_text(json.dumps(rig, ensure_ascii=False, indent=2), encoding="utf-8")
save_preview(src)
composite_black(src, [bone["image"] for bone in rig["bones"]], PUPPET / "qa_view_390x600.png")
black = Image.new("RGBA", src.size, (16, 16, 18, 255))
black.alpha_composite(src)
black.convert("RGB").save(PUPPET / "qa_source_black.png")
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import argparse
from pathlib import Path
from PIL import Image, ImageFilter
def clean_alpha(src: Path, dst: Path) -> tuple[int, int]:
img = Image.open(src).convert("RGBA")
w, h = img.size
px = img.load()
alpha = img.getchannel("A")
solid = alpha.point(lambda a: 255 if a > 8 else 0)
inner = solid.filter(ImageFilter.MinFilter(21))
edge = Image.new("L", img.size, 0)
edge.paste(solid)
edge_px = edge.load()
inner_px = inner.load()
removed = 0
softened = 0
for y in range(h):
upper_zone = y < int(h * 0.55)
for x in range(w):
r, g, b, a = px[x, y]
if a == 0:
continue
near_edge = edge_px[x, y] and not inner_px[x, y]
if not near_edge and a > 225:
continue
bright = (r + g + b) / 3
chroma = max(r, g, b) - min(r, g, b)
semi = a < 245
neutral_matte = semi and bright > 118 and chroma < 82
pink_or_cyan_matte = (
semi
and upper_zone
and bright > 130
and (
(r > 135 and b > 130 and g > 80)
or (g > 135 and b > 130 and r < 175)
)
and chroma < 135
)
if near_edge and (neutral_matte or pink_or_cyan_matte):
px[x, y] = (r, g, b, 0)
removed += 1
elif semi and neutral_matte:
px[x, y] = (r, g, b, int(a * 0.2))
softened += 1
dst.parent.mkdir(parents=True, exist_ok=True)
img.save(dst)
return removed, softened
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("paths", nargs="+", type=Path)
parser.add_argument("--in-place", action="store_true")
args = parser.parse_args()
for path in args.paths:
dst = path if args.in_place else path.with_name(path.stem + "_clean.png")
removed, softened = clean_alpha(path, dst)
print(f"{path} -> {dst} removed={removed} softened={softened}")
if __name__ == "__main__":
main()
@@ -0,0 +1,74 @@
from __future__ import annotations
import json
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"
PLAN = ROOT / "DansoriEQ" / "docs" / "LIVE2D_CHARACTER_INTEGRATION_PLAN.md"
def make_qa() -> None:
src = Image.open(PUPPET / "leesori_v2_source.png").convert("RGBA")
width, height = 390, 600
stage = Image.new("RGBA", (width, height), (16, 16, 18, 255))
target_w = int(width * 0.87)
target_h = int(target_w * src.height / src.width)
resized = src.resize((target_w, target_h), Image.Resampling.LANCZOS)
x = width - target_w - int(width * 0.065)
y = height - target_h - int(height * -0.65)
stage.alpha_composite(resized, (x, y))
stage.convert("RGB").save(PUPPET / "qa_view_390x600_v3_clean_outline.png")
def validate_rig() -> None:
rig = json.loads((PUPPET / "rig.json").read_text(encoding="utf-8-sig"))
forbidden = {"legs", "upperarm_l", "hand_l"}
names = {bone["name"] for bone in rig["bones"]}
found = forbidden & names
if found:
raise RuntimeError(f"Unexpected animated bones remain: {sorted(found)}")
def update_plan() -> None:
entry = """
### 2026-07-04 - LeeSoriV2 Outline/Left-Hand Cleanup
User feedback:
- The knee-up framing is correct.
- The green pants side line still alternates between one and two outlines.
- The left hand looks slightly awkward.
Changed:
- Regenerated `LeeSoriV2` puppet parts with tighter moving masks.
- Removed animated `upperarm_l` and `hand_l` bones; the pocketed left hand now stays in the base image.
- Kept the lower body only in the base image.
- Kept right-side hand/arm and head/chest motion for visible idle movement.
- Retained the accepted framing values:
- `right: 6.5%`
- `bottom: -65%`
- `width: 87%`
QA artifact:
- `src/DansoriEQ.App/Assets/Characters/Puppets/LeeSoriV2/qa_view_390x600_v3_clean_outline.png`
"""
text = PLAN.read_text(encoding="utf-8")
if "LeeSoriV2 Outline/Left-Hand Cleanup" not in text:
PLAN.write_text(text.rstrip() + entry + "\n", encoding="utf-8")
def main() -> None:
validate_rig()
make_qa()
update_plan()
if __name__ == "__main__":
main()
+102
View File
@@ -0,0 +1,102 @@
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()
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import json
from pathlib import Path
from PIL import Image
ROOT = Path(r"D:\Work_AI\Dansori")
APP = ROOT / "DansoriEQ" / "src" / "DansoriEQ.App"
HOST = APP / "Assets" / "Live2DHost"
PUPPET = APP / "Assets" / "Characters" / "Puppets" / "LeeSoriV2"
PLAN = ROOT / "DansoriEQ" / "docs" / "LIVE2D_CHARACTER_INTEGRATION_PLAN.md"
def remove_legs_bone() -> None:
rig_path = PUPPET / "rig.json"
rig = json.loads(rig_path.read_text(encoding="utf-8-sig"))
rig["bones"] = [bone for bone in rig["bones"] if bone.get("name") != "legs"]
rig["status"] = "new_sheet_overlap_puppet_v4_static_torso_clean_waist"
rig["note"] = (
"New LeeSori sheet-based overlap puppet. Torso, waist, lower body, and "
"pocketed left hand stay in the base image to avoid doubled outlines, "
"green waist ghosting, and awkward hand motion."
)
rig_path.write_text(json.dumps(rig, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def update_css() -> None:
path = HOST / "style.css"
css = path.read_text(encoding="utf-8")
css = css.replace(" right: 17.5%;\n bottom: -20%;\n width: 65%;", " right: 6.5%;\n bottom: -65%;\n width: 87%;")
path.write_text(css, encoding="utf-8")
def make_qa() -> None:
src = Image.open(PUPPET / "leesori_v2_source.png").convert("RGBA")
width, height = 390, 600
stage = Image.new("RGBA", (width, height), (16, 16, 18, 255))
target_w = int(width * 0.87)
target_h = int(target_w * src.height / src.width)
resized = src.resize((target_w, target_h), Image.Resampling.LANCZOS)
right = int(width * 0.065)
bottom = int(height * -0.65)
x = width - target_w - right
y = height - target_h - bottom
stage.alpha_composite(resized, (x, y))
stage.convert("RGB").save(PUPPET / "qa_view_390x600_knee_upper.png")
def update_plan() -> None:
entry = """
### 2026-07-04 - LeeSoriV2 Knee-Up Framing and Lower-Body Outline Fix
User request:
- Frame LeeSori from around the midpoint between above-knee and left-hand tip up to the top of the head.
- Fix the doubled green outline around both legs that appeared while the lower body moved.
Changed:
- Updated WebView puppet framing:
- `right: 6.5%`
- `bottom: -65%`
- `width: 87%`
- Removed the animated `legs` bone from `LeeSoriV2/rig.json`.
- The lower body now remains only in the base image, so the green pants side line no longer alternates between one and two outlines.
QA artifact:
- `src/DansoriEQ.App/Assets/Characters/Puppets/LeeSoriV2/qa_view_390x600_knee_upper.png`
"""
text = PLAN.read_text(encoding="utf-8")
if "LeeSoriV2 Knee-Up Framing" not in text:
PLAN.write_text(text.rstrip() + entry + "\n", encoding="utf-8")
def main() -> None:
remove_legs_bone()
update_css()
make_qa()
update_plan()
if __name__ == "__main__":
main()