mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-02-18 16:33:57 +08:00
clips: direct rendering with raylib (#36935)
* good clips * replace * fix * fix font * lil more
This commit is contained in:
@@ -10,6 +10,27 @@ import zstandard as zstd
|
||||
|
||||
LOG_COMPRESSION_LEVEL = 10 # little benefit up to level 15. level ~17 is a small step change
|
||||
|
||||
class Timer:
|
||||
"""Simple lap timer for profiling sequential operations."""
|
||||
|
||||
def __init__(self):
|
||||
self._start = self._lap = time.monotonic()
|
||||
self._sections = {}
|
||||
|
||||
def lap(self, name):
|
||||
now = time.monotonic()
|
||||
self._sections[name] = now - self._lap
|
||||
self._lap = now
|
||||
|
||||
@property
|
||||
def total(self):
|
||||
return time.monotonic() - self._start
|
||||
|
||||
def fmt(self, duration):
|
||||
parts = ", ".join(f"{k}={v:.2f}s" + (f" ({duration/v:.0f}x)" if k == 'render' and v > 0 else "") for k, v in self._sections.items())
|
||||
total = self.total
|
||||
realtime = f"{duration/total:.1f}x realtime" if total > 0 else "N/A"
|
||||
return f"{duration}s in {total:.1f}s ({realtime}) | {parts}"
|
||||
|
||||
def sudo_write(val: str, path: str) -> None:
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import atexit
|
||||
import cffi
|
||||
import os
|
||||
import queue
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
@@ -40,6 +41,9 @@ PROFILE_RENDER = int(os.getenv("PROFILE_RENDER", "0"))
|
||||
PROFILE_STATS = int(os.getenv("PROFILE_STATS", "100")) # Number of functions to show in profile output
|
||||
RECORD = os.getenv("RECORD") == "1"
|
||||
RECORD_OUTPUT = str(Path(os.getenv("RECORD_OUTPUT", "output")).with_suffix(".mp4"))
|
||||
RECORD_BITRATE = os.getenv("RECORD_BITRATE", "") # Target bitrate e.g. "2000k"
|
||||
RECORD_SPEED = int(os.getenv("RECORD_SPEED", "1")) # Speed multiplier
|
||||
OFFSCREEN = os.getenv("OFFSCREEN") == "1" # Disable FPS limiting for fast offline rendering
|
||||
|
||||
GL_VERSION = """
|
||||
#version 300 es
|
||||
@@ -213,6 +217,9 @@ class GuiApplication:
|
||||
self._render_texture: rl.RenderTexture | None = None
|
||||
self._burn_in_shader: rl.Shader | None = None
|
||||
self._ffmpeg_proc: subprocess.Popen | None = None
|
||||
self._ffmpeg_queue: queue.Queue | None = None
|
||||
self._ffmpeg_thread: threading.Thread | None = None
|
||||
self._ffmpeg_stop_event: threading.Event | None = None
|
||||
self._textures: dict[str, rl.Texture] = {}
|
||||
self._target_fps: int = _DEFAULT_FPS
|
||||
self._last_fps_log_time: float = time.monotonic()
|
||||
@@ -277,25 +284,36 @@ class GuiApplication:
|
||||
rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
||||
|
||||
if RECORD:
|
||||
output_fps = fps * RECORD_SPEED
|
||||
ffmpeg_args = [
|
||||
'ffmpeg',
|
||||
'-v', 'warning', # Reduce ffmpeg log spam
|
||||
'-stats', # Show encoding progress
|
||||
'-nostats', # Suppress encoding progress
|
||||
'-f', 'rawvideo', # Input format
|
||||
'-pix_fmt', 'rgba', # Input pixel format
|
||||
'-s', f'{self._width}x{self._height}', # Input resolution
|
||||
'-r', str(fps), # Input frame rate
|
||||
'-i', 'pipe:0', # Input from stdin
|
||||
'-vf', 'vflip,format=yuv420p', # Flip vertically and convert rgba to yuv420p
|
||||
'-c:v', 'libx264', # Video codec
|
||||
'-preset', 'ultrafast', # Encoding speed
|
||||
'-vf', 'vflip,format=yuv420p', # Flip vertically and convert to yuv420p
|
||||
'-r', str(output_fps), # Output frame rate (for speed multiplier)
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'ultrafast',
|
||||
]
|
||||
if RECORD_BITRATE:
|
||||
ffmpeg_args += ['-b:v', RECORD_BITRATE, '-maxrate', RECORD_BITRATE, '-bufsize', RECORD_BITRATE]
|
||||
ffmpeg_args += [
|
||||
'-y', # Overwrite existing file
|
||||
'-f', 'mp4', # Output format
|
||||
RECORD_OUTPUT, # Output file path
|
||||
]
|
||||
self._ffmpeg_proc = subprocess.Popen(ffmpeg_args, stdin=subprocess.PIPE)
|
||||
self._ffmpeg_queue = queue.Queue(maxsize=60) # Buffer up to 60 frames
|
||||
self._ffmpeg_stop_event = threading.Event()
|
||||
self._ffmpeg_thread = threading.Thread(target=self._ffmpeg_writer_thread, daemon=True)
|
||||
self._ffmpeg_thread.start()
|
||||
|
||||
rl.set_target_fps(fps)
|
||||
# OFFSCREEN disables FPS limiting for fast offline rendering (e.g. clips)
|
||||
rl.set_target_fps(0 if OFFSCREEN else fps)
|
||||
|
||||
self._target_fps = fps
|
||||
self._set_styles()
|
||||
@@ -337,6 +355,21 @@ class GuiApplication:
|
||||
print(f"{green}UI window ready in {elapsed_ms:.1f} ms{reset}")
|
||||
sys.exit(0)
|
||||
|
||||
def _ffmpeg_writer_thread(self):
|
||||
"""Background thread that writes frames to ffmpeg."""
|
||||
while True:
|
||||
try:
|
||||
data = self._ffmpeg_queue.get(timeout=1.0)
|
||||
if data is None: # Sentinel to stop
|
||||
break
|
||||
self._ffmpeg_proc.stdin.write(data)
|
||||
except queue.Empty:
|
||||
if self._ffmpeg_stop_event.is_set():
|
||||
break
|
||||
continue
|
||||
except Exception:
|
||||
break
|
||||
|
||||
def set_modal_overlay(self, overlay, callback: Callable | None = None):
|
||||
if self._modal_overlay.overlay is not None:
|
||||
if hasattr(self._modal_overlay.overlay, 'hide_event'):
|
||||
@@ -409,11 +442,17 @@ class GuiApplication:
|
||||
return texture
|
||||
|
||||
def close_ffmpeg(self):
|
||||
if self._ffmpeg_thread is not None:
|
||||
# Signal thread to stop, send sentinel, then wait for it to drain
|
||||
self._ffmpeg_stop_event.set()
|
||||
self._ffmpeg_queue.put(None)
|
||||
self._ffmpeg_thread.join(timeout=30)
|
||||
|
||||
if self._ffmpeg_proc is not None:
|
||||
self._ffmpeg_proc.stdin.flush()
|
||||
self._ffmpeg_proc.stdin.close()
|
||||
try:
|
||||
self._ffmpeg_proc.wait(timeout=5)
|
||||
self._ffmpeg_proc.wait(timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._ffmpeg_proc.terminate()
|
||||
self._ffmpeg_proc.wait()
|
||||
@@ -525,8 +564,7 @@ class GuiApplication:
|
||||
image = rl.load_image_from_texture(self._render_texture.texture)
|
||||
data_size = image.width * image.height * 4
|
||||
data = bytes(rl.ffi.buffer(image.data, data_size))
|
||||
self._ffmpeg_proc.stdin.write(data)
|
||||
self._ffmpeg_proc.stdin.flush()
|
||||
self._ffmpeg_queue.put(data) # Async write via background thread
|
||||
rl.unload_image(image)
|
||||
|
||||
self._monitor_fps()
|
||||
|
||||
@@ -1,310 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from argparse import ArgumentParser, ArgumentTypeError
|
||||
from collections.abc import Sequence
|
||||
import logging
|
||||
import subprocess
|
||||
import threading
|
||||
import queue
|
||||
import multiprocessing
|
||||
import itertools
|
||||
import numpy as np
|
||||
import tqdm
|
||||
from argparse import ArgumentParser
|
||||
from pathlib import Path
|
||||
from random import randint
|
||||
from subprocess import Popen
|
||||
from typing import Literal
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from cereal.messaging import SubMaster
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.common.params import Params, UnknownKeyName
|
||||
from openpilot.common.prefix import OpenpilotPrefix
|
||||
from openpilot.common.utils import managed_proc
|
||||
from openpilot.tools.lib.route import Route
|
||||
from openpilot.tools.lib.logreader import LogReader
|
||||
from openpilot.tools.lib.filereader import FileReader
|
||||
from openpilot.tools.lib.framereader import FrameReader, ffprobe
|
||||
from openpilot.selfdrive.test.process_replay.migration import migrate_all
|
||||
from openpilot.common.prefix import OpenpilotPrefix
|
||||
from openpilot.common.utils import Timer
|
||||
from msgq.visionipc import VisionIpcServer, VisionStreamType
|
||||
|
||||
DEFAULT_OUTPUT = 'output.mp4'
|
||||
DEMO_START = 90
|
||||
DEMO_END = 105
|
||||
DEMO_ROUTE = 'a2a0ccea32023010/2023-07-27--13-01-19'
|
||||
FRAMERATE = 20
|
||||
PIXEL_DEPTH = '24'
|
||||
RESOLUTION = '2160x1080'
|
||||
SECONDS_TO_WARM = 2
|
||||
PROC_WAIT_SECONDS = 30*10
|
||||
DEMO_ROUTE, DEMO_START, DEMO_END = 'a2a0ccea32023010/2023-07-27--13-01-19', 90, 105
|
||||
|
||||
OPENPILOT_FONT = str(Path(BASEDIR, 'selfdrive/assets/fonts/Inter-Regular.ttf').resolve())
|
||||
REPLAY = str(Path(BASEDIR, 'tools/replay/replay').resolve())
|
||||
UI = str(Path(BASEDIR, 'selfdrive/ui/ui').resolve())
|
||||
|
||||
logger = logging.getLogger('clip.py')
|
||||
logger = logging.getLogger('clip')
|
||||
|
||||
|
||||
def check_for_failure(procs: list[Popen]):
|
||||
for proc in procs:
|
||||
exit_code = proc.poll()
|
||||
if exit_code is not None and exit_code != 0:
|
||||
cmd = str(proc.args)
|
||||
if isinstance(proc.args, str):
|
||||
cmd = proc.args
|
||||
elif isinstance(proc.args, Sequence):
|
||||
cmd = str(proc.args[0])
|
||||
msg = f'{cmd} failed, exit code {exit_code}'
|
||||
logger.error(msg)
|
||||
stdout, stderr = proc.communicate()
|
||||
if stdout:
|
||||
logger.error(stdout.decode())
|
||||
if stderr:
|
||||
logger.error(stderr.decode())
|
||||
raise ChildProcessError(msg)
|
||||
|
||||
|
||||
def escape_ffmpeg_text(value: str):
|
||||
special_chars = {',': '\\,', ':': '\\:', '=': '\\=', '[': '\\[', ']': '\\]'}
|
||||
value = value.replace('\\', '\\\\\\\\\\\\\\\\')
|
||||
for char, escaped in special_chars.items():
|
||||
value = value.replace(char, escaped)
|
||||
return value
|
||||
|
||||
|
||||
def get_logreader(route: Route):
|
||||
return LogReader(route.qlog_paths()[0] if len(route.qlog_paths()) else route.name.canonical_name)
|
||||
|
||||
|
||||
def get_meta_text(lr: LogReader, route: Route):
|
||||
init_data = lr.first('initData')
|
||||
car_params = lr.first('carParams')
|
||||
origin_parts = init_data.gitRemote.split('/')
|
||||
origin = origin_parts[3] if len(origin_parts) > 3 else 'unknown'
|
||||
return ', '.join([
|
||||
f"openpilot v{init_data.version}",
|
||||
f"route: {route.name.canonical_name}",
|
||||
f"car: {car_params.carFingerprint}",
|
||||
f"origin: {origin}",
|
||||
f"branch: {init_data.gitBranch}",
|
||||
f"commit: {init_data.gitCommit[:7]}",
|
||||
f"modified: {str(init_data.dirty).lower()}",
|
||||
])
|
||||
|
||||
|
||||
def parse_args(parser: ArgumentParser):
|
||||
def parse_args():
|
||||
parser = ArgumentParser(description="Direct clip renderer")
|
||||
parser.add_argument("route", nargs="?", help="Route ID (dongle/route or dongle/route/start/end)")
|
||||
parser.add_argument("-s", "--start", type=int, help="Start time in seconds")
|
||||
parser.add_argument("-e", "--end", type=int, help="End time in seconds")
|
||||
parser.add_argument("-o", "--output", default="output.mp4", help="Output file path")
|
||||
parser.add_argument("-d", "--data-dir", help="Local directory with route data")
|
||||
parser.add_argument("-t", "--title", help="Title overlay text")
|
||||
parser.add_argument("-f", "--file-size", type=float, default=9.0, help="Target file size in MB")
|
||||
parser.add_argument("-x", "--speed", type=int, default=1, help="Speed multiplier")
|
||||
parser.add_argument("--demo", action="store_true", help="Use demo route with default timing")
|
||||
parser.add_argument("--big", action="store_true", default=True, help="Use big UI (2160x1080)")
|
||||
parser.add_argument("--qcam", action="store_true", help="Use qcamera instead of fcamera")
|
||||
parser.add_argument("--windowed", action="store_true", help="Show window")
|
||||
parser.add_argument("--no-metadata", action="store_true", help="Disable metadata overlay")
|
||||
parser.add_argument("--no-time-overlay", action="store_true", help="Disable time overlay")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.demo:
|
||||
args.route = DEMO_ROUTE
|
||||
if args.start is None or args.end is None:
|
||||
args.start = DEMO_START
|
||||
args.end = DEMO_END
|
||||
elif args.route.count('/') == 1:
|
||||
if args.start is None or args.end is None:
|
||||
parser.error('must provide both start and end if timing is not in the route ID')
|
||||
elif args.route.count('/') == 3:
|
||||
if args.start is not None or args.end is not None:
|
||||
parser.error('don\'t provide timing when including it in the route ID')
|
||||
args.route, args.start, args.end = args.route or DEMO_ROUTE, args.start or DEMO_START, args.end or DEMO_END
|
||||
elif not args.route:
|
||||
parser.error("route is required (or use --demo)")
|
||||
|
||||
if args.route and args.route.count('/') == 3:
|
||||
parts = args.route.split('/')
|
||||
args.route = '/'.join(parts[:2])
|
||||
args.start = int(parts[2])
|
||||
args.end = int(parts[3])
|
||||
args.route, args.start, args.end = '/'.join(parts[:2]), args.start or int(parts[2]), args.end or int(parts[3])
|
||||
|
||||
if args.start is None or args.end is None:
|
||||
parser.error("--start and --end are required")
|
||||
if args.end <= args.start:
|
||||
parser.error(f'end ({args.end}) must be greater than start ({args.start})')
|
||||
if args.start < SECONDS_TO_WARM:
|
||||
parser.error(f'start must be greater than {SECONDS_TO_WARM}s to allow the UI time to warm up')
|
||||
|
||||
try:
|
||||
args.route = Route(args.route, data_dir=args.data_dir)
|
||||
except Exception as e:
|
||||
parser.error(f'failed to get route: {e}')
|
||||
|
||||
# FIXME: length isn't exactly max segment seconds, simplify to replay exiting at end of data
|
||||
length = round(args.route.max_seg_number * 60)
|
||||
if args.start >= length:
|
||||
parser.error(f'start ({args.start}s) cannot be after end of route ({length}s)')
|
||||
if args.end > length:
|
||||
parser.error(f'end ({args.end}s) cannot be after end of route ({length}s)')
|
||||
|
||||
parser.error(f"end ({args.end}) must be greater than start ({args.start})")
|
||||
return args
|
||||
|
||||
|
||||
def populate_car_params(lr: LogReader):
|
||||
init_data = lr.first('initData')
|
||||
assert init_data is not None
|
||||
def setup_env(output_path: str, big: bool = False, speed: int = 1, target_mb: float = 0, duration: int = 0):
|
||||
os.environ.update({"RECORD": "1", "OFFSCREEN": "1", "RECORD_OUTPUT": str(Path(output_path).with_suffix(".mp4"))})
|
||||
if speed > 1:
|
||||
os.environ["RECORD_SPEED"] = str(speed)
|
||||
if target_mb > 0 and duration > 0:
|
||||
os.environ["RECORD_BITRATE"] = f"{int(target_mb * 8 * 1024 / (duration / speed))}k"
|
||||
if big:
|
||||
os.environ["BIG"] = "1"
|
||||
|
||||
|
||||
def _download_segment(path: str) -> bytes:
|
||||
with FileReader(path) as f:
|
||||
return bytes(f.read())
|
||||
|
||||
|
||||
def _parse_and_chunk_segment(args: tuple) -> list[dict]:
|
||||
raw_data, fps = args
|
||||
from openpilot.tools.lib.logreader import _LogFileReader
|
||||
messages = migrate_all(list(_LogFileReader("", dat=raw_data, sort_by_time=True)))
|
||||
if not messages:
|
||||
return []
|
||||
|
||||
dt_ns, chunks, current, next_time = 1e9 / fps, [], {}, messages[0].logMonoTime + 1e9 / fps # type: ignore[var-annotated]
|
||||
for msg in messages:
|
||||
if msg.logMonoTime >= next_time:
|
||||
chunks.append(current)
|
||||
current, next_time = {}, next_time + dt_ns * ((msg.logMonoTime - next_time) // dt_ns + 1)
|
||||
current[msg.which()] = msg
|
||||
return chunks + [current] if current else chunks
|
||||
|
||||
|
||||
def load_logs_parallel(log_paths: list[str], fps: int = 20) -> list[dict]:
|
||||
num_workers = min(16, len(log_paths), (multiprocessing.cpu_count() or 1))
|
||||
logger.info(f"Downloading {len(log_paths)} segments with {num_workers} workers...")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_workers) as pool:
|
||||
futures = {pool.submit(_download_segment, path): idx for idx, path in enumerate(log_paths)}
|
||||
raw_data = {futures[f]: f.result() for f in as_completed(futures)}
|
||||
|
||||
logger.info("Parsing and chunking segments...")
|
||||
with multiprocessing.Pool(num_workers) as pool:
|
||||
return list(itertools.chain.from_iterable(pool.map(_parse_and_chunk_segment, [(raw_data[i], fps) for i in range(len(log_paths))])))
|
||||
|
||||
|
||||
def patch_submaster(message_chunks, ui_state):
|
||||
# Reset started_frame so alerts render correctly (recv_frame must be >= started_frame)
|
||||
ui_state.started_frame = 0
|
||||
ui_state.started_time = time.monotonic()
|
||||
|
||||
def mock_update(timeout=None):
|
||||
sm, t = ui_state.sm, time.monotonic()
|
||||
sm.updated = dict.fromkeys(sm.services, False)
|
||||
if sm.frame < len(message_chunks):
|
||||
for svc, msg in message_chunks[sm.frame].items():
|
||||
if svc in sm.data:
|
||||
sm.seen[svc] = sm.updated[svc] = sm.alive[svc] = sm.valid[svc] = True
|
||||
sm.data[svc] = getattr(msg.as_builder(), svc)
|
||||
sm.logMonoTime[svc], sm.recv_time[svc], sm.recv_frame[svc] = msg.logMonoTime, t, sm.frame
|
||||
sm.frame += 1
|
||||
ui_state.sm.update = mock_update
|
||||
|
||||
|
||||
def get_frame_dimensions(camera_path: str) -> tuple[int, int]:
|
||||
"""Get frame dimensions from a video file using ffprobe."""
|
||||
probe = ffprobe(camera_path)
|
||||
stream = probe["streams"][0]
|
||||
return stream["width"], stream["height"]
|
||||
|
||||
|
||||
def iter_segment_frames(camera_paths, start_time, end_time, fps=20, use_qcam=False, frame_size: tuple[int, int] | None = None):
|
||||
frames_per_seg = fps * 60
|
||||
start_frame, end_frame = int(start_time * fps), int(end_time * fps)
|
||||
current_seg: int = -1
|
||||
seg_frames: FrameReader | np.ndarray | None = None
|
||||
|
||||
for global_idx in range(start_frame, end_frame):
|
||||
seg_idx, local_idx = global_idx // frames_per_seg, global_idx % frames_per_seg
|
||||
|
||||
if seg_idx != current_seg:
|
||||
current_seg = seg_idx
|
||||
path = camera_paths[seg_idx] if seg_idx < len(camera_paths) else None
|
||||
if not path:
|
||||
raise RuntimeError(f"No camera file for segment {seg_idx}")
|
||||
|
||||
if use_qcam:
|
||||
w, h = frame_size or get_frame_dimensions(path)
|
||||
with FileReader(path) as f:
|
||||
result = subprocess.run(["ffmpeg", "-v", "quiet", "-i", "-", "-f", "rawvideo", "-pix_fmt", "nv12", "-"],
|
||||
input=f.read(), capture_output=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg failed: {result.stderr.decode()}")
|
||||
seg_frames = np.frombuffer(result.stdout, dtype=np.uint8).reshape(-1, w * h * 3 // 2)
|
||||
else:
|
||||
seg_frames = FrameReader(path, pix_fmt="nv12")
|
||||
|
||||
assert seg_frames is not None
|
||||
frame = seg_frames[local_idx] if use_qcam else seg_frames.get(local_idx) # type: ignore[index, union-attr]
|
||||
yield global_idx, frame
|
||||
|
||||
|
||||
class FrameQueue:
|
||||
def __init__(self, camera_paths, start_time, end_time, fps=20, prefetch_count=60, use_qcam=False):
|
||||
# Probe first valid camera file for dimensions
|
||||
first_path = next((p for p in camera_paths if p), None)
|
||||
if not first_path:
|
||||
raise RuntimeError("No valid camera paths")
|
||||
self.frame_w, self.frame_h = get_frame_dimensions(first_path)
|
||||
|
||||
self._queue, self._stop, self._error = queue.Queue(maxsize=prefetch_count), threading.Event(), None
|
||||
self._thread = threading.Thread(target=self._worker,
|
||||
args=(camera_paths, start_time, end_time, fps, use_qcam, (self.frame_w, self.frame_h)), daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _worker(self, camera_paths, start_time, end_time, fps, use_qcam, frame_size):
|
||||
try:
|
||||
for idx, data in iter_segment_frames(camera_paths, start_time, end_time, fps, use_qcam, frame_size):
|
||||
if self._stop.is_set():
|
||||
break
|
||||
self._queue.put((idx, data.tobytes()))
|
||||
except Exception as e:
|
||||
logger.exception("Decode error")
|
||||
self._error = e
|
||||
finally:
|
||||
self._queue.put(None)
|
||||
|
||||
def get(self, timeout=60.0):
|
||||
if self._error:
|
||||
raise self._error
|
||||
result = self._queue.get(timeout=timeout)
|
||||
if result is None:
|
||||
raise StopIteration("No more frames")
|
||||
return result
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
|
||||
def load_route_metadata(route):
|
||||
from openpilot.common.params import Params, UnknownKeyName
|
||||
lr = LogReader(route.log_paths()[0])
|
||||
init_data, car_params = lr.first('initData'), lr.first('carParams')
|
||||
|
||||
params = Params()
|
||||
entries = init_data.params.entries
|
||||
for cp in entries:
|
||||
key, value = cp.key, cp.value
|
||||
for entry in init_data.params.entries:
|
||||
try:
|
||||
params.put(key, params.cpp2python(key, value))
|
||||
params.put(entry.key, params.cpp2python(entry.key, entry.value))
|
||||
except UnknownKeyName:
|
||||
# forks of openpilot may have other Params keys configured. ignore these
|
||||
logger.warning(f"unknown Params key '{key}', skipping")
|
||||
logger.debug('persisted CarParams')
|
||||
pass
|
||||
|
||||
origin = init_data.gitRemote.split('/')[3] if len(init_data.gitRemote.split('/')) > 3 else 'unknown'
|
||||
return {
|
||||
'version': init_data.version, 'route': route.name.canonical_name,
|
||||
'car': car_params.carFingerprint if car_params else 'unknown', 'origin': origin,
|
||||
'branch': init_data.gitBranch, 'commit': init_data.gitCommit[:7], 'modified': str(init_data.dirty).lower(),
|
||||
}
|
||||
|
||||
|
||||
def validate_env(parser: ArgumentParser):
|
||||
if platform.system() not in ['Linux']:
|
||||
parser.exit(1, f'clip.py: error: {platform.system()} is not a supported operating system\n')
|
||||
for proc in ['Xvfb', 'ffmpeg']:
|
||||
if shutil.which(proc) is None:
|
||||
parser.exit(1, f'clip.py: error: missing {proc} command, is it installed?\n')
|
||||
for proc in [REPLAY, UI]:
|
||||
if shutil.which(proc) is None:
|
||||
parser.exit(1, f'clip.py: error: missing {proc} command, did you build openpilot yet?\n')
|
||||
def draw_text_box(rl, text, x, y, size, gui_app, font, font_scale, color=None, center=False):
|
||||
box_color, text_color = rl.Color(0, 0, 0, 85), color or rl.WHITE
|
||||
# measure_text_ex is NOT auto-scaled, so multiply by font_scale
|
||||
# draw_text_ex IS auto-scaled, so pass size directly
|
||||
text_size = rl.measure_text_ex(font, text, size * font_scale, 0)
|
||||
text_width, text_height = int(text_size.x), int(text_size.y)
|
||||
if center:
|
||||
x = (gui_app.width - text_width) // 2
|
||||
rl.draw_rectangle(x - 8, y - 4, text_width + 16, text_height + 8, box_color)
|
||||
rl.draw_text_ex(font, text, rl.Vector2(x, y), size, 0, text_color)
|
||||
|
||||
|
||||
def validate_output_file(output_file: str):
|
||||
if not output_file.endswith('.mp4'):
|
||||
raise ArgumentTypeError('output must be an mp4')
|
||||
return output_file
|
||||
def render_overlays(rl, gui_app, font, font_scale, metadata, title, start_time, frame_idx, show_metadata, show_time):
|
||||
if show_metadata and metadata and frame_idx < FRAMERATE * 5:
|
||||
m = metadata
|
||||
text = ", ".join([f"openpilot v{m['version']}", f"route: {m['route']}", f"car: {m['car']}", f"origin: {m['origin']}",
|
||||
f"branch: {m['branch']}", f"commit: {m['commit']}", f"modified: {m['modified']}"])
|
||||
# Truncate if too wide (leave 20px margin on each side)
|
||||
max_width = gui_app.width - 40
|
||||
while rl.measure_text_ex(font, text, 15 * font_scale, 0).x > max_width and len(text) > 20:
|
||||
text = text[:-4] + "..."
|
||||
draw_text_box(rl, text, 0, 8, 15, gui_app, font, font_scale, center=True)
|
||||
|
||||
|
||||
def validate_route(route: str):
|
||||
if route.count('/') not in (1, 3):
|
||||
raise ArgumentTypeError(f'route must include or exclude timing, example: {DEMO_ROUTE}')
|
||||
return route
|
||||
|
||||
|
||||
def validate_title(title: str):
|
||||
if len(title) > 80:
|
||||
raise ArgumentTypeError('title must be no longer than 80 chars')
|
||||
return title
|
||||
|
||||
|
||||
def wait_for_frames(procs: list[Popen]):
|
||||
sm = SubMaster(['uiDebug'])
|
||||
no_frames_drawn = True
|
||||
while no_frames_drawn:
|
||||
sm.update()
|
||||
no_frames_drawn = sm['uiDebug'].drawTimeMillis == 0.
|
||||
check_for_failure(procs)
|
||||
|
||||
|
||||
def clip(
|
||||
data_dir: str | None,
|
||||
quality: Literal['low', 'high'],
|
||||
prefix: str,
|
||||
route: Route,
|
||||
out: str,
|
||||
start: int,
|
||||
end: int,
|
||||
speed: int,
|
||||
target_mb: int,
|
||||
title: str | None,
|
||||
):
|
||||
logger.info(f'clipping route {route.name.canonical_name}, start={start} end={end} quality={quality} target_filesize={target_mb}MB')
|
||||
lr = get_logreader(route)
|
||||
|
||||
begin_at = max(start - SECONDS_TO_WARM, 0)
|
||||
duration = end - start
|
||||
bit_rate_kbps = int(round(target_mb * 8 * 1024 * 1024 / duration / 1000))
|
||||
|
||||
# TODO: evaluate creating fn that inspects /tmp/.X11-unix and creates unused display to avoid possibility of collision
|
||||
display = f':{randint(99, 999)}'
|
||||
|
||||
box_style = 'box=1:boxcolor=black@0.33:boxborderw=7'
|
||||
meta_text = get_meta_text(lr, route)
|
||||
overlays = [
|
||||
# metadata overlay
|
||||
f"drawtext=text='{escape_ffmpeg_text(meta_text)}':fontfile={OPENPILOT_FONT}:fontcolor=white:fontsize=15:{box_style}:x=(w-text_w)/2:y=5.5:enable='between(t,1,5)'",
|
||||
# route time overlay
|
||||
f"drawtext=text='%{{eif\\:floor(({start}+t)/60)\\:d\\:2}}\\:%{{eif\\:mod({start}+t\\,60)\\:d\\:2}}':fontfile={OPENPILOT_FONT}:fontcolor=white:fontsize=24:{box_style}:x=w-text_w-38:y=38"
|
||||
]
|
||||
if title:
|
||||
overlays.append(f"drawtext=text='{escape_ffmpeg_text(title)}':fontfile={OPENPILOT_FONT}:fontcolor=white:fontsize=32:{box_style}:x=(w-text_w)/2:y=53")
|
||||
draw_text_box(rl, title, 0, 60, 32, gui_app, font, font_scale, center=True)
|
||||
|
||||
if speed > 1:
|
||||
overlays += [
|
||||
f"setpts=PTS/{speed}",
|
||||
"fps=60",
|
||||
]
|
||||
if show_time:
|
||||
t = start_time + frame_idx / FRAMERATE
|
||||
time_text = f"{int(t)//60:02d}:{int(t)%60:02d}"
|
||||
time_width = int(rl.measure_text_ex(font, time_text, 24 * font_scale, 0).x)
|
||||
draw_text_box(rl, time_text, gui_app.width - time_width - 45, 45, 24, gui_app, font, font_scale)
|
||||
|
||||
ffmpeg_cmd = [
|
||||
'ffmpeg', '-y',
|
||||
'-video_size', RESOLUTION,
|
||||
'-framerate', str(FRAMERATE),
|
||||
'-f', 'x11grab',
|
||||
'-rtbufsize', '100M',
|
||||
'-draw_mouse', '0',
|
||||
'-i', display,
|
||||
'-c:v', 'libx264',
|
||||
'-maxrate', f'{bit_rate_kbps}k',
|
||||
'-bufsize', f'{bit_rate_kbps*2}k',
|
||||
'-crf', '23',
|
||||
'-filter:v', ','.join(overlays),
|
||||
'-preset', 'ultrafast',
|
||||
'-tune', 'zerolatency',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-movflags', '+faststart',
|
||||
'-f', 'mp4',
|
||||
'-t', str(duration),
|
||||
out,
|
||||
]
|
||||
|
||||
replay_cmd = [REPLAY, '--ecam', '-c', '1', '-s', str(begin_at), '--prefix', prefix]
|
||||
if data_dir:
|
||||
replay_cmd.extend(['--data_dir', data_dir])
|
||||
if quality == 'low':
|
||||
replay_cmd.append('--qcam')
|
||||
replay_cmd.append(route.name.canonical_name)
|
||||
def clip(route: Route, output: str, start: int, end: int, headless: bool = True, big: bool = False,
|
||||
title: str | None = None, show_metadata: bool = True, show_time: bool = True, use_qcam: bool = False):
|
||||
timer, duration = Timer(), end - start
|
||||
|
||||
ui_cmd = [UI, '-platform', 'xcb']
|
||||
xvfb_cmd = ['Xvfb', display, '-terminate', '-screen', '0', f'{RESOLUTION}x{PIXEL_DEPTH}']
|
||||
import pyray as rl
|
||||
if big:
|
||||
from openpilot.selfdrive.ui.layouts.main import MainLayout
|
||||
else:
|
||||
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout as MainLayout # type: ignore[assignment]
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
|
||||
timer.lap("import")
|
||||
|
||||
with OpenpilotPrefix(prefix, shared_download_cache=True):
|
||||
populate_car_params(lr)
|
||||
env = os.environ.copy()
|
||||
env['DISPLAY'] = display
|
||||
logger.info(f"Clipping {route.name.canonical_name}, {start}s-{end}s ({duration}s)")
|
||||
seg_start, seg_end = start // 60, (end - 1) // 60 + 1
|
||||
all_chunks = load_logs_parallel(route.log_paths()[seg_start:seg_end], fps=FRAMERATE)
|
||||
timer.lap("logs")
|
||||
|
||||
with managed_proc(xvfb_cmd, env) as xvfb_proc, managed_proc(ui_cmd, env) as ui_proc, managed_proc(replay_cmd, env) as replay_proc:
|
||||
procs = [xvfb_proc, ui_proc, replay_proc]
|
||||
logger.info('waiting for replay to begin (loading segments, may take a while)...')
|
||||
wait_for_frames(procs)
|
||||
logger.debug(f'letting UI warm up ({SECONDS_TO_WARM}s)...')
|
||||
time.sleep(SECONDS_TO_WARM)
|
||||
check_for_failure(procs)
|
||||
with managed_proc(ffmpeg_cmd, env) as ffmpeg_proc:
|
||||
procs.append(ffmpeg_proc)
|
||||
logger.info(f'recording in progress ({duration}s)...')
|
||||
ffmpeg_proc.wait(duration + PROC_WAIT_SECONDS)
|
||||
check_for_failure(procs)
|
||||
logger.info(f'recording complete: {Path(out).resolve()}')
|
||||
frame_start = (start - seg_start * 60) * FRAMERATE
|
||||
message_chunks = all_chunks[frame_start:frame_start + duration * FRAMERATE]
|
||||
if not message_chunks:
|
||||
logger.error("No messages to render")
|
||||
sys.exit(1)
|
||||
|
||||
metadata = load_route_metadata(route) if show_metadata else None
|
||||
if headless:
|
||||
rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN)
|
||||
|
||||
with OpenpilotPrefix(shared_download_cache=True):
|
||||
camera_paths = route.qcamera_paths() if use_qcam else route.camera_paths()
|
||||
frame_queue = FrameQueue(camera_paths, start, end, fps=FRAMERATE, use_qcam=use_qcam)
|
||||
|
||||
vipc = VisionIpcServer("camerad")
|
||||
vipc.create_buffers(VisionStreamType.VISION_STREAM_ROAD, 4, frame_queue.frame_w, frame_queue.frame_h)
|
||||
vipc.start_listener()
|
||||
|
||||
patch_submaster(message_chunks, ui_state)
|
||||
gui_app.init_window("clip", fps=FRAMERATE)
|
||||
|
||||
main_layout = MainLayout()
|
||||
main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
font = gui_app.font(FontWeight.NORMAL)
|
||||
timer.lap("setup")
|
||||
|
||||
frame_idx = 0
|
||||
with tqdm.tqdm(total=len(message_chunks), desc="Rendering", unit="frame") as pbar:
|
||||
for should_render in gui_app.render():
|
||||
if frame_idx >= len(message_chunks):
|
||||
break
|
||||
_, frame_bytes = frame_queue.get()
|
||||
vipc.send(VisionStreamType.VISION_STREAM_ROAD, frame_bytes, frame_idx, int(frame_idx * 5e7), int(frame_idx * 5e7))
|
||||
ui_state.update()
|
||||
if should_render:
|
||||
main_layout.render()
|
||||
render_overlays(rl, gui_app, font, FONT_SCALE, metadata, title, start, frame_idx, show_metadata, show_time)
|
||||
frame_idx += 1
|
||||
pbar.update(1)
|
||||
timer.lap("render")
|
||||
|
||||
frame_queue.stop()
|
||||
gui_app.close()
|
||||
timer.lap("ffmpeg")
|
||||
|
||||
logger.info(f"Clip saved to: {Path(output).resolve()}")
|
||||
logger.info(f"Generated {timer.fmt(duration)}")
|
||||
|
||||
|
||||
def main():
|
||||
p = ArgumentParser(prog='clip.py', description='clip your openpilot route.', epilog='comma.ai')
|
||||
validate_env(p)
|
||||
route_group = p.add_mutually_exclusive_group(required=True)
|
||||
route_group.add_argument('route', nargs='?', type=validate_route, help=f'The route (e.g. {DEMO_ROUTE} or {DEMO_ROUTE}/{DEMO_START}/{DEMO_END})')
|
||||
route_group.add_argument('--demo', help='use the demo route', action='store_true')
|
||||
p.add_argument('-d', '--data-dir', help='local directory where route data is stored')
|
||||
p.add_argument('-e', '--end', help='stop clipping at <end> seconds', type=int)
|
||||
p.add_argument('-f', '--file-size', help='target file size (Discord/GitHub support max 10MB, default is 9MB)', type=float, default=9.)
|
||||
p.add_argument('-o', '--output', help='output clip to (.mp4)', type=validate_output_file, default=DEFAULT_OUTPUT)
|
||||
p.add_argument('-p', '--prefix', help='openpilot prefix', default=f'clip_{randint(100, 99999)}')
|
||||
p.add_argument('-q', '--quality', help='quality of camera (low = qcam, high = hevc)', choices=['low', 'high'], default='high')
|
||||
p.add_argument('-x', '--speed', help='record the clip at this speed multiple', type=int, default=1)
|
||||
p.add_argument('-s', '--start', help='start clipping at <start> seconds', type=int)
|
||||
p.add_argument('-t', '--title', help='overlay this title on the video (e.g. "Chill driving across the Golden Gate Bridge")', type=validate_title)
|
||||
args = parse_args(p)
|
||||
exit_code = 1
|
||||
try:
|
||||
clip(
|
||||
data_dir=args.data_dir,
|
||||
quality=args.quality,
|
||||
prefix=args.prefix,
|
||||
route=args.route,
|
||||
out=args.output,
|
||||
start=args.start,
|
||||
end=args.end,
|
||||
speed=args.speed,
|
||||
target_mb=args.file_size,
|
||||
title=args.title,
|
||||
)
|
||||
exit_code = 0
|
||||
except KeyboardInterrupt as e:
|
||||
logger.exception('interrupted by user', exc_info=e)
|
||||
except Exception as e:
|
||||
logger.exception('encountered error', exc_info=e)
|
||||
sys.exit(exit_code)
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s\t%(message)s")
|
||||
args = parse_args()
|
||||
assert args.big, "Clips doesn't support mici UI yet. TODO: make it work"
|
||||
|
||||
setup_env(args.output, big=args.big, speed=args.speed, target_mb=args.file_size, duration=args.end - args.start)
|
||||
clip(Route(args.route, data_dir=args.data_dir), args.output, args.start, args.end, not args.windowed,
|
||||
args.big, args.title, not args.no_metadata, not args.no_time_overlay, args.qcam)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(name)s %(levelname)s\t%(message)s')
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user