feat(clip): title and metadata overlay (#35099)

* wip

* moar

* ensure inter is installed

* line len

* refactor

* dont need this

* no longer than

* show meta for 4s
pull/35123/head
Trey Moen 5 days ago committed by GitHub
parent 8ee99523f4
commit f704d18a8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 80
      tools/clip/run.py
  2. 1
      tools/install_ubuntu_dependencies.sh
  3. 7
      tools/lib/route.py

@ -16,7 +16,7 @@ from typing import Literal
from cereal.messaging import SubMaster from cereal.messaging import SubMaster
from openpilot.common.basedir import BASEDIR from openpilot.common.basedir import BASEDIR
from openpilot.common.prefix import OpenpilotPrefix from openpilot.common.prefix import OpenpilotPrefix
from openpilot.tools.lib.route import SegmentRange, get_max_seg_number_cached from openpilot.tools.lib.route import Route
DEFAULT_OUTPUT = 'output.mp4' DEFAULT_OUTPUT = 'output.mp4'
DEMO_START = 90 DEMO_START = 90
@ -26,7 +26,7 @@ FRAMERATE = 20
PIXEL_DEPTH = '24' PIXEL_DEPTH = '24'
RESOLUTION = '2160x1080' RESOLUTION = '2160x1080'
SECONDS_TO_WARM = 2 SECONDS_TO_WARM = 2
PROC_WAIT_SECONDS = 5 PROC_WAIT_SECONDS = 30
REPLAY = str(Path(BASEDIR, 'tools/replay/replay').resolve()) REPLAY = str(Path(BASEDIR, 'tools/replay/replay').resolve())
UI = str(Path(BASEDIR, 'selfdrive/ui/ui').resolve()) UI = str(Path(BASEDIR, 'selfdrive/ui/ui').resolve())
@ -52,6 +52,29 @@ def check_for_failure(proc: Popen):
raise ChildProcessError(msg) 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_meta_text(route: Route):
metadata = route.get_metadata()
origin_parts = metadata['git_remote'].split('/')
origin = origin_parts[3] if len(origin_parts) > 3 else 'unknown'
return ', '.join([
f'openpilot v{metadata['version']}',
f'route: {metadata['fullname']}',
f'car: {metadata['platform']}',
f'origin: {origin}',
f'branch: {metadata['git_branch']}',
f'commit: {metadata['git_commit'][:7]}',
f'modified: {str(metadata['git_dirty']).lower()}',
])
def parse_args(parser: ArgumentParser): def parse_args(parser: ArgumentParser):
args = parser.parse_args() args = parser.parse_args()
if args.demo: if args.demo:
@ -74,20 +97,17 @@ def parse_args(parser: ArgumentParser):
if args.start < SECONDS_TO_WARM: 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') parser.error(f'start must be greater than {SECONDS_TO_WARM}s to allow the UI time to warm up')
# if using local files, don't worry about length check right now so we skip the network call try:
# TODO: derive segment count from local FS args.route = Route(args.route, data_dir=args.data_dir)
if not args.data_dir: except Exception as e:
try: parser.error(f'failed to get route: {e}')
num_segs = get_max_seg_number_cached(SegmentRange(args.route))
except Exception as e: # FIXME: length isn't exactly max segment seconds, simplify to replay exiting at end of data
parser.error(f'failed to get route length: {e}') length = round(args.route.max_seg_number * 60)
if args.start >= length:
# FIXME: length isn't exactly max segment seconds, simplify to replay exiting at end of data parser.error(f'start ({args.start}s) cannot be after end of route ({length}s)')
length = round(num_segs * 60) if args.end > length:
if args.start >= length: parser.error(f'end ({args.end}s) cannot be after end of route ({length}s)')
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)')
return args return args
@ -119,6 +139,12 @@ def validate_route(route: str):
return 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]): def wait_for_frames(procs: list[Popen]):
sm = SubMaster(['uiDebug']) sm = SubMaster(['uiDebug'])
no_frames_drawn = True no_frames_drawn = True
@ -129,20 +155,27 @@ def wait_for_frames(procs: list[Popen]):
check_for_failure(proc) check_for_failure(proc)
def clip(data_dir: str | None, quality: Literal['low', 'high'], prefix: str, route: str, output_filepath: str, start: int, end: int, target_size_mb: int): def clip(data_dir: str | None, quality: Literal['low', 'high'], prefix: str, route: Route, out: str, start: int, end: int, target_mb: int, title: str | None):
logger.info(f'clipping route {route}, start={start} end={end} quality={quality} target_filesize={target_size_mb}MB') logger.info(f'clipping route {route.name.canonical_name}, start={start} end={end} quality={quality} target_filesize={target_mb}MB')
begin_at = max(start - SECONDS_TO_WARM, 0) begin_at = max(start - SECONDS_TO_WARM, 0)
duration = end - start duration = end - start
bit_rate_kbps = int(round(target_size_mb * 8 * 1024 * 1024 / duration / 1000)) 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 # TODO: evaluate creating fn that inspects /tmp/.X11-unix and creates unused display to avoid possibility of collision
display = f':{randint(99, 999)}' display = f':{randint(99, 999)}'
meta_text = get_meta_text(route)
overlays = [
f"drawtext=text='{escape_ffmpeg_text(meta_text)}':fontfile=Inter.tff:fontcolor=white:fontsize=18:box=1:boxcolor=black@0.33:boxborderw=7:x=(w-text_w)/2:y=5.5:enable='between(t,1,5)'"
]
if title:
overlays.append(f"drawtext=text='{escape_ffmpeg_text(title)}':fontfile=Inter.tff:fontcolor=white:fontsize=32:box=1:boxcolor=black@0.33:boxborderw=10:x=(w-text_w)/2:y=53")
ffmpeg_cmd = [ ffmpeg_cmd = [
'ffmpeg', '-y', '-video_size', RESOLUTION, '-framerate', str(FRAMERATE), '-f', 'x11grab', '-draw_mouse', '0', 'ffmpeg', '-y', '-video_size', RESOLUTION, '-framerate', str(FRAMERATE), '-f', 'x11grab', '-draw_mouse', '0',
'-i', display, '-c:v', 'libx264', '-maxrate', f'{bit_rate_kbps}k', '-bufsize', f'{bit_rate_kbps*2}k', '-crf', '23', '-i', display, '-c:v', 'libx264', '-maxrate', f'{bit_rate_kbps}k', '-bufsize', f'{bit_rate_kbps*2}k', '-crf', '23',
'-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-f', 'mp4', '-t', str(duration), output_filepath, '-filter:v', ','.join(overlays), '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-f', 'mp4', '-t', str(duration), out
] ]
replay_cmd = [REPLAY, '-c', '1', '-s', str(begin_at), '--prefix', prefix] replay_cmd = [REPLAY, '-c', '1', '-s', str(begin_at), '--prefix', prefix]
@ -150,7 +183,7 @@ def clip(data_dir: str | None, quality: Literal['low', 'high'], prefix: str, rou
replay_cmd.extend(['--data_dir', data_dir]) replay_cmd.extend(['--data_dir', data_dir])
if quality == 'low': if quality == 'low':
replay_cmd.append('--qcam') replay_cmd.append('--qcam')
replay_cmd.append(route) replay_cmd.append(route.name.canonical_name)
ui_cmd = [UI, '-platform', 'xcb'] ui_cmd = [UI, '-platform', 'xcb']
xvfb_cmd = ['Xvfb', display, '-terminate', '-screen', '0', f'{RESOLUTION}x{PIXEL_DEPTH}'] xvfb_cmd = ['Xvfb', display, '-terminate', '-screen', '0', f'{RESOLUTION}x{PIXEL_DEPTH}']
@ -183,7 +216,7 @@ def clip(data_dir: str | None, quality: Literal['low', 'high'], prefix: str, rou
ffmpeg_proc.wait(duration + PROC_WAIT_SECONDS) ffmpeg_proc.wait(duration + PROC_WAIT_SECONDS)
for proc in procs: for proc in procs:
check_for_failure(proc) check_for_failure(proc)
logger.info(f'recording complete: {Path(output_filepath).resolve()}') logger.info(f'recording complete: {Path(out).resolve()}')
def main(): def main():
@ -199,9 +232,10 @@ def main():
p.add_argument('-p', '--prefix', help='openpilot prefix', default=f'clip_{randint(100, 99999)}') 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('-q', '--quality', help='quality of camera (low = qcam, high = hevc)', choices=['low', 'high'], default='high')
p.add_argument('-s', '--start', help='start clipping at <start> seconds', type=int) 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) args = parse_args(p)
try: try:
clip(args.data_dir, args.quality, args.prefix, args.route, args.output, args.start, args.end, args.file_size) clip(args.data_dir, args.quality, args.prefix, args.route, args.output, args.start, args.end, args.file_size, args.title)
except KeyboardInterrupt as e: except KeyboardInterrupt as e:
logger.exception('interrupted by user', exc_info=e) logger.exception('interrupted by user', exc_info=e)
except Exception as e: except Exception as e:

@ -33,6 +33,7 @@ function install_ubuntu_common_requirements() {
git \ git \
git-lfs \ git-lfs \
ffmpeg \ ffmpeg \
fonts-inter \
libavformat-dev \ libavformat-dev \
libavcodec-dev \ libavcodec-dev \
libavdevice-dev \ libavdevice-dev \

@ -21,6 +21,7 @@ class Route:
def __init__(self, name, data_dir=None): def __init__(self, name, data_dir=None):
self._name = RouteName(name) self._name = RouteName(name)
self.files = None self.files = None
self.metadata = None
if data_dir is not None: if data_dir is not None:
self._segments = self._get_segments_local(data_dir) self._segments = self._get_segments_local(data_dir)
else: else:
@ -59,6 +60,12 @@ class Route:
qcamera_path_by_seg_num = {s.name.segment_num: s.qcamera_path for s in self._segments} qcamera_path_by_seg_num = {s.name.segment_num: s.qcamera_path for s in self._segments}
return [qcamera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)] return [qcamera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)]
def get_metadata(self):
if not self.metadata:
api = CommaApi(get_token())
self.metadata = api.get('v1/route/' + self.name.canonical_name)
return self.metadata
# TODO: refactor this, it's super repetitive # TODO: refactor this, it's super repetitive
def _get_segments_remote(self): def _get_segments_remote(self):
api = CommaApi(get_token()) api = CommaApi(get_token())

Loading…
Cancel
Save