@energy8platform/golem 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/editor.css +3 -4
- package/dist/editor.js +108 -61
- package/dist/lib/cli.js +101 -70
- package/dist/lib/cli.js.map +4 -4
- package/dist/lib/e8/agent.d.ts +35 -0
- package/dist/lib/e8/host.d.ts +22 -0
- package/dist/lib/e8/node.d.ts +66 -0
- package/dist/lib/e8/runtime.d.ts +10 -0
- package/dist/lib/e8/schema.d.ts +3 -0
- package/dist/lib/e8/types.d.ts +80 -0
- package/dist/lib/e8-agent.js +6781 -0
- package/dist/lib/e8-agent.js.map +7 -0
- package/dist/lib/e8-client.js +116 -0
- package/dist/lib/e8-host.js +6929 -0
- package/dist/lib/e8-host.js.map +7 -0
- package/dist/lib/e8-runtime.js +2024 -0
- package/dist/lib/e8-runtime.js.map +7 -0
- package/dist/lib/e8-schema.js +7 -0
- package/dist/lib/e8-schema.js.map +7 -0
- package/dist/lib/editor/api.d.ts +1 -0
- package/dist/lib/editor/embed.d.ts +9 -0
- package/dist/lib/editor/server.d.ts +23 -1
- package/dist/lib/editor/store.d.ts +2 -0
- package/dist/lib/editor/timeline.d.ts +15 -0
- package/dist/lib/editor-entry.d.ts +1 -1
- package/dist/lib/editor-entry.js +102 -70
- package/dist/lib/editor-entry.js.map +4 -4
- package/dist/lib/rig-import-layers.d.ts +2 -0
- package/dist/lib/rig-version.d.ts +12 -0
- package/dist/lib/tool-schema.d.ts +3 -0
- package/dist/lib/tools.d.ts +2 -0
- package/dist/lib/tools.js +49 -10
- package/dist/lib/tools.js.map +4 -4
- package/editor.html +2 -2
- package/package.json +38 -3
- package/skills/e8-golem/SKILL.md +71 -0
- package/skills/golem-symbol-animation/SKILL.md +87 -0
- package/skills/golem-symbol-animation/agents/openai.yaml +4 -0
- package/skills/golem-symbol-animation/references/facial-controls.md +48 -0
- package/skills/golem-symbol-animation/references/game-engine-integration.md +133 -0
- package/skills/golem-symbol-animation/references/golem-authoring.md +138 -0
- package/skills/golem-symbol-animation/references/interactive-editor.md +17 -0
- package/skills/golem-symbol-animation/references/motion-craft.md +113 -0
- package/skills/golem-symbol-animation/references/packed-delivery.md +88 -0
- package/skills/golem-symbol-animation/references/quantitative-qa.md +106 -0
- package/skills/golem-symbol-animation/references/spine-rive-study.md +87 -0
- package/skills/golem-symbol-animation/scripts/atlas_parts.py +87 -0
- package/skills/golem-symbol-animation/scripts/check_package.py +71 -0
- package/skills/golem-symbol-animation/scripts/image_gates.py +143 -0
- package/skills/golem-symbol-cutting/SKILL.md +70 -0
- package/skills/golem-symbol-cutting/agents/openai.yaml +4 -0
- package/skills/golem-symbol-cutting/assets/h1/h1.atlas.png +0 -0
- package/skills/golem-symbol-cutting/assets/h1/h1.png +0 -0
- package/skills/golem-symbol-cutting/references/cutting-workflow.md +92 -0
- package/skills/golem-symbol-cutting/references/facial-layers.md +44 -0
- package/skills/golem-symbol-cutting/references/generation-workflow.md +109 -0
- package/skills/golem-symbol-cutting/references/golem-handoff.md +62 -0
- package/skills/golem-symbol-cutting/references/h1-example.md +46 -0
- package/skills/golem-symbol-cutting/references/hybrid-workflow.md +119 -0
- package/skills/golem-symbol-cutting/references/registration-and-motion.md +89 -0
- package/skills/golem-symbol-cutting/references/spine-rive-construction.md +73 -0
- package/skills/golem-symbol-cutting/scripts/atlas_parts.py +87 -0
- package/skills/golem-symbol-cutting/scripts/hybrid_parts.py +279 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Approved artwork with generated hidden surfaces
|
|
2
|
+
|
|
3
|
+
Use this route when identity, silhouette, expression and visible ink must match approved art.
|
|
4
|
+
A complete generated atlas is a different brief. Parts may mix approved visible pixels,
|
|
5
|
+
generated hidden continuations, and explicitly redrawn props; record this per part.
|
|
6
|
+
|
|
7
|
+
## Partition by ownership, not rectangles
|
|
8
|
+
|
|
9
|
+
Measure the original canvas. Mark semantic paint regions using seeds and polygons along
|
|
10
|
+
ink. Order layers back to front before cutting: the shared line travels with the foreground
|
|
11
|
+
part. Give a thick contour enough `inkPx` to retain its whole stroke; inspect both sides of
|
|
12
|
+
that contour in motion. A polygon may split one unbroken paint region (`raw`, optionally
|
|
13
|
+
`within` a prior owner). Use `minus` to correct the claim locally.
|
|
14
|
+
|
|
15
|
+
Run `scripts/hybrid_parts.py partition plan.json masks/`. Example plan (illustrative coordinates):
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"source": "approved.png",
|
|
20
|
+
"ink": 40,
|
|
21
|
+
"layers": [
|
|
22
|
+
{"id": "coat", "seeds": [[110, 160]], "inkPx": 3},
|
|
23
|
+
{"id": "sleeve", "seeds": [[175, 150]], "inkPx": 4}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The script produces one ownership mask per part and a report of unassigned pixels, areas
|
|
29
|
+
and island counts. Every nonzero-alpha source pixel must have one owner. It assigns nearby
|
|
30
|
+
unclaimed ink deterministically; this is not semantic recognition. Inspect the result and
|
|
31
|
+
correct seeds/polygons when another feature gets assigned to the wrong part.
|
|
32
|
+
|
|
33
|
+
Do not automatically give all small islands to a neighbour: eyelashes, fang tips and sparks
|
|
34
|
+
can be meaningful. Transfer a confirmed stray through explicit masks, retaining the source.
|
|
35
|
+
If the mesh tracer would omit an intentional island, use `rig_mesh_from_image`
|
|
36
|
+
`minIslandRatio: 0` or a separate registered attachment; do not alter the approved drawing
|
|
37
|
+
just to satisfy a tracer threshold.
|
|
38
|
+
|
|
39
|
+
## Repair hidden anatomy and ragged props
|
|
40
|
+
|
|
41
|
+
Define each part's complete silhouette separately from its visible ownership. The repair
|
|
42
|
+
region is the complete shape hidden by foreground layers, with real overlap through the
|
|
43
|
+
planned joint range. It is not an arbitrary dilation into neighbouring anatomy.
|
|
44
|
+
|
|
45
|
+
`hybrid_parts.py guide approved.png own.png hidden.png guide.png` preserves the owned
|
|
46
|
+
pixels and marks the repair in magenta. The guide is an authoring aid; use the approved art
|
|
47
|
+
as an additional style reference. Prompt templates and known failure modes are in
|
|
48
|
+
[generation-workflow.md](generation-workflow.md). Explicitly say that the removed occluder
|
|
49
|
+
must not be reconstructed. Judge scale and colour after generation, not from the prompt.
|
|
50
|
+
|
|
51
|
+
Keep stable, non-collinear landmarks away from the changed region (two distinct points
|
|
52
|
+
suffice for a similarity fit; three or more expose inconsistency). For example:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{"source": [[70,90],[160,90],[100,180]], "generated": [[74,92],[168,92],[105,186]]}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Run `hybrid_parts.py extract approved.png generated.png hidden.png own.png landmarks.json fill.png`.
|
|
59
|
+
Optional `--background-mask`, `--tone-mask`, `--ink-mask`, `--feather` and `--max-fit-error`
|
|
60
|
+
control the ordered cleanup. Masks use source-canvas coordinates after fitting:
|
|
61
|
+
|
|
62
|
+
1. Fit scale/rotation/translation; fail if the landmark residual exceeds the declared limit.
|
|
63
|
+
2. Clear only reviewed background pixels. White skin highlights and eye whites remain art.
|
|
64
|
+
3. Measure tone correction in unchanged opaque host material, never neighbouring skin/fabric.
|
|
65
|
+
4. Blend the repair boundary toward the nearest owned colour, preserving its alpha.
|
|
66
|
+
5. Replace a pale outline fringe only in a reviewed ink mask using the host's ink.
|
|
67
|
+
|
|
68
|
+
The final repair is clipped to the explicit region and fails on remaining guide magenta.
|
|
69
|
+
A fit report records scale, angle, residual and colour shift. This does not establish that the
|
|
70
|
+
painted hidden anatomy is correct: inspect the full output and exposed motion frames.
|
|
71
|
+
|
|
72
|
+
For a ragged prop, generate the entire object and register it with the same landmarks.
|
|
73
|
+
Declare it as `replacement` in assembly, with its approved contour change recorded. A rigid
|
|
74
|
+
camera housing and flexible strap usually need separate controls/weights. A closed eyelid
|
|
75
|
+
can be an authored stroke or source-derived variant if the art supports that; do not regenerate
|
|
76
|
+
a whole head solely to blink. Every variant must retain shared facial landmarks.
|
|
77
|
+
|
|
78
|
+
## Assemble without revealing backing
|
|
79
|
+
|
|
80
|
+
Run `hybrid_parts.py assemble assembly.json work/`. Example part:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"source": "approved.png",
|
|
85
|
+
"parts": [
|
|
86
|
+
{"id":"coat", "own":"masks/coat.png", "shape":"shapes/coat.png",
|
|
87
|
+
"fill":"fills/coat.png", "joint":[120,180], "parent":"root", "underlap":2},
|
|
88
|
+
{"id":"sleeve", "own":"masks/sleeve.png", "shape":"shapes/sleeve.png",
|
|
89
|
+
"joint":[170,120], "parent":"coat", "underlap":2}
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`parts` are back to front. Canvas dimensions come from the source, not fixed character
|
|
95
|
+
constants. The script writes trimmed RGBA layers, `layers/layers.json`, debug fill masks,
|
|
96
|
+
an assembled PNG and provenance. The import document uses the source canvas coordinates;
|
|
97
|
+
resize/offset once in the rig hierarchy if the game canvas differs.
|
|
98
|
+
Debug masks include generated fills, deterministic underlap and all backing pixels, so the
|
|
99
|
+
rest-pose visibility check measures every hidden repair rather than only generator output.
|
|
100
|
+
|
|
101
|
+
Underlap uses the part's own colour, stays inside its complete silhouette, and lies beneath
|
|
102
|
+
fully opaque foreground pixels. Generated fills sit deeper than the antialiased edge. Do not
|
|
103
|
+
copy the front layer's ink into the backing: it becomes a ghost contour when the front moves.
|
|
104
|
+
A static underpainting is optional and must remain well inside the silhouette, composed only
|
|
105
|
+
from declared stable surfaces. It must not contain a rest copy of a moving leg, tail or scarf.
|
|
106
|
+
Prefer local repair where a static underpainting would conceal a legitimate changing gap.
|
|
107
|
+
For a justified static backing, the assembly plan accepts
|
|
108
|
+
`"backing": {"id":"underpaint","parts":["coat"],"inset":12,"joint":[120,180],"bone":"root"}`.
|
|
109
|
+
The inset is in source pixels and must be positive; choose it from the motion clearance,
|
|
110
|
+
then inspect inward swings for exposed ghost surfaces.
|
|
111
|
+
|
|
112
|
+
Render the normal and magenta-debug assembly at rest and at joint extrema. Generated fill
|
|
113
|
+
pixels should be invisible in approved rest art, except explicitly reviewed replacement
|
|
114
|
+
regions. Test the actual rig, including semitransparent edges and texture resampling; an
|
|
115
|
+
ownership partition alone does not prove a clean composite.
|
|
116
|
+
|
|
117
|
+
Pass placements and provenance directly into animation. See
|
|
118
|
+
[quantitative-qa.md](../../golem-symbol-animation/references/quantitative-qa.md) for current
|
|
119
|
+
capture verification, reference holes, exclusion limits and negative tests.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Register features before accepting motion
|
|
2
|
+
|
|
3
|
+
Use when generating facial parts, adding expression variants/FX, or fixing size,
|
|
4
|
+
placement and overlap. The acceptance target is the composed character in motion.
|
|
5
|
+
|
|
6
|
+
## Establish a measured reference
|
|
7
|
+
|
|
8
|
+
1. Inspect the supplied setup art and existing action references before generating.
|
|
9
|
+
For a spritesheet, identify its cell dimensions and the relevant expression
|
|
10
|
+
frames; sheet packing alone does not establish timing or action semantics.
|
|
11
|
+
2. Record the reference-to-document transform using stable landmarks: eye centers,
|
|
12
|
+
nose base, mouth corners/upper lip, chin, neck contact and clothing anchors as
|
|
13
|
+
relevant. Distinguish reference pixels, generated pixels, document pixels and
|
|
14
|
+
rendered cell pixels. Atlas dimensions are not a scale reference.
|
|
15
|
+
3. For each variant record its intended width relative to the face, upper-lip or
|
|
16
|
+
other fixed anchor, center/baseline offset, and allowed contour changes. Use the
|
|
17
|
+
matching reference frame when available. Without a matching frame, use accepted
|
|
18
|
+
neighboring states and the brief, and label the choice as authored. Do not claim
|
|
19
|
+
to have measured an expression that the reference does not contain.
|
|
20
|
+
|
|
21
|
+
A crop's opaque bbox is useful for detecting padding, but teeth height, cavity
|
|
22
|
+
shape and painted cheek content affect perceived size. Equal bbox dimensions or
|
|
23
|
+
centers do not guarantee equal mouth size or vertical registration. A common scale
|
|
24
|
+
factor works only if the variants already share verified registration; independent
|
|
25
|
+
generations must each be fitted to the same landmark system. Prefer uniform scaling
|
|
26
|
+
for intact parts; inspect any nonuniform fitting for unintended distortion.
|
|
27
|
+
|
|
28
|
+
For a layer containing paired features, measure and register each feature separately.
|
|
29
|
+
Shrinking the pair's bounding box also shrinks the distance between the features;
|
|
30
|
+
small replacement pupils or symbols should normally keep the accepted eye spacing.
|
|
31
|
+
Use independent atlas regions/attachments or preserve their document-space offsets
|
|
32
|
+
on a shared canvas. Match each feature's anchor, not only the pair's centre.
|
|
33
|
+
|
|
34
|
+
## Construct local changes and actual occluders
|
|
35
|
+
|
|
36
|
+
- A mouth-only variant contains the intended line/lips/cavity/teeth/tongue, not
|
|
37
|
+
unrelated cheeks, nose, beard or a new face silhouette. Specify these exclusions
|
|
38
|
+
in generation prompts and inspect the result. Remove the original mouth from the
|
|
39
|
+
base and restore its backing. Reject or regenerate oversized semantic patches
|
|
40
|
+
instead of merely shrinking their unwanted face pixels.
|
|
41
|
+
- Keep the accepted head silhouette and stable landmarks. Full-head replacements
|
|
42
|
+
are appropriate for a required view or global expression change; for a local
|
|
43
|
+
mouth/blink, prefer local attachments or deformation. A shared generation canvas
|
|
44
|
+
is not proof that independently generated heads match.
|
|
45
|
+
- Define draw order for the actual overlap, for example back hood/torso → neck →
|
|
46
|
+
front collar → head/beard → mouth/FX. This is an anatomy-dependent example, not a
|
|
47
|
+
universal hierarchy. Generate a front collar when required, not a torso crop
|
|
48
|
+
masquerading as one. Check that it covers the neck without cutting the chin or
|
|
49
|
+
beard through the planned rotations and translations.
|
|
50
|
+
- Register eye glow to the final fitted eye locations, and horn highlights to the
|
|
51
|
+
final horn. Parent them to the corresponding control. Recompute their local
|
|
52
|
+
placement after changing the base or expression registration. Inspect every
|
|
53
|
+
state where the effect is visible; one aligned setup does not prove the swaps.
|
|
54
|
+
|
|
55
|
+
## Account for articulation and the composed transform
|
|
56
|
+
|
|
57
|
+
Fit features after accounting for attachment size/center, bone hierarchy and keyed
|
|
58
|
+
scale/translation/deformation. A PNG that fits the head at setup can still spill
|
|
59
|
+
outside it during an action. Unless the brief calls for expansion, keep mouth
|
|
60
|
+
corners within the intended cheek contour and align the stable upper-lip/nose
|
|
61
|
+
relationship; increasing openness need not increase outer width or move the whole
|
|
62
|
+
mouth upward.
|
|
63
|
+
|
|
64
|
+
When the intended performance opens the jaw, prepare continuous lower-face/chin
|
|
65
|
+
art and a mouth cavity that cover the motion. In animation, use a suitable mesh or
|
|
66
|
+
jaw control with a smooth influence into the lower face. Keep nose/upper-face
|
|
67
|
+
landmarks anchored unless their movement is deliberate. Coordinate the mouth with
|
|
68
|
+
the moving surface and check the whole opening/closing path, not only its peak.
|
|
69
|
+
Mesh topology and vertex counts do not themselves establish correct articulation.
|
|
70
|
+
|
|
71
|
+
## Review a complete correction
|
|
72
|
+
|
|
73
|
+
For each affected action, render a comparison using the same camera, background
|
|
74
|
+
and scale, with setup, every expression, both sides of each switch and extremes.
|
|
75
|
+
Include a face/joint closeup and playback at actual cell size and speed. Check:
|
|
76
|
+
|
|
77
|
+
- silhouette/proportions and stable landmarks against the reference;
|
|
78
|
+
- all variants, including the largest mouth, and simultaneous FX;
|
|
79
|
+
- mouth backing, neck/collar coverage and absence of duplicate outlines;
|
|
80
|
+
- interpolation, mesh folds, jaw closing, loop seams and return to idle;
|
|
81
|
+
- whether idle and the intended accent are perceptible in normal playback.
|
|
82
|
+
|
|
83
|
+
After a user reports a defect, turn it into an observable criterion, for example
|
|
84
|
+
“upper lip stays below the nose and mouth corners remain inside both cheeks during
|
|
85
|
+
all three states.” Preserve accepted parts and correct the failed relationship.
|
|
86
|
+
If the same defect survives a correction, revisit the reference transform,
|
|
87
|
+
landmarks, runtime transforms or semantic part boundary before trying another
|
|
88
|
+
arbitrary percentage. Do not report “matches exactly” from a bbox measurement or
|
|
89
|
+
“fixed” from a build/rig checker. Describe the visual evidence and remaining limits.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Construction lessons from Spine and Rive
|
|
2
|
+
|
|
3
|
+
Use this reference for a new character, large joint motion, turns, reuse of a public rig,
|
|
4
|
+
or a decision between cuts and meshes. These are construction choices, not a fixed recipe.
|
|
5
|
+
The examples below were inspected on 2026-10-05; distinguish source-file measurements,
|
|
6
|
+
runtime observation and an interpretation of a finished image.
|
|
7
|
+
|
|
8
|
+
## Obtain evidence before cutting
|
|
9
|
+
|
|
10
|
+
A Spine reference is useful when its skeleton JSON, text atlas and all page images are
|
|
11
|
+
available together. Keep the exporter version, selected skin, animation names and source
|
|
12
|
+
URL. A `.spine` project contains authoring information; a rendered GIF only shows the
|
|
13
|
+
result. For Rive, preserve the `.riv`, artboard and named timeline/state machine. Loading
|
|
14
|
+
a `.riv` in a runtime reveals playback and exposed inputs; it does not prove recovery of
|
|
15
|
+
the editor's paths, bone weights or original layered artwork. Do not describe observation
|
|
16
|
+
of a finished animation as measurement of its topology.
|
|
17
|
+
|
|
18
|
+
Public availability is not permission to redistribute artwork. Record its actual license
|
|
19
|
+
or reuse terms with the source. Use examples to learn mechanisms; keep downloaded source
|
|
20
|
+
art outside a production delivery unless its use is authorized.
|
|
21
|
+
|
|
22
|
+
## Choose the smallest construction that carries the action
|
|
23
|
+
|
|
24
|
+
| Example / source | Construction lesson | Application to cutting |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| [Spine Raptor](https://esotericsoftware.com/spine-examples-raptor) | A continuous weighted surface spans several bones; independent controls preserve contacts | A thigh need not become one PNG per bone. Keep the joint's painted continuation and blend only where it should bend |
|
|
27
|
+
| [Spine Stretchyman](https://esotericsoftware.com/spine-examples-stretchyman) | IK controls endpoints while paths shape flexible limbs | Prepare a continuous limb strip with stable ends and inspect its most compressed curve before committing to a mesh |
|
|
28
|
+
| [Spine Spineboy](https://esotericsoftware.com/spine-examples-spineboy) | Rigid parts, deformation, attachment swaps and clipping coexist | Keep rigid hands/props rigid, prepare local swaps, and draw the complete masked object rather than cutting the currently visible fragment |
|
|
29
|
+
| [Spine Mix and Match](https://esotericsoftware.com/spine-examples-mix-and-match) | Appearance variants share controls and placeholders | Register variants to joints and semantic landmarks; do not assemble arbitrary first attachments from different skins |
|
|
30
|
+
| [Rive bones](https://rive.app/docs/editor/manipulating-shapes/bones) | Parenting moves a whole element; binding deforms raster vertices or vector points/handles | Choose rigid parenting for shoes/hands, weighted deformation for sleeves; vector curves are not a raster atlas to crop |
|
|
31
|
+
| [Rive meshes](https://rive.app/docs/editor/manipulating-shapes/meshes) | A raster image can retain its mesh while its image changes | Reuse only when dimensions, landmarks and intended deformation correspond; shared topology does not guarantee matching artwork |
|
|
32
|
+
| [Rive Solos](https://rive.app/docs/editor/manipulating-shapes/solos) | Mutually exclusive drawings occupy one logical control | Place eyelid, mouth or grip alternatives in one swap slot when they replace each other; isolate simultaneously visible teeth/tongue separately |
|
|
33
|
+
|
|
34
|
+
For every required extreme, record: which edge becomes visible, which feature is rigid,
|
|
35
|
+
which surface bends, what remains in contact, what changes drawing and what crosses an
|
|
36
|
+
occluder. This defines the cuts more reliably than naming anatomical parts first.
|
|
37
|
+
|
|
38
|
+
## Coverage, mesh topology and variants
|
|
39
|
+
|
|
40
|
+
Separate three margins: painted joint backing covers articulation; transparent padding
|
|
41
|
+
allows texture sampling; packing extrusion protects atlas edges. They solve different
|
|
42
|
+
problems. A mask changes visibility and cannot provide missing skin, cloth or outline.
|
|
43
|
+
|
|
44
|
+
Keep triangles local to the material they deform. Do not bridge an open mouth, the gap
|
|
45
|
+
between legs, a disconnected spark or a silhouette notch just to obtain a smaller mesh.
|
|
46
|
+
Put enough vertices around a bend to retain volume, then use few enough to remain editable.
|
|
47
|
+
Pin rigid tips and landmarks; blend adjacent bones around the joint rather than smearing
|
|
48
|
+
weights over the entire image. Auto tracing/weights are starting points; inspect the bind
|
|
49
|
+
pose and both bending directions. Do not transfer numerical weights between unrelated rigs.
|
|
50
|
+
|
|
51
|
+
A torso, forearm or head changing front/back orientation may need alternate views or
|
|
52
|
+
registered segment swaps. Mark the switching pose and matching landmarks during cutting.
|
|
53
|
+
Design the front/back layer relationship at contact: a prop may need a rear body and a
|
|
54
|
+
foreground grip so fingers can cross it without changing the bone hierarchy.
|
|
55
|
+
|
|
56
|
+
For a face, define the clean backing, eyelid coverage, pupil containment, mouth cavity,
|
|
57
|
+
and expression ownership. A local expression should not change the head silhouette by
|
|
58
|
+
accident. Test combined gaze × blink × emotion × mouth opening, not only isolated drawings.
|
|
59
|
+
Rive's public `look.riv` and `birb.riv` demonstrate independent named facial/motion timelines;
|
|
60
|
+
their runtime playback alone does not establish whether a particular feature uses a swap
|
|
61
|
+
or deformation internally.
|
|
62
|
+
|
|
63
|
+
## Atlas evidence and handoff
|
|
64
|
+
|
|
65
|
+
Spine attachment keys are skin placeholders. An attachment's `path`, or its `name` override,
|
|
66
|
+
can select a different texture region. Linked meshes share geometry while using another
|
|
67
|
+
image. Sequence numbering has a declared start (Spine defaults to 1); never infer frame
|
|
68
|
+
chronology from packing coordinates. Retain original size, trim offset, packed rotation,
|
|
69
|
+
page and source-to-document placement independently.
|
|
70
|
+
|
|
71
|
+
Choose a coherent skin explicitly before validating the setup. Record inaccessible source
|
|
72
|
+
layers and absent views as missing evidence. Include the hardest bend, the occlusion crossing
|
|
73
|
+
and the swap boundary in the cutting QA handoff; pass that same brief to animation.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Inspect a PNG and crop parts using an agent-authored manifest; requires Pillow."""
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from PIL import Image, ImageDraw
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def checker(size):
|
|
12
|
+
bg = Image.new('RGBA', size, '#b7bcc4')
|
|
13
|
+
draw = ImageDraw.Draw(bg)
|
|
14
|
+
for y in range(0, size[1], 20):
|
|
15
|
+
for x in range(0, size[0], 20):
|
|
16
|
+
if (x // 20 + y // 20) % 2:
|
|
17
|
+
draw.rectangle((x, y, x + 19, y + 19), fill='#e5e7eb')
|
|
18
|
+
return bg
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run():
|
|
22
|
+
parser = argparse.ArgumentParser(description=__doc__, epilog='Manifest: [{"id":"head","rect":[x,y,width,height]}, ...]. Coordinates are ORIGINAL PNG pixels, manually selected from raster inspection. Output PNG crops preserve all RGBA values, without trimming, rotation or alpha cleanup.')
|
|
23
|
+
parser.add_argument('atlas', type=Path)
|
|
24
|
+
parser.add_argument('out_dir', type=Path)
|
|
25
|
+
parser.add_argument('--manifest', type=Path, help='Agent-authored JSON crop list; omit for inspection only')
|
|
26
|
+
parser.add_argument('--inspection-width', type=int, default=1600)
|
|
27
|
+
parser.add_argument('--overwrite', action='store_true', help='Replace only this invocation\'s named outputs')
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
if args.inspection_width < 1:
|
|
30
|
+
parser.error('--inspection-width must be positive')
|
|
31
|
+
with Image.open(args.atlas) as source:
|
|
32
|
+
if source.format != 'PNG':
|
|
33
|
+
parser.error('input must be a PNG')
|
|
34
|
+
image = source.convert('RGBA')
|
|
35
|
+
specs = json.loads(args.manifest.read_text()) if args.manifest else []
|
|
36
|
+
if not isinstance(specs, list):
|
|
37
|
+
parser.error('manifest must be a list of {id, rect} objects')
|
|
38
|
+
ids = set()
|
|
39
|
+
for item in specs:
|
|
40
|
+
if not isinstance(item, dict) or set(item) != {'id', 'rect'}:
|
|
41
|
+
parser.error('each part must contain exactly id and rect')
|
|
42
|
+
name, rect = item['id'], item['rect']
|
|
43
|
+
if not isinstance(name, str) or not re.fullmatch(r'[a-z][a-z0-9_]*', name) or name in ids:
|
|
44
|
+
parser.error('part IDs must be unique snake_case names')
|
|
45
|
+
ids.add(name)
|
|
46
|
+
if not isinstance(rect, list) or len(rect) != 4 or any(type(v) is not int for v in rect):
|
|
47
|
+
parser.error(f'{name}: rect must contain four integers')
|
|
48
|
+
x, y, w, h = rect
|
|
49
|
+
if x < 0 or y < 0 or w < 1 or h < 1 or x + w > image.width or y + h > image.height:
|
|
50
|
+
parser.error(f'{name}: rectangle is outside {image.width}x{image.height}')
|
|
51
|
+
outputs = [args.out_dir / 'inspection.jpg', args.out_dir / 'source-record.json']
|
|
52
|
+
if specs:
|
|
53
|
+
outputs += [args.out_dir / 'parts.jpg'] + [args.out_dir / 'layers' / f'{s["id"]}.png' for s in specs]
|
|
54
|
+
protected = {args.atlas.resolve()}
|
|
55
|
+
if args.manifest:
|
|
56
|
+
protected.add(args.manifest.resolve())
|
|
57
|
+
for output in outputs:
|
|
58
|
+
if output.resolve() in protected:
|
|
59
|
+
parser.error(f'output would overwrite an input: {output}')
|
|
60
|
+
if output.exists() and not args.overwrite:
|
|
61
|
+
parser.error(f'output exists: {output}; choose another directory or --overwrite')
|
|
62
|
+
args.out_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
inspection = checker(image.size)
|
|
64
|
+
inspection.alpha_composite(image)
|
|
65
|
+
inspection.thumbnail((args.inspection_width, image.height))
|
|
66
|
+
inspection.convert('RGB').save(outputs[0], quality=92)
|
|
67
|
+
record = {'source': str(args.atlas.resolve()), 'sha256': hashlib.sha256(args.atlas.read_bytes()).hexdigest(), 'size': list(image.size), 'inspectionSize': list(inspection.size), 'sourcePixelsPerInspectionPixel': [image.width / inspection.width, image.height / inspection.height], 'parts': specs, 'operations': 'RGBA crop only; no trim, alpha cleanup, rotation'}
|
|
68
|
+
if specs:
|
|
69
|
+
(args.out_dir / 'layers').mkdir(exist_ok=True)
|
|
70
|
+
sheet = checker((1000, ((len(specs) + 3) // 4) * 240))
|
|
71
|
+
draw = ImageDraw.Draw(sheet)
|
|
72
|
+
for i, item in enumerate(specs):
|
|
73
|
+
x, y, w, h = item['rect']
|
|
74
|
+
part = image.crop((x, y, x + w, y + h))
|
|
75
|
+
part.save(args.out_dir / 'layers' / f'{item["id"]}.png')
|
|
76
|
+
part.thumbnail((230, 190))
|
|
77
|
+
sx, sy = (i % 4) * 250, (i // 4) * 240
|
|
78
|
+
sheet.alpha_composite(part, (sx + (250 - part.width) // 2, sy + 42))
|
|
79
|
+
draw.text((sx + 7, sy + 5), item['id'], fill='black')
|
|
80
|
+
draw.text((sx + 7, sy + 20), f'{x},{y} {w}x{h}', fill='black')
|
|
81
|
+
sheet.convert('RGB').save(args.out_dir / 'parts.jpg', quality=92)
|
|
82
|
+
outputs[1].write_text(json.dumps(record, indent=2) + '\n')
|
|
83
|
+
print(json.dumps({'out': str(args.out_dir.resolve()), 'size': list(image.size), 'parts': len(specs)}))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == '__main__':
|
|
87
|
+
run()
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Approved art + generated hidden surfaces. Dependencies: Pillow, numpy.
|
|
3
|
+
|
|
4
|
+
Run --help. All masks and landmarks use ORIGINAL source-canvas pixels. No character-specific sizes.
|
|
5
|
+
Partition plans: {source, layers:[{id,seeds:[[x,y]],polys:[[[x,y],...]],raw:[...],minus:[...],inkPx:4}]}
|
|
6
|
+
Layers are back-to-front. Shared ink belongs to the foreground claimant; islands are preserved.
|
|
7
|
+
Assembly plans: {source, parts:[{id,own,shape,fill?,replacement?,joint:[x,y],bone?,parent?,underlap:2}]}.
|
|
8
|
+
Paths are relative to the plan. own/shape are grayscale masks on the source canvas; fill/replacement RGBA.
|
|
9
|
+
"""
|
|
10
|
+
import argparse
|
|
11
|
+
from collections import deque
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import numpy as np
|
|
17
|
+
from PIL import Image, ImageDraw, ImageFilter
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def rgba(path):
|
|
21
|
+
return np.array(Image.open(path).convert('RGBA'))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def mask(path, shape):
|
|
25
|
+
a = np.array(Image.open(path).convert('L')) > 127
|
|
26
|
+
if a.shape != shape:
|
|
27
|
+
raise ValueError(f'mask {path}: dimensions differ from source')
|
|
28
|
+
return a
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def polygon(shape, polys):
|
|
32
|
+
im = Image.new('L', (shape[1], shape[0]))
|
|
33
|
+
draw = ImageDraw.Draw(im)
|
|
34
|
+
for p in polys:
|
|
35
|
+
draw.polygon([tuple(x) for x in p], fill=255)
|
|
36
|
+
return np.array(im) > 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def dilate(m, radius):
|
|
40
|
+
if radius < 0 or int(radius) != radius:
|
|
41
|
+
raise ValueError('dilation radius must be a nonnegative integer')
|
|
42
|
+
return np.array(Image.fromarray(m.astype('uint8') * 255).filter(ImageFilter.MaxFilter(radius * 2 + 1))) > 0 if radius else m.copy()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def labels(m):
|
|
46
|
+
"""Four-connected components, preserving small islands; returns labels and areas."""
|
|
47
|
+
h, w = m.shape
|
|
48
|
+
out = np.zeros((h, w), np.int32)
|
|
49
|
+
sizes = [0]
|
|
50
|
+
for y, x in zip(*np.nonzero(m)):
|
|
51
|
+
if out[y, x]:
|
|
52
|
+
continue
|
|
53
|
+
i = len(sizes)
|
|
54
|
+
q = deque([(y, x)])
|
|
55
|
+
out[y, x] = i
|
|
56
|
+
size = 0
|
|
57
|
+
while q:
|
|
58
|
+
cy, cx = q.popleft()
|
|
59
|
+
size += 1
|
|
60
|
+
for ny, nx in ((cy-1,cx),(cy+1,cx),(cy,cx-1),(cy,cx+1)):
|
|
61
|
+
if 0 <= ny < h and 0 <= nx < w and m[ny, nx] and not out[ny, nx]:
|
|
62
|
+
out[ny, nx] = i
|
|
63
|
+
q.append((ny, nx))
|
|
64
|
+
sizes.append(size)
|
|
65
|
+
return out, np.array(sizes)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def nearest(own):
|
|
69
|
+
"""Deterministic nearest (Manhattan distance) source indices and distance; empty source is an error."""
|
|
70
|
+
if not own.any():
|
|
71
|
+
raise ValueError('nearest-colour source mask is empty')
|
|
72
|
+
h, w = own.shape
|
|
73
|
+
iy, ix = np.indices(own.shape)
|
|
74
|
+
dist = np.full(own.shape, -1, np.int32)
|
|
75
|
+
dist[own] = 0
|
|
76
|
+
edge = own & dilate(~own, 1)
|
|
77
|
+
q = deque(zip(*np.nonzero(edge)))
|
|
78
|
+
while q:
|
|
79
|
+
y, x = q.popleft()
|
|
80
|
+
for ny, nx in ((y-1,x),(y+1,x),(y,x-1),(y,x+1)):
|
|
81
|
+
if 0 <= ny < h and 0 <= nx < w and dist[ny, nx] < 0:
|
|
82
|
+
dist[ny,nx] = dist[y,x] + 1
|
|
83
|
+
iy[ny,nx], ix[ny,nx] = iy[y,x], ix[y,x]
|
|
84
|
+
q.append((ny,nx))
|
|
85
|
+
return iy, ix, dist
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def partition(ref, layers, ink=40):
|
|
89
|
+
visible = ref[...,3] > 0
|
|
90
|
+
inkm = visible & (ref[...,:3].max(axis=2) < ink)
|
|
91
|
+
lab, size = labels(visible & ~inkm)
|
|
92
|
+
owner = np.full(visible.shape, -1, np.int32)
|
|
93
|
+
claims = []
|
|
94
|
+
ids = [p['id'] for p in layers]
|
|
95
|
+
if len(set(ids)) != len(ids) or any(not re.fullmatch('[a-z][a-z0-9_]*', i) for i in ids):
|
|
96
|
+
raise ValueError('part ids must be unique snake_case')
|
|
97
|
+
for i, p in enumerate(layers):
|
|
98
|
+
c = np.zeros_like(visible)
|
|
99
|
+
for x,y in p.get('seeds', []):
|
|
100
|
+
if not (0 <= y < lab.shape[0] and 0 <= x < lab.shape[1]) or not lab[y,x]:
|
|
101
|
+
raise ValueError(f'{ids[i]}: seed {x},{y} is outside paint or on ink')
|
|
102
|
+
c |= lab == lab[y,x]
|
|
103
|
+
if p.get('polys'):
|
|
104
|
+
hit = np.bincount(lab[polygon(visible.shape,p['polys'])], minlength=len(size))
|
|
105
|
+
take = (size > 0) & (hit * 2 >= size)
|
|
106
|
+
take[0] = False
|
|
107
|
+
c |= take[lab]
|
|
108
|
+
c |= polygon(visible.shape,p.get('raw',[])) & visible
|
|
109
|
+
c |= polygon(visible.shape,p.get('rawInk',[])) & inkm
|
|
110
|
+
c &= ~polygon(visible.shape,p.get('minus',[]))
|
|
111
|
+
if p.get('within'):
|
|
112
|
+
j = ids.index(p['within'])
|
|
113
|
+
if j >= i: raise ValueError('within must name an earlier layer')
|
|
114
|
+
c &= owner == j
|
|
115
|
+
owner[c & ~inkm] = i
|
|
116
|
+
claims.append(c)
|
|
117
|
+
for i,c in enumerate(claims):
|
|
118
|
+
owner[inkm & dilate(c, layers[i].get('inkPx',4))] = i
|
|
119
|
+
if (owner >= 0).any():
|
|
120
|
+
iy,ix,_ = nearest(owner >= 0)
|
|
121
|
+
rest = visible & (owner < 0)
|
|
122
|
+
owner[rest] = owner[iy[rest],ix[rest]]
|
|
123
|
+
return {p['id']: owner == i for i,p in enumerate(layers)}, visible & (owner < 0)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def fit_similarity(generated, source):
|
|
127
|
+
"""Least-squares similarity from explicit corresponding landmarks; reports fit error, never guesses scale."""
|
|
128
|
+
g, s = np.array(generated,float), np.array(source,float)
|
|
129
|
+
if g.shape != s.shape or g.ndim != 2 or g.shape[1] != 2 or len(g) < 2 or not np.isfinite(g).all() or not np.isfinite(s).all():
|
|
130
|
+
raise ValueError('need at least two finite matching landmark pairs')
|
|
131
|
+
a,b = g-g.mean(0),s-s.mean(0)
|
|
132
|
+
if (a*a).sum() < 1e-9: raise ValueError('generated landmarks coincide')
|
|
133
|
+
u,sv,vt = np.linalg.svd(a.T @ b)
|
|
134
|
+
fix = np.diag([1, np.linalg.det(u @ vt)])
|
|
135
|
+
rot = u @ fix @ vt
|
|
136
|
+
scale = float((sv * np.diag(fix)).sum() / (a*a).sum())
|
|
137
|
+
if scale <= 0: raise ValueError('invalid fitted scale')
|
|
138
|
+
offset = s.mean(0)-g.mean(0) @ (scale*rot)
|
|
139
|
+
predicted = g @ (scale*rot)+offset
|
|
140
|
+
residual = float(np.max(np.linalg.norm(predicted-s,axis=1)))
|
|
141
|
+
# Pillow maps each output/source point back into the generated image.
|
|
142
|
+
inv = np.linalg.inv(scale*rot)
|
|
143
|
+
shift = -offset @ inv
|
|
144
|
+
coeff = (inv[0,0],inv[1,0],shift[0],inv[0,1],inv[1,1],shift[1])
|
|
145
|
+
return coeff, {'scale':scale,'rotation_degrees':float(np.degrees(np.arctan2(rot[0,1],rot[0,0]))),'max_residual':residual}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def extract(ref, gen, region, own, pairs, background=None, tone=None, ink=None, feather=4, max_error=2):
|
|
149
|
+
coeff, stats = fit_similarity(pairs['generated'],pairs['source'])
|
|
150
|
+
if stats['max_residual'] > max_error: raise ValueError(f'landmark residual exceeds {max_error}: {stats}')
|
|
151
|
+
im = Image.fromarray(gen).convert('RGBa').transform((ref.shape[1],ref.shape[0]),Image.Transform.AFFINE,coeff,Image.Resampling.BICUBIC).convert('RGBA')
|
|
152
|
+
out = np.array(im)
|
|
153
|
+
if background is not None: out[background,3] = 0 # explicit background mask; never globally delete white paint
|
|
154
|
+
if tone is not None:
|
|
155
|
+
band = tone & own & (out[...,3]>240) & (ref[...,3]>240)
|
|
156
|
+
if not band.any(): raise ValueError('tone mask contains no comparable opaque approved pixels')
|
|
157
|
+
offset = np.median(ref[band,:3].astype(float)-out[band,:3],axis=0)
|
|
158
|
+
out[...,:3] = np.clip(out[...,:3].astype(float)+offset,0,255).astype('uint8')
|
|
159
|
+
stats['tone_offset'] = offset.tolist()
|
|
160
|
+
iy,ix,dist = nearest(own)
|
|
161
|
+
if feather:
|
|
162
|
+
w = np.clip(dist/max(feather,1),0,1)[...,None]
|
|
163
|
+
paint = region & (out[...,3]>0)
|
|
164
|
+
out[paint,:3] = (out[paint,:3]*w[paint]+ref[iy[paint],ix[paint],:3]*(1-w[paint])).astype('uint8')
|
|
165
|
+
if ink is not None:
|
|
166
|
+
known = own & (ref[...,:3].max(axis=2)<60)
|
|
167
|
+
ky,kx,_ = nearest(known)
|
|
168
|
+
fix = ink & region & (out[...,3]>0)
|
|
169
|
+
out[fix,:3] = ref[ky[fix],kx[fix],:3]
|
|
170
|
+
out[~region] = 0
|
|
171
|
+
pink = region & (out[...,3]>0) & (out[...,0]>220) & (out[...,1]<70) & (out[...,2]>220)
|
|
172
|
+
stats['magenta_pixels'] = int(pink.sum())
|
|
173
|
+
if pink.any(): raise ValueError(f'generation still contains guide magenta: {stats}')
|
|
174
|
+
return out, stats
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def underlap(ref, own, front, shape, radius):
|
|
178
|
+
"""Only extend the part's own colour, inside its complete silhouette and under fully opaque foreground."""
|
|
179
|
+
iy,ix,dist = nearest(own)
|
|
180
|
+
use = (dist>0) & (dist<=radius) & front & shape
|
|
181
|
+
out = np.zeros_like(ref)
|
|
182
|
+
out[use,:3] = ref[iy[use],ix[use],:3]
|
|
183
|
+
out[use,3] = 255
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def assemble(plan, base, outdir):
|
|
188
|
+
ref = rgba(base/plan['source']); shape = ref.shape[:2]; parts = plan['parts']
|
|
189
|
+
ids = [p['id'] for p in parts]
|
|
190
|
+
if not ids or len(set(ids)) != len(ids) or any(not re.fullmatch('[a-z][a-z0-9_]*', i) for i in ids):
|
|
191
|
+
raise ValueError('assembly part ids must be unique snake_case')
|
|
192
|
+
owns = [mask(base/p['own'],shape) for p in parts]
|
|
193
|
+
coverage = np.sum(owns,axis=0)
|
|
194
|
+
if (coverage>1).any(): raise ValueError('approved pixel ownership overlaps')
|
|
195
|
+
if ((ref[...,3]>0)&(coverage==0)).any(): raise ValueError('approved pixels are unassigned')
|
|
196
|
+
layers = outdir/'layers'; debug = outdir/'debug-masks'
|
|
197
|
+
layers.mkdir(parents=True,exist_ok=True);debug.mkdir(exist_ok=True)
|
|
198
|
+
entries=[]; completed={}; composite=Image.new('RGBA',(shape[1],shape[0]))
|
|
199
|
+
for i,p in enumerate(parts):
|
|
200
|
+
own=owns[i]; front=np.any(owns[i+1:],axis=0) if i+1<len(parts) else np.zeros(shape,bool)
|
|
201
|
+
front &= ref[...,3] == 255
|
|
202
|
+
complete=mask(base/p['shape'],shape)
|
|
203
|
+
if (own & ~complete).any(): raise ValueError(f"{p['id']}: complete shape excludes approved pixels")
|
|
204
|
+
img=underlap(ref,own,front,complete,p.get('underlap',2))
|
|
205
|
+
fillmask=img[...,3]>0
|
|
206
|
+
if p.get('fill'):
|
|
207
|
+
fill=rgba(base/p['fill'])
|
|
208
|
+
if fill.shape!=ref.shape: raise ValueError('fill canvas mismatch')
|
|
209
|
+
paint=(fill[...,3]>0)&~own&complete&front&~dilate(~front,1)
|
|
210
|
+
img[paint]=fill[paint];fillmask|=paint
|
|
211
|
+
img[own]=ref[own]
|
|
212
|
+
if p.get('replacement'):
|
|
213
|
+
replacement=rgba(base/p['replacement'])
|
|
214
|
+
if replacement.shape!=ref.shape: raise ValueError('replacement canvas mismatch')
|
|
215
|
+
# A fully generated part is an explicitly declared departure from source-pixel identity.
|
|
216
|
+
img=replacement.copy();fillmask=img[...,3]>0
|
|
217
|
+
yy,xx=np.nonzero(img[...,3])
|
|
218
|
+
if not len(xx): raise ValueError(f"empty part {p['id']}")
|
|
219
|
+
x,y,x1,y1=int(xx.min()),int(yy.min()),int(xx.max()+1),int(yy.max()+1)
|
|
220
|
+
Image.fromarray(img[y:y1,x:x1]).save(layers/f"{p['id']}.png")
|
|
221
|
+
Image.fromarray((fillmask[y:y1,x:x1]*255).astype('uint8')).save(debug/f"{p['id']}.png")
|
|
222
|
+
completed[p['id']]=img.copy()
|
|
223
|
+
composite.alpha_composite(Image.fromarray(img))
|
|
224
|
+
e={'id':p['id'],'file':p['id']+'.png','bbox':[x,y,x1-x,y1-y],'joint':p['joint'],'z':i,'inpainted':bool(p.get('fill') or p.get('replacement'))}
|
|
225
|
+
for k in ['bone','parent','slot','swap']:
|
|
226
|
+
if k in p:e[k]=p[k]
|
|
227
|
+
entries.append(e)
|
|
228
|
+
if plan.get('backing'):
|
|
229
|
+
b=plan['backing']; back=Image.new('RGBA',(shape[1],shape[0]))
|
|
230
|
+
if not b.get('parts') or b.get('inset',0)<=0: raise ValueError('backing requires stable part ids and a positive inset')
|
|
231
|
+
for lid in b['parts']:
|
|
232
|
+
if lid not in completed: raise ValueError('unknown backing part '+lid)
|
|
233
|
+
back.alpha_composite(Image.fromarray(completed[lid]))
|
|
234
|
+
pixels=np.array(back);inside=~dilate(ref[...,3]<255,int(b['inset']))
|
|
235
|
+
pixels[~inside]=0; yy,xx=np.nonzero(pixels[...,3])
|
|
236
|
+
if not len(xx): raise ValueError('backing inset leaves no painted pixels')
|
|
237
|
+
x,y,x1,y1=int(xx.min()),int(yy.min()),int(xx.max()+1),int(yy.max()+1)
|
|
238
|
+
lid=b.get('id','underpaint')
|
|
239
|
+
if lid in ids or not re.fullmatch('[a-z][a-z0-9_]*',lid): raise ValueError('backing id must be unique snake_case')
|
|
240
|
+
Image.fromarray(pixels[y:y1,x:x1]).save(layers/(lid+'.png'))
|
|
241
|
+
Image.fromarray(((pixels[y:y1,x:x1,3]>0)*255).astype('uint8')).save(debug/(lid+'.png'))
|
|
242
|
+
entries.insert(0,{'id':lid,'file':lid+'.png','bbox':[x,y,x1-x,y1-y],'joint':b['joint'],'bone':b['bone'],'z':-1})
|
|
243
|
+
back=Image.fromarray(pixels);back.alpha_composite(composite);composite=back
|
|
244
|
+
composite.save(outdir/'assembly.png')
|
|
245
|
+
(layers/'layers.json').write_text(json.dumps(entries,indent=2))
|
|
246
|
+
(outdir/'provenance.json').write_text(json.dumps({'source_sha256':hashlib.sha256((base/plan['source']).read_bytes()).hexdigest(),'plan':plan},indent=2))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main():
|
|
250
|
+
ap=argparse.ArgumentParser(description=__doc__,formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
251
|
+
sub=ap.add_subparsers(dest='cmd',required=True)
|
|
252
|
+
for cmd in ['partition','assemble']:
|
|
253
|
+
p=sub.add_parser(cmd);p.add_argument('plan',type=Path);p.add_argument('output',type=Path)
|
|
254
|
+
p=sub.add_parser('guide');p.add_argument('source',type=Path);p.add_argument('own',type=Path);p.add_argument('region',type=Path);p.add_argument('output',type=Path)
|
|
255
|
+
p=sub.add_parser('extract');p.add_argument('source',type=Path);p.add_argument('generated',type=Path);p.add_argument('region',type=Path);p.add_argument('own',type=Path);p.add_argument('landmarks',type=Path);p.add_argument('output',type=Path)
|
|
256
|
+
for name in ['background','tone','ink']:p.add_argument('--'+name+'-mask',type=Path)
|
|
257
|
+
p.add_argument('--feather',type=int,default=4);p.add_argument('--max-fit-error',type=float,default=2)
|
|
258
|
+
a=ap.parse_args()
|
|
259
|
+
if a.cmd in ['partition','assemble']:
|
|
260
|
+
plan=json.loads(a.plan.read_text());base=a.plan.parent;a.output.mkdir(parents=True,exist_ok=True)
|
|
261
|
+
if a.cmd=='assemble': assemble(plan,base,a.output);return
|
|
262
|
+
ref=rgba(base/plan['source']);masks,missing=partition(ref,plan['layers'],plan.get('ink',40))
|
|
263
|
+
for k,m in masks.items():
|
|
264
|
+
if not m.any():raise ValueError(f'empty part {k}')
|
|
265
|
+
Image.fromarray((m*255).astype('uint8')).save(a.output/(k+'.png'))
|
|
266
|
+
report={'unassigned':int(missing.sum()),'parts':{k:int(m.sum()) for k,m in masks.items()},'islands':{k:len(labels(m)[1])-1 for k,m in masks.items()}}
|
|
267
|
+
(a.output/'partition.json').write_text(json.dumps(report,indent=2));print(json.dumps(report))
|
|
268
|
+
if missing.any():raise ValueError('unassigned source pixels')
|
|
269
|
+
else:
|
|
270
|
+
ref=rgba(a.source);shape=ref.shape[:2];own=mask(a.own,shape);region=mask(a.region,shape)
|
|
271
|
+
if a.cmd=='guide':
|
|
272
|
+
out=np.zeros_like(ref);out[own]=ref[own];out[region]=(255,0,255,255);Image.fromarray(out).save(a.output)
|
|
273
|
+
else:
|
|
274
|
+
get=lambda name:mask(getattr(a,name+'_mask'),shape) if getattr(a,name+'_mask') else None
|
|
275
|
+
out,stats=extract(ref,rgba(a.generated),region,own,json.loads(a.landmarks.read_text()),get('background'),get('tone'),get('ink'),a.feather,a.max_fit_error)
|
|
276
|
+
Image.fromarray(out).save(a.output);a.output.with_suffix('.fit.json').write_text(json.dumps(stats,indent=2));print(json.dumps(stats))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
if __name__=='__main__':main()
|