Remotion: Körperteil-Masken (Rig) für Pino, Noa und Lulu, VerformTest darauf umgestellt
- werkzeuge/rig.py erzeugt pro Figur Teil- und Einfluss-Masken, Gelenkpunkte und Hierarchie (src/rigs.ts) - Gitter-Verformung mit Masken-Gewichten und 3-px-Gitter - Pinos Sprung gedämpft, Kopfbereich deutlich größer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
@ -34,6 +34,33 @@ Mit `rembg` (Modell `isnet-general-use`), danach Maske härten, nur die größte
|
||||||
Fläche behalten, Rand um 2 px einziehen (sonst heller Saum vor dunklem
|
Fläche behalten, Rand um 2 px einziehen (sonst heller Saum vor dunklem
|
||||||
Hintergrund). Am besten funktionieren Einzelbilder vor hellem Papier.
|
Hintergrund). Am besten funktionieren Einzelbilder vor hellem Papier.
|
||||||
|
|
||||||
|
## Körperteil-Masken (Rig)
|
||||||
|
`werkzeuge/rig.py` zerlegt jede Figur in Körperteile und erzeugt:
|
||||||
|
|
||||||
|
| Pfad | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| `public/figuren/<figur>/teile/<teil>.png` | harte Maske, genau an der Kontur |
|
||||||
|
| `public/figuren/<figur>/einfluss/<teil>.png` | weiche Maske für die Verformung: Teil + alles, was daran hängt, weicher Übergang am Gelenk, reicht über die Kontur hinaus |
|
||||||
|
| `public/figuren/<figur>/uebersicht.png` | alle Teile farbig, zum Prüfen |
|
||||||
|
| `src/rigs.ts` | Gelenkpunkte und Hierarchie (automatisch erzeugt) |
|
||||||
|
|
||||||
|
Teile: Lulu – kopf, hals, ohr_links/rechts, horn_links/rechts, rumpf,
|
||||||
|
bein_vorne_links/rechts, bein_hinten_links/rechts, schwanz, schal_schlinge,
|
||||||
|
schal_ende, schal_boden. Pino – koerper, kopf, ohr_links/rechts, bauch,
|
||||||
|
arm_links/rechts, fuss_links/rechts, muetze, bommel. Noa – kopf,
|
||||||
|
zopf_links/rechts, rumpf, arm_links/rechts, hand_links/rechts,
|
||||||
|
bein_links/rechts, stiefel_links/rechts.
|
||||||
|
|
||||||
|
Zuordnung: Accessoires über ihre Farbe, alles andere über Kernflächen pro
|
||||||
|
Teil, die bis zur Kontur wachsen – nur innerhalb der Figur, also nie über
|
||||||
|
Lücken (Schwanz ↔ Bein). Neue Figur oder neues Bild: Skelett und Flächen in
|
||||||
|
`rig.py` eintragen, dann
|
||||||
|
```bash
|
||||||
|
python3 werkzeuge/rig.py # braucht numpy, scipy, pillow
|
||||||
|
```
|
||||||
|
und `uebersicht.png` prüfen. In einer Szene: `gelenk('lulu', 'hals', grad)`
|
||||||
|
(siehe `src/szenen/VerformTest.tsx`).
|
||||||
|
|
||||||
## Bekannte Grenzen
|
## Bekannte Grenzen
|
||||||
- Die Figuren haben nur die Pose ihres Bildes. Neue Posen brauchen ein neues
|
- Die Figuren haben nur die Pose ihres Bildes. Neue Posen brauchen ein neues
|
||||||
Bild (z. B. aus Gemini).
|
Bild (z. B. aus Gemini).
|
||||||
|
|
|
||||||
BIN
remotion/public/figuren/lulu/einfluss/bein_hinten_links.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
remotion/public/figuren/lulu/einfluss/bein_hinten_rechts.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
remotion/public/figuren/lulu/einfluss/bein_vorne_links.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
remotion/public/figuren/lulu/einfluss/bein_vorne_rechts.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
remotion/public/figuren/lulu/einfluss/hals.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
remotion/public/figuren/lulu/einfluss/horn_links.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
remotion/public/figuren/lulu/einfluss/horn_rechts.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
remotion/public/figuren/lulu/einfluss/kopf.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
remotion/public/figuren/lulu/einfluss/ohr_links.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
remotion/public/figuren/lulu/einfluss/ohr_rechts.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
remotion/public/figuren/lulu/einfluss/rumpf.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
remotion/public/figuren/lulu/einfluss/schal_boden.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
remotion/public/figuren/lulu/einfluss/schal_ende.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
remotion/public/figuren/lulu/einfluss/schal_schlinge.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
remotion/public/figuren/lulu/einfluss/schwanz.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
remotion/public/figuren/lulu/teile/bein_hinten_links.png
Normal file
|
After Width: | Height: | Size: 839 B |
BIN
remotion/public/figuren/lulu/teile/bein_hinten_rechts.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
remotion/public/figuren/lulu/teile/bein_vorne_links.png
Normal file
|
After Width: | Height: | Size: 828 B |
BIN
remotion/public/figuren/lulu/teile/bein_vorne_rechts.png
Normal file
|
After Width: | Height: | Size: 823 B |
BIN
remotion/public/figuren/lulu/teile/hals.png
Normal file
|
After Width: | Height: | Size: 563 B |
BIN
remotion/public/figuren/lulu/teile/horn_links.png
Normal file
|
After Width: | Height: | Size: 477 B |
BIN
remotion/public/figuren/lulu/teile/horn_rechts.png
Normal file
|
After Width: | Height: | Size: 437 B |
BIN
remotion/public/figuren/lulu/teile/kopf.png
Normal file
|
After Width: | Height: | Size: 1,003 B |
BIN
remotion/public/figuren/lulu/teile/ohr_links.png
Normal file
|
After Width: | Height: | Size: 557 B |
BIN
remotion/public/figuren/lulu/teile/ohr_rechts.png
Normal file
|
After Width: | Height: | Size: 534 B |
BIN
remotion/public/figuren/lulu/teile/rumpf.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
remotion/public/figuren/lulu/teile/schal_boden.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
remotion/public/figuren/lulu/teile/schal_ende.png
Normal file
|
After Width: | Height: | Size: 845 B |
BIN
remotion/public/figuren/lulu/teile/schal_schlinge.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
remotion/public/figuren/lulu/teile/schwanz.png
Normal file
|
After Width: | Height: | Size: 599 B |
BIN
remotion/public/figuren/lulu/uebersicht.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
remotion/public/figuren/noa/einfluss/arm_links.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
remotion/public/figuren/noa/einfluss/arm_rechts.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
remotion/public/figuren/noa/einfluss/bein_links.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
remotion/public/figuren/noa/einfluss/bein_rechts.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
remotion/public/figuren/noa/einfluss/hand_links.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
remotion/public/figuren/noa/einfluss/hand_rechts.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
remotion/public/figuren/noa/einfluss/kopf.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
remotion/public/figuren/noa/einfluss/rumpf.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
remotion/public/figuren/noa/einfluss/stiefel_links.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
remotion/public/figuren/noa/einfluss/stiefel_rechts.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
remotion/public/figuren/noa/einfluss/zopf_links.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
remotion/public/figuren/noa/einfluss/zopf_rechts.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
remotion/public/figuren/noa/teile/arm_links.png
Normal file
|
After Width: | Height: | Size: 601 B |
BIN
remotion/public/figuren/noa/teile/arm_rechts.png
Normal file
|
After Width: | Height: | Size: 589 B |
BIN
remotion/public/figuren/noa/teile/bein_links.png
Normal file
|
After Width: | Height: | Size: 284 B |
BIN
remotion/public/figuren/noa/teile/bein_rechts.png
Normal file
|
After Width: | Height: | Size: 333 B |
BIN
remotion/public/figuren/noa/teile/hand_links.png
Normal file
|
After Width: | Height: | Size: 379 B |
BIN
remotion/public/figuren/noa/teile/hand_rechts.png
Normal file
|
After Width: | Height: | Size: 363 B |
BIN
remotion/public/figuren/noa/teile/kopf.png
Normal file
|
After Width: | Height: | Size: 994 B |
BIN
remotion/public/figuren/noa/teile/rumpf.png
Normal file
|
After Width: | Height: | Size: 806 B |
BIN
remotion/public/figuren/noa/teile/stiefel_links.png
Normal file
|
After Width: | Height: | Size: 429 B |
BIN
remotion/public/figuren/noa/teile/stiefel_rechts.png
Normal file
|
After Width: | Height: | Size: 481 B |
BIN
remotion/public/figuren/noa/teile/zopf_links.png
Normal file
|
After Width: | Height: | Size: 491 B |
BIN
remotion/public/figuren/noa/teile/zopf_rechts.png
Normal file
|
After Width: | Height: | Size: 432 B |
BIN
remotion/public/figuren/noa/uebersicht.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
remotion/public/figuren/pino/einfluss/arm_links.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
remotion/public/figuren/pino/einfluss/arm_rechts.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
remotion/public/figuren/pino/einfluss/bauch.png
Normal file
|
After Width: | Height: | Size: 6 KiB |
BIN
remotion/public/figuren/pino/einfluss/bommel.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
remotion/public/figuren/pino/einfluss/fuss_links.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
remotion/public/figuren/pino/einfluss/fuss_rechts.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
remotion/public/figuren/pino/einfluss/koerper.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
remotion/public/figuren/pino/einfluss/kopf.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
remotion/public/figuren/pino/einfluss/muetze.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
remotion/public/figuren/pino/einfluss/ohr_links.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
remotion/public/figuren/pino/einfluss/ohr_rechts.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
remotion/public/figuren/pino/teile/arm_links.png
Normal file
|
After Width: | Height: | Size: 976 B |
BIN
remotion/public/figuren/pino/teile/arm_rechts.png
Normal file
|
After Width: | Height: | Size: 751 B |
BIN
remotion/public/figuren/pino/teile/bauch.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
remotion/public/figuren/pino/teile/bommel.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
remotion/public/figuren/pino/teile/fuss_links.png
Normal file
|
After Width: | Height: | Size: 923 B |
BIN
remotion/public/figuren/pino/teile/fuss_rechts.png
Normal file
|
After Width: | Height: | Size: 923 B |
BIN
remotion/public/figuren/pino/teile/koerper.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
remotion/public/figuren/pino/teile/kopf.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
remotion/public/figuren/pino/teile/muetze.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
remotion/public/figuren/pino/teile/ohr_links.png
Normal file
|
After Width: | Height: | Size: 731 B |
BIN
remotion/public/figuren/pino/teile/ohr_rechts.png
Normal file
|
After Width: | Height: | Size: 654 B |
BIN
remotion/public/figuren/pino/uebersicht.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
280
remotion/src/rigs.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
// Automatisch erzeugt von werkzeuge/rig.py – nicht von Hand ändern.
|
||||||
|
// Gelenkpunkte (um) in Bildpixeln der Figur, eltern = woran das Teil hängt.
|
||||||
|
// Masken: figuren/<figur>/teile/<teil>.png und figuren/<figur>/einfluss/<teil>.png
|
||||||
|
|
||||||
|
export type RigTeil = {eltern: string | null; um: [number, number] | null};
|
||||||
|
|
||||||
|
export const RIGS: Record<string, Record<string, RigTeil>> = {
|
||||||
|
"lulu": {
|
||||||
|
"rumpf": {
|
||||||
|
"eltern": null,
|
||||||
|
"um": [
|
||||||
|
130,
|
||||||
|
292
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hals": {
|
||||||
|
"eltern": "rumpf",
|
||||||
|
"um": [
|
||||||
|
124,
|
||||||
|
244
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"kopf": {
|
||||||
|
"eltern": "hals",
|
||||||
|
"um": [
|
||||||
|
111,
|
||||||
|
150
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ohr_links": {
|
||||||
|
"eltern": "kopf",
|
||||||
|
"um": [
|
||||||
|
44,
|
||||||
|
57
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ohr_rechts": {
|
||||||
|
"eltern": "kopf",
|
||||||
|
"um": [
|
||||||
|
146,
|
||||||
|
72
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"horn_links": {
|
||||||
|
"eltern": "kopf",
|
||||||
|
"um": [
|
||||||
|
65,
|
||||||
|
44
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"horn_rechts": {
|
||||||
|
"eltern": "kopf",
|
||||||
|
"um": [
|
||||||
|
122,
|
||||||
|
46
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bein_vorne_links": {
|
||||||
|
"eltern": "rumpf",
|
||||||
|
"um": [
|
||||||
|
92,
|
||||||
|
332
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bein_vorne_rechts": {
|
||||||
|
"eltern": "rumpf",
|
||||||
|
"um": [
|
||||||
|
124,
|
||||||
|
340
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bein_hinten_links": {
|
||||||
|
"eltern": "rumpf",
|
||||||
|
"um": [
|
||||||
|
186,
|
||||||
|
345
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bein_hinten_rechts": {
|
||||||
|
"eltern": "rumpf",
|
||||||
|
"um": [
|
||||||
|
230,
|
||||||
|
335
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"schwanz": {
|
||||||
|
"eltern": "rumpf",
|
||||||
|
"um": [
|
||||||
|
246,
|
||||||
|
288
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"schal_schlinge": {
|
||||||
|
"eltern": "hals",
|
||||||
|
"um": [
|
||||||
|
124,
|
||||||
|
244
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"schal_ende": {
|
||||||
|
"eltern": "schal_schlinge",
|
||||||
|
"um": [
|
||||||
|
62,
|
||||||
|
252
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"schal_boden": {
|
||||||
|
"eltern": "rumpf",
|
||||||
|
"um": [
|
||||||
|
160,
|
||||||
|
360
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pino": {
|
||||||
|
"koerper": {
|
||||||
|
"eltern": null,
|
||||||
|
"um": [
|
||||||
|
230,
|
||||||
|
430
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"kopf": {
|
||||||
|
"eltern": "koerper",
|
||||||
|
"um": [
|
||||||
|
400,
|
||||||
|
465
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ohr_links": {
|
||||||
|
"eltern": "kopf",
|
||||||
|
"um": [
|
||||||
|
290,
|
||||||
|
258
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ohr_rechts": {
|
||||||
|
"eltern": "kopf",
|
||||||
|
"um": [
|
||||||
|
510,
|
||||||
|
270
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bauch": {
|
||||||
|
"eltern": "koerper",
|
||||||
|
"um": [
|
||||||
|
330,
|
||||||
|
600
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"arm_links": {
|
||||||
|
"eltern": "koerper",
|
||||||
|
"um": [
|
||||||
|
322,
|
||||||
|
462
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"arm_rechts": {
|
||||||
|
"eltern": "koerper",
|
||||||
|
"um": [
|
||||||
|
458,
|
||||||
|
468
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fuss_links": {
|
||||||
|
"eltern": "koerper",
|
||||||
|
"um": [
|
||||||
|
310,
|
||||||
|
568
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fuss_rechts": {
|
||||||
|
"eltern": "koerper",
|
||||||
|
"um": [
|
||||||
|
495,
|
||||||
|
600
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bommel": {
|
||||||
|
"eltern": "muetze",
|
||||||
|
"um": [
|
||||||
|
445,
|
||||||
|
88
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"muetze": {
|
||||||
|
"eltern": "kopf",
|
||||||
|
"um": [
|
||||||
|
400,
|
||||||
|
200
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"noa": {
|
||||||
|
"rumpf": {
|
||||||
|
"eltern": null,
|
||||||
|
"um": [
|
||||||
|
115,
|
||||||
|
165
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"kopf": {
|
||||||
|
"eltern": "rumpf",
|
||||||
|
"um": [
|
||||||
|
120,
|
||||||
|
156
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"zopf_links": {
|
||||||
|
"eltern": "kopf",
|
||||||
|
"um": [
|
||||||
|
48,
|
||||||
|
120
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"zopf_rechts": {
|
||||||
|
"eltern": "kopf",
|
||||||
|
"um": [
|
||||||
|
178,
|
||||||
|
108
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"arm_links": {
|
||||||
|
"eltern": "rumpf",
|
||||||
|
"um": [
|
||||||
|
64,
|
||||||
|
172
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"arm_rechts": {
|
||||||
|
"eltern": "rumpf",
|
||||||
|
"um": [
|
||||||
|
166,
|
||||||
|
172
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bein_links": {
|
||||||
|
"eltern": "rumpf",
|
||||||
|
"um": [
|
||||||
|
92,
|
||||||
|
322
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bein_rechts": {
|
||||||
|
"eltern": "rumpf",
|
||||||
|
"um": [
|
||||||
|
128,
|
||||||
|
322
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hand_links": {
|
||||||
|
"eltern": "arm_links",
|
||||||
|
"um": [
|
||||||
|
54,
|
||||||
|
292
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hand_rechts": {
|
||||||
|
"eltern": "arm_rechts",
|
||||||
|
"um": [
|
||||||
|
186,
|
||||||
|
275
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stiefel_links": {
|
||||||
|
"eltern": "bein_links",
|
||||||
|
"um": [
|
||||||
|
92,
|
||||||
|
350
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stiefel_rechts": {
|
||||||
|
"eltern": "bein_rechts",
|
||||||
|
"um": [
|
||||||
|
132,
|
||||||
|
350
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,15 +1,26 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {AbsoluteFill, Img, staticFile, useCurrentFrame} from 'remotion';
|
import {AbsoluteFill, Img, staticFile, useCurrentFrame} from 'remotion';
|
||||||
import {feder, glatt, kreis, kurve} from '../bewegung';
|
import {feder, kreis, kurve} from '../bewegung';
|
||||||
import {LULU, PINO} from '../figuren';
|
import {LULU, PINO} from '../figuren';
|
||||||
|
import {RIGS} from '../rigs';
|
||||||
import {VerformFigur, type Knochen} from '../teile/VerformFigur';
|
import {VerformFigur, type Knochen} from '../teile/VerformFigur';
|
||||||
|
|
||||||
// Test für weiche Animation (6 s, ohne Ton): Lulu beugt den Hals zu Pino
|
// Test für weiche Animation (6 s, ohne Ton): Lulu beugt den Hals zu Pino
|
||||||
// hinunter, Pino freut sich und hüpft. Alles über Gitter-Verformung und
|
// hinunter, Pino freut sich und hüpft. Alles über Gitter-Verformung mit den
|
||||||
// Federn, Hintergrund und Papier sind vorberechnete Bilder.
|
// Körperteil-Masken aus werkzeuge/rig.py und Federn; Hintergrund und Papier
|
||||||
|
// sind vorberechnete Bilder.
|
||||||
|
|
||||||
const BODEN = 884;
|
const BODEN = 884;
|
||||||
|
|
||||||
|
// Drehgelenk eines Rig-Teils: Gelenkpunkt und Einfluss-Maske (Teil + alles, was daran hängt).
|
||||||
|
const gelenk = (figur: string, teil: string, grad: number): Knochen => ({
|
||||||
|
name: teil,
|
||||||
|
art: 'drehen',
|
||||||
|
um: RIGS[figur][teil].um!,
|
||||||
|
grad,
|
||||||
|
gewicht: {maske: `${figur}/einfluss/${teil}`},
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- Lulu ----------
|
// ---------- Lulu ----------
|
||||||
const halsKurve = (f: number) =>
|
const halsKurve = (f: number) =>
|
||||||
kurve(f, [
|
kurve(f, [
|
||||||
|
|
@ -29,70 +40,52 @@ const luluKnochen = (f: number): Knochen[] => {
|
||||||
const kopf = feder((g) => -halsKurve(g) * 0.35 + neugierKurve(g), f, {steifheit: 0.07, daempfung: 0.18});
|
const kopf = feder((g) => -halsKurve(g) * 0.35 + neugierKurve(g), f, {steifheit: 0.07, daempfung: 0.18});
|
||||||
const ohrL = feder((g) => -26 * ohrZucken(g, [20, 98]), f, {steifheit: 0.25, daempfung: 0.12}) + (hals - halsTraege) * 0.6;
|
const ohrL = feder((g) => -26 * ohrZucken(g, [20, 98]), f, {steifheit: 0.25, daempfung: 0.12}) + (hals - halsTraege) * 0.6;
|
||||||
const ohrR = feder((g) => 22 * ohrZucken(g, [60, 104]), f, {steifheit: 0.25, daempfung: 0.12}) - (hals - halsTraege) * 0.5;
|
const ohrR = feder((g) => 22 * ohrZucken(g, [60, 104]), f, {steifheit: 0.25, daempfung: 0.12}) - (hals - halsTraege) * 0.5;
|
||||||
const schwanz = 13 * Math.sin(f * 0.085) + feder((g) => halsKurve(g) * 0.5, f, {steifheit: 0.04, daempfung: 0.1}) - halsKurve(f) * 0.5;
|
const schwanz = -6 + 10 * Math.sin(f * 0.085) + feder((g) => halsKurve(g) * 0.5, f, {steifheit: 0.04, daempfung: 0.1}) - halsKurve(f) * 0.5;
|
||||||
const schal = (halsTraege - hals) * 0.9 + 2.5 * Math.sin(f * 0.07);
|
const schal = (halsTraege - hals) * 0.9 + 2.5 * Math.sin(f * 0.07);
|
||||||
const atmen = Math.sin(f * 0.1);
|
const atmen = Math.sin(f * 0.1);
|
||||||
return [
|
return [
|
||||||
{art: 'drehen', um: [45, 56], grad: ohrL, gewicht: kreis(22, 32, 18, 42)},
|
gelenk('lulu', 'ohr_links', ohrL),
|
||||||
{art: 'drehen', um: [150, 68], grad: ohrR, gewicht: kreis(176, 56, 16, 38)},
|
gelenk('lulu', 'ohr_rechts', ohrR),
|
||||||
{art: 'drehen', um: [112, 158], grad: kopf, gewicht: (x, y) => glatt(172, 128, y)},
|
gelenk('lulu', 'kopf', kopf),
|
||||||
{art: 'drehen', um: [125, 238], grad: hals, gewicht: (x, y) => glatt(244, 150, y)},
|
gelenk('lulu', 'hals', hals),
|
||||||
{
|
gelenk('lulu', 'schwanz', schwanz),
|
||||||
art: 'drehen',
|
gelenk('lulu', 'schal_ende', schal),
|
||||||
um: [244, 288],
|
{name: 'atmen', art: 'stauchen', um: [175, 330], sx: 1 - atmen * 0.006, sy: 1 + atmen * 0.012, gewicht: kreis(180, 300, 50, 110)},
|
||||||
grad: schwanz,
|
|
||||||
gewicht: (x, y) => glatt(236, 252, x) * glatt(268, 284, y) * glatt(392, 376, y),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
art: 'drehen',
|
|
||||||
um: [62, 252],
|
|
||||||
grad: schal,
|
|
||||||
gewicht: (x, y) => glatt(252, 330, y) * glatt(74, 62, x) * glatt(395, 380, y),
|
|
||||||
},
|
|
||||||
{art: 'stauchen', um: [175, 330], sx: 1 - atmen * 0.006, sy: 1 + atmen * 0.012, gewicht: kreis(180, 300, 50, 110)},
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- Pino ----------
|
// ---------- Pino ----------
|
||||||
// Sprung: Anlauf (Ducken), Flug, Landung mit Nachfedern.
|
// Kleiner Freudenhüpfer: kurz ducken, abheben, landen, nachfedern.
|
||||||
const SPRUNG = {duck: 84, ab: 93, auf: 111};
|
const SPRUNG = {duck: 86, ab: 93, auf: 107};
|
||||||
const sprungHoehe = (f: number) => {
|
const sprungHoehe = (f: number) => {
|
||||||
if (f < SPRUNG.ab || f > SPRUNG.auf) return 0;
|
if (f < SPRUNG.ab || f > SPRUNG.auf) return 0;
|
||||||
const t = (f - SPRUNG.ab) / (SPRUNG.auf - SPRUNG.ab);
|
const t = (f - SPRUNG.ab) / (SPRUNG.auf - SPRUNG.ab);
|
||||||
return 4 * t * (1 - t);
|
return 4 * t * (1 - t);
|
||||||
};
|
};
|
||||||
const stauchZiel = (f: number) => {
|
const stauchZiel = (f: number) => {
|
||||||
if (f >= SPRUNG.duck && f < SPRUNG.ab) return kurve(f, [[SPRUNG.duck, 1], [SPRUNG.ab - 2, 0.86]]);
|
if (f >= SPRUNG.duck && f < SPRUNG.ab) return kurve(f, [[SPRUNG.duck, 1], [SPRUNG.ab - 1, 0.95]]);
|
||||||
if (f >= SPRUNG.ab && f <= SPRUNG.auf) return 1 + 0.09 * Math.sin(Math.PI * Math.min(1, (f - SPRUNG.ab) / 8)) * (1 - sprungHoehe(f) * 0.3);
|
if (f >= SPRUNG.ab && f <= SPRUNG.auf) return 1 + 0.035 * Math.sin(Math.PI * Math.min(1, (f - SPRUNG.ab) / 7));
|
||||||
if (f > SPRUNG.auf && f < SPRUNG.auf + 4) return 0.84;
|
if (f > SPRUNG.auf && f < SPRUNG.auf + 3) return 0.955;
|
||||||
return 1;
|
return 1;
|
||||||
};
|
};
|
||||||
const pinoKopfKurve = (f: number) => kurve(f, [[0, 0], [38, 0], [56, -9], [84, -9], [92, -3], [128, -3], [146, 7], [180, 7]]);
|
const pinoKopfKurve = (f: number) => kurve(f, [[0, 0], [38, 0], [56, -7], [86, -7], [94, -2], [128, -2], [146, 5], [180, 5]]);
|
||||||
const freude = (f: number) => (f > SPRUNG.auf + 4 && f < SPRUNG.auf + 40 ? Math.sin((f - SPRUNG.auf - 4) * 0.35) : 0);
|
const freude = (f: number) => (f > SPRUNG.auf + 3 && f < SPRUNG.auf + 38 ? Math.sin((f - SPRUNG.auf - 3) * 0.35) : 0);
|
||||||
|
|
||||||
const pinoWerte = (f: number) => {
|
const pinoWerte = (f: number) => {
|
||||||
const stauch = feder(stauchZiel, f, {steifheit: 0.3, daempfung: 0.22});
|
const stauch = feder(stauchZiel, f, {steifheit: 0.3, daempfung: 0.3});
|
||||||
const hoch = sprungHoehe(f) * 80;
|
const hoch = sprungHoehe(f) * 28;
|
||||||
const tempo = (g: number) => (sprungHoehe(g + 1) - sprungHoehe(g - 1)) * 40;
|
const tempo = (g: number) => (sprungHoehe(g + 1) - sprungHoehe(g - 1)) * 40;
|
||||||
const bommel = feder((g) => tempo(g) * 1.4, f, {steifheit: 0.12, daempfung: 0.1});
|
const bommel = feder((g) => tempo(g) * 0.8, f, {steifheit: 0.12, daempfung: 0.1});
|
||||||
|
const ohr = feder((g) => (g > SPRUNG.auf && g < SPRUNG.auf + 3 ? -14 : 0), f, {steifheit: 0.25, daempfung: 0.12});
|
||||||
const atmen = Math.sin(f * 0.11);
|
const atmen = Math.sin(f * 0.11);
|
||||||
const knochen: Knochen[] = [
|
const knochen: Knochen[] = [
|
||||||
{art: 'drehen', um: [432, 92], grad: bommel + 3 * Math.sin(f * 0.06), gewicht: kreis(432, 48, 34, 56)},
|
gelenk('pino', 'bommel', bommel + 3 * Math.sin(f * 0.06)),
|
||||||
{
|
gelenk('pino', 'ohr_links', ohr),
|
||||||
art: 'drehen',
|
gelenk('pino', 'ohr_rechts', -ohr * 0.7),
|
||||||
um: [330, 292],
|
gelenk('pino', 'arm_links', freude(f) * 12),
|
||||||
grad: feder((g) => (g > SPRUNG.auf && g < SPRUNG.auf + 4 ? -20 : 0), f, {steifheit: 0.25, daempfung: 0.12}),
|
gelenk('pino', 'arm_rechts', -freude(f) * 10),
|
||||||
gewicht: kreis(318, 266, 22, 42),
|
gelenk('pino', 'kopf', feder(pinoKopfKurve, f, {steifheit: 0.08, daempfung: 0.2})),
|
||||||
},
|
{name: 'atmen', art: 'stauchen', um: [420, 600], sx: 1 + atmen * 0.01, sy: 1 + atmen * 0.014, gewicht: kreis(420, 540, 70, 170)},
|
||||||
{art: 'drehen', um: [430, 500], grad: freude(f) * 14, gewicht: kreis(432, 522, 22, 42)},
|
|
||||||
{art: 'drehen', um: [520, 495], grad: -freude(f) * 12, gewicht: kreis(540, 515, 20, 38)},
|
|
||||||
{
|
|
||||||
art: 'drehen',
|
|
||||||
um: [380, 480],
|
|
||||||
grad: feder(pinoKopfKurve, f, {steifheit: 0.08, daempfung: 0.2}),
|
|
||||||
gewicht: (x, y) => glatt(520, 380, y) * glatt(230, 330, x),
|
|
||||||
},
|
|
||||||
{art: 'stauchen', um: [420, 600], sx: 1 + atmen * 0.01, sy: 1 + atmen * 0.014, gewicht: kreis(420, 540, 70, 170)},
|
|
||||||
];
|
];
|
||||||
return {stauch, hoch, knochen};
|
return {stauch, hoch, knochen};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,24 +7,48 @@ import type {FigurDaten} from '../figuren';
|
||||||
// verschieben, werden die Gitterpunkte über „Knochen“ gedreht und gestaucht.
|
// verschieben, werden die Gitterpunkte über „Knochen“ gedreht und gestaucht.
|
||||||
// So biegen sich Hals, Ohren, Schwanz usw. weich.
|
// So biegen sich Hals, Ohren, Schwanz usw. weich.
|
||||||
|
|
||||||
export type Gewicht = (x: number, y: number) => number;
|
// Gewicht eines Gitterpunkts für einen Knochen: entweder eine Formel in
|
||||||
|
// Bildkoordinaten oder der Name einer Maske (Graustufen-PNG in public/figuren/,
|
||||||
|
// gleich groß wie die Figur, weiß = bewegt sich voll mit).
|
||||||
|
export type Gewicht = ((x: number, y: number) => number) | {maske: string};
|
||||||
|
|
||||||
export type Knochen =
|
type Basis = {name: string; um: [number, number]; gewicht: Gewicht};
|
||||||
| {art: 'drehen'; um: [number, number]; grad: number; gewicht: Gewicht}
|
export type Knochen = (Basis & {art: 'drehen'; grad: number}) | (Basis & {art: 'stauchen'; sx: number; sy: number});
|
||||||
| {art: 'stauchen'; um: [number, number]; sx: number; sy: number; gewicht: Gewicht};
|
|
||||||
|
|
||||||
const RAND = 0.35; // Platz um das Bild, falls Teile über den Rand hinausschwingen
|
const RAND = 0.35; // Platz um das Bild, falls Teile über den Rand hinausschwingen
|
||||||
const SPALTEN = 48;
|
const ZELLE = 3; // Gitterweite in Bildpixeln
|
||||||
const ZEILEN = 64;
|
|
||||||
|
type Maske = {breite: number; hoehe: number; werte: Uint8ClampedArray};
|
||||||
|
|
||||||
|
const maskenWert = (m: Maske, x: number, y: number) => {
|
||||||
|
const ix = Math.min(m.breite - 1, Math.max(0, Math.round(x)));
|
||||||
|
const iy = Math.min(m.hoehe - 1, Math.max(0, Math.round(y)));
|
||||||
|
return m.werte[(iy * m.breite + ix) * 4] / 255;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ladeMaske = (name: string) =>
|
||||||
|
new Promise<Maske>((ok, fehler) => {
|
||||||
|
const bild = new Image();
|
||||||
|
bild.onload = () => {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = bild.width;
|
||||||
|
c.height = bild.height;
|
||||||
|
const ctx = c.getContext('2d')!;
|
||||||
|
ctx.drawImage(bild, 0, 0);
|
||||||
|
ok({breite: bild.width, hoehe: bild.height, werte: ctx.getImageData(0, 0, bild.width, bild.height).data});
|
||||||
|
};
|
||||||
|
bild.onerror = fehler;
|
||||||
|
bild.src = staticFile(`figuren/${name}.png`);
|
||||||
|
});
|
||||||
|
|
||||||
// Knochen werden der Reihe nach angewendet: zuerst die äußeren (Ohr),
|
// Knochen werden der Reihe nach angewendet: zuerst die äußeren (Ohr),
|
||||||
// dann die inneren (Hals), damit Kinder mit den Eltern mitwandern.
|
// dann die inneren (Hals), damit Kinder mit den Eltern mitwandern.
|
||||||
// Die Gewichte beziehen sich immer auf die Ruheposition.
|
// Die Gewichte beziehen sich auf die Ruheposition und werden pro Knochen einmal berechnet.
|
||||||
const verforme = (x: number, y: number, knochen: Knochen[]): [number, number] => {
|
const verforme = (n: number, x: number, y: number, knochen: Knochen[], gewichte: Map<string, Float32Array>): [number, number] => {
|
||||||
let px = x;
|
let px = x;
|
||||||
let py = y;
|
let py = y;
|
||||||
for (const k of knochen) {
|
for (const k of knochen) {
|
||||||
const w = k.gewicht(x, y);
|
const w = gewichte.get(k.name)![n];
|
||||||
if (w <= 0) continue;
|
if (w <= 0) continue;
|
||||||
const [ux, uy] = k.um;
|
const [ux, uy] = k.um;
|
||||||
if (k.art === 'drehen') {
|
if (k.art === 'drehen') {
|
||||||
|
|
@ -59,6 +83,8 @@ type Szene = {
|
||||||
kamera: THREE.OrthographicCamera;
|
kamera: THREE.OrthographicCamera;
|
||||||
pos: THREE.BufferAttribute;
|
pos: THREE.BufferAttribute;
|
||||||
ruhe: Float32Array;
|
ruhe: Float32Array;
|
||||||
|
masken: Map<string, Maske>;
|
||||||
|
gewichte: Map<string, Float32Array>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VerformFigur: React.FC<Props> = ({daten, hoehe, x, y, knochen, drehung = 0, stauchX = 1, stauchY = 1, filter}) => {
|
export const VerformFigur: React.FC<Props> = ({daten, hoehe, x, y, knochen, drehung = 0, stauchX = 1, stauchY = 1, filter}) => {
|
||||||
|
|
@ -85,6 +111,8 @@ export const VerformFigur: React.FC<Props> = ({daten, hoehe, x, y, knochen, dreh
|
||||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
const kamera = new THREE.OrthographicCamera(-mx, W + mx, my, -(H + my), -10, 10);
|
const kamera = new THREE.OrthographicCamera(-mx, W + mx, my, -(H + my), -10, 10);
|
||||||
|
|
||||||
|
const SPALTEN = Math.ceil(W / ZELLE);
|
||||||
|
const ZEILEN = Math.ceil(H / ZELLE);
|
||||||
const ruhe = new Float32Array((SPALTEN + 1) * (ZEILEN + 1) * 2);
|
const ruhe = new Float32Array((SPALTEN + 1) * (ZEILEN + 1) * 2);
|
||||||
const uv = new Float32Array((SPALTEN + 1) * (ZEILEN + 1) * 2);
|
const uv = new Float32Array((SPALTEN + 1) * (ZEILEN + 1) * 2);
|
||||||
const index: number[] = [];
|
const index: number[] = [];
|
||||||
|
|
@ -111,24 +139,40 @@ export const VerformFigur: React.FC<Props> = ({daten, hoehe, x, y, knochen, dreh
|
||||||
geo.setIndex(index);
|
geo.setIndex(index);
|
||||||
|
|
||||||
const welt = new THREE.Scene();
|
const welt = new THREE.Scene();
|
||||||
new THREE.TextureLoader().load(staticFile(daten.datei), (tex) => {
|
const maskenNamen = [...new Set(knochen.flatMap((k) => ('maske' in k.gewicht ? [k.gewicht.maske] : [])))];
|
||||||
|
const textur = new THREE.TextureLoader().loadAsync(staticFile(daten.datei));
|
||||||
|
Promise.all([textur, Promise.all(maskenNamen.map(ladeMaske))]).then(([tex, geladen]) => {
|
||||||
tex.colorSpace = THREE.SRGBColorSpace;
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
tex.premultiplyAlpha = true;
|
tex.premultiplyAlpha = true;
|
||||||
tex.anisotropy = 4;
|
tex.anisotropy = 4;
|
||||||
const mat = new THREE.MeshBasicMaterial({map: tex, transparent: true, premultipliedAlpha: true});
|
const mat = new THREE.MeshBasicMaterial({map: tex, transparent: true, premultipliedAlpha: true});
|
||||||
welt.add(new THREE.Mesh(geo, mat));
|
welt.add(new THREE.Mesh(geo, mat));
|
||||||
szene.current = {renderer, szene: welt, kamera, pos, ruhe};
|
const masken = new Map(maskenNamen.map((m, i) => [m, geladen[i]]));
|
||||||
|
szene.current = {renderer, szene: welt, kamera, pos, ruhe, masken, gewichte: new Map()};
|
||||||
setBereit(true);
|
setBereit(true);
|
||||||
});
|
});
|
||||||
return () => renderer.dispose();
|
return () => renderer.dispose();
|
||||||
|
// Die Knochen-Namen und Masken stehen beim ersten Frame fest.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [daten.datei, W, H, mx, my]);
|
}, [daten.datei, W, H, mx, my]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const sz = szene.current;
|
const sz = szene.current;
|
||||||
if (!sz) return;
|
if (!sz) return;
|
||||||
const n = sz.ruhe.length / 2;
|
const n = sz.ruhe.length / 2;
|
||||||
|
for (const k of knochen) {
|
||||||
|
if (sz.gewichte.has(k.name)) continue;
|
||||||
|
const g = new Float32Array(n);
|
||||||
|
const gw = k.gewicht;
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const [px, py] = verforme(sz.ruhe[i * 2], sz.ruhe[i * 2 + 1], knochen);
|
const x = sz.ruhe[i * 2];
|
||||||
|
const y = sz.ruhe[i * 2 + 1];
|
||||||
|
g[i] = 'maske' in gw ? maskenWert(sz.masken.get(gw.maske)!, x, y) : gw(x, y);
|
||||||
|
}
|
||||||
|
sz.gewichte.set(k.name, g);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const [px, py] = verforme(i, sz.ruhe[i * 2], sz.ruhe[i * 2 + 1], knochen, sz.gewichte);
|
||||||
sz.pos.setXYZ(i, px, -py, 0);
|
sz.pos.setXYZ(i, px, -py, 0);
|
||||||
}
|
}
|
||||||
sz.pos.needsUpdate = true;
|
sz.pos.needsUpdate = true;
|
||||||
|
|
|
||||||
356
remotion/werkzeuge/rig.py
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
"""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, 244), (112, 156), 14,
|
||||||
|
[(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", (400, 465), (420, 320), 26,
|
||||||
|
[(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), (380, 505), (392, 540), (372, 556), (345, 548), (330, 515), (318, 480)]),
|
||||||
|
("arm_rechts", "koerper", (458, 468), (455, 545), 10,
|
||||||
|
[(445, 465), (472, 470), (478, 520), (470, 556), (442, 556), (440, 510)]),
|
||||||
|
("fuss_links", "koerper", (310, 568), (320, 645), 10,
|
||||||
|
[(305, 565), (355, 563), (360, 600), (350, 655), (315, 658), (305, 620)]),
|
||||||
|
("fuss_rechts", "koerper", (495, 600), (540, 575), 10,
|
||||||
|
[(495, 560), (540, 548), (554, 575), (545, 640), (505, 640), (490, 600)]),
|
||||||
|
],
|
||||||
|
"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)]),
|
||||||
|
("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)
|
||||||
|
|
||||||
|
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()
|
||||||