Skip to content

Inspecting & validating assets

Two deterministic, read-only commands (issue #47) answer "what's in this track?" and "is this asset well-formed?" without opening the previewer — the CI-friendly home for the format contract io_export, edits, and events already imply.

inspect

python -m openfacefx inspect track.json          # human table
python -m openfacefx inspect track.json --json    # schema-stable JSON stats

inspect prints duration, fps, channel/keyframe counts, per-channel key count / min / max / start / end / time-coverage, event & variant counts, and the weight-vs-pose-vs-gesture channel split. --json emits a schema-stable object (format: openfacefx.inspect) — every documented key is always present, lists empty rather than absent — so a CI step can assert on it. It reuses qa.summarize / qa.cue_flags for the shared counters, so the numbers match the generate commands' --json output.

validate

python -m openfacefx validate track.json          # lint; exits nonzero on a violation
python -m openfacefx validate line.edits.json --json     # deterministic problem list
python -m openfacefx validate events.json --strict       # warnings become errors

validate is a lint gate. It auto-detects the asset kind — a .track.json, an *.edits.json sidecar, or a standalone events file — parses it (via io_export.from_dict / the edits / events validators), checks the contract, and exits nonzero with a deterministic, sorted, machine-readable problem list ({severity, code, where, detail}) so CI diffs stay clean. Checks:

  • per-channel key times monotonic non-decreasing and within [0, duration];
  • weight channels (viseme / blendshape / emotion) values in [0, 1]; the signed pose channels (headYaw/Pitch/Roll, eyeYaw/Pitch — angles, in degrees or [-1, 1]) flagged only when wildly out of range (> ±360), not for being outside [0, 1] — the same weight-vs-angle distinction the CSV importer draws;
  • viseme_set / target_set consistent with the channel names;
  • event / variant blocks via events.validate_events, plus a check that every event type is a known EVENT_TYPES member;
  • --strict promotes warnings (empty channels, a zero-length track) to errors.

validate exits 0 on every track the generators and importers produce and nonzero on a corrupted one (out-of-order times, out-of-range weight, unknown event type, viseme_set mismatch). Library callers get inspect_track(track), validate_asset(data) / validate_file(path), and detect_kind(data). Read-only (never writes), stdlib only, deterministic across Python 3.9/3.13.

openfacefx.inspect

Read-only track/asset inspection and a CI-friendly contract linter (issue #47).

Two deterministic, read-only views that never open the previewer and never write a file:

  • :func:inspect_track -- what's in this track? A schema-stable stats dict (duration, fps, channel/keyframe counts, per-channel key count / min / max / time-coverage, event & variant counts, the weight/pose/gesture channel split), reusing :func:openfacefx.qa.summarize and cue_flags for the shared counters. Every documented key is always present -- lists are empty, not absent -- so a CI step can assert on it without scraping console text.

  • :func:validate_asset -- is this asset well-formed? The format contract io_export / edits / events already imply but never enforce standalone. It parses a .track.json, an *.edits.json sidecar, or a standalone events file (kind auto-detected), checks the contract, and returns a deterministic, sorted, machine-readable problem list (severity / code / where / detail) so a lint gate can fail a build with a clean diff. strict promotes warnings (empty channels, zero-length track) to errors.

Weight vs pose: viseme / blendshape / emotion channels are [0, 1] weights; the signed pose channels (head/eye angles) are flagged only when wildly out of range, since they legitimately carry degrees or [-1, 1]. numpy is not needed here -- pure stdlib (math/json), no clock, no RNG.

FORMAT = 'openfacefx.inspect' module-attribute

VERSION = 1 module-attribute

POSE_CHANNELS = frozenset({'headPitch', 'headYaw', 'headRoll', 'eyePitch', 'eyeYaw'}) module-attribute

inspect_track(track, *, segments=None, oov_words=None) -> Dict

A deterministic, schema-stable stats dict for track.

Reuses :func:openfacefx.qa.summarize for the shared counters and cue-flag logic, then adds per-channel detail and the weight/pose/gesture split. Every key is always present; channel_detail is sorted by name.

Source code in src/openfacefx/inspect.py
def inspect_track(track, *, segments=None, oov_words=None) -> Dict:
    """A deterministic, schema-stable stats dict for ``track``.

    Reuses :func:`openfacefx.qa.summarize` for the shared counters and cue-flag
    logic, then adds per-channel detail and the weight/pose/gesture split. Every
    key is always present; ``channel_detail`` is sorted by name."""
    from .qa import summarize
    from .gestures import GESTURE_CHANNELS
    qa = summarize(track, segments=segments, oov_words=oov_words)
    duration = qa["duration"]
    detail: List[Dict] = []
    weight = pose = gesture = 0
    for c in sorted(track.channels, key=lambda ch: ch.name):
        vals = [k.value for k in c.keys]
        times = [k.time for k in c.keys]
        is_pose = c.name in POSE_CHANNELS
        pose += is_pose
        weight += not is_pose
        gesture += c.name in GESTURE_CHANNELS
        start = round(min(times), 4) if times else 0.0
        end = round(max(times), 4) if times else 0.0
        detail.append({
            "name": c.name,
            "kind": "pose" if is_pose else "weight",
            "keys": len(c.keys),
            "min": round(min(vals), 4) if vals else 0.0,
            "max": round(max(vals), 4) if vals else 0.0,
            "start": start,
            "end": end,
            "coverage": round((end - start) / duration, 4) if duration > 0 else 0.0,
        })
    vs = list(track.target_set) if track.target_set is not None else list(VISEMES)
    return {
        "format": FORMAT,
        "version": VERSION,
        "fps": qa["fps"],
        "duration": duration,
        "channels": qa["channels"],
        "keyframes": qa["keyframes"],
        "weight_channels": weight,
        "pose_channels": pose,
        "gesture_channels": gesture,
        "events": qa["events"],
        "variants": _variant_count(track),
        "viseme_set": vs,
        "channel_detail": detail,
        "cue_warnings": qa["cue_warnings"],
        "oov_words": qa["oov_words"],
        "warnings": qa["warnings"],
    }

render_inspect(doc: Dict) -> str

A compact human table of an :func:inspect_track dict.

Source code in src/openfacefx/inspect.py
def render_inspect(doc: Dict) -> str:
    """A compact human table of an :func:`inspect_track` dict."""
    lines = [
        f"duration {doc['duration']}s @ {doc['fps']} fps",
        f"channels {doc['channels']} ({doc['weight_channels']} weight, "
        f"{doc['pose_channels']} pose; {doc['gesture_channels']} gesture) | "
        f"keyframes {doc['keyframes']} | events {doc['events']} | "
        f"variants {doc['variants']}",
        f"{'channel':<16} {'kind':<6} {'keys':>5} {'min':>7} {'max':>7} "
        f"{'start':>7} {'end':>7} {'cover':>6}",
    ]
    for d in doc["channel_detail"]:
        lines.append(f"{d['name']:<16} {d['kind']:<6} {d['keys']:>5} "
                     f"{d['min']:>7} {d['max']:>7} {d['start']:>7} "
                     f"{d['end']:>7} {d['coverage']:>6}")
    if doc["cue_warnings"]:
        lines.append(f"cue warnings: {len(doc['cue_warnings'])}")
    for w in doc["warnings"]:
        lines.append(f"warning: {w}")
    return "\n".join(lines)

detect_kind(data) -> str

track / edits / events / unknown from a parsed JSON dict.

Source code in src/openfacefx/inspect.py
def detect_kind(data) -> str:
    """``track`` / ``edits`` / ``events`` / ``unknown`` from a parsed JSON dict."""
    if not isinstance(data, dict):
        return "unknown"
    fmt = data.get("format")
    if fmt == "openfacefx.track":
        return "track"
    if fmt == "openfacefx.edits":
        return "edits"
    if "events" in data or "variants" in data:
        return "events"
    return "unknown"

validate_asset(data, *, kind: Optional[str] = None, strict: bool = False) -> Tuple[str, List[Dict]]

Validate a parsed asset dict; returns (kind, problems).

problems is a deterministic, sorted list of {severity, code, where, detail}. strict relabels every warning as an error (so the gate fails on empty channels / a zero-length track too).

Source code in src/openfacefx/inspect.py
def validate_asset(data, *, kind: Optional[str] = None,
                   strict: bool = False) -> Tuple[str, List[Dict]]:
    """Validate a parsed asset dict; returns ``(kind, problems)``.

    ``problems`` is a deterministic, sorted list of ``{severity, code, where,
    detail}``. ``strict`` relabels every ``warning`` as an ``error`` (so the gate
    fails on empty channels / a zero-length track too)."""
    kind = kind or detect_kind(data)
    if kind == "track":
        problems = _validate_track(data)
    elif kind == "edits":
        problems = _validate_edits(data)
    elif kind == "events":
        problems = _validate_events_file(data)
    else:
        problems = [_p("error", "unknown_asset", "file",
                       "not a recognised openfacefx track, edits sidecar, or "
                       "events file")]
    if strict:
        for p in problems:
            if p["severity"] == "warning":
                p["severity"] = "error"
    problems.sort(key=lambda p: (_SEVERITY_RANK.get(p["severity"], 9),
                                 p["code"], p["where"], p["detail"]))
    return kind, problems

validate_file(path: str, *, strict: bool = False) -> Tuple[str, List[Dict]]

Read path and :func:validate_asset it. JSON / read errors become a single problem (so a lint gate still exits nonzero, never crashes).

Source code in src/openfacefx/inspect.py
def validate_file(path: str, *, strict: bool = False) -> Tuple[str, List[Dict]]:
    """Read ``path`` and :func:`validate_asset` it. JSON / read errors become a
    single problem (so a lint gate still exits nonzero, never crashes)."""
    try:
        with open(path, encoding="utf-8") as fh:
            data = json.load(fh)
    except OSError as e:
        return "unknown", [_p("error", "unreadable", "file", f"cannot read: {e}")]
    except json.JSONDecodeError as e:
        return "unknown", [_p("error", "not_json", "file", f"not valid JSON: {e}")]
    return validate_asset(data, strict=strict)

render_problems(kind: str, problems: List[Dict]) -> str

Human summary of a :func:validate_asset result.

Source code in src/openfacefx/inspect.py
def render_problems(kind: str, problems: List[Dict]) -> str:
    """Human summary of a :func:`validate_asset` result."""
    if not problems:
        return f"OK: {kind} is well-formed (0 problems)"
    errs = sum(1 for p in problems if p["severity"] == "error")
    warns = len(problems) - errs
    lines = [f"{kind}: {errs} error(s), {warns} warning(s)"]
    for p in problems:
        lines.append(f"  {p['severity']:<7} [{p['code']}] {p['where']}: "
                     f"{p['detail']}")
    return "\n".join(lines)

diff

OpenFaceFX ships a hard determinism guarantee; diff is the golden-file / snapshot gate that leverages it (issue #50) — "did this solver-param / coarticulation / retarget change actually move the curves, and by how much?" It is distinct from its neighbours: validate checks a single file against the contract, and diff-edits writes a sidecar for re-application; diff always takes two tracks and never writes. A raw cmp is too brittle (4-dp time quantisation, RDP key placement), so it compares semantically.

python -m openfacefx diff golden.track.json candidate.track.json            # exact-match gate
python -m openfacefx diff golden.track.json candidate.track.json --tolerance 0.002
python -m openfacefx diff a.track.json b.track.json --json > drift.json

The report gives the duration delta, an fps mismatch, per-channel added/removed, and for shared channels the max-abs / RMS / mean-abs value delta on a shared dense grid (the same np.interp resampling edits.sample uses) plus time-coverage and first/last-key drift, and event add/remove/changed. It exits 0 when every delta ≤ --tolerance (default 0.0 → exact match) and nonzero otherwise, emitting a deterministic, sorted problem list ({channel, metric, value}) so CI diffs stay stable; --json prints the full schema-stable report, human mode a worst-first table. The magnitudes are symmetric — diff(a, b) and diff(b, a) agree up to sign, and added/removed swap. Library callers get diff_tracks(a, b, *, tolerance); pure numpy + stdlib, no solver, no RNG, no writes.

openfacefx.trackdiff

Structured A/B track drift report with a tolerance-gated verdict (issue #50).

OpenFaceFX ships a hard determinism guarantee but no tool to leverage it. This is the golden-file / snapshot gate: "did this solver-param / coarticulation / retarget change actually move the curves, and by how much?" It is distinct from its neighbours -- :func:openfacefx.inspect.validate_asset checks a single file against the format contract, and :func:openfacefx.edits.diff_edits writes a sidecar for re-application; diff always takes two tracks and never writes. A raw cmp is too brittle (4-dp time quantisation, RDP key placement), so this compares semantically: per-channel value deltas on a shared dense grid (the same np.interp resampling :func:openfacefx.edits.sample uses), plus structural drift (added/removed channels, duration/fps, events).

The report is a plain, JSON-ready dict with a deterministic, sorted problem list ({channel, metric, value}); ok is true iff every delta is within tolerance and there is no structural drift. numpy + stdlib, no solver, no RNG, no writes -- same inputs, same bytes on Python 3.9/3.13.

FORMAT = 'openfacefx.diff' module-attribute

VERSION = 1 module-attribute

diff_tracks(a: FaceTrack, b: FaceTrack, *, tolerance: float = 0.0) -> Dict

A structured drift report between tracks a and b.

tolerance (default 0.0 -> exact match) gates the ok verdict: every per-channel value delta (max-abs / RMS / mean-abs, coverage and first/last-key drift) and the duration/fps deltas must be <= tolerance, and there must be no structural drift (added/removed channels, changed events). The magnitudes are symmetric, so diff(a, b) and diff(b, a) agree up to sign and the added/removed channel lists swap.

Source code in src/openfacefx/trackdiff.py
def diff_tracks(a: FaceTrack, b: FaceTrack, *, tolerance: float = 0.0) -> Dict:
    """A structured drift report between tracks ``a`` and ``b``.

    ``tolerance`` (default ``0.0`` -> exact match) gates the ``ok`` verdict: every
    per-channel value delta (max-abs / RMS / mean-abs, coverage and first/last-key
    drift) and the duration/fps deltas must be ``<= tolerance``, and there must be
    no structural drift (added/removed channels, changed events). The magnitudes
    are symmetric, so ``diff(a, b)`` and ``diff(b, a)`` agree up to sign and the
    added/removed channel lists swap."""
    tol = float(tolerance)
    problems: List[Dict] = []

    dur_a, dur_b = round(a.duration, 4), round(b.duration, 4)
    ddur = round(dur_a - dur_b, 6)
    if abs(ddur) > tol:
        problems.append(_p("", "duration", abs(ddur)))
    dfps = round(float(a.fps) - float(b.fps), 6)
    if abs(dfps) > tol:
        problems.append(_p("", "fps", abs(dfps)))

    amap = {c.name: c for c in a.channels}
    bmap = {c.name: c for c in b.channels}
    added = sorted(set(bmap) - set(amap))       # in B, not A
    removed = sorted(set(amap) - set(bmap))     # in A, not B
    shared = sorted(set(amap) & set(bmap))
    for name in added:
        problems.append(_p(name, "added", 1.0))
    for name in removed:
        problems.append(_p(name, "removed", 1.0))

    end = max(dur_a, dur_b)
    fps = max(float(a.fps), float(b.fps)) or 60.0
    n = max(_GRID_MIN, int(round(end * fps)) + 1) if end > 0 else _GRID_MIN
    grid = np.linspace(0.0, end, n)
    deltas: List[Dict] = []
    for name in shared:
        d = sample(amap[name], grid) - sample(bmap[name], grid)
        max_abs = round(float(np.max(np.abs(d))), 6)
        rms = round(float(np.sqrt(np.mean(d * d))), 6)
        mean_abs = round(float(np.mean(np.abs(d))), 6)
        fa, la, cva = _coverage(amap[name])
        fb, lb, cvb = _coverage(bmap[name])
        cov, first_k, last_k = round(cva - cvb, 6), round(fa - fb, 6), round(la - lb, 6)
        deltas.append({
            "channel": name, "max_abs": max_abs, "rms": rms, "mean_abs": mean_abs,
            "coverage_delta": cov, "first_key_delta": first_k,
            "last_key_delta": last_k,
        })
        for metric, value in (("max_abs", max_abs), ("rms", rms),
                              ("mean_abs", mean_abs), ("coverage", abs(cov)),
                              ("first_key", abs(first_k)), ("last_key", abs(last_k))):
            if value > tol:
                problems.append(_p(name, metric, round(value, 6)))

    events = _event_diff(a, b)
    for kind in ("added", "removed", "changed"):
        if events[kind]:
            problems.append(_p("", "events_" + kind, float(events[kind])))

    problems.sort(key=lambda pr: (pr["channel"], pr["metric"]))
    return {
        "format": FORMAT,
        "version": VERSION,
        "tolerance": tol,
        "ok": not problems,
        "duration": {"a": dur_a, "b": dur_b, "delta": ddur},
        "fps": {"a": a.fps, "b": b.fps, "delta": dfps},
        "channels": {"added": added, "removed": removed, "shared": shared},
        "events": events,
        "deltas": deltas,
        "problems": problems,
    }

render_diff(report: Dict) -> str

A compact worst-first human summary of a :func:diff_tracks report.

Source code in src/openfacefx/trackdiff.py
def render_diff(report: Dict) -> str:
    """A compact worst-first human summary of a :func:`diff_tracks` report."""
    if report["ok"]:
        return f"OK: tracks match within tolerance {report['tolerance']}"
    lines = [f"DRIFT: {len(report['problems'])} problem(s) over tolerance "
             f"{report['tolerance']}"]
    du, fp = report["duration"], report["fps"]
    if du["delta"]:
        lines.append(f"  duration {du['a']} vs {du['b']} (delta {du['delta']})")
    if fp["delta"]:
        lines.append(f"  fps {fp['a']} vs {fp['b']}")
    ch = report["channels"]
    if ch["added"]:
        lines.append(f"  added channels: {', '.join(ch['added'])}")
    if ch["removed"]:
        lines.append(f"  removed channels: {', '.join(ch['removed'])}")
    worst = sorted(report["deltas"], key=lambda d: -d["max_abs"])
    for d in worst:
        if d["max_abs"] or d["rms"]:
            lines.append(f"  {d['channel']:<16} max_abs {d['max_abs']:<10} "
                         f"rms {d['rms']:<10} mean_abs {d['mean_abs']}")
    ev = report["events"]
    if ev["added"] or ev["removed"] or ev["changed"]:
        lines.append(f"  events: +{ev['added']} -{ev['removed']} "
                     f"~{ev['changed']}")
    return "\n".join(lines)