374 lines
17 KiB
Python
374 lines
17 KiB
Python
"""Erzeugt die Körperteil-Masken („Rig“) für die freigestellten Figuren.
|
||
|
||
Für jede Figur ist unten ein Skelett aus Knochenlinien (in Bildpixeln) definiert.
|
||
Jedes Pixel der Figur wird genau einem Teil zugeordnet:
|
||
1. Accessoires (Schal, Mütze, Fäustlinge, Stiefel …) über ihre Farbe,
|
||
2. alles andere über den kürzesten Weg *innerhalb der Figur* zum nächsten
|
||
Knochen. Der Weg führt nicht über Lücken, deshalb greift z. B. Lulus
|
||
Schwanz nie ins Bein daneben.
|
||
|
||
Ausgabe je Figur unter public/figuren/<figur>/:
|
||
teile/<teil>.png harte Maske des Teils (weiß = gehört dazu)
|
||
einfluss/<teil>.png weiche Maske für die Verformung: das Teil plus alles,
|
||
was daran hängt, mit weichem Übergang am Gelenk
|
||
uebersicht.png alle Teile farbig, zum Prüfen
|
||
Dazu src/rigs.ts mit Gelenkpunkten und Hierarchie.
|
||
|
||
Aufruf: python3 werkzeuge/rig.py (braucht numpy, scipy, pillow)
|
||
"""
|
||
|
||
import colorsys
|
||
import json
|
||
import os
|
||
import sys
|
||
|
||
import numpy as np
|
||
from PIL import Image, ImageDraw, ImageFilter
|
||
from scipy import ndimage
|
||
|
||
HIER = os.path.dirname(os.path.abspath(__file__))
|
||
FIGUREN = os.path.join(HIER, "..", "public", "figuren")
|
||
RIGS_TS = os.path.join(HIER, "..", "src", "rigs.ts")
|
||
|
||
# Knochen: name, eltern, von (Gelenk), bis. weich = Länge des Übergangs am Gelenk in px.
|
||
# Accessoire-Gruppen: Farbe + Bereich; ihre Teile werden über Startlinien getrennt.
|
||
|
||
FIGUREN_DEF = {
|
||
"lulu": {
|
||
"knochen": [
|
||
("rumpf", None, (130, 292), (232, 296), 0,
|
||
[(120, 245), (170, 238), (230, 250), (248, 290), (245, 345), (200, 352), (150, 347), (105, 337), (95, 300)]),
|
||
("hals", "rumpf", (124, 252), (112, 156), 70,
|
||
[(78, 150), (132, 140), (142, 178), (128, 205), (85, 205), (72, 176)]),
|
||
("kopf", "hals", (111, 150), (86, 72), 10,
|
||
[(10, 60), (40, 45), (60, 47), (80, 25), (110, 30), (140, 52), (146, 90), (136, 135), (126, 152),
|
||
(95, 160), (60, 156), (25, 140), (14, 110)]),
|
||
("ohr_links", "kopf", (44, 57), (12, 25), 5,
|
||
[(0, 10), (30, 12), (52, 40), (52, 62), (40, 70), (20, 60), (4, 40)]),
|
||
("ohr_rechts", "kopf", (146, 72), (186, 56), 5,
|
||
[(140, 56), (165, 42), (196, 45), (193, 62), (170, 78), (146, 88), (140, 78)]),
|
||
("horn_links", "kopf", (65, 44), (62, 8), 4,
|
||
[(55, 0), (78, 0), (78, 30), (73, 44), (58, 44), (56, 30)]),
|
||
("horn_rechts", "kopf", (122, 46), (126, 10), 4,
|
||
[(112, 8), (137, 8), (137, 28), (129, 48), (117, 48), (112, 30)]),
|
||
("bein_vorne_links", "rumpf", (92, 332), (80, 465), 12,
|
||
[(95, 320), (106, 335), (100, 400), (95, 445), (93, 472), (62, 472), (70, 440), (72, 400), (78, 340)]),
|
||
("bein_vorne_rechts", "rumpf", (124, 340), (127, 475), 12,
|
||
[(108, 338), (141, 335), (140, 400), (138, 470), (141, 487), (110, 487), (115, 440), (112, 380)]),
|
||
("bein_hinten_links", "rumpf", (186, 345), (193, 448), 12,
|
||
[(168, 342), (200, 346), (205, 400), (206, 460), (178, 460), (182, 420), (170, 380)]),
|
||
("bein_hinten_rechts", "rumpf", (230, 335), (235, 470), 12,
|
||
[(212, 332), (243, 324), (250, 360), (248, 420), (251, 481), (222, 481), (225, 430), (215, 380)]),
|
||
("schwanz", "rumpf", (246, 288), (266, 364), 6,
|
||
[(244, 282), (256, 300), (262, 330), (277, 350), (273, 373), (256, 373), (254, 340), (244, 300)]),
|
||
],
|
||
"accessoires": [
|
||
{
|
||
"farbe": "gruen",
|
||
"bereich": None,
|
||
"teile": [
|
||
("schal_schlinge", "hals", [(70, 228), (160, 205)], (124, 244), 10),
|
||
("schal_ende", "schal_schlinge", [(40, 292), (45, 372)], (62, 252), 10),
|
||
("schal_boden", "rumpf", [(230, 420), (350, 440)], (160, 360), 10),
|
||
("schal_boden", None, [(160, 362), (190, 388)], None, 0),
|
||
],
|
||
},
|
||
{
|
||
"farbe": "rot",
|
||
"bereich": (283, 378, 318, 404),
|
||
"teile": [("schal_boden", None, [(300, 392)], None, 0)],
|
||
},
|
||
],
|
||
},
|
||
"pino": {
|
||
"knochen": [
|
||
("koerper", None, (230, 430), (400, 560), 0,
|
||
[(20, 350), (150, 200), (245, 200), (262, 300), (275, 445), (300, 560), (200, 640), (60, 560)]),
|
||
("kopf", "koerper", (405, 450), (420, 320), 60,
|
||
[(245, 150), (300, 115), (420, 120), (520, 180), (554, 240), (554, 455), (470, 465), (380, 462),
|
||
(300, 450), (268, 400), (262, 300), (240, 240)]),
|
||
("ohr_links", "kopf", (290, 258), (256, 222), 6, "kreis", (272, 240, 27)),
|
||
("ohr_rechts", "kopf", (510, 270), (538, 248), 6, "kreis", (525, 258, 21)),
|
||
("bauch", "koerper", (330, 600), (430, 470), 20,
|
||
[(300, 450), (470, 460), (485, 520), (480, 600), (470, 640), (400, 645), (360, 610), (300, 545)]),
|
||
("arm_links", "koerper", (322, 462), (370, 540), 10,
|
||
[(322, 456), (345, 470), (382, 505), (400, 535), (398, 562), (372, 564), (345, 552), (330, 515), (318, 480)]),
|
||
("arm_rechts", "koerper", (458, 468), (455, 545), 10,
|
||
[(445, 465), (472, 470), (478, 520), (474, 562), (440, 564), (424, 550), (422, 515), (430, 498), (442, 490)]),
|
||
("fuss_links", "koerper", (310, 568), (320, 645), 10,
|
||
[(300, 560), (360, 556), (364, 600), (350, 655), (315, 658), (300, 620)]),
|
||
("fuss_rechts", "koerper", (495, 600), (540, 575), 10,
|
||
[(495, 556), (540, 540), (554, 560), (554, 640), (505, 640), (490, 600)]),
|
||
("krallen_hand_links", "arm_links", (366, 524), (372, 540), 4,
|
||
[(348, 524), (366, 514), (395, 520), (395, 546), (352, 548)]),
|
||
("krallen_hand_rechts", "arm_rechts", (452, 526), (454, 542), 4,
|
||
[(434, 528), (455, 522), (474, 528), (472, 546), (436, 546)]),
|
||
("krallen_fuss_links", "fuss_links", (332, 590), (332, 568), 4,
|
||
[(306, 566), (330, 558), (358, 562), (356, 594), (308, 594)]),
|
||
("krallen_fuss_rechts", "fuss_rechts", (526, 580), (540, 556), 4,
|
||
[(512, 560), (532, 546), (554, 548), (554, 578), (516, 582)]),
|
||
],
|
||
"accessoires": [
|
||
{
|
||
"farbe": "weiss",
|
||
"bereich": (360, 0, 500, 92),
|
||
"teile": [("bommel", "muetze", [(445, 45)], (445, 88), 8)],
|
||
},
|
||
{
|
||
"farbe": "blau",
|
||
"bereich": (250, 60, 554, 215),
|
||
"teile": [("muetze", "kopf", [(330, 170), (480, 170)], (400, 200), 10)],
|
||
},
|
||
],
|
||
},
|
||
"noa": {
|
||
"knochen": [
|
||
("rumpf", None, (115, 165), (115, 320), 0,
|
||
[(80, 150), (160, 150), (165, 200), (170, 320), (75, 325), (82, 200)]),
|
||
("kopf", "rumpf", (120, 156), (112, 40), 12,
|
||
[(55, 15), (115, 3), (165, 15), (190, 45), (197, 90), (182, 108), (170, 135), (140, 152), (100, 155),
|
||
(70, 140), (48, 118), (32, 95), (30, 60)]),
|
||
("zopf_links", "kopf", (48, 120), (15, 135), 6,
|
||
[(0, 120), (20, 108), (45, 112), (52, 130), (45, 150), (20, 155), (5, 145)]),
|
||
("zopf_rechts", "kopf", (178, 108), (200, 126), 6,
|
||
[(172, 100), (195, 95), (210, 108), (212, 130), (195, 148), (176, 140)]),
|
||
("arm_links", "rumpf", (64, 172), (52, 288), 12,
|
||
[(35, 180), (62, 165), (78, 180), (80, 290), (60, 300), (35, 295), (28, 240)]),
|
||
("arm_rechts", "rumpf", (166, 172), (183, 272), 12,
|
||
[(160, 170), (180, 180), (192, 230), (195, 275), (178, 280), (165, 240), (158, 190)]),
|
||
("daumen_links", "hand_links", (68, 300), (78, 310), 3,
|
||
[(67, 292), (76, 294), (81, 304), (79, 315), (70, 315), (66, 302)]),
|
||
("daumen_rechts", "hand_rechts", (186, 296), (192, 281), 3,
|
||
[(184, 279), (192, 278), (199, 286), (198, 298), (188, 299), (183, 290)]),
|
||
("bein_links", "rumpf", (92, 322), (92, 350), 6,
|
||
[(78, 320), (108, 320), (108, 352), (80, 352)]),
|
||
("bein_rechts", "rumpf", (128, 322), (132, 350), 6,
|
||
[(110, 320), (142, 320), (142, 352), (112, 352)]),
|
||
],
|
||
"accessoires": [
|
||
{
|
||
"farbe": "rot",
|
||
"bereich": (0, 260, 219, 340),
|
||
"teile": [
|
||
("hand_links", "arm_links", [(55, 310)], (54, 292), 6),
|
||
("hand_rechts", "arm_rechts", [(188, 292)], (186, 275), 6),
|
||
],
|
||
},
|
||
{
|
||
"farbe": "rot",
|
||
"bereich": (0, 340, 219, 429),
|
||
"teile": [
|
||
("stiefel_links", "bein_links", [(92, 362), (95, 415)], (92, 350), 6),
|
||
("stiefel_rechts", "bein_rechts", [(140, 362), (150, 410)], (132, 350), 6),
|
||
],
|
||
},
|
||
],
|
||
},
|
||
}
|
||
|
||
|
||
def farbmaske(rgb, art):
|
||
h, w, _ = rgb.shape
|
||
hsv = np.array([colorsys.rgb_to_hsv(*(p / 255)) for p in rgb.reshape(-1, 3)]).reshape(h, w, 3)
|
||
hue, sat, val = hsv[..., 0] * 360, hsv[..., 1], hsv[..., 2]
|
||
if art == "gruen":
|
||
m = (hue > 52) & (hue < 180) & (sat > 0.06) & (val > 0.12)
|
||
elif art == "blau":
|
||
m = (hue > 185) & (hue < 260) & (sat > 0.12)
|
||
elif art == "rot":
|
||
m = ((hue < 22) | (hue > 335)) & (sat > 0.35) & (val > 0.3)
|
||
elif art == "weiss":
|
||
m = (sat < 0.18) & (val > 0.72)
|
||
else:
|
||
raise ValueError(art)
|
||
return m
|
||
|
||
|
||
def linie_pixel(punkte, form):
|
||
bild = Image.new("L", (form[1], form[0]), 0)
|
||
d = ImageDraw.Draw(bild)
|
||
if len(punkte) == 1:
|
||
x, y = punkte[0]
|
||
d.ellipse((x - 2, y - 2, x + 2, y + 2), fill=1)
|
||
else:
|
||
d.line(punkte, fill=1, width=3)
|
||
return np.asarray(bild).astype(bool)
|
||
|
||
|
||
def flaechen_pixel(flaeche, form):
|
||
bild = Image.new("L", (form[1], form[0]), 0)
|
||
d = ImageDraw.Draw(bild)
|
||
if flaeche[0] == "kreis":
|
||
x, y, r = flaeche[1]
|
||
d.ellipse((x - r, y - r, x + r, y + r), fill=1)
|
||
else:
|
||
d.polygon(flaeche[0], fill=1)
|
||
return np.asarray(bild).astype(bool)
|
||
|
||
|
||
def ausbreiten(labels, erlaubt):
|
||
"""Wachsen der Labels innerhalb von `erlaubt`, Pixel für Pixel (Weg innerhalb der Figur)."""
|
||
labels = labels.copy()
|
||
kreuz = ndimage.generate_binary_structure(2, 2)
|
||
while True:
|
||
frei = erlaubt & (labels == 0)
|
||
if not frei.any():
|
||
break
|
||
gewachsen = ndimage.grey_dilation(labels, footprint=kreuz)
|
||
neu = frei & (gewachsen > 0)
|
||
if not neu.any():
|
||
break
|
||
labels[neu] = gewachsen[neu]
|
||
return labels
|
||
|
||
|
||
def rampe_entlang(form, von, bis, weich):
|
||
"""0 hinter dem Gelenk, 1 nach `weich` px in Knochenrichtung."""
|
||
if weich <= 0:
|
||
return np.ones(form)
|
||
yy, xx = np.mgrid[0 : form[0], 0 : form[1]].astype(float)
|
||
dx, dy = bis[0] - von[0], bis[1] - von[1]
|
||
l = np.hypot(dx, dy)
|
||
t = ((xx - von[0]) * dx + (yy - von[1]) * dy) / l
|
||
s = np.clip((t + weich * 0.4) / weich, 0, 1)
|
||
return s * s * (3 - 2 * s)
|
||
|
||
|
||
def baue(figur, d):
|
||
bild = Image.open(os.path.join(FIGUREN, f"{figur}.png")).convert("RGBA")
|
||
arr = np.asarray(bild).astype(float)
|
||
rgb, alpha = arr[..., :3], arr[..., 3]
|
||
figur_maske = alpha > 40
|
||
form = figur_maske.shape
|
||
|
||
namen = []
|
||
info = {} # name -> (eltern, gelenk, bis, weich)
|
||
for name, eltern, von, bis, weich, *_ in d["knochen"]:
|
||
namen.append(name)
|
||
info[name] = (eltern, von, bis, weich)
|
||
for gruppe in d["accessoires"]:
|
||
for name, eltern, linie, gelenk, weich in gruppe["teile"]:
|
||
if name not in info:
|
||
namen.append(name)
|
||
info[name] = (eltern, gelenk, linie[-1], weich)
|
||
nummer = {n: i + 1 for i, n in enumerate(namen)}
|
||
|
||
labels = np.zeros(form, dtype=np.int32)
|
||
|
||
# 1. Accessoires über Farbe
|
||
for gruppe in d["accessoires"]:
|
||
m = figur_maske & farbmaske(rgb, gruppe["farbe"])
|
||
if gruppe["bereich"]:
|
||
x0, y0, x1, y1 = gruppe["bereich"]
|
||
b = np.zeros(form, bool)
|
||
b[y0:y1, x0:x1] = True
|
||
m &= b
|
||
m = ndimage.binary_opening(m, iterations=1)
|
||
m = ndimage.binary_closing(m, iterations=2) & figur_maske
|
||
start = np.zeros(form, np.int32)
|
||
for name, _, linie, _, _ in gruppe["teile"]:
|
||
start[linie_pixel(linie, form) & m] = nummer[name]
|
||
# Flächen ohne Startlinie (z. B. Schalstück zwischen zwei Beinen) bekommen ab einer
|
||
# Mindestgröße das nächstgelegene Teil der Gruppe, kleine Farbflecken fallen weg.
|
||
lab, n = ndimage.label(m)
|
||
verbunden = set(np.unique(lab[start > 0])) - {0}
|
||
_, (iy, ix) = ndimage.distance_transform_edt(start == 0, return_indices=True)
|
||
for k in range(1, n + 1):
|
||
if k in verbunden:
|
||
continue
|
||
flaeche = lab == k
|
||
if flaeche.sum() >= 40:
|
||
ys, xs = np.where(flaeche)
|
||
y0, x0 = ys[len(ys) // 2], xs[len(xs) // 2]
|
||
start[flaeche] = start[iy[y0, x0], ix[y0, x0]]
|
||
verbunden.add(k)
|
||
behalten = np.isin(lab, list(verbunden)) & (lab > 0)
|
||
behalten = ndimage.binary_fill_holes(behalten) & figur_maske # z. B. Blümchen im Schal
|
||
gruppe_labels = ausbreiten(start, behalten)
|
||
frei = labels == 0
|
||
labels[frei & (gruppe_labels > 0)] = gruppe_labels[frei & (gruppe_labels > 0)]
|
||
|
||
# 2. Körper: Kernflächen der Teile (spätere Teile überschreiben frühere), dazu die Knochenlinie
|
||
start = np.zeros(form, np.int32)
|
||
rest = figur_maske & (labels == 0)
|
||
for name, _, von, bis, _, *flaeche in d["knochen"]:
|
||
kern = linie_pixel([von, bis], form)
|
||
if flaeche:
|
||
kern |= flaechen_pixel(flaeche, form)
|
||
start[kern & rest] = nummer[name]
|
||
for name in [k[0] for k in d["knochen"]]:
|
||
if not (start == nummer[name]).any():
|
||
print(f" Warnung: Knochen {name} liegt nicht auf der Figur", file=sys.stderr)
|
||
koerper = ausbreiten(start, rest)
|
||
labels[rest] = koerper[rest]
|
||
# 3. abgeschnittene Inseln über die ganze Figur auffüllen
|
||
labels = ausbreiten(labels, figur_maske)
|
||
# 4. Teile, die an einem Accessoire hängen (z. B. Daumen am Fäustling), nehmen sich ihre Fläche daraus
|
||
accessoire_namen = {t[0] for g in d["accessoires"] for t in g["teile"]}
|
||
for name, eltern, _, _, _, *flaeche in d["knochen"]:
|
||
if eltern in accessoire_namen and flaeche:
|
||
f = flaechen_pixel(flaeche, form) & figur_maske & (labels == nummer[eltern])
|
||
labels[f] = nummer[name]
|
||
|
||
ziel = os.path.join(FIGUREN, figur)
|
||
os.makedirs(os.path.join(ziel, "teile"), exist_ok=True)
|
||
os.makedirs(os.path.join(ziel, "einfluss"), exist_ok=True)
|
||
|
||
kinder = {n: [m for m in namen if info[m][0] == n] for n in namen}
|
||
|
||
def unterbaum(n):
|
||
return [n] + [x for k in kinder[n] for x in unterbaum(k)]
|
||
|
||
# Für die Verformung reicht jedes Teil über die Kontur hinaus: Punkte außerhalb der Figur
|
||
# bekommen das Teil des nächsten Figurpixels. Sonst bleiben Gitterpunkte am Rand stehen,
|
||
# während die Figur sich dreht, und die Randpixel verschmieren.
|
||
_, (iy, ix) = ndimage.distance_transform_edt(~figur_maske, return_indices=True)
|
||
labels_aussen = labels[iy, ix]
|
||
|
||
for name in namen:
|
||
teil = labels == nummer[name]
|
||
Image.fromarray((teil * 255).astype("uint8")).save(os.path.join(ziel, "teile", f"{name}.png"))
|
||
eltern, gelenk, bis, weich = info[name]
|
||
baum = np.isin(labels_aussen, [nummer[x] for x in unterbaum(name)])
|
||
weich_bild = Image.fromarray((baum * 255).astype("uint8")).filter(ImageFilter.GaussianBlur(1.5))
|
||
e = np.asarray(weich_bild).astype(float) / 255
|
||
if gelenk is not None:
|
||
e *= rampe_entlang(form, gelenk, bis, weich)
|
||
Image.fromarray((e * 255).astype("uint8")).save(os.path.join(ziel, "einfluss", f"{name}.png"))
|
||
|
||
# Übersicht
|
||
rng = np.random.default_rng(3)
|
||
farben = rng.integers(60, 255, size=(len(namen) + 1, 3))
|
||
farben[0] = (30, 30, 60)
|
||
uebersicht = farben[labels].astype(float)
|
||
uebersicht = uebersicht * 0.55 + rgb * 0.45
|
||
uebersicht[~figur_maske] = (30, 30, 60)
|
||
ueb = Image.fromarray(uebersicht.astype("uint8"))
|
||
dr = ImageDraw.Draw(ueb)
|
||
for name in namen:
|
||
ys, xs = np.where(labels == nummer[name])
|
||
if len(xs):
|
||
dr.text((int(xs.mean()) - 10, int(ys.mean()) - 4), name, fill=(255, 255, 255))
|
||
ueb.save(os.path.join(ziel, "uebersicht.png"))
|
||
|
||
groessen = {n: int((labels == nummer[n]).sum()) for n in namen}
|
||
print(figur, groessen)
|
||
return {
|
||
n: {"eltern": info[n][0], "um": list(info[n][1]) if info[n][1] else None}
|
||
for n in namen
|
||
}
|
||
|
||
|
||
def main():
|
||
rigs = {figur: baue(figur, d) for figur, d in FIGUREN_DEF.items()}
|
||
with open(RIGS_TS, "w") as f:
|
||
f.write("// Automatisch erzeugt von werkzeuge/rig.py – nicht von Hand ändern.\n")
|
||
f.write("// Gelenkpunkte (um) in Bildpixeln der Figur, eltern = woran das Teil hängt.\n")
|
||
f.write("// Masken: figuren/<figur>/teile/<teil>.png und figuren/<figur>/einfluss/<teil>.png\n\n")
|
||
f.write("export type RigTeil = {eltern: string | null; um: [number, number] | null};\n\n")
|
||
daten = json.dumps(rigs, indent="\t", ensure_ascii=False)
|
||
f.write(f"export const RIGS: Record<string, Record<string, RigTeil>> = {daten};\n")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|