Skip to content

LOD variant export

Game runtimes carry facial animation at several detail levels and thin it with distance — Unity's "Optimal" compression is keyframe reduction under an error tolerance, and MetaHuman drops curve detail and updates at ~30 fps above LOD0. OpenFaceFX already owns the machinery (curves._rdp / edits.sample), so the lod command (issue #36) is a pure re-run at a tiered tolerance table — no ML, no engine, no camera. From one solved track it emits K variants, finest first:

python -m openfacefx lod clip.track.json -o out/clip                    # default 3 tiers
python -m openfacefx lod clip.track.json --rdp 0.002,0.01,0.04 --fps 60,30,15 -o out/clip
python -m openfacefx lod clip.track.json --rdp 0.005,0.02 --fps 60,60 --format csv -o out/clip

writes out/clip_lod0.json, out/clip_lod1.json, … plus a out/clip_lod.json metadata sidecar.

Two tiers

  • RDP tier — re-run _rdp per channel at a tolerance table (default --rdp 0.002,0.01,0.04): LOD0 keeps the dense curves, higher tiers keep only the major inflections. A pure RDP tier only ever selects a subset of the source keyframes — it never invents one — so a tier at the source epsilon reproduces the input byte-identically (LOD0).
  • fps tier — before thinning, step/linear-resample each channel onto a coarser grid via edits.sample (default --fps 60,30,15), so a distant LOD updates less often; the kept keys land only on that coarse grid.

Each tier is (epsilon, fps): a tier at (or above) the source fps is pure-RDP; a coarser fps resamples first. --fps is capped at the source rate (LOD never upsamples). Higher tiers carry a monotonically non-increasing keyframe count.

Metadata sidecar

*_lod.json (format: openfacefx.lod) round-trips through JSON and names every variant's epsilon, fps, channel and keyframe counts, plus an advisory screen-coverage → LOD-index switching table (Unity LODGroup-style min_screen_height thresholds, descending, last = 0.0 fallback). OpenFaceFX has no camera at export, so the actual switch stays the engine's job — this is advice.

{
  "format": "openfacefx.lod", "version": 1,
  "source_fps": 60.0, "duration": 2.3,
  "levels": [
    { "index": 0, "file": "clip_lod0.json", "epsilon": 0.002, "fps": 60.0, "channels": 11, "keyframes": 172 },
    { "index": 1, "file": "clip_lod1.json", "epsilon": 0.01,  "fps": 30.0, "channels": 11, "keyframes": 169 },
    { "index": 2, "file": "clip_lod2.json", "epsilon": 0.04,  "fps": 15.0, "channels": 9,  "keyframes": 92 }
  ],
  "switching": [
    { "lod": 0, "min_screen_height": 0.5 },
    { "lod": 1, "min_screen_height": 0.2 },
    { "lod": 2, "min_screen_height": 0.0 }
  ]
}

The FaceTrack.variants slot is the issue-#6 event-take alternatives and is not overloaded for LOD — variants are separate files. Each variant carries the event/take layer through unchanged. Library callers get generate_lods(track, *, rdp, fps), make_lod, lod_metadata, switching_table, and the LOD_DEFAULT_RDP/LOD_DEFAULT_FPS tables. numpy + stdlib, deterministic across Python 3.9/3.13; purely additive (the default pipeline is unchanged without the command).

openfacefx.lod

Offline LOD (level-of-detail) variant export (issue #36).

Game runtimes carry facial animation at several detail levels and thin it with distance -- Unity's "Optimal" compression is keyframe reduction under an error tolerance, and MetaHuman drops curve detail and updates at ~30 fps above LOD0. OpenFaceFX already owns the exact machinery (curves._rdp / edits.sample), so this is a pure re-run at a tiered tolerance table -- no ML, no engine, no camera.

From ONE solved track it produces K variants, finest first, along two tiers:

  • RDP tier -- re-run _rdp per channel at a tolerance table (e.g. eps = [0.002, 0.01, 0.04]): LOD0 keeps the dense curves, higher tiers keep only the major inflections. A pure RDP tier only ever selects a subset of the source keyframes -- it never invents a key.
  • fps tier -- before thinning, step/linear-resample each channel onto a coarser time grid via edits.sample (e.g. 60/30/15 fps), so a distant LOD updates less often; the kept keys land only on that coarse grid.

A tier that keeps the source fps is pure-RDP (so LOD0 at the source epsilon is byte-identical to the input); a coarser fps resamples first. Each variant is a normal FaceTrack written to its own file; a *_lod.json metadata sidecar names every variant's epsilon + fps and ships an advisory screen-coverage -> LOD-index switching table (the engine owns the actual switch). The event/take layer -- including FaceTrack.variants (issue #6), which is NOT overloaded for LOD -- is carried through each variant unchanged. numpy + stdlib, deterministic.

LOD_DEFAULT_RDP: List[float] = [0.002, 0.01, 0.04] module-attribute

LOD_DEFAULT_FPS: List[float] = [60.0, 30.0, 15.0] module-attribute

make_lod(track: FaceTrack, eps: float, fps: float) -> FaceTrack

One LOD variant of track. When fps >= track.fps it is a pure RDP tier -- each channel's existing keyframes re-thinned at eps (a subset, never invented), so eps <= the source epsilon reproduces the source exactly. A coarser fps resamples each channel onto that grid first, then thins, so the keys land only on the coarse grid.

Source code in src/openfacefx/lod.py
def make_lod(track: FaceTrack, eps: float, fps: float) -> FaceTrack:
    """One LOD variant of ``track``. When ``fps >= track.fps`` it is a *pure RDP*
    tier -- each channel's existing keyframes re-thinned at ``eps`` (a subset,
    never invented), so ``eps <= the source epsilon`` reproduces the source
    exactly. A coarser ``fps`` resamples each channel onto that grid first, then
    thins, so the keys land only on the coarse grid."""
    if fps >= track.fps:
        channels = [_rdp_thin(c, eps) for c in track.channels]
        out_fps = track.fps
    else:
        grid = _grid(track.duration, fps)
        channels = _resample_thin(track.channels, grid, eps)
        out_fps = fps
    target_set = list(track.target_set) if track.target_set is not None else None
    out = FaceTrack(out_fps, channels, target_set)
    # Carry the event/take layer (issue #6) through unchanged -- LOD thins curves,
    # not notifies; FaceTrack.variants stays event-take alternatives, not LOD.
    out.events = list(getattr(track, "events", None) or [])
    out.variants = getattr(track, "variants", None)
    return out

generate_lods(track: FaceTrack, *, rdp: Optional[List[float]] = None, fps: Optional[List[float]] = None) -> Tuple[List[FaceTrack], List[Tuple[float, float]]]

Return (variants, levels) -- K deterministic LOD variants (finest first) and the resolved (eps, fps) per level.

Source code in src/openfacefx/lod.py
def generate_lods(track: FaceTrack, *, rdp: Optional[List[float]] = None,
                  fps: Optional[List[float]] = None
                  ) -> Tuple[List[FaceTrack], List[Tuple[float, float]]]:
    """Return ``(variants, levels)`` -- K deterministic LOD variants (finest
    first) and the resolved ``(eps, fps)`` per level."""
    levels = _resolve_levels(rdp, fps, track.fps)
    return [make_lod(track, eps, f) for eps, f in levels], levels

switching_table(k: int) -> List[dict]

Advisory screen-coverage -> LOD-index thresholds for k levels.

lod i is the engine's pick while the face covers at least min_screen_height of the view height (Unity LODGroup-style screen-relative size); the last level is the 0.0 fallback. OpenFaceFX has no camera at export, so the switch stays the engine's job -- this is advice.

Source code in src/openfacefx/lod.py
def switching_table(k: int) -> List[dict]:
    """Advisory screen-coverage -> LOD-index thresholds for ``k`` levels.

    ``lod`` i is the engine's pick while the face covers at least
    ``min_screen_height`` of the view height (Unity ``LODGroup``-style
    screen-relative size); the last level is the ``0.0`` fallback. OpenFaceFX has
    no camera at export, so the switch stays the engine's job -- this is advice."""
    return [{"lod": i,
             "min_screen_height": 0.0 if i == k - 1 else round(0.5 * 0.4 ** i, 4)}
            for i in range(k)]

lod_metadata(track: FaceTrack, levels: List[Tuple[float, float]], variants: List[FaceTrack], files: List[str]) -> dict

The *_lod.json sidecar: source stats, one entry per variant naming its epsilon + fps + counts, and the advisory switching table. Plain JSON-ready.

Source code in src/openfacefx/lod.py
def lod_metadata(track: FaceTrack, levels: List[Tuple[float, float]],
                 variants: List[FaceTrack], files: List[str]) -> dict:
    """The ``*_lod.json`` sidecar: source stats, one entry per variant naming its
    epsilon + fps + counts, and the advisory switching table. Plain JSON-ready."""
    entries = []
    for i, ((eps, _req_fps), variant, path) in enumerate(
            zip(levels, variants, files)):
        entries.append({
            "index": i,
            "file": path,
            "epsilon": round(eps, 6),
            "fps": variant.fps,
            "channels": len(variant.channels),
            "keyframes": sum(len(c.keys) for c in variant.channels),
        })
    return {
        "format": "openfacefx.lod",
        "version": 1,
        "source_fps": track.fps,
        "duration": round(track.duration, 4),
        "levels": entries,
        "switching": switching_table(len(levels)),
    }