A third playable character, built with the pipeline skill from a source that was already in the repo: the animation library ships a rigged Mannequin mesh on the exact 53-joint reference skeleton, CC0, so it needed no download and retargets perfectly. 18 clips, 0.3% cross-leg bleed, 7% of verts at four influences — a clean authored-weight import. Licence recorded in mannequin.license.json as the other skins do. It has no cloth chains, correctly: it is a mannequin and has neither hair nor clothes. Importing it turned up two real bugs, both of which would have hit any flat-coloured or single-piece model: - LevelMaterials.apply_character_look treated ANY untextured surface on a character as the model's own outline shell and hid it, so the mannequin rendered as a solid black silhouette — its body and joint materials are untextured flat colours, not ink. _is_line_work() now asks whether the surface is named eyes*, is drawn front-face-culled (the inverted-hull setup), or is near-black. Taila and Miku are unaffected: their materials are textured and never reach that branch. Verified by render. - verify_character.py failed the build for having one mesh. That check cannot tell "the pipeline joined them" from "the artist authored one mesh" — Quaternius' mannequin is one piece on purpose. It is advisory now; the join path's two unambiguous signatures, cross-leg bleed and the 4-influences-everywhere spread, are still hard checks. Also restored Miku's description, which the re-import had blanked. 3 GLB skins selectable (6 with the built-in colour skins). Smoke 0 failures, 11/11 movement tests, cloth idle 0.024-0.078 deg/frame. Co-Authored-By: Claude Opus 5 <[email protected]>
103 lines
4.8 KiB
Python
103 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""How much leg is rendering IN FRONT OF the skirt, in pixels.
|
|
|
|
python tools/measure_clipview.py <dir written by debug/skirt_clip_view.gd>
|
|
|
|
That tool saves each pose twice, both drawing DISTANCE FROM THE CAMERA into the
|
|
colour channel: `cloth_N.png` with only the cloth drawn and `clipview_N.png` with
|
|
only the body. A pixel counts when the body is nearer than the nearest cloth
|
|
there — which is exactly what "the thigh is showing through the skirt" means.
|
|
|
|
Depth, not silhouettes. With the legs apart you see the FAR side of the skirt
|
|
through the gap between them and the thigh is correctly in front of that; a mask
|
|
test counts all of it, reported 25% of the cloth covered on poses that are fine,
|
|
and sent two rounds of tuning after a defect that was not there.
|
|
|
|
`hip_N.txt` gives the screen row of the hip joint; only rows below it count,
|
|
because above it the torso is inside the skirt and in front of its waistband. Magenta is cloth, grey is body. So the cloth
|
|
silhouette comes from the second image, and any grey inside it in the first is
|
|
the leg in front of the skirt — the defect — while grey outside it is just the
|
|
leg past the hem, which is correct.
|
|
|
|
Reading that off the two pictures by eye is unreliable: the two failures look
|
|
alike, the silhouettes interleave, and it was misjudged in both directions more
|
|
than once. This counts it.
|
|
|
|
WHAT THIS IS AND IS NOT FOR. It measures the garment's CONTINUITY well — a torn
|
|
panel shows up as a hole in the cloth mask. It is NOT a clipping test. When the
|
|
legs are apart you see the far side of the skirt through the gap between them,
|
|
and the thighs are legitimately in front of that, so those pixels count as hits
|
|
while being correct rendering. Every large reading here traced back to that.
|
|
Judge clipping from debug/skirt_probe.gd and the plain textured render.
|
|
|
|
It is also not repeatable enough to A/B tuning changes. The solver can be given a
|
|
fixed timestep, but the AnimationTree still advances on the real frame delta, so
|
|
the pose at a given frame drifts between runs: the same build measured 93k and
|
|
76k. Differences under about 20% here mean nothing.
|
|
|
|
The cloth mask is eroded a little first. The two frames are one apart (the tool
|
|
swaps materials between them), so the character has moved a fraction of a
|
|
millimetre and the silhouette edges do not line up exactly; without the erosion
|
|
every outline pixel reads as a hit.
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
from PIL import Image, ImageChops, ImageFilter
|
|
|
|
## Depth difference, in 8-bit steps, that counts as the body being in front.
|
|
## One step is about 6 mm over the 1.6 m the shader encodes; three keeps
|
|
## coincident surfaces and the one-frame offset between the two shots quiet.
|
|
NEAR_EPS = 3
|
|
MIN_BLOB = 40 # ignore specks — anti-aliasing along an edge, not a defect
|
|
|
|
|
|
def depth(path):
|
|
"""Per-pixel camera distance as an 8-bit band; 0 means no geometry."""
|
|
return Image.open(path).convert("RGB").split()[0]
|
|
|
|
|
|
def main():
|
|
root = sys.argv[1] if len(sys.argv) > 1 else "."
|
|
total = 0
|
|
shots = 0
|
|
for n in range(256):
|
|
full = os.path.join(root, "clipview_%d.png" % n)
|
|
only = os.path.join(root, "cloth_%d.png" % n)
|
|
if not (os.path.exists(full) and os.path.exists(only)):
|
|
continue
|
|
# `clipview` draws EVERYTHING, so it holds the nearest of cloth-or-body;
|
|
# `cloth` draws only the garment. Where the combined pass is nearer than
|
|
# the cloth pass, something that is not cloth is in front of it.
|
|
both_pass = depth(full)
|
|
cloth = depth(only)
|
|
w, h = cloth.size
|
|
top = 0
|
|
hipf = os.path.join(root, "hip_%d.txt" % n)
|
|
if os.path.exists(hipf):
|
|
with open(hipf) as fh:
|
|
top = max(0, int(fh.read().strip()))
|
|
box = (0, top, w, h)
|
|
b = both_pass.crop(box)
|
|
c = cloth.crop(box)
|
|
has_c = c.point(lambda p: 255 if p > 0 else 0)
|
|
# Nearer than the nearest cloth, by more than a little depth noise.
|
|
nearer = ImageChops.subtract(c, b).point(lambda p: 255 if p > NEAR_EPS else 0)
|
|
hit_mask = ImageChops.multiply(has_c, nearer)
|
|
hit = sum(hit_mask.point(lambda p: 1 if p else 0).get_flattened_data())
|
|
area = sum(has_c.point(lambda p: 1 if p else 0).get_flattened_data())
|
|
if hit >= MIN_BLOB:
|
|
over = Image.merge("RGB", (c, c, c)).convert("RGB")
|
|
over.paste(Image.new("RGB", over.size, (255, 32, 32)), (0, top), hit_mask)
|
|
over.save(os.path.join(root, "over_%d.png" % n))
|
|
total += hit
|
|
shots += 1
|
|
flag = "" if hit < MIN_BLOB else " <-- leg in front of cloth"
|
|
print(" shot %d: %6d px of leg over %7d px of cloth (%.2f%%)%s"
|
|
% (n, hit, area, 100.0 * hit / max(area, 1), flag))
|
|
if shots:
|
|
print(" TOTAL %d px over %d shots" % (total, shots))
|
|
|
|
|
|
main()
|