169 lines
6.5 KiB
Python
169 lines
6.5 KiB
Python
|
|
#!/usr/bin/env python
|
||
|
|
"""Stack the pristine, current-pipeline and new-pipeline streams side by side.
|
||
|
|
|
||
|
|
`bench.py render` writes one composite per timestamp per variant. This turns three
|
||
|
|
such streams into a single video with the panes labelled, so the repair can be judged
|
||
|
|
against both the truth and what the pipeline does today:
|
||
|
|
|
||
|
|
bench.py render --case C --variant pristine --out /tmp/streams
|
||
|
|
bench.py render --case C --variant today --out /tmp/streams
|
||
|
|
bench.py render --case C --variant new --out /tmp/streams
|
||
|
|
make_comparison_video.py --streams /tmp/streams --satellite 16 --out compare.mp4
|
||
|
|
|
||
|
|
The panes must stay in step, which is the whole reason this does not simply hand
|
||
|
|
ffmpeg three directories: a variant that produced no composite for a timestamp -- what
|
||
|
|
the current pipeline does past its gap limit -- would otherwise shorten that pane and
|
||
|
|
slide it out of alignment with the others. Every stream is emitted against the same
|
||
|
|
canonical timeline, with black where a composite is missing, so frame N is the same
|
||
|
|
instant in all three panes. Those black runs are not padding; they are what the
|
||
|
|
current pipeline actually shows.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
from PIL import Image
|
||
|
|
|
||
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
|
|
||
|
|
from ffmpeg_video import find_ffmpeg
|
||
|
|
from suvi import paths, vfs
|
||
|
|
|
||
|
|
VARIANTS = ("pristine", "today", "new")
|
||
|
|
LABELS = {
|
||
|
|
"pristine": "PRISTINE (ground truth)",
|
||
|
|
"today": "TODAY (hold-last + black frames)",
|
||
|
|
"new": "NEW (disc+header, optical flow)",
|
||
|
|
}
|
||
|
|
_STAMP = re.compile(r"Composite-(\d+)\.jpg$")
|
||
|
|
|
||
|
|
|
||
|
|
def stream_frames(directory):
|
||
|
|
"""{timestamp: path} for one rendered stream."""
|
||
|
|
frames = {}
|
||
|
|
try:
|
||
|
|
entries = os.listdir(directory)
|
||
|
|
except OSError:
|
||
|
|
return frames
|
||
|
|
for entry in entries:
|
||
|
|
match = _STAMP.search(entry)
|
||
|
|
if match:
|
||
|
|
frames[int(match.group(1))] = os.path.join(directory, entry)
|
||
|
|
return frames
|
||
|
|
|
||
|
|
|
||
|
|
def encode_stream(ffmpeg, frames, timeline, out_path, framerate, crf, reliever=None):
|
||
|
|
"""Encode one pane, emitting a black frame wherever the variant has nothing.
|
||
|
|
|
||
|
|
Reads every composite in the pane, so it hands file handles back as it goes --
|
||
|
|
three panes of a week-long window is over 7,000 files, enough on its own to
|
||
|
|
exhaust the mount and take unrelated software down with it.
|
||
|
|
"""
|
||
|
|
command = (
|
||
|
|
f'{ffmpeg} -y -f image2pipe -framerate {framerate} -i - '
|
||
|
|
f'-c:v libx264 -crf {crf} -preset veryfast -pix_fmt yuv420p "{out_path}"'
|
||
|
|
)
|
||
|
|
process = subprocess.Popen(command, shell=True, stdin=subprocess.PIPE,
|
||
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
|
|
blank = None
|
||
|
|
present = 0
|
||
|
|
for when in timeline:
|
||
|
|
path = frames.get(when)
|
||
|
|
if path:
|
||
|
|
with open(path, "rb") as handle:
|
||
|
|
process.stdin.write(handle.read())
|
||
|
|
present += 1
|
||
|
|
if reliever is not None:
|
||
|
|
reliever.tick()
|
||
|
|
else:
|
||
|
|
if blank is None:
|
||
|
|
size = Image.open(next(iter(frames.values()))).size if frames else (1920, 1080)
|
||
|
|
blank = Image.fromarray(np.zeros((size[1], size[0], 3), dtype="uint8"))
|
||
|
|
blank.save(process.stdin, "jpeg", quality=95)
|
||
|
|
process.stdin.close()
|
||
|
|
process.wait()
|
||
|
|
return present
|
||
|
|
|
||
|
|
|
||
|
|
def main(argv=None):
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
||
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
|
|
parser.add_argument("--streams", required=True, help="root written by bench.py render")
|
||
|
|
parser.add_argument("--satellite", type=int, default=16)
|
||
|
|
parser.add_argument("--out", required=True)
|
||
|
|
parser.add_argument("--framerate", type=int, default=60)
|
||
|
|
parser.add_argument("--crf", type=int, default=18)
|
||
|
|
parser.add_argument("--keep-panes", action="store_true",
|
||
|
|
help="keep the intermediate per-pane videos")
|
||
|
|
args = parser.parse_args(argv)
|
||
|
|
|
||
|
|
ffmpeg = find_ffmpeg()
|
||
|
|
streams = {}
|
||
|
|
for variant in VARIANTS:
|
||
|
|
directory = os.path.join(args.streams, variant, f"goes{args.satellite}")
|
||
|
|
streams[variant] = stream_frames(directory)
|
||
|
|
print(f" {variant:>8}: {len(streams[variant])} composites in {directory}")
|
||
|
|
if not any(streams.values()):
|
||
|
|
print("No composites found; run bench.py render first.")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
# One canonical timeline across every variant, so the panes stay in step.
|
||
|
|
everything = set()
|
||
|
|
for frames in streams.values():
|
||
|
|
everything.update(frames)
|
||
|
|
timeline = list(range(min(everything), max(everything) + paths.CADENCE, paths.CADENCE))
|
||
|
|
print(f" timeline: {len(timeline)} slots "
|
||
|
|
f"({len(timeline) / args.framerate:.0f}s per pane at {args.framerate}fps)")
|
||
|
|
|
||
|
|
panes = []
|
||
|
|
reliever = vfs.Reliever(label="encode")
|
||
|
|
for variant in VARIANTS:
|
||
|
|
pane = f"{os.path.splitext(args.out)[0]}.{variant}.mp4"
|
||
|
|
present = encode_stream(
|
||
|
|
ffmpeg, streams[variant], timeline, pane, args.framerate, args.crf, reliever
|
||
|
|
)
|
||
|
|
missing = len(timeline) - present
|
||
|
|
print(f" encoded {variant}: {present} frames"
|
||
|
|
+ (f", {missing} black ({missing / len(timeline):.0%})" if missing else ""))
|
||
|
|
panes.append(pane)
|
||
|
|
|
||
|
|
inputs = " ".join(f'-i "{p}"' for p in panes)
|
||
|
|
font = os.path.join(os.path.dirname(os.path.abspath(__file__)), "OpenSans-Regular.ttf")
|
||
|
|
labelled = []
|
||
|
|
for index, variant in enumerate(VARIANTS):
|
||
|
|
labelled.append(
|
||
|
|
f"[{index}:v]scale=960:-2,"
|
||
|
|
f"drawtext=fontfile='{font}':text='{LABELS[variant]}':"
|
||
|
|
f"x=12:y=12:fontsize=22:fontcolor=yellow:box=1:boxcolor=black@0.5[v{index}]"
|
||
|
|
)
|
||
|
|
graph = ";".join(labelled) + ";[v0][v1][v2]hstack=inputs=3[out]"
|
||
|
|
command = (
|
||
|
|
f'{ffmpeg} -y {inputs} -filter_complex "{graph}" -map "[out]" '
|
||
|
|
f'-c:v libx264 -crf {args.crf} -preset veryfast -pix_fmt yuv420p "{args.out}"'
|
||
|
|
)
|
||
|
|
print(" stacking panes...")
|
||
|
|
result = subprocess.run(command, shell=True, stdout=subprocess.DEVNULL,
|
||
|
|
stderr=subprocess.PIPE)
|
||
|
|
if result.returncode != 0:
|
||
|
|
print(result.stderr.decode()[-1500:])
|
||
|
|
return 1
|
||
|
|
|
||
|
|
if not args.keep_panes:
|
||
|
|
for pane in panes:
|
||
|
|
try:
|
||
|
|
os.remove(pane)
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
reliever.finish()
|
||
|
|
size = os.path.getsize(args.out) / 1024 / 1024
|
||
|
|
print(f"Wrote {args.out} ({size:.0f} MB)")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|