Jasna 源码拆解:本地 GPU 视频马赛克修复流水线完整指南
【免费下载链接】jasnaVideo restoration tool项目地址: https://gitcode.com/GitHub_Trending/ja/jasna
手里有一段 3 分 22 秒的 1080p 视频,画面里有几处马赛克遮挡。上传到云端工具,意味着等排队、等下载,素材还得离开本机;用纯 CPU 软件解码逐帧处理,一分钟素材要熬十分钟。Jasna 是另一条路:它把解码、检测、跟踪、裁剪、重建、回填、编码整条链路压进一块本地 GPU,视频马赛克修复变成显卡上的即时计算——1080p 全程约 34 秒,典型显存占用 4.8 GB。本文基于仓库源码,拆解这条流水线"检测—裁剪—重建—回填"四个环节在代码里如何落地,以及每个设计对应的代价。
输入到输出:七个环节如何摊进四个线程
一条完整修复链包含七个环节:NVDEC/AMF 硬件解码 → 逐帧检测马赛克(默认rfdetr-v6)→ 跨帧跟踪聚合成 clip → 每 clip 按 256×256 送 BasicVSR++ 重建 → 可选二级修复(UNet 4x / RTX Super Res / Topaz TVAI)→ 按 mask 把结果混回原帧 → NVENC/AMF 硬件编码。这些环节不是单线程顺序执行,而是拆成四个并发循环,用有界队列衔接,见 jasna/pipeline_threads.py:
decode_detect_loop:GPU 解码 + 逐帧检测 + 跟踪,产出带原始裁剪序列的ClipRestoreItem;primary_restore_loop:对每个 clip 跑 BasicVSR++,产出PrimaryRestoreResult;secondary_restore_loop:可选二级修复,产出 uint8 帧序列;blend_encode_loop:第二次解码原始视频,把修复结果按 mask 混合进原帧后编码输出。
解码、检测、重建、编码四类负载因此在时间上互相重叠:解码线程推进第 N 批帧时,重建线程在处理第 N-1 批 clip。队列的水位(多少帧缓冲)动态决定各线程何时让路——这是后文"背压"一节的主角。
把全帧缩小成 256×256 的 patch:检测双输出与 bbox 生长
核心矛盾:马赛克只占画面的一小部分,对全帧跑超分网络等于把算力浪费在没被遮挡的像素上。Jasna 的解法是把任务拆成两问——"哪里要修"交给轻量检测模型,"怎么修"只在定位到的 patch 上重建。README 把这条不变量写得很直白:"The rest of the picture is not touched."(其余画面一律不动)。
检测环节的关键决策是选RF-DETR 而不是 YOLO:RF-DETR 同时输出 bbox 和逐像素 mask,后者是回填阶段的混合权重依据。Detections数据结构(jasna/mosaic/detections.py)把两者分置 CPU/GPU:
@dataclass(frozen=True) class Detections: boxes_xyxy: list[np.ndarray] # (N_i, 4) xyxy in pixels, CPU masks: list[torch.Tensor] # (N_i, Hm, Wm) bool, GPUbbox 是整数坐标,走 numpy 做跟踪矩阵运算不占显存带宽;mask 是布尔张量,留在 GPU 上等待后续逐像素混合。这是一次按"谁消费、消费在哪"做的设备分工。
模型参数按型号预配置在 jasna/mosaic/detection_registry.py:
| 模型 | 输入分辨率 | 置信度阈值 | batch |
|---|---|---|---|
rfdetr-v6(默认) | 576 | 0.35 | 动态 |
rfdetr-v6-large | 768 | 0.40 | 动态 |
rfdetr-vr-v1 | 768 | 0.40 | 动态 |
rfdetr-v5 | 768 | 0.25 | 固定 4 |
NVIDIA 上检测模型在首次运行时被编译成 TensorRT 引擎(jasna/mosaic/rfdetr.py 的compile_rfdetr_engine,workspace_gb=20给 FP16 编译留足工作区),AMD 则直接加载 torch 权重走RfDetrTorchRunner。代价是 NVIDIA 首跑要等编译,收益是引擎产物缓存在model_weights目录、跨版本复用。
拿到 bbox 后,裁剪逻辑在 jasna/crop_buffer.py 里把小框"生长"成 256×256 的输入:
RESTORATION_SIZE = 256 BORDER_RATIO = 0.06 MIN_BORDER = 20 MAX_EXPANSION_FACTOR = 1.0expand_bbox先用max(20, 长边×0.06)的边距向外扩一圈上下文,再向 256 的宽高比生长,扩张总量不超过原框的一倍。为什么要带上下文边距?因为时序重建模型靠 patch 边缘的未遮挡像素推断遮挡区的内容,纯马赛克框给它没有参照物。扩完的裁剪序列经prepare_crops_for_restoration统一预处理:按整批最大尺寸只降不升地缩放,再用反射填充补齐到 256×256,同时记录pad_offsets与resize_shapes两组元数据——它们要一路携带到回填阶段,把修复像素精确映射回原帧坐标。
跟踪、拆 clip 与交叉淡化:时序重建的接缝怎么办
核心矛盾:BasicVSR++ 是时序模型,靠 clip 内前后帧互推信息,但 clip 不能无限长——显存扛不住,而且马赛克可能持续几十秒。clip 一拆,边界处就会出现"两个 clip 各修各的、风格不一致"的接缝闪烁。
跨帧关联在 jasna/tracking/clip_tracker.py 的ClipTracker.update里:新帧的每个检测框与上一帧所有存活 track 算 IoU,贪心匹配,阈值 0.3。匹配不上的检测开新 track,匹配不上的 track 进入 coast(用最后一帧的 bbox/mask 合成延续,上限max_detection_gap)或直接收尾。同一帧内重叠的框会先按 IoU 连通分组合并,避免一个马赛克被切成两个 track。
场景切换由 jasna/tracking/scene_detector.py 的SceneCutDetector处理:帧被降到 192px 宽,相邻帧平均亮度差超过近期窗口中位数的 3 倍即判为硬切,clip 强制断在镜头边界——这是"两个不同镜头的像素永远不会混进同一个时序模型"的保证。
clip 达到max_clip_size上限(基准测试默认 180 帧)时不是硬切,而是带重叠拆分:新 track 从2 × temporal_overlap帧之前开始,复制尾部的 bbox 与 mask 序列继续跟踪。同一时间窗因此被相邻两个 clip 各修一遍,回填时按权重融合。权重在 jasna/pipeline_overlap.py 里生成:
for j in range(2 * bf): local_idx = d - bf + j weights[local_idx] = (j + 0.5) / (2 * bf)bf = temporal_overlap // 3是交叉淡化窗口,权重在重叠区中心从 1/4 线性爬升到 3/4,与父 clip 侧互补——重叠区两帧结果各占一部分,边界不再跳变。compute_keep_range再配合:续接 clip 的开头d - bf帧直接丢弃(父 clip 已覆盖),只有"因超长被拆"的 clip 才保留尾部d - bf帧参与淡化。代价是重叠帧被重建两次,temporal_overlap调大以重叠计算量换边界平滑度,基准测试统一取 15 帧。
mask 驱动回填:lerp 贴回与乱序到帧的竞态
核心矛盾:修复结果活在 256×256 patch 坐标系里,而原帧在原始分辨率;且多个 clip 的完成顺序与帧号无关——第 100 帧可能等的是第 500 帧那个 clip 的重建。直接"修完就写"会产生竞态:帧还没凑齐就被编码走。
回填在 jasna/blend_buffer.py 的BlendBuffer里分两层。第一层是就绪判定:register_frame为每帧登记它依赖哪些 track,is_frame_ready只有在该帧所有 track 的结果都到达后才放行,blend_encode_loop据此阻塞等待,"修到一半就编码"从结构上不可能。第二层是像素混合:
original_crop = blended[:, y1:y2, x1:x2].float() original_crop.lerp_(resized_back, blend_mask.unsqueeze(0)) .blend_mask 为检测 mask 膨胀+渐隐后的权重 original_crop.round_().clamp_(0, 255)流程是:按pad_offsets/resize_shapes把 256 patch 里的有效区裁出来,双线性放大回裁剪尺寸,再以检测 mask(经膨胀与边缘渐隐)为权重对原帧区域做lerp——mask 内部完全采用修复像素,边缘平滑过渡回原像素,clamp_保证 uint8 不溢出。若某帧同时有多个 track,后到的 clip 以差分方式叠加(delta = (resized - original) * mask),避免互相覆盖。
还有一个容易忽略的事实:blend_encode_loop用的是第二个VideoReader,把原片从头再硬件解码一遍取原帧。Jasna 宁可多付一次解码(硬件解码 4K 约 681 fps,见 benchmarks/2026-07-21_v0.8.2_e2e_decode_backends.md),也不在解码线程里缓存整片原帧——缓存的显存代价远高于一次 NVDEC 重放。VR180 路径下回填走project_region/source_region_from_patch的差分投影:只把"修复图减原图"的差值反投影回源坐标,因为 patch 内马赛克之外的区域模型本就不改,差值约等于 0,重投影误差不会被摊到未触碰的像素上。
背压与双解码:四线程如何不互相卡死
四个循环之间的队列不是普通Queue,而是 jasna/frame_queue.py 的FrameQueue——按帧数而非条目数限流:
def put(self, item, frame_count=0): with self._cond: if frame_count > 0: while self._current_frames > 0 and \ self._current_frames + frame_count > self._max_frames: self._cond.wait() self._deque.append((item, frame_count)) self._current_frames += frame_count一个 clip 项携带它覆盖的帧数,队列水位按帧累计;生产者试图放入会超水位的数据时挂起等待。这意味着瓶颈自动反传到最上游:重建慢了,解码线程在队列满时停手,硬件解码器不再无谓地吐帧占显存。配合解码侧的_BatchPrefetcher独立预取线程(解码与消费重叠)以及JASNA_DECODE_BACKEND环境变量在vali/pyav-hw/pyav-sw之间切换(jasna/media/video_decoder.py,其中 AV1 被强制改用带 NVDEC 配置的 FFmpeg 原生解码器,因为 PyAV 默认的 libdav1d 不带硬件加速配置),整条链路在任意一环变慢时都会以"上游停手"而非"队列无限膨胀"的方式收敛。
数据账本:fps、显存与关键参数
以下均为仓库内官方基准(benchmarks/)实测值,非估算。
端到端吞吐(RTX 5090,v0.9.1 系列,BasicVSR++,--max-clip-size 180 --temporal-overlap 15,见 benchmarks/2026-07-28_engine_batch_vram.md):
| 输入 | 端到端耗时 | 吞吐 | VRAM 典型/峰值 |
|---|---|---|---|
| 720p H.264 | 31.9 s | 190.0 fps | 4312 / 4720 MiB |
| 1080p H.264 | 33.8 s | 179.4 fps | 4867 / 5779 MiB |
| 2160p H.264 | 63.3 s | 95.7 fps | 7326 / 11176 MiB |
README 汇总为 GB 计:720p 4.2/4.6、1080p 4.8/5.6、2160p 7.2/10.9——8 GB 显存显卡可完整跑通 1080p。同机对比 Lada 0.11.0:1080p 3.6 倍、2160p 4.7 倍(README.md),代价是 Jasna 显存占用更高(Lada 1080p 约 2.0/3.3 GB)。
关键参数账(源码常量与基准配置):
| 参数 | 值 | 出处 |
|---|---|---|
| 重建输入尺寸 | 256×256 | crop_buffer.py |
| 裁剪上下文边距 | max(20px, 长边×6%) | 同上 |
| track 匹配 IoU 阈值 | 0.3 | clip_tracker.py |
| 交叉淡化窗口 | temporal_overlap ÷ 3 | pipeline_threads.py |
| TensorRT 编译工作区 | 20 GB | rfdetr.py |
| 预处理/上采样固定 batch | 60 / 30 | benchmarks/2026-07-28_engine_batch_vram.md |
clip 长度的显存-速度权衡(1080p,overlap 8,clip size 扫描):
| clip 帧数 | 端到端 | VRAM 典型 |
|---|---|---|
| 30 | 51.4 s | 3411 MiB |
| 90 | 34.4 s | 4095 MiB |
| 180 | 35.0 s | 5047 MiB |
| 720 | 33.0 s | 8159 MiB |
短 clip 慢在重叠区反复重算,超过 180 帧后速度基本不动、显存线性上涨——180 是速度/显存的拐点,显存吃紧时官方建议先把它降到 60。v0.9.1 还有一笔显存账:preprocess/upsample 子引擎曾按 clip 全长(180 帧)编译,TensorRT 为最大 profile 预留 scratch,常驻 4560 MiB;改为固定 batch 60/30 后降到 916 MiB,端到端耗时不变(benchmarks/2026-07-27_v0.9.1_e2e_cudagraphs.md)。
适用边界与已知妥协
- 首跑税:NVIDIA 上首次运行需为当前显卡现场编译检测与重建引擎,官方口径 15–60 分钟,期间不能动电脑;
model_weights目录可拷给新版本复用,但升级 LTX 模型时需重新下载。 - 硬件门槛:NVIDIA 需 compute capability ≥ 7.5(GTX 10 系不支持),AMD RX 7000/9000 仍标注 experimental 且二级修复不可用;路径不含非英文字符,不支持网络盘。
- 模型分级:BasicVSR++ 免费;LTX(扩散式重建,更丰富细节,4 遍 pass,Standard/Good/High/Top 四档速度)与支持者许可绑定,且需 8 GB VRAM + 16 GB RAM。二级修复中 UNet 4x、RTX Super Res、Topaz TVAI 也分属不同许可档(docs/en/models.md)。
- 被放弃的优化:FP8 上采样引擎曾使 v0.9.1 显存回退 +1.8 GB(可发布路线编译出约 5.7 GB scratch 的 Myelin 节点),最终整条移除;loop_body 量化到 FP8/INT8 反而约慢 1.9 倍。这说明 TensorRT 量化收益高度依赖引擎结构,不能照搬经验值(benchmarks/2026-07-28_engine_batch_vram.md)。
- 权衡取舍:用更高显存(约为 Lada 两倍)换 3–5 倍吞吐;用一次重复解码换缓存显存;用重叠帧双倍重建换 clip 边界无闪烁。每一项都有对应的回退旋钮(
max_clip_size、temporal_overlap、JASNA_DECODE_BACKEND),调优入口见 docs/en/tuning.md。
收束
Jasna 的价值在于把"马赛克只占画面一小块"这个先验彻底兑现成工程:RF-DETR 的 mask 定位、256×256 patch 域重建、lerp按权重回填、FrameQueue帧数背压,四者共同守住了"只改马赛克、不碰背景"这条底线,同时让 1080p 修复落在半分钟量级。建议从免费的 BasicVSR++ 路径起步跑通一遍完整作业,用model_weights复用规避首跑编译,再按显存余量决定是否需要 LTX 或二级修复。
【免费下载链接】jasnaVideo restoration tool项目地址: https://gitcode.com/GitHub_Trending/ja/jasna
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考