feat(tooling): chromakey garment-clipping QA harness (T-1089)

Automated garment-under-animation QA: CharacterVisual composite with the
garment's covered body segments overridden to flat unshaded magenta, cycled
clips x frames x 4 yaws; PIL analyzer flags connected key-pixel blobs and
emits report.json + highlighted failure frames. Capture scene lives under
client/tools/garment_qa/ (res:// boundary; outside the gdUnit scan root),
driver/analyzer/config under tooling/garment-qa/.

Verified: 72 captures across peasant set x average_m/f x Walk/Sprint/
Crouch_Fwd. Finding: no true mid-cloth clip-through; flags are coverage-claim
vs silhouette mismatch (sleeveless/short-sleeve exposure at collar/cuffs) —
a two-pass garment-behind-pixel discriminator is the queued refinement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 14:26:58 +02:00
co-authored by Claude Fable 5
parent e5657179b5
commit eaca6c8c44
6 changed files with 619 additions and 0 deletions
+316
View File
@@ -0,0 +1,316 @@
extends Node3D
## Chromakey garment-clipping QA capture scene (T-1089).
##
## Technique: replace the materials of exactly the body segments a garment CLAIMS
## to cover (coverage.json "hides") with a flat UNSHADED key color (pure magenta).
## The garment keeps its normal materials; head/hands/uncovered segments keep theirs.
## Any key-color pixel the analyzer finds through the garment = a clip-through.
##
## This scene lives under client/ (not tooling/) because Godot res:// paths cannot
## leave the client project root — see tooling/garment-qa/README.md. It reuses
## CharacterVisual (client/scripts/rendering/character_visual.gd): load_descriptor()
## builds the character, get_active_coverage() exposes each garment's coverage.json,
## and get_animation_player() drives the clip cycling (mirrors the T-1088 sandbox).
##
## Config is a JSON file whose path arrives in the GARMENT_QA_CONFIG env var:
## {
## "garments": [{"item_id": "peasant_tunic", "slot": "torso"}, ...]
## (or {"glb": "res://...", "slot": "...", "covers": ["torso", ...]}
## for a raw WIP garment not yet in the catalogue),
## "body_types": ["average_m", "average_f"],
## "clips": ["Walk", "Sprint", "Crouch_Fwd"],
## "frames_per_clip": 3,
## "yaws": [0, 90, 180, 270],
## "head_id": "head_001", "hair_id": "buzzed",
## "eyebrow_id": "regular", "skin_tone": 3,
## "out_dir": "/abs/path/for/pngs"
## }
const KEY_COLOR := Color(1.0, 0.0, 1.0) # pure magenta — no skin/garment texture lands here
const VIEWPORT_SIZE := Vector2i(768, 1024) # portrait — maximises body pixels per capture
const BODY_KEY_TO_ENUM := {
"average_m": CharacterVisualDescriptor.BodyType.AVERAGE_M,
"average_f": CharacterVisualDescriptor.BodyType.AVERAGE_F,
"muscular_m": CharacterVisualDescriptor.BodyType.MUSCULAR_M,
"muscular_f": CharacterVisualDescriptor.BodyType.MUSCULAR_F,
"thin_m": CharacterVisualDescriptor.BodyType.THIN_M,
"thin_f": CharacterVisualDescriptor.BodyType.THIN_F,
"heavy_m": CharacterVisualDescriptor.BodyType.HEAVY_M,
"heavy_f": CharacterVisualDescriptor.BodyType.HEAVY_F,
"teen_m": CharacterVisualDescriptor.BodyType.TEEN_M,
"teen_f": CharacterVisualDescriptor.BodyType.TEEN_F,
"child": CharacterVisualDescriptor.BodyType.CHILD,
}
var _config: Dictionary = {}
var _out_dir: String = ""
var _cam: Camera3D = null
var _key_material: ShaderMaterial = null
func _ready() -> void:
if not _load_config():
get_tree().quit(1)
return
get_window().size = VIEWPORT_SIZE
_key_material = _make_key_material()
_setup_stage()
_run.call_deferred()
func _load_config() -> bool:
var path := OS.get_environment("GARMENT_QA_CONFIG")
if path.is_empty():
push_error("chromakey_scene: GARMENT_QA_CONFIG env var not set")
return false
if not FileAccess.file_exists(path):
push_error("chromakey_scene: config file not found: %s" % path)
return false
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_error("chromakey_scene: could not open config: %s" % path)
return false
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
push_error("chromakey_scene: config is not a JSON object: %s" % path)
return false
_config = parsed
_out_dir = str(_config.get("out_dir", ""))
if _out_dir.is_empty():
push_error("chromakey_scene: config missing 'out_dir'")
return false
return true
## Flat, unshaded magenta. render_mode unshaded → ALBEDO is written straight to the
## framebuffer with no lighting; cull_disabled so a body backface poking through an
## opening still registers. No outline pass is attached to this material (see _apply_key).
func _make_key_material() -> ShaderMaterial:
var shader := Shader.new()
shader.code = (
"shader_type spatial;\n"
+ "render_mode unshaded, cull_disabled;\n"
+ "void fragment() {\n"
+ "\tALBEDO = vec3(1.0, 0.0, 1.0);\n"
+ "}\n"
)
var mat := ShaderMaterial.new()
mat.shader = shader
return mat
func _setup_stage() -> void:
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-45, 30, 0)
light.light_energy = 1.2
add_child(light)
var env := Environment.new()
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.8, 0.8, 0.85)
env.ambient_light_energy = 0.9
env.background_mode = Environment.BG_COLOR
# Dark neutral background — never magenta, never skin-toned, so it cannot
# be mistaken for a clip pixel by the analyzer.
env.background_color = Color(0.18, 0.19, 0.22)
var we := WorldEnvironment.new()
we.environment = env
add_child(we)
_cam = Camera3D.new()
_cam.fov = 50.0
add_child(_cam)
# Fixed frame that fits a standing ~1.8 m character with margin. The character
# rotates (not the camera), so the four yaws give front/left/back/right views.
_cam.position = Vector3(0.0, 0.95, 2.4)
_cam.look_at_from_position(_cam.position, Vector3(0.0, 0.95, 0.0), Vector3.UP)
func _run() -> void:
DirAccess.make_dir_recursive_absolute(_out_dir)
var body_types: Array = _config.get("body_types", ["average_m"])
print("chromakey_scene: out_dir = ", _out_dir)
print("chromakey_scene: body_types = ", body_types)
for body_key: String in body_types:
await _capture_body(str(body_key))
print("chromakey_scene: DONE")
get_tree().quit(0)
func _capture_body(body_key: String) -> void:
if not BODY_KEY_TO_ENUM.has(body_key):
push_warning("chromakey_scene: unknown body_type '%s' — skipped" % body_key)
return
var cv := CharacterVisual.new()
add_child(cv)
cv.load_descriptor(_build_descriptor(body_key))
var skel := cv.get_skeleton()
if skel == null:
push_error("chromakey_scene: no skeleton for %s" % body_key)
cv.queue_free()
return
_attach_raw_garments(skel)
var covered := _covered_segments(cv)
var keyed := _apply_key(skel, covered)
print(
"chromakey_scene[%s]: covered=%s keyed_meshes=%d" % [body_key, covered.keys(), keyed]
)
var ap := cv.get_animation_player()
if ap == null:
push_warning("chromakey_scene[%s]: no AnimationPlayer — skipping clips" % body_key)
cv.queue_free()
await _wait_frames(2)
return
var clips: Array = _config.get("clips", ["Walk"])
var frames: int = int(_config.get("frames_per_clip", 3))
var yaws: Array = _config.get("yaws", [0, 90, 180, 270])
for clip: String in clips:
var resolved := _resolve_clip(ap, str(clip))
if resolved.is_empty():
push_warning("chromakey_scene[%s]: clip '%s' not found" % [body_key, clip])
continue
cv.play_animation(resolved)
ap.pause() # freeze — seek() below poses without advancing between yaw shots
var length := ap.current_animation_length
var times := _sample_times(length, frames)
for fi in times.size():
ap.seek(times[fi], true)
await get_tree().process_frame # let the seeked pose propagate to the skeleton
for yaw in yaws:
cv.rotation_degrees.y = float(yaw)
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
var fname := (
"%s__%s__f%d__yaw%03d.png" % [body_key, str(clip), fi, int(yaw)]
)
img.save_png(_out_dir.path_join(fname))
print("chromakey_scene[%s]: captured clip '%s' (%d frames)" % [body_key, clip, times.size()])
cv.queue_free()
await _wait_frames(2)
func _build_descriptor(body_key: String) -> CharacterVisualDescriptor:
var desc := CharacterVisualDescriptor.new()
desc.body_type = BODY_KEY_TO_ENUM[body_key]
desc.head_id = str(_config.get("head_id", "head_001"))
desc.hair_id = str(_config.get("hair_id", "buzzed"))
desc.eyebrow_id = str(_config.get("eyebrow_id", "regular"))
desc.skin_tone = int(_config.get("skin_tone", 3))
var slots := {}
for g: Dictionary in _config.get("garments", []):
if g.has("item_id") and g.has("slot"):
slots[str(g["slot"])] = str(g["item_id"])
desc.clothing_slots = slots
return desc
## Reparent skinned meshes from a raw garment GLB (config "glb" entries) onto our
## skeleton — mirrors character_visual.gd:517-535. Used only for WIP garments that
## are not yet catalogue items; catalogue garments load via clothing_slots instead.
func _attach_raw_garments(skel: Skeleton3D) -> void:
for g: Dictionary in _config.get("garments", []):
if not g.has("glb"):
continue
var scene := load(str(g["glb"])) as PackedScene
if scene == null:
push_error("chromakey_scene: raw garment GLB failed to load: %s" % g["glb"])
continue
var inst := scene.instantiate()
var stack: Array[Node] = [inst]
while not stack.is_empty():
var n: Node = stack.pop_back()
for c in n.get_children():
stack.append(c)
if n is MeshInstance3D and (n as MeshInstance3D).skin != null:
var mi := n as MeshInstance3D
mi.get_parent().remove_child(mi)
mi.owner = null
skel.add_child(mi)
inst.queue_free()
## Union of the body segments every equipped garment claims to cover. Catalogue
## garments report this via CharacterVisual.get_active_coverage() (coverage.json
## "hides"); raw garments carry an explicit "covers" list in the config.
func _covered_segments(cv: CharacterVisual) -> Dictionary:
var covered := {}
for g: Dictionary in _config.get("garments", []):
if g.has("item_id"):
var cov: Dictionary = cv.get_active_coverage(str(g["item_id"]))
for seg in cov.get("hides", []):
covered[str(seg)] = true
if g.has("covers"):
for seg in g["covers"]:
covered[str(seg)] = true
return covered
## Key-colour every loaded body segment in the covered set and delete the outline
## duplicates (the dark inverted-hull outline would otherwise poke through the garment
## and mask a clip as dark rather than magenta). Returns the number of keyed meshes.
func _apply_key(skel: Skeleton3D, covered: Dictionary) -> int:
var keyed := 0
var to_free: Array[Node] = []
for child in skel.get_children():
if not (child is MeshInstance3D):
continue
var mi := child as MeshInstance3D
if mi.name.begins_with("_outline_"):
to_free.append(mi) # strip ALL outlines — see docstring
continue
var seg := str(mi.get_meta("segment", ""))
if not seg.is_empty() and covered.has(seg):
mi.material_override = _key_material
keyed += 1
for n in to_free:
skel.remove_child(n)
n.free()
return keyed
## Resolve a logical clip name to a playable animation. Tries an exact match first,
## then a case-insensitive exact match, then a case-insensitive substring — robust
## to library prefixes. Returns "" if nothing matches.
func _resolve_clip(ap: AnimationPlayer, requested: String) -> String:
var names: Array[String] = []
for lib_name in ap.get_animation_library_list():
var lib := ap.get_animation_library(lib_name)
for a in lib.get_animation_list():
names.append(str(a))
for n in names:
if n == requested:
return n
for n in names:
if n.to_lower() == requested.to_lower():
return n
for n in names:
if n.to_lower().contains(requested.to_lower()):
return n
return ""
## Evenly spaced sample times across a clip, centred in each 1/count slice so we
## never land exactly on t=0 (rest pose) or t=length (loop wrap).
func _sample_times(length: float, count: int) -> Array[float]:
var out: Array[float] = []
if count <= 1 or length <= 0.0:
out.append(maxf(length, 0.0) * 0.5)
return out
for i in count:
out.append(length * (float(i) + 0.5) / float(count))
return out
func _wait_frames(n: int) -> void:
for i in n:
await get_tree().process_frame
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://cgarmentqachroma0"]
[ext_resource type="Script" path="res://tools/garment_qa/chromakey_scene.gd" id="1"]
[node name="ChromakeyQA" type="Node3D"]
script = ExtResource("1")
+26
View File
@@ -0,0 +1,26 @@
# garment-qa — chromakey garment-clipping QA (T-1089)
Automatically detects clothing clip-through. The capture scene paints the body
segments a garment *claims to cover* (coverage.json `hides`) flat **unshaded magenta**,
leaves the garment and uncovered segments (head/hands/etc.) normal, then cycles
animation clips × sampled frames × 4 camera yaws and saves a PNG per view. Any magenta
the camera sees = body poking through cloth. The analyzer counts connected magenta
pixels per capture and flags clips.
**Run:** `tooling/garment-qa/run-garment-qa [config.json]` (defaults to
`configs/peasant.json`). It launches Godot (`~/bin/godot4`, `opengl3`, `xvfb-run` when
headless) on the capture scene, then runs the analyzer.
**Config** (path passed to the scene via `GARMENT_QA_CONFIG`): `garments`
(`{item_id,slot}` catalogue items, or `{glb,slot,covers[]}` for a raw WIP garment),
`body_types`, `clips`, `frames_per_clip`, `yaws`, `head_id/hair_id/eyebrow_id/skin_tone`,
`out_dir`.
**Read the report:** `<out_dir>/report.json` — per-capture `key_pixels`,
`largest_component`, `fail`, plus a `by_group` (body__clip) roll-up; the same summary
prints to stdout. A capture FAILS when its largest connected magenta blob ≥ `--min-pixels`
(default 8, tolerating AA edges). Failing frames are copied to `<out_dir>/failures/` with
clip pixels recoloured lime and each blob boxed red.
**Note:** the Godot scene lives at `client/tools/garment_qa/chromakey_scene.gd` (not here)
because Godot `res://` paths cannot leave the client project root.
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Chromakey garment-clipping analyzer (T-1089).
Counts key-colour (pure magenta) pixels in each capture PNG produced by
client/tools/garment_qa/chromakey_scene.gd. Any solid patch of key pixels showing
through a garment is a body-clip-through: the QA scene painted the covered body
segments flat magenta, so magenta the camera can see = body poking through cloth.
A capture FAILS when its largest connected key-pixel component is >= --min-pixels
(default 8; a small tolerance for anti-aliased edges). For each failing capture a
highlighted copy is written to <out_dir>/failures/ with the clip pixels recoloured
lime and each component boxed in red. Results land in <out_dir>/report.json plus a
one-screen summary on stdout.
Reads the same JSON config as the capture scene (via --config) to locate out_dir,
or takes --dir directly.
"""
from __future__ import annotations
import argparse
import collections
import json
import sys
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw
# Key-colour gate. Pure magenta is (255, 0, 255); the blue channel is the decisive
# discriminator — skin/garment texture is never simultaneously high-red, low-green
# AND high-blue, so this never fires on legitimate body or cloth pixels.
R_MIN = 200
G_MAX = 60
B_MIN = 200
def build_key_mask(img: Image.Image) -> Image.Image:
"""Return an "L" mask, 255 where the pixel is key-colour, else 0."""
r, g, b = img.convert("RGB").split()
r_ok = r.point(lambda v: 255 if v >= R_MIN else 0)
g_ok = g.point(lambda v: 255 if v <= G_MAX else 0)
b_ok = b.point(lambda v: 255 if v >= B_MIN else 0)
return ImageChops.multiply(ImageChops.multiply(r_ok, g_ok), b_ok)
def connected_components(mask: Image.Image) -> list[dict]:
"""8-connected components of the key-pixel mask.
Only the mask bounding box is scanned, so cost tracks the (sparse) clip area,
not the whole frame. Returns one dict per component: size + pixel bbox.
"""
bbox = mask.getbbox()
if bbox is None:
return []
x0, y0, x1, y1 = bbox
region = mask.crop(bbox)
width, height = region.size
px = region.load()
seen = [[False] * width for _ in range(height)]
components: list[dict] = []
for sy in range(height):
for sx in range(width):
if px[sx, sy] == 0 or seen[sy][sx]:
continue
size = 0
min_x = max_x = sx
min_y = max_y = sy
queue = collections.deque([(sx, sy)])
seen[sy][sx] = True
while queue:
cx, cy = queue.popleft()
size += 1
min_x, max_x = min(min_x, cx), max(max_x, cx)
min_y, max_y = min(min_y, cy), max(max_y, cy)
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
nx, ny = cx + dx, cy + dy
if 0 <= nx < width and 0 <= ny < height:
if px[nx, ny] != 0 and not seen[ny][nx]:
seen[ny][nx] = True
queue.append((nx, ny))
components.append(
{
"size": size,
# bbox back in full-image coordinates
"bbox": [x0 + min_x, y0 + min_y, x0 + max_x + 1, y0 + max_y + 1],
}
)
components.sort(key=lambda c: c["size"], reverse=True)
return components
def write_highlight(img: Image.Image, mask: Image.Image, comps: list[dict], dest: Path) -> None:
"""Recolour key pixels lime and box each component in red."""
out = img.convert("RGB")
out.paste((0, 255, 0), mask=mask)
draw = ImageDraw.Draw(out)
for comp in comps:
draw.rectangle(comp["bbox"], outline=(255, 0, 0), width=2)
dest.parent.mkdir(parents=True, exist_ok=True)
out.save(dest)
def analyze_dir(out_dir: Path, min_pixels: int) -> dict:
captures = sorted(p for p in out_dir.glob("*.png") if p.is_file())
fail_dir = out_dir / "failures"
results: list[dict] = []
for path in captures:
img = Image.open(path)
mask = build_key_mask(img)
total = mask.histogram()[255]
comps = connected_components(mask) if total else []
largest = comps[0]["size"] if comps else 0
failed = largest >= min_pixels
if failed:
write_highlight(img, mask, comps, fail_dir / (path.stem + "_HL.png"))
results.append(
{
"file": path.name,
"key_pixels": total,
"components": len(comps),
"largest_component": largest,
"component_sizes": [c["size"] for c in comps[:10]],
"fail": failed,
}
)
return summarize(out_dir, min_pixels, results)
def summarize(out_dir: Path, min_pixels: int, results: list[dict]) -> dict:
failures = [r for r in results if r["fail"]]
by_group: dict[str, dict] = {}
for r in results:
# filename: <body>__<clip>__f<n>__yaw<deg>.png
parts = r["file"].split("__")
group = "__".join(parts[:2]) if len(parts) >= 2 else r["file"]
entry = by_group.setdefault(group, {"captures": 0, "failures": 0, "worst": 0})
entry["captures"] += 1
entry["failures"] += 1 if r["fail"] else 0
entry["worst"] = max(entry["worst"], r["largest_component"])
return {
"out_dir": str(out_dir),
"min_component_pixels": min_pixels,
"key_gate": {"r_min": R_MIN, "g_max": G_MAX, "b_min": B_MIN},
"total_captures": len(results),
"failures": len(failures),
"by_group": by_group,
"captures": results,
}
def print_summary(report: dict) -> None:
print("=" * 64)
print("garment-qa chromakey analysis")
print(f" out_dir : {report['out_dir']}")
print(f" min clip pixels : {report['min_component_pixels']}")
print(f" captures : {report['total_captures']}")
print(f" FAILING captures : {report['failures']}")
print("-" * 64)
print(f" {'group (body__clip)':<28}{'caps':>6}{'fails':>7}{'worst':>7}")
for group, g in sorted(report["by_group"].items()):
print(f" {group:<28}{g['captures']:>6}{g['failures']:>7}{g['worst']:>7}")
print("=" * 64)
def resolve_out_dir(args: argparse.Namespace) -> Path:
if args.dir:
return Path(args.dir)
if args.config:
cfg = json.loads(Path(args.config).read_text())
out = cfg.get("out_dir")
if not out:
sys.exit("analyze_captures: config has no 'out_dir'")
return Path(out)
sys.exit("analyze_captures: pass --dir or --config")
def main() -> int:
parser = argparse.ArgumentParser(description="Chromakey garment-clipping analyzer")
parser.add_argument("--config", help="capture config JSON (reads out_dir from it)")
parser.add_argument("--dir", help="directory of capture PNGs (overrides --config out_dir)")
parser.add_argument(
"--min-pixels",
type=int,
default=8,
help="largest connected key-pixel component that counts as a clip (default 8)",
)
parser.add_argument("--report", help="report JSON path (default: <out_dir>/report.json)")
args = parser.parse_args()
out_dir = resolve_out_dir(args)
if not out_dir.is_dir():
sys.exit(f"analyze_captures: not a directory: {out_dir}")
report = analyze_dir(out_dir, args.min_pixels)
report_path = Path(args.report) if args.report else out_dir / "report.json"
report_path.write_text(json.dumps(report, indent=2))
print_summary(report)
print(f" report : {report_path}")
if report["failures"]:
print(f" highlighted failing frames : {out_dir / 'failures'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+15
View File
@@ -0,0 +1,15 @@
{
"garments": [
{"item_id": "peasant_tunic", "slot": "torso"},
{"item_id": "peasant_pants", "slot": "legs"}
],
"body_types": ["average_m", "average_f"],
"clips": ["Walk", "Sprint", "Crouch_Fwd"],
"frames_per_clip": 3,
"yaws": [0, 90, 180, 270],
"head_id": "head_001",
"hair_id": "buzzed",
"eyebrow_id": "regular",
"skin_tone": 3,
"out_dir": "/var/mnt/data/projects/settled-reach/.cache/garment-qa/peasant"
}
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# run-garment-qa: drive the chromakey garment-clipping QA harness (T-1089).
#
# Usage: tooling/garment-qa/run-garment-qa [config.json]
# config.json defaults to tooling/garment-qa/configs/peasant.json
#
# Step 1 launches Godot on the client project with the capture scene, pointing it
# at the config via GARMENT_QA_CONFIG. Step 2 runs the pixel analyzer over the PNGs
# the scene wrote. Both steps are deliberately kept to a single command each.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
CONFIG="${1:-$SCRIPT_DIR/configs/peasant.json}"
if [[ ! -f "$CONFIG" ]]; then
echo "run-garment-qa: config not found: $CONFIG" >&2
exit 2
fi
GODOT="${GODOT:-$HOME/bin/godot4}"
if [[ ! -x "$GODOT" ]]; then
GODOT="$(command -v godot4 || command -v godot || true)"
fi
if [[ -z "$GODOT" ]]; then
echo "run-garment-qa: godot binary not found (set GODOT=/path/to/godot4)" >&2
exit 3
fi
PY="$REPO_ROOT/.venv/bin/python"
[[ -x "$PY" ]] || PY="python3"
SCENE="res://tools/garment_qa/chromakey_scene.tscn"
echo "run-garment-qa: config = $CONFIG"
echo "run-garment-qa: godot = $GODOT"
# Step 1 — capture. Needs a display; use xvfb-run when running headless.
export GARMENT_QA_CONFIG="$CONFIG"
if [[ -n "${DISPLAY:-}" ]]; then
"$GODOT" --path "$REPO_ROOT/client" --rendering-driver opengl3 "$SCENE"
else
xvfb-run -a "$GODOT" --path "$REPO_ROOT/client" --rendering-driver opengl3 "$SCENE"
fi
# Step 2 — analyze.
"$PY" "$SCRIPT_DIR/analyze_captures.py" --config "$CONFIG"