youtube/remotion/werkzeuge/rig.py

374 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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