From 9e822abbc69253ba4e70052b2ca25aebcbb9b3ec Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 2 Oct 2026 11:25:06 +0200 Subject: [PATCH] fix(nvof): recreate the optical flow session when frame size changes --- src/nodes/scene_cut/cuda_camera_motion.cpp | 25 ++++-- tests/cuda/smoke_camera_motion_resize.py | 97 ++++++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 tests/cuda/smoke_camera_motion_resize.py diff --git a/src/nodes/scene_cut/cuda_camera_motion.cpp b/src/nodes/scene_cut/cuda_camera_motion.cpp index ea62cbee..b3bfad79 100644 --- a/src/nodes/scene_cut/cuda_camera_motion.cpp +++ b/src/nodes/scene_cut/cuda_camera_motion.cpp @@ -210,6 +210,14 @@ class CudaCameraMotion : public NodeSISO, public have_prev_ = false; } + void destroyOFSession() { + destroyOFBuffers(); + if (hOF_ && of_.nvOFDestroy) { + CCM_CHECK_OF(of_.nvOFDestroy(hOF_)); + hOF_ = nullptr; + } + } + #if HAVE_CCM_GPU_IRLS void releaseGpuIrlsBuffers() { if (d_gpu_irls_reduce_) { @@ -298,10 +306,18 @@ class CudaCameraMotion : public NodeSISO, public #endif bool ensureSession(int width, int height) { - if (hOF_ && of_w_ == width && of_h_ == height && inBuf_ && refBuf_ && outBuf_) { + if (hOF_ && of_w_ == width && of_h_ == height && inBuf_ && refBuf_ && outBuf_ && costBuf_) { return true; } - destroyOFBuffers(); + if (hOF_) { + // NvOFInit cannot resize an initialized handle. Drain this node's + // stream before replacing the session and its reference buffers. + if (CCM_CHECK_CU(cuStreamSynchronize(stream_))) return false; + logstream << "cuda_camera_motion: recreating NVOF session " + << of_w_ << "x" << of_h_ << " -> " << width << "x" << height; + } + destroyOFSession(); + if (!initOF()) return false; NV_OF_INIT_PARAMS init{}; init.width = (uint32_t)width; @@ -850,7 +866,7 @@ class CudaCameraMotion : public NodeSISO, public ~CudaCameraMotion() { if (cu_ctx_) CCM_CHECK_CU(cuCtxSetCurrent(cu_ctx_)); - destroyOFBuffers(); + destroyOFSession(); #if HAVE_CCM_GPU_IRLS releaseGpuIrlsBuffers(); if (gpu_irls_module_) { @@ -858,7 +874,6 @@ class CudaCameraMotion : public NodeSISO, public gpu_irls_module_ = nullptr; } #endif - if (hOF_ && of_.nvOFDestroy) { of_.nvOFDestroy(hOF_); hOF_ = nullptr; } } bool consumeEofIfPresent() override { return false; } @@ -889,7 +904,7 @@ class CudaCameraMotion : public NodeSISO, public writeMetadata(frm, false, MotionSummary{}, AffineSummary{}, 0.0f, "invalid_luma_plane"); this->sink_->put(frm); return; } - if (!initCudaContextFromFrame(frm) || !initOF() || !ensureSession(frm.width(), frm.height())) { + if (!initCudaContextFromFrame(frm) || !ensureSession(frm.width(), frm.height())) { if (strict_cuda_) throw Error("cuda_camera_motion: failed to initialize NVOF"); writeMetadata(frm, false, MotionSummary{}, AffineSummary{}, 0.0f, "nvof_init_failed"); this->sink_->put(frm); return; diff --git a/tests/cuda/smoke_camera_motion_resize.py b/tests/cuda/smoke_camera_motion_resize.py new file mode 100644 index 00000000..dcde7eab --- /dev/null +++ b/tests/cuda/smoke_camera_motion_resize.py @@ -0,0 +1,97 @@ +"""GPU regression: NVOF must reseed after 720p -> 1080p -> 720p transitions. + +The finite test patterns are uploaded once into the same CUDA device. This is +fixture setup; the camera-motion path consumes and forwards CUDA frames only. +Run in an NVIDIA container with HAVE_NVOF and HAVE_CCM_GPU_IRLS enabled. +""" + +import argparse +import json +import time + +from _avplumber import VideoFrame +from pyplumber import AVPlumber +from pyplumber.node import CudaCameraMotion, DecVideo, Demux, FilterVideo, InputRec + + +def run(timeout): + avp = AVPlumber() + errors = [] + avp.on_exception = lambda *error: errors.append(tuple(map(str, error))) + avp.executeCommandsFromString('hwaccel.init {"name":"flow_gpu","type":"cuda"}') + avp.edges.planCapacity("*", 8) + nodes = [] + for label, width, height in [("hd", 1280, 720), ("fhd", 1920, 1080)]: + nodes.extend([ + InputRec({"name": f"{label}_input", "format": "lavfi", + "url": f"testsrc2=size={width}x{height}:rate=25,trim=end_frame=2", + "dst": f"{label}_packets"}), + Demux({"name": f"{label}_demux", "src": f"{label}_packets", + "routing": {"v:0": f"{label}_video"}}), + DecVideo({"name": f"{label}_decode", "src": f"{label}_video", + "dst": f"{label}_software"}), + FilterVideo({"name": f"{label}_upload", "src": f"{label}_software", + "dst": f"{label}_cuda", "graph": "format=nv12,hwupload", + "hwaccel": "flow_gpu", "dst_pixel_format": "cuda", + "dst_width": width, "dst_height": height}), + ]) + for node in nodes: + node.parameters.update({"group": "fixtures", "auto_restart": "off"}) + avp.addNode(node) + avp.getEdge("flow_input", "VideoFrame") + flow = CudaCameraMotion({"name": "flow", "group": "flow", "auto_restart": "off", + "src": "flow_input", "dst": "flow_output", + "affine_backend": "gpu_irls", "strict_cuda": True}) + avp.addNode(flow) + source = avp.getEdge("flow_input", "VideoFrame") + output = avp.getEdge("flow_output", "VideoFrame") + deadline = time.monotonic() + timeout + + def get(edge): + while time.monotonic() < deadline: + assert not errors, errors + frame = edge.tryGet(100) + if frame is not None: + return frame + raise AssertionError("timed out waiting for a CUDA frame") + + results = [] + frames = {} + try: + avp.group("fixtures").startNodes() + for label in ("hd", "fhd"): + edge = avp.getEdge(f"{label}_cuda", "VideoFrame") + frames[label] = [get(edge), get(edge)] + assert get(edge).pts.timestamp == -(1 << 63), "fixture did not reach EOF" + avp.group("flow").startNodes() + for label in ("hd", "fhd", "hd", "fhd"): + for index, frame in enumerate(frames[label]): + assert source.enqueue(frame) + processed = get(output) + motion = json.loads(processed.metadata["camera_motion"]) + assert (processed.width, processed.height) == (frame.width, frame.height) + assert motion["status"] == "ok", motion + assert motion["has_prev"] == (index != 0), motion + if index: + assert motion["affine_valid"], motion + results.append({"width": frame.width, "height": frame.height, + "has_prev": motion["has_prev"], + "affine_valid": motion["affine_valid"]}) + source.enqueue(VideoFrame()) + assert get(output).pts.timestamp == -(1 << 63) + names = [node.parameters["name"] for node in nodes] + ["flow"] + while any(avp.node(name).isWorking for name in names): + assert time.monotonic() < deadline, "workers did not finish after EOF" + time.sleep(0.01) + assert not errors, errors + finally: + frames.clear() + nodes.clear() + avp.shutdown() + print(json.dumps({"status": "passed", "transitions": 3, "frames": results}), flush=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--timeout", type=float, default=30) + run(parser.parse_args().timeout)