Files
SkinPress/scripts/relearn/relearn_bijection.py
M1rsem ec62941845 Initial release: SkinPress — Minecraft skin to Bambu 3MF
ModVer full-body and MakerWorld chibi customizers with tests and templates.
2026-07-20 10:02:33 +03:00

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()