Ruview/scripts/fal-visual-teacher/extract_frames.py
ruv 5c50fa8588 feat: train real fal.ai synthetic visual teacher LoRA (ADR-353)
Trains a real fal.ai LoRA fine-tune (fal-ai/flux-lora-fast-training) on
frames extracted from the real fal.ai-generated room-visualization MP4s
already captured this session, so display-layer generation has a specific
trained style target instead of prompt engineering against a stock model.

Architecture boundary: this model only ever produces supplementary
synthetic visual/pixel output. It has no path into measured/derived RF,
pose, vitals, identity, or confidence values, which remain local and
authoritative.

Real run: request 01a060b2-c940-7972-abf9-ea48847eb2ae, 1000 steps, 27
training images, ~$2.04 spend, validated with a real inference call
against the trained LoRA (assets/fal-visual-teacher-sample.png).
2026-09-02 08:45:23 -04:00

99 lines
3.9 KiB
Python

#!/usr/bin/env python3
"""Extract training stills from the real fal.ai room-visualization MP4s.
Reads the source videos (real fal.ai-generated room visualizations, one per
style preset) and writes JPEG frames + a caption .txt per frame into a local
(gitignored) training-data directory, ready to be zipped for fal.ai LoRA
training. Frame interval is time-based (not frame-count-based) so each
source contributes visual diversity proportional to its length.
"""
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
# (source mp4 path, style tag, caption) -- sources are real fal.ai output,
# captured earlier this session by the fal-room-viz / room-viz-h3-upgrade
# agents. Caption describes the RuView visual style so the LoRA learns the
# recurring motifs (glowing pose overlay, WiFi signal arcs, vitals readout,
# dark tech aesthetic) rather than any single room layout.
SOURCES = [
("/tmp/room-viz-architectural/room-viz.mp4", "architectural",
"ruview style room visualization, architectural wireframe overlay, "
"glowing cyan pose skeleton, wifi signal arcs, dark tech aesthetic"),
("/tmp/room-viz-abstract/room-viz.mp4", "abstract",
"ruview style room visualization, abstract particle field, "
"glowing pose overlay, wifi signal arcs, dark tech aesthetic"),
("/tmp/room-viz-branded/room-viz.mp4", "branded",
"ruview style room visualization, branded hud overlay, "
"glowing cyan and emerald pose skeleton, vitals readout, dark tech aesthetic"),
("/tmp/room-viz-h3-architectural/room-viz.mp4", "h3-architectural",
"ruview style room visualization, architectural wireframe overlay, "
"glowing pose skeleton, wifi signal arcs, vitals readout, dark tech aesthetic"),
("/tmp/room-viz-h3-cinematic/room-viz.mp4", "h3-cinematic",
"ruview style room visualization, cinematic lighting, "
"glowing pose overlay, wifi signal arcs, vitals readout, dark tech aesthetic"),
("/tmp/room-viz-h3-minimal/room-viz.mp4", "h3-minimal",
"ruview style room visualization, minimal clean overlay, "
"glowing pose skeleton, subtle wifi signal arcs, dark tech aesthetic"),
("/tmp/room-viz-h3-abstract/room-viz.mp4", "h3-abstract",
"ruview style room visualization, abstract particle field, "
"glowing pose overlay, wifi signal arcs, dark tech aesthetic"),
]
FRAME_INTERVAL_SEC = 1.5
def extract(src: Path, style: str, caption: str, out_dir: Path) -> int:
pattern = out_dir / f"{style}_%03d.jpg"
cmd = [
"ffmpeg", "-y", "-loglevel", "error",
"-i", str(src),
"-vf", f"fps=1/{FRAME_INTERVAL_SEC}",
"-q:v", "2",
str(pattern),
]
subprocess.run(cmd, check=True)
frames = sorted(out_dir.glob(f"{style}_*.jpg"))
for frame in frames:
frame.with_suffix(".txt").write_text(caption, encoding="utf-8")
return len(frames)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--out", default=str(REPO_ROOT / "scripts" / "fal-visual-teacher" / "training-data"),
help="Output directory for extracted frames + captions (gitignored)",
)
args = parser.parse_args()
out_dir = Path(args.out)
if out_dir.exists():
shutil.rmtree(out_dir)
out_dir.mkdir(parents=True)
total = 0
for src_str, style, caption in SOURCES:
src = Path(src_str)
if not src.exists():
print(f"SKIP (missing): {src}", file=sys.stderr)
continue
n = extract(src, style, caption, out_dir)
print(f"{style}: {n} frames from {src}")
total += n
print(f"\nTotal frames extracted: {total}")
print(f"Training data dir: {out_dir}")
if total < 4:
print("ERROR: fal.ai training needs at least 4 images", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())