330 lines
10 KiB
Python
330 lines
10 KiB
Python
"""Learn body-part pixel identity (by part name suffix) -> UV from examples."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SKINS = ROOT / "tests" / "fixtures" / "skins"
|
|
EXTRACT = ROOT / "3mf_extract"
|
|
SETTINGS = (EXTRACT / "Metadata" / "model_settings.config").read_text(
|
|
encoding="utf-8", errors="replace"
|
|
)
|
|
|
|
EXAMPLES = {
|
|
"jay": {"skin": "jay.png", "oids": list(range(1312, 1318)), "arms": "classic"},
|
|
"foresta": {"skin": "foresta.png", "oids": list(range(1306, 1312)), "arms": "slim"},
|
|
"jem": {"skin": "jem.png", "oids": list(range(1288, 1294)), "arms": "slim"},
|
|
"smile": {"skin": "smile.png", "oids": list(range(1294, 1300)), "arms": "classic"},
|
|
}
|
|
|
|
TEMPLATE_CLASSIC = {
|
|
"head": 305,
|
|
"torso": 293,
|
|
"leg_r": 478,
|
|
"leg_l": 650,
|
|
"arm_r": 838,
|
|
"arm_l": 840,
|
|
}
|
|
TEMPLATE_SLIM = {
|
|
"head": 479,
|
|
"torso": 294,
|
|
"leg_r": 839,
|
|
"leg_l": 844,
|
|
"arm_r": 1006,
|
|
"arm_l": 1165,
|
|
}
|
|
|
|
OVERLAY = {
|
|
"classic": {
|
|
"head": [
|
|
("top", 40, 0, 8, 8),
|
|
("bottom", 48, 0, 8, 8),
|
|
("right", 32, 8, 8, 8),
|
|
("front", 40, 8, 8, 8),
|
|
("left", 48, 8, 8, 8),
|
|
("back", 56, 8, 8, 8),
|
|
],
|
|
"torso": [
|
|
("top", 20, 32, 8, 4),
|
|
("bottom", 28, 32, 8, 4),
|
|
("right", 16, 36, 4, 12),
|
|
("front", 20, 36, 8, 12),
|
|
("left", 28, 36, 4, 12),
|
|
("back", 32, 36, 8, 12),
|
|
],
|
|
"arm_r": [
|
|
("top", 44, 32, 4, 4),
|
|
("bottom", 48, 32, 4, 4),
|
|
("right", 40, 36, 4, 12),
|
|
("front", 44, 36, 4, 12),
|
|
("left", 48, 36, 4, 12),
|
|
("back", 52, 36, 4, 12),
|
|
],
|
|
"arm_l": [
|
|
("top", 52, 48, 4, 4),
|
|
("bottom", 56, 48, 4, 4),
|
|
("right", 48, 52, 4, 12),
|
|
("front", 52, 52, 4, 12),
|
|
("left", 56, 52, 4, 12),
|
|
("back", 60, 52, 4, 12),
|
|
],
|
|
"leg_r": [
|
|
("top", 4, 32, 4, 4),
|
|
("bottom", 8, 32, 4, 4),
|
|
("right", 0, 36, 4, 12),
|
|
("front", 4, 36, 4, 12),
|
|
("left", 8, 36, 4, 12),
|
|
("back", 12, 36, 4, 12),
|
|
],
|
|
"leg_l": [
|
|
("top", 4, 48, 4, 4),
|
|
("bottom", 8, 48, 4, 4),
|
|
("right", 0, 52, 4, 12),
|
|
("front", 4, 52, 4, 12),
|
|
("left", 8, 52, 4, 12),
|
|
("back", 12, 52, 4, 12),
|
|
],
|
|
}
|
|
}
|
|
OVERLAY["slim"] = {
|
|
**OVERLAY["classic"],
|
|
"arm_r": [
|
|
("top", 44, 32, 3, 4),
|
|
("bottom", 47, 32, 3, 4),
|
|
("right", 40, 36, 4, 12),
|
|
("front", 44, 36, 3, 12),
|
|
("left", 47, 36, 4, 12),
|
|
("back", 51, 36, 3, 12),
|
|
],
|
|
"arm_l": [
|
|
("top", 52, 48, 3, 4),
|
|
("bottom", 55, 48, 3, 4),
|
|
("right", 48, 52, 4, 12),
|
|
("front", 52, 52, 3, 12),
|
|
("left", 55, 52, 4, 12),
|
|
("back", 59, 52, 3, 12),
|
|
],
|
|
}
|
|
|
|
|
|
def object_name(oid: int) -> str:
|
|
m = re.search(
|
|
rf'<object id="{oid}">\s*<metadata key="name" value="([^"]+)"', SETTINGS
|
|
)
|
|
return (m.group(1) if m else "").lower()
|
|
|
|
|
|
def get_pixel_parts(oid: int) -> list[dict]:
|
|
m = re.search(rf'<object id="{oid}">(.*?)</object>', SETTINGS, re.S)
|
|
if not m:
|
|
return []
|
|
out = []
|
|
for idx, pm in enumerate(
|
|
re.finditer(r'<part id="(\d+)"[^>]*>(.*?)</part>', m.group(1), re.S)
|
|
):
|
|
pb = pm.group(2)
|
|
name_m = re.search(r'key="name" value="([^"]+)"', pb)
|
|
svol_m = re.search(r'key="source_volume_id" value="([^"]+)"', pb)
|
|
name = name_m.group(1) if name_m else ""
|
|
if "ixel" not in name.lower() and "pixel" not in name.lower():
|
|
continue
|
|
# identity: prefer unique svol if not all zeros; else name suffix; else index
|
|
svol = svol_m.group(1) if svol_m else None
|
|
suffix_m = re.search(r"(\d+)$", name.replace(".", " "))
|
|
# better: Hat_Item_Pixel.067 -> 67
|
|
suffix_m = re.search(r"\.(\d+)$", name)
|
|
if not suffix_m:
|
|
suffix_m = re.search(r"(\d+)$", name)
|
|
suffix = suffix_m.group(1) if suffix_m else str(idx)
|
|
out.append(
|
|
{
|
|
"part_id": pm.group(1),
|
|
"name": name,
|
|
"svol": svol,
|
|
"suffix": suffix,
|
|
"index": idx,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def pixel_keys(oid: int, mode: str) -> set[str]:
|
|
"""Return set of identity keys for kept pixels."""
|
|
parts = get_pixel_parts(oid)
|
|
if not parts:
|
|
return set()
|
|
svols = [p["svol"] for p in parts]
|
|
unique_svol = len(set(svols)) > 1 and not all(s in (None, "0") for s in svols)
|
|
if mode == "svol" or (mode == "auto" and unique_svol):
|
|
return {p["svol"] for p in parts if p["svol"] and p["svol"].isdigit()}
|
|
return {p["suffix"] for p in parts}
|
|
|
|
|
|
def classify_plate_parts(eoids: list[int]) -> dict[str, int]:
|
|
head = torso = None
|
|
arms, legs = [], []
|
|
for oid in eoids:
|
|
n = object_name(oid)
|
|
if "hat" in n or ("head" in n and "joint" not in n):
|
|
head = oid
|
|
elif "tors" in n:
|
|
torso = oid
|
|
elif "arm" in n:
|
|
arms.append(oid)
|
|
elif "leg" in n:
|
|
legs.append(oid)
|
|
out = {}
|
|
if head:
|
|
out["head"] = head
|
|
if torso:
|
|
out["torso"] = torso
|
|
if len(arms) >= 1:
|
|
out["arm_r"] = arms[0]
|
|
if len(arms) >= 2:
|
|
out["arm_l"] = arms[1]
|
|
if len(legs) >= 1:
|
|
out["leg_r"] = legs[0]
|
|
if len(legs) >= 2:
|
|
out["leg_l"] = legs[1]
|
|
return out
|
|
|
|
|
|
def all_texels(regions):
|
|
for face, x0, y0, w, h in regions:
|
|
for j in range(h):
|
|
for i in range(w):
|
|
yield face, i, j, x0 + i, y0 + j
|
|
|
|
|
|
def learn_part(part_key, template_oid, example_oids: dict[str, int], regions, skins, key_mode):
|
|
tmpl_parts = get_pixel_parts(template_oid)
|
|
if not tmpl_parts:
|
|
return {}, key_mode
|
|
|
|
# Determine key mode from template
|
|
svols = [p["svol"] for p in tmpl_parts]
|
|
unique_svol = len(set(svols)) > 1 and not all(s in (None, "0") for s in svols)
|
|
mode = "svol" if (key_mode == "auto" and unique_svol) or key_mode == "svol" else "suffix"
|
|
|
|
def key_of(p):
|
|
return p["svol"] if mode == "svol" else p["suffix"]
|
|
|
|
tmpl_keys = [key_of(p) for p in tmpl_parts]
|
|
example_kept = {
|
|
ename: pixel_keys(oid, mode) for ename, oid in example_oids.items()
|
|
}
|
|
for ename, kept in example_kept.items():
|
|
print(f" {ename}.{part_key}: kept_keys={len(kept)} mode={mode}")
|
|
|
|
candidates = list(all_texels(regions))
|
|
mapping = {}
|
|
for key in sorted(set(tmpl_keys), key=lambda x: int(x) if str(x).isdigit() else x):
|
|
pattern = {ename: (key in kept) for ename, kept in example_kept.items()}
|
|
best = None
|
|
best_score = -1
|
|
for face, i, j, u, v in candidates:
|
|
score = sum(
|
|
1
|
|
for ename, should_keep in pattern.items()
|
|
if (skins[ename].getpixel((u, v))[3] > 10) == should_keep
|
|
)
|
|
if score > best_score:
|
|
best_score = score
|
|
best = {
|
|
"face": face,
|
|
"i": i,
|
|
"j": j,
|
|
"u": u,
|
|
"v": v,
|
|
"score": score,
|
|
"total": len(pattern),
|
|
"key_mode": mode,
|
|
}
|
|
if best and best_score == best["total"]:
|
|
mapping[str(key)] = best
|
|
|
|
print(f" {part_key}: {len(mapping)}/{len(set(tmpl_keys))} perfect (mode={mode})")
|
|
return mapping, mode
|
|
|
|
|
|
def main():
|
|
skins = {
|
|
e: Image.open(SKINS / m["skin"]).convert("RGBA") for e, m in EXAMPLES.items()
|
|
}
|
|
classic_plate = {
|
|
e: classify_plate_parts(m["oids"])
|
|
for e, m in EXAMPLES.items()
|
|
if m["arms"] == "classic"
|
|
}
|
|
slim_plate = {
|
|
e: classify_plate_parts(m["oids"])
|
|
for e, m in EXAMPLES.items()
|
|
if m["arms"] == "slim"
|
|
}
|
|
|
|
result = {"classic": {}, "slim": {}, "key_modes": {"classic": {}, "slim": {}}}
|
|
|
|
print("=== CLASSIC ===")
|
|
for part_key, oid in TEMPLATE_CLASSIC.items():
|
|
ex = {e: p[part_key] for e, p in classic_plate.items() if part_key in p}
|
|
if part_key == "head":
|
|
for e, p in slim_plate.items():
|
|
if "head" in p:
|
|
ex[e] = p["head"]
|
|
mapping, mode = learn_part(
|
|
part_key, oid, ex, OVERLAY["classic"][part_key], skins, "auto"
|
|
)
|
|
result["classic"][part_key] = mapping
|
|
result["key_modes"]["classic"][part_key] = mode
|
|
|
|
print("\n=== SLIM ===")
|
|
for part_key, oid in TEMPLATE_SLIM.items():
|
|
ex = {e: p[part_key] for e, p in slim_plate.items() if part_key in p}
|
|
regions = OVERLAY["slim"][part_key]
|
|
if part_key == "head":
|
|
for e, p in classic_plate.items():
|
|
if "head" in p:
|
|
ex[e] = p["head"]
|
|
regions = OVERLAY["classic"]["head"]
|
|
mapping, mode = learn_part(part_key, oid, ex, regions, skins, "auto")
|
|
result["slim"][part_key] = mapping
|
|
result["key_modes"]["slim"][part_key] = mode
|
|
|
|
out = ROOT / "skin_figurine" / "data" / "pixel_uv_maps.json"
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
print(f"\nWrote {out}")
|
|
|
|
# Validate
|
|
for style, plates, tmpl in [
|
|
("classic", classic_plate, TEMPLATE_CLASSIC),
|
|
("slim", slim_plate, TEMPLATE_SLIM),
|
|
]:
|
|
print(f"\nValidation {style}:")
|
|
for ename, parts in plates.items():
|
|
skin = skins[ename]
|
|
for part_key, eoid in parts.items():
|
|
amap = result[style][part_key]
|
|
mode = result["key_modes"][style][part_key]
|
|
kept = pixel_keys(eoid, mode)
|
|
pred = {
|
|
k
|
|
for k, info in amap.items()
|
|
if skin.getpixel((info["u"], info["v"]))[3] > 10
|
|
}
|
|
domain = set(amap)
|
|
kept_d = kept & domain
|
|
pred_d = pred & domain
|
|
print(
|
|
f" {ename}.{part_key}: kept={len(kept_d)} pred={len(pred_d)} "
|
|
f"inter={len(kept_d & pred_d)} miss={len(kept_d - pred_d)} extra={len(pred_d - kept_d)}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|