feat: clip.py (#35071)
parent
a0bcea5719
commit
45787163a2
3 changed files with 224 additions and 1 deletions
@ -0,0 +1,215 @@ |
||||
#!/usr/bin/env python3 |
||||
|
||||
import atexit |
||||
import logging |
||||
import os |
||||
import platform |
||||
import shutil |
||||
import time |
||||
from argparse import ArgumentParser, ArgumentTypeError |
||||
from collections.abc import Sequence |
||||
from pathlib import Path |
||||
from random import randint |
||||
from subprocess import Popen, PIPE |
||||
from typing import Literal |
||||
|
||||
from cereal.messaging import SubMaster |
||||
from openpilot.common.basedir import BASEDIR |
||||
from openpilot.common.prefix import OpenpilotPrefix |
||||
from openpilot.tools.lib.route import SegmentRange, get_max_seg_number_cached |
||||
|
||||
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 = 5 |
||||
|
||||
REPLAY = str(Path(BASEDIR, 'tools/replay/replay').resolve()) |
||||
UI = str(Path(BASEDIR, 'selfdrive/ui/ui').resolve()) |
||||
|
||||
logger = logging.getLogger('clip.py') |
||||
|
||||
|
||||
def check_for_failure(proc: Popen): |
||||
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 parse_args(parser: ArgumentParser): |
||||
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') |
||||
parts = args.route.split('/') |
||||
args.route = '/'.join(parts[:2]) |
||||
args.start = int(parts[2]) |
||||
args.end = int(parts[3]) |
||||
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') |
||||
|
||||
# if using local files, don't worry about length check right now so we skip the network call |
||||
# TODO: derive segment count from local FS |
||||
if not args.data_dir: |
||||
try: |
||||
num_segs = get_max_seg_number_cached(SegmentRange(args.route)) |
||||
except Exception as e: |
||||
parser.error(f'failed to get route length: {e}') |
||||
|
||||
# FIXME: length isn't exactly max segment seconds, simplify to replay exiting at end of data |
||||
length = round(num_segs * 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)') |
||||
|
||||
return args |
||||
|
||||
|
||||
def start_proc(args: list[str], env: dict[str, str]): |
||||
return Popen(args, env=env, stdout=PIPE, stderr=PIPE) |
||||
|
||||
|
||||
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 validate_output_file(output_file: str): |
||||
if not output_file.endswith('.mp4'): |
||||
raise ArgumentTypeError('output must be an mp4') |
||||
return output_file |
||||
|
||||
|
||||
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 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. |
||||
for proc in procs: |
||||
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): |
||||
logger.info(f'clipping route {route}, start={start} end={end} quality={quality} target_filesize={target_size_mb}MB') |
||||
|
||||
begin_at = max(start - SECONDS_TO_WARM, 0) |
||||
duration = end - start |
||||
bit_rate_kbps = int(round(target_size_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)}' |
||||
|
||||
ffmpeg_cmd = [ |
||||
'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', |
||||
'-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-f', 'mp4', '-t', str(duration), output_filepath, |
||||
] |
||||
|
||||
replay_cmd = [REPLAY, '-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) |
||||
|
||||
ui_cmd = [UI, '-platform', 'xcb'] |
||||
xvfb_cmd = ['Xvfb', display, '-terminate', '-screen', '0', f'{RESOLUTION}x{PIXEL_DEPTH}'] |
||||
|
||||
with OpenpilotPrefix(prefix, shared_download_cache=True): |
||||
env = os.environ.copy() |
||||
env['DISPLAY'] = display |
||||
|
||||
xvfb_proc = start_proc(xvfb_cmd, env) |
||||
atexit.register(lambda: xvfb_proc.terminate()) |
||||
ui_proc = start_proc(ui_cmd, env) |
||||
atexit.register(lambda: ui_proc.terminate()) |
||||
replay_proc = start_proc(replay_cmd, env) |
||||
atexit.register(lambda: replay_proc.terminate()) |
||||
procs = [replay_proc, ui_proc, xvfb_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) |
||||
for proc in procs: |
||||
check_for_failure(proc) |
||||
|
||||
ffmpeg_proc = start_proc(ffmpeg_cmd, env) |
||||
procs.append(ffmpeg_proc) |
||||
atexit.register(lambda: ffmpeg_proc.terminate()) |
||||
|
||||
logger.info(f'recording in progress ({duration}s)...') |
||||
ffmpeg_proc.wait(duration + PROC_WAIT_SECONDS) |
||||
for proc in procs: |
||||
check_for_failure(proc) |
||||
logger.info(f'recording complete: {Path(output_filepath).resolve()}') |
||||
|
||||
|
||||
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('-s', '--start', help='start clipping at <start> seconds', type=int) |
||||
args = parse_args(p) |
||||
try: |
||||
clip(args.data_dir, args.quality, args.prefix, args.route, args.output, args.start, args.end, args.file_size) |
||||
except KeyboardInterrupt as e: |
||||
logger.exception('interrupted by user', exc_info=e) |
||||
except Exception as e: |
||||
logger.exception('encountered error', exc_info=e) |
||||
finally: |
||||
atexit._run_exitfuncs() |
||||
|
||||
|
||||
if __name__ == '__main__': |
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(name)s %(levelname)s\t%(message)s') |
||||
main() |
Loading…
Reference in new issue