dashcamd v2: native C++ process with direct camera capture

- New dashcamd: connects to camerad via VisionIPC, feeds raw NV12
  frames directly to OMX H.264 encoder. Full 1928x1208 resolution,
  4Mbps, 3-minute MP4 segments. Works regardless of UI state.
- Added encode_frame_nv12() to OmxEncoder — skips RGBA->NV12 conversion
- Suspends recording after 10 minutes of standstill
- Disabled old screen recorder timer in onroad.cc
- Suppress debug button alert (clpDebug event still fires for screen toggle)
- launch_openpilot.sh self-cleans other instances before starting
- Register DashcamDebug param in params.cc and manager.py
- Add dashcamd to build system (SConscript) and process_config
- Updated CLAUDE.md with all session changes
- Added GOALS.md feature roadmap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 08:54:41 +00:00
parent ed9c14616a
commit 4afd25fb5b
15 changed files with 560 additions and 119 deletions

View File

@@ -3,4 +3,5 @@ SConscript(['controls/lib/lateral_mpc_lib/SConscript'])
SConscript(['controls/lib/longitudinal_mpc_lib/SConscript'])
SConscript(['locationd/SConscript'])
SConscript(['modeld/SConscript'])
SConscript(['ui/SConscript'])
SConscript(['ui/SConscript'])
SConscript(['clearpilot/SConscript'])

View File

@@ -0,0 +1,16 @@
Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal')
clearpilot_env = env.Clone()
clearpilot_env['CPPPATH'] += ['#selfdrive/frogpilot/screenrecorder/openmax/include/']
# Disable --as-needed so static lib ordering doesn't matter
clearpilot_env['LINKFLAGS'] = [f for f in clearpilot_env.get('LINKFLAGS', []) if f != '-Wl,--as-needed']
if arch == "larch64":
omx_obj = File('#selfdrive/frogpilot/screenrecorder/omx_encoder.o')
clearpilot_env.Program(
'dashcamd',
['dashcamd.cc', omx_obj],
LIBS=[common, 'json11', cereal, visionipc, messaging,
'zmq', 'capnp', 'kj', 'm', 'OpenCL', 'ssl', 'crypto', 'pthread',
'OmxCore', 'avformat', 'avcodec', 'avutil', 'yuv']
)

View File

@@ -0,0 +1,140 @@
/*
* CLEARPILOT dashcamd — records raw camera footage to MP4 using OMX H.264 hardware encoder.
*
* Connects to camerad via VisionIPC, receives NV12 frames, and feeds them directly
* to the Qualcomm OMX encoder. Produces 3-minute MP4 segments in /data/media/0/videos/.
*
* Suspends recording after 10 minutes of standstill, resumes when car moves.
*/
#include <cstdio>
#include <ctime>
#include <string>
#include <sys/stat.h>
#include <sys/resource.h>
#include <unistd.h>
#include "cereal/messaging/messaging.h"
#include "cereal/visionipc/visionipc_client.h"
#include "common/params.h"
#include "common/timing.h"
#include "common/swaglog.h"
#include "common/util.h"
#include "selfdrive/frogpilot/screenrecorder/omx_encoder.h"
const std::string VIDEOS_DIR = "/data/media/0/videos";
const int SEGMENT_SECONDS = 180; // 3 minutes
const int CAMERA_FPS = 20;
const int FRAMES_PER_SEGMENT = SEGMENT_SECONDS * CAMERA_FPS;
const int BITRATE = 4 * 1024 * 1024; // 4 Mbps
const double STANDSTILL_TIMEOUT_SECONDS = 600.0; // 10 minutes
ExitHandler do_exit;
static std::string make_filename() {
char buf[64];
time_t t = time(NULL);
struct tm tm = *localtime(&t);
snprintf(buf, sizeof(buf), "%04d%02d%02d-%02d%02d%02d.mp4",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec);
return std::string(buf);
}
int main(int argc, char *argv[]) {
setpriority(PRIO_PROCESS, 0, -10);
// Ensure output directory exists
mkdir(VIDEOS_DIR.c_str(), 0755);
LOGW("dashcamd: connecting to camerad road stream");
VisionIpcClient vipc("camerad", VISION_STREAM_ROAD, false);
while (!do_exit && !vipc.connect(false)) {
usleep(100000);
}
if (do_exit) return 0;
int width = vipc.buffers[0].width;
int height = vipc.buffers[0].height;
int y_stride = vipc.buffers[0].stride;
int uv_stride = y_stride;
LOGW("dashcamd: connected %dx%d, stride=%d", width, height, y_stride);
// Subscribe to carState for standstill detection
SubMaster sm({"carState"});
// Create encoder — H.264, no downscale, with MP4 remuxing (h265=false)
OmxEncoder encoder(VIDEOS_DIR.c_str(), width, height, CAMERA_FPS, BITRATE, false, false);
int frame_count = FRAMES_PER_SEGMENT; // force new segment on first frame
uint64_t start_ts = 0;
bool recording = false;
bool suspended = false;
double standstill_start = 0.0;
while (!do_exit) {
VisionBuf *buf = vipc.recv();
if (buf == nullptr) continue;
// Check standstill state
sm.update(0);
bool is_standstill = sm.valid("carState") && sm["carState"].getCarState().getStandstill();
double now = nanos_since_boot() / 1e9;
if (is_standstill) {
if (standstill_start == 0.0) {
standstill_start = now;
}
// Suspend after 10 minutes of continuous standstill
if (!suspended && (now - standstill_start) >= STANDSTILL_TIMEOUT_SECONDS) {
LOGW("dashcamd: suspending — standstill for 10 minutes");
if (recording) {
encoder.encoder_close();
recording = false;
frame_count = FRAMES_PER_SEGMENT;
}
suspended = true;
}
} else {
standstill_start = 0.0;
if (suspended) {
LOGW("dashcamd: resuming — car moving");
suspended = false;
}
}
if (suspended) continue;
// Start new segment if needed
if (frame_count >= FRAMES_PER_SEGMENT) {
if (recording) {
encoder.encoder_close();
}
std::string filename = make_filename();
LOGW("dashcamd: opening segment %s", filename.c_str());
encoder.encoder_open(filename.c_str());
frame_count = 0;
start_ts = nanos_since_boot();
recording = true;
}
uint64_t ts = nanos_since_boot() - start_ts;
// Feed NV12 frame directly to OMX encoder
uint8_t *y_ptr = buf->y;
uint8_t *uv_ptr = buf->uv;
encoder.encode_frame_nv12(y_ptr, y_stride, uv_ptr, uv_stride, width, height, ts);
frame_count++;
}
if (recording) {
encoder.encoder_close();
}
LOGW("dashcamd: stopped");
return 0;
}

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
CLEARPILOT dashcamd — records raw camera footage to MP4 using hardware H.264 encoder.
Connects directly to camerad via VisionIPC, receives NV12 frames, and pipes them
to ffmpeg's h264_v4l2m2m encoder. Produces 3-minute MP4 segments in /data/media/0/videos/.
This replaces the FrogPilot screen recorder approach (QWidget::grab -> OMX) with a
direct camera capture that works regardless of UI state (screen off, alternate modes, etc).
"""
import os
import time
import subprocess
import signal
from pathlib import Path
from datetime import datetime
from cereal.visionipc import VisionIpcClient, VisionStreamType
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive import sentry
PROCESS_NAME = "selfdrive.clearpilot.dashcamd"
VIDEOS_DIR = "/data/media/0/videos"
SEGMENT_SECONDS = 180 # 3 minutes
CAMERA_FPS = 20
FRAMES_PER_SEGMENT = SEGMENT_SECONDS * CAMERA_FPS
def make_filename():
return datetime.now().strftime("%Y%m%d-%H%M%S") + ".mp4"
def open_encoder(width, height, filepath):
"""Start an ffmpeg subprocess that accepts raw NV12 on stdin and writes MP4."""
cmd = [
"ffmpeg", "-y", "-nostdin", "-loglevel", "error",
"-f", "rawvideo",
"-pix_fmt", "nv12",
"-s", f"{width}x{height}",
"-r", str(CAMERA_FPS),
"-i", "pipe:0",
"-c:v", "h264_v4l2m2m",
"-b:v", "4M",
"-f", "mp4",
"-movflags", "+faststart",
filepath,
]
return subprocess.Popen(cmd, stdin=subprocess.PIPE)
def main():
sentry.set_tag("daemon", PROCESS_NAME)
cloudlog.bind(daemon=PROCESS_NAME)
os.makedirs(VIDEOS_DIR, exist_ok=True)
params = Params()
# Connect to camerad road stream
cloudlog.info("dashcamd: connecting to camerad road stream")
vipc = VisionIpcClient("camerad", VisionStreamType.VISION_STREAM_ROAD, False)
while not vipc.connect(False):
time.sleep(0.1)
width, height = vipc.width, vipc.height
# NV12 frame: Y plane (w*h) + UV plane (w*h/2)
frame_size = width * height * 3 // 2
cloudlog.info(f"dashcamd: connected, {width}x{height}, frame_size={frame_size}")
frame_count = 0
encoder = None
lock_path = None
try:
while True:
buf = vipc.recv()
if buf is None:
continue
# Start new segment if needed
if encoder is None or frame_count >= FRAMES_PER_SEGMENT:
# Close previous segment
if encoder is not None:
encoder.stdin.close()
encoder.wait()
if lock_path and os.path.exists(lock_path):
os.remove(lock_path)
cloudlog.info(f"dashcamd: closed segment, {frame_count} frames")
# Open new segment
filename = make_filename()
filepath = os.path.join(VIDEOS_DIR, filename)
lock_path = filepath + ".lock"
Path(lock_path).touch()
cloudlog.info(f"dashcamd: opening segment {filename}")
encoder = open_encoder(width, height, filepath)
frame_count = 0
# Write raw NV12 frame to ffmpeg stdin
try:
encoder.stdin.write(buf.data[:frame_size])
frame_count += 1
except BrokenPipeError:
cloudlog.error("dashcamd: encoder pipe broken, restarting segment")
encoder = None
except (KeyboardInterrupt, SystemExit):
pass
finally:
if encoder is not None:
encoder.stdin.close()
encoder.wait()
if lock_path and os.path.exists(lock_path):
os.remove(lock_path)
cloudlog.info("dashcamd: stopped")
if __name__ == "__main__":
main()

View File

@@ -1233,7 +1233,7 @@ class Controls:
self.frogpilot_variables.use_ev_tables = self.params.get_bool("EVTable")
def update_clearpilot_events(self, CS):
if (len(CS.buttonEvents) > 0):
if (len(CS.buttonEvents) > 0):
print (CS.buttonEvents)
if any(be.pressed and be.type == FrogPilotButtonType.lkas for be in CS.buttonEvents):
self.events.add(EventName.clpDebug)

View File

@@ -778,8 +778,8 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = {
ET.SOFT_DISABLE: soft_disable_alert("Sensor Data Invalid"),
},
# CLEARPILOT: alert suppressed — event still fires for screen toggle and future actions
EventName.clpDebug: {
ET.PERMANENT: clp_debug_notice,
},
EventName.noGps: {

View File

@@ -565,6 +565,60 @@ int OmxEncoder::encode_frame_rgba(const uint8_t *ptr, int in_width, int in_heigh
return ret;
}
// CLEARPILOT: encode raw NV12 frames directly (no RGBA conversion needed)
int OmxEncoder::encode_frame_nv12(const uint8_t *y_ptr, int y_stride, const uint8_t *uv_ptr, int uv_stride,
int in_width, int in_height, uint64_t ts) {
if (!this->is_open) {
return -1;
}
OMX_BUFFERHEADERTYPE* in_buf = nullptr;
while (!this->free_in.try_pop(in_buf, 20)) {
if (do_exit) {
return -1;
}
}
int ret = this->counter;
uint8_t *in_buf_ptr = in_buf->pBuffer;
int venus_y_stride = VENUS_Y_STRIDE(COLOR_FMT_NV12, this->width);
int venus_uv_stride = VENUS_UV_STRIDE(COLOR_FMT_NV12, this->width);
uint8_t *dst_y = in_buf_ptr;
uint8_t *dst_uv = in_buf_ptr + (venus_y_stride * VENUS_Y_SCANLINES(COLOR_FMT_NV12, this->height));
// Copy Y plane row by row (source stride may differ from VENUS stride)
for (int row = 0; row < in_height; row++) {
memcpy(dst_y + row * venus_y_stride, y_ptr + row * y_stride, in_width);
}
// Copy UV plane row by row
int uv_height = in_height / 2;
for (int row = 0; row < uv_height; row++) {
memcpy(dst_uv + row * venus_uv_stride, uv_ptr + row * uv_stride, in_width);
}
in_buf->nFilledLen = VENUS_BUFFER_SIZE(COLOR_FMT_NV12, this->width, this->height);
in_buf->nFlags = OMX_BUFFERFLAG_ENDOFFRAME;
in_buf->nOffset = 0;
in_buf->nTimeStamp = ts / 1000LL;
this->last_t = in_buf->nTimeStamp;
OMX_CHECK(OMX_EmptyThisBuffer(this->handle, in_buf));
while (true) {
OMX_BUFFERHEADERTYPE *out_buf;
if (!this->done_out.try_pop(out_buf)) {
break;
}
handle_out_buf(this, out_buf);
}
this->dirty = true;
this->counter++;
return ret;
}
void OmxEncoder::encoder_open(const char* filename) {
int err;

View File

@@ -19,6 +19,8 @@ public:
~OmxEncoder();
int encode_frame_rgba(const uint8_t *ptr, int in_width, int in_height, uint64_t ts);
int encode_frame_nv12(const uint8_t *y_ptr, int y_stride, const uint8_t *uv_ptr, int uv_stride,
int in_width, int in_height, uint64_t ts);
void encoder_open(const char* filename);
void encoder_close();

View File

@@ -135,6 +135,7 @@ def manager_init(frogpilot_functions) -> None:
("DisableOpenpilotLongitudinal", "0"),
("DisableVTSCSmoothing", "0"),
("DisengageVolume", "100"),
("DashcamDebug", "1"),
("DragonPilotTune", "0"),
("DriverCamera", "0"),
("DynamicPathWidth", "0"),

View File

@@ -51,6 +51,10 @@ def allow_uploads(started, params, CP: car.CarParams) -> bool:
allow_uploads = not (params.get_bool("DeviceManagement") and params.get_bool("NoUploads"))
return allow_uploads
# ClearPilot functions
def dashcam_should_run(started, params, CP: car.CarParams) -> bool:
return started or params.get_bool("DashcamDebug")
procs = [
DaemonProcess("manage_athenad", "selfdrive.athena.manage_athenad", "AthenadPid"),
@@ -102,6 +106,9 @@ procs = [
# FrogPilot processes
PythonProcess("fleet_manager", "selfdrive.frogpilot.fleetmanager.fleet_manager", always_run),
PythonProcess("frogpilot_process", "selfdrive.frogpilot.frogpilot_process", always_run),
# ClearPilot processes
NativeProcess("dashcamd", "selfdrive/clearpilot", ["./dashcamd"], dashcam_should_run),
]
managed_processes = {p.name: p for p in procs}

View File

@@ -919,14 +919,14 @@ void AnnotatedCameraWidget::initializeFrogPilotWidgets() {
animationFrameIndex = (animationFrameIndex + 1) % totalFrames;
});
// CLEARPILOT: screen recorder timer — feeds frames to the OMX encoder
QTimer *record_timer = new QTimer(this);
connect(record_timer, &QTimer::timeout, this, [this]() {
if (recorder_btn) {
recorder_btn->update_screen();
}
});
record_timer->start(1000 / UI_FREQ);
// CLEARPILOT: screen recorder disabled — replaced by dedicated dashcamd process
// QTimer *record_timer = new QTimer(this);
// connect(record_timer, &QTimer::timeout, this, [this]() {
// if (recorder_btn) {
// recorder_btn->update_screen();
// }
// });
// record_timer->start(1000 / UI_FREQ);
}
void AnnotatedCameraWidget::updateFrogPilotWidgets() {