447 lines
14 KiB
Python
447 lines
14 KiB
Python
"""Re-learn body pixel maps with 1:1 UV assignment (no overfitting collisions)."""
|
|
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"
|
|
)
|
|
OUT = ROOT / "skin_figurine" / "data" / "pixel_uv_maps.json"
|
|
|
|
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,
|
|
},
|
|
"slim": {
|
|
"head": 479,
|
|
"torso": 294,
|
|
"leg_r": 839,
|
|
"leg_l": 844,
|
|
"arm_r": 1006,
|
|
"arm_l": 1165,
|
|
},
|
|
}
|
|
|
|
REGIONS = {
|
|
"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),
|
|
],
|
|
}
|
|
}
|
|
REGIONS["slim"] = {
|
|
**REGIONS["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):
|
|
m = re.search(rf'<object id="{oid}">(.*?)</object>', SETTINGS, re.S)
|
|
out = []
|
|
for idx, pm in enumerate(
|
|
re.finditer(r'<part id="(\d+)"[^>]*>(.*?)</part>', m.group(1), re.S)
|
|
):
|
|
pb = pm.group(2)
|
|
name = re.search(r'key="name" value="([^"]+)"', pb)
|
|
svol = re.search(r'key="source_volume_id" value="([^"]+)"', pb)
|
|
name_s = name.group(1) if name else ""
|
|
if "ixel" not in name_s.lower():
|
|
continue
|
|
svol_s = svol.group(1) if svol else None
|
|
suf = re.search(r"\.(\d+)$", name_s)
|
|
out.append(
|
|
{
|
|
"svol": svol_s,
|
|
"suffix": suf.group(1) if suf else str(idx),
|
|
"name": name_s,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def key_mode_for(parts) -> str:
|
|
svols = [p["svol"] for p in parts]
|
|
if len(set(svols)) > 1 and not all(s in (None, "0") for s in svols):
|
|
return "svol"
|
|
return "suffix"
|
|
|
|
|
|
def keys_of(parts, mode):
|
|
if mode == "svol":
|
|
return [p["svol"] for p in parts]
|
|
return [p["suffix"] for p in parts]
|
|
|
|
|
|
def classify_plate(eoids):
|
|
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 arms:
|
|
out["arm_r"] = arms[0]
|
|
if len(arms) > 1:
|
|
out["arm_l"] = arms[1]
|
|
if legs:
|
|
out["leg_r"] = legs[0]
|
|
if len(legs) > 1:
|
|
out["leg_l"] = legs[1]
|
|
return out
|
|
|
|
|
|
def texels(regions):
|
|
out = []
|
|
for face, x0, y0, w, h in regions:
|
|
for j in range(h):
|
|
for i in range(w):
|
|
out.append({"face": face, "i": i, "j": j, "u": x0 + i, "v": y0 + j})
|
|
return out
|
|
|
|
|
|
def learn_bijection(part_key, tmpl_oid, example_oids, regions, skins):
|
|
"""Greedy 1:1 assignment maximizing agreement across examples."""
|
|
parts = get_pixel_parts(tmpl_oid)
|
|
mode = key_mode_for(parts)
|
|
keys = keys_of(parts, mode)
|
|
# unique keys preserving order
|
|
seen = set()
|
|
uniq_keys = []
|
|
for k in keys:
|
|
if k not in seen:
|
|
seen.add(k)
|
|
uniq_keys.append(k)
|
|
|
|
example_kept = {}
|
|
for ename, oid in example_oids.items():
|
|
ep = get_pixel_parts(oid)
|
|
emode = key_mode_for(ep) if ep else mode
|
|
# For left/right mismatch, use whatever keys the example has
|
|
kept = set(keys_of(ep, emode if emode == mode else mode))
|
|
# If modes differ, try suffix always for body
|
|
if mode == "suffix":
|
|
kept = set(p["suffix"] for p in ep)
|
|
else:
|
|
kept = set(p["svol"] for p in ep if p["svol"] and p["svol"].isdigit())
|
|
example_kept[ename] = kept
|
|
print(f" {ename}.{part_key} kept={len(kept & set(uniq_keys))}/{len(kept)}")
|
|
|
|
cands = texels(regions)
|
|
# score matrix: for each key, score each candidate
|
|
# pattern for key across examples
|
|
patterns = {
|
|
k: {ename: (k in kept) for ename, kept in example_kept.items()}
|
|
for k in uniq_keys
|
|
}
|
|
|
|
def score_kv(k, cand):
|
|
sc = 0
|
|
for ename, should in patterns[k].items():
|
|
opaque = skins[ename].getpixel((cand["u"], cand["v"]))[3] > 10
|
|
if opaque == should:
|
|
sc += 1
|
|
return sc
|
|
|
|
# Greedy: assign highest-scoring free pairs first
|
|
pairs = []
|
|
for k in uniq_keys:
|
|
for ci, cand in enumerate(cands):
|
|
pairs.append((score_kv(k, cand), k, ci))
|
|
pairs.sort(reverse=True)
|
|
|
|
assigned_k = set()
|
|
assigned_c = set()
|
|
mapping = {}
|
|
total = len(example_oids)
|
|
for sc, k, ci in pairs:
|
|
if k in assigned_k or ci in assigned_c:
|
|
continue
|
|
if sc < total:
|
|
# allow slightly imperfect only if nothing better — skip weak
|
|
continue
|
|
cand = cands[ci]
|
|
mapping[str(k)] = {
|
|
"face": cand["face"],
|
|
"i": cand["i"],
|
|
"j": cand["j"],
|
|
"u": cand["u"],
|
|
"v": cand["v"],
|
|
"score": sc,
|
|
"total": total,
|
|
"key_mode": mode,
|
|
}
|
|
assigned_k.add(k)
|
|
assigned_c.add(ci)
|
|
|
|
# Second pass: assign remaining with best available (even imperfect)
|
|
for k in uniq_keys:
|
|
if k in assigned_k:
|
|
continue
|
|
best = None
|
|
best_sc = -1
|
|
best_ci = None
|
|
for ci, cand in enumerate(cands):
|
|
if ci in assigned_c:
|
|
continue
|
|
sc = score_kv(k, cand)
|
|
if sc > best_sc:
|
|
best_sc = sc
|
|
best = cand
|
|
best_ci = ci
|
|
if best is not None:
|
|
mapping[str(k)] = {
|
|
"face": best["face"],
|
|
"i": best["i"],
|
|
"j": best["j"],
|
|
"u": best["u"],
|
|
"v": best["v"],
|
|
"score": best_sc,
|
|
"total": total,
|
|
"key_mode": mode,
|
|
}
|
|
assigned_k.add(k)
|
|
assigned_c.add(best_ci)
|
|
|
|
perfect = sum(1 for v in mapping.values() if v["score"] == v["total"])
|
|
print(
|
|
f" {part_key}: mapped {len(mapping)}/{len(uniq_keys)} "
|
|
f"(perfect={perfect}) mode={mode}"
|
|
)
|
|
return mapping, mode
|
|
|
|
|
|
def remap_left_from_right(data, style):
|
|
"""Rebuild left maps from right maps by face/i/j -> left UV atlas."""
|
|
region_defs = {
|
|
k: {face: (x0, y0, w, h) for face, x0, y0, w, h in REGIONS[style][k]}
|
|
for k in ("arm_r", "arm_l", "leg_r", "leg_l")
|
|
}
|
|
for rk, lk in (("arm_r", "arm_l"), ("leg_r", "leg_l")):
|
|
right = data[style][rk]
|
|
left_keys = sorted(
|
|
data[style][lk].keys(), key=lambda x: int(x) if x.isdigit() else x
|
|
)
|
|
right_items = sorted(
|
|
right.items(), key=lambda kv: int(kv[0]) if kv[0].isdigit() else kv[0]
|
|
)
|
|
new_left = {}
|
|
for idx, lk_key in enumerate(left_keys):
|
|
if idx >= len(right_items):
|
|
break
|
|
_rk, info = right_items[idx]
|
|
face = info["face"]
|
|
i, j = info["i"], info["j"]
|
|
x0, y0, w, h = region_defs[lk][face]
|
|
i = min(max(i, 0), w - 1)
|
|
j = min(max(j, 0), h - 1)
|
|
new_left[lk_key] = {
|
|
**info,
|
|
"i": i,
|
|
"j": j,
|
|
"u": x0 + i,
|
|
"v": y0 + j,
|
|
"remapped_from_right": True,
|
|
}
|
|
data[style][lk] = new_left
|
|
print(f" remap {style}.{lk}: {len(new_left)}")
|
|
|
|
|
|
def main():
|
|
skins = {
|
|
e: Image.open(SKINS / m["skin"]).convert("RGBA") for e, m in EXAMPLES.items()
|
|
}
|
|
plates = {e: classify_plate(m["oids"]) for e, m in EXAMPLES.items()}
|
|
|
|
# Load existing head maps if present (keep good head); else learn
|
|
existing = {}
|
|
if OUT.exists():
|
|
existing = json.loads(OUT.read_text(encoding="utf-8"))
|
|
|
|
result = {
|
|
"classic": {},
|
|
"slim": {},
|
|
"key_modes": {"classic": {}, "slim": {}},
|
|
}
|
|
|
|
for style in ("classic", "slim"):
|
|
print(f"\n=== {style.upper()} ===")
|
|
# examples for this style + always include all for head
|
|
style_examples = {
|
|
e: plates[e]
|
|
for e, m in EXAMPLES.items()
|
|
if m["arms"] == style or True # use all plates' matching parts when present
|
|
}
|
|
for part_key, oid in TEMPLATE[style].items():
|
|
ex = {}
|
|
for ename, parts in plates.items():
|
|
if part_key not in parts:
|
|
continue
|
|
# For body parts, only use examples whose arm style matches,
|
|
# except head always
|
|
if part_key != "head" and EXAMPLES[ename]["arms"] != style:
|
|
# Still useful: use opacity pattern from skin with this example's
|
|
# kept keys only if the example object was built from same template style
|
|
continue
|
|
ex[ename] = parts[part_key]
|
|
if part_key == "head":
|
|
ex = {e: plates[e]["head"] for e in plates if "head" in plates[e]}
|
|
|
|
# Prefer keeping previously perfect head maps
|
|
if (
|
|
part_key == "head"
|
|
and existing.get(style, {}).get("head")
|
|
and len(existing[style]["head"]) >= 300
|
|
):
|
|
result[style][part_key] = existing[style]["head"]
|
|
result["key_modes"][style][part_key] = existing.get("key_modes", {}).get(
|
|
style, {}
|
|
).get(part_key, "svol")
|
|
print(f" head: kept existing {len(result[style]['head'])} entries")
|
|
continue
|
|
|
|
mapping, mode = learn_bijection(
|
|
part_key, oid, ex, REGIONS[style][part_key], skins
|
|
)
|
|
result[style][part_key] = mapping
|
|
result["key_modes"][style][part_key] = mode
|
|
|
|
remap_left_from_right(result, style)
|
|
|
|
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 in ("classic", "slim"):
|
|
print(f"\nValidation {style}:")
|
|
for ename, meta in EXAMPLES.items():
|
|
if meta["arms"] != style and style == "slim":
|
|
continue
|
|
if meta["arms"] != style and style == "classic":
|
|
# still validate head
|
|
pass
|
|
skin = skins[ename]
|
|
parts = plates[ename]
|
|
for part_key, eoid in parts.items():
|
|
if part_key not in result[style]:
|
|
continue
|
|
if part_key != "head" and meta["arms"] != style:
|
|
continue
|
|
amap = result[style][part_key]
|
|
mode = result["key_modes"][style][part_key]
|
|
ep = get_pixel_parts(eoid)
|
|
kept = set(
|
|
p["svol"] if mode == "svol" else p["suffix"] for p in ep
|
|
)
|
|
pred = {
|
|
k
|
|
for k, info in amap.items()
|
|
if skin.getpixel((info["u"], info["v"]))[3] > 10
|
|
}
|
|
inter = len((kept & set(amap)) & pred)
|
|
print(
|
|
f" {ename}.{part_key}: kept={len(kept & set(amap))} "
|
|
f"pred={len(pred)} inter={inter}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|