Files
Papay-Shooter/tools/measure_clipview.py
Nicholas ButzkeandClaude Opus 5 cd0d1b2d99 feat(characters): import the Quaternius mannequin as a selectable skin
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]>
2026-07-26 13:25:26 -04:00

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