本地视频语义检索工具 VideoSeek支持以图搜视频、文本搜视频、片段预览与本地索引最近我做了一个桌面端项目VideoSeek。它主要解决一个很实际的问题计算机干宣传岗存于本地视频越来越多而且都是自己拍的网上找不到某次领导要我必须用某些画面的视频就给了我一张截图而我光靠画面和我那残存的记忆已无能为力但由于我学过计算机视觉相关的一些知识…于是我做了一个本地视频语义检索工具支持文本搜视频图片搜视频本地视频库管理视频片段预览本地向量索引远程公告和版本检查这篇文章想完整记录一下这个项目的设计思路、技术实现、踩坑过程以及我后面是怎么把它从“能跑”整理成“能发布”的。一、项目能做什么VideoSeek是一个本地桌面工具核心能力是1. 文本检索视频输入一句自然语言描述例如夜晚街道上一个人独自行走动漫角色特写镜头大量人物奔跑打斗的场景系统会把文字编码成向量然后到本地视频库里搜索最相似的画面片段。2. 图片检索视频除了文字也支持直接上传一张图作为查询条件做“以图搜视频”。3. 本地视频库管理支持在界面中维护多个视频目录并且可以添加库删除库单库同步全量更新索引直接打开库目录4. 命中片段预览搜索结果不只是告诉你“在哪个文件”而是可以直接生成短片段进行预览确认效率高很多。5. 参数设置很多核心参数都可以直接调索引抽帧频率搜索返回数量预览时长预览分辨率缩略图尺寸FFmpeg 路径二、项目演示索引生成反击的巨兽每集24分钟1秒1帧抽帧生成向量生成索引耗时在1分钟左右搜索演示搜索只需要一两毫秒三、项目技术栈这个项目本质上是一个“桌面 UI 多媒体处理 向量检索”的组合主要技术栈如下PySide6桌面 UIONNX Runtime运行 CLIP 模型FAISS向量索引与相似度检索OpenCV图像读取与处理FFmpeg抽帧、预览片段生成四、整体实现思路整体流程可以拆成 4 步1. 视频抽帧先从本地视频中按一定频率抽取关键帧。2. 特征提取把每一帧送入 CLIP 视觉编码器得到向量表示。3. 建立索引把这些向量保存下来并构建 FAISS 索引。4. 查询匹配当用户输入文本或图片后同样编码成向量再去索引中做最近邻搜索。可以简单理解成这样视频 - 抽帧 - 图像向量 - FAISS索引 文本/图片 - 查询向量 - 相似度搜索 - 命中片段五、关键代码实现1. 视频抽帧项目里抽帧是通过 FFmpeg 做的。这样比很多纯 Python 解码方式更稳也更适合生成后续预览片段。def extract_frames_with_ffmpeg(video_path): config load_config() fps config.get(fps, 1) cap cv2.VideoCapture(video_path) width int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) cap.release() if width 0 or height 0: return [], [] ffmpeg_bin get_ffmpeg_path() command [ ffmpeg_bin, -i, video_path, -vf, ffps{fps}, -sn, -f, image2pipe, -pix_fmt, bgr24, -vcodec, rawvideo, -, ] 这里的 fps 含义很明确 每秒抽取多少帧用于建索引。 例如 fps 1就是每秒抽 1 帧。2. 图像编码抽出来的帧会送到 CLIP ONNX 模型中编码class CLIPOnnxEngine: def __init__(self): providers [CUDAExecutionProvider, CPUExecutionProvider] self.visual_session ort.InferenceSession( get_resource_path(models/clip_visual.onnx), providersproviders, ) self.text_session ort.InferenceSession( get_resource_path(models/clip_text.onnx), providersproviders, ) def encode_images(self, frames): embeddings [] for frame in frames: blob self._preprocess(frame) feat self.visual_session.run(None, {input: blob})[0].astype(np.float32) feat / (np.linalg.norm(feat, axis-1, keepdimsTrue) 1e-10) embeddings.append(feat) return np.vstack(embeddings)文本查询则走文本编码器def get_text_embedding(text): return engine.encode_text(text)3. 建立向量索引使用 FAISS 构建全局索引measure_time(Index build time:) def create_clip_index(vectors_list, index_file): vectors np.asarray(vectors_list, dtypefloat32) vectors np.asarray([ vector / np.linalg.norm(vector) if np.linalg.norm(vector) ! 0 else vector for vector in vectors ], dtypefloat32) index faiss.IndexFlatIP(vectors.shape[1]) index.add(vectors) faiss.write_index(index, index_file) return index4. 结果搜索搜索时的逻辑也比较直接def search_vector(query_vector, index, timestamps, video_paths, top_k10): actual_k min(top_k, index.ntotal) if actual_k 0: return [] distances, indices index.search(query_vector, actual_k) matched_results [] for rank, index_value in enumerate(indices[0]): if index_value -1 or index_value len(video_paths): continue timestamp timestamps[index_value] video_path video_paths[index_value] matched_results.append((timestamp, timestamp, distances[0][rank], video_path)) return matched_results最终拿到的是命中的时间戳 、相似度分数 、原始视频路径六、项目重构从“能跑”到“能维护”其实这个项目一开始并没有现在这么整洁。最早版本的问题很典型UI、索引、搜索、配置全写在一层改一个按钮容易牵到业务逻辑加功能时越来越难维护一些历史文件还混着编码污染使用的是pytorch打包后安装包高达3个G后来我使用codex辅助做了一系列比较大的结构整理把代码重新分层重构了项目。改用onnx runtime并将资源文件分离最后将安装包减小到170MB资源文件安装后选择下载。七、结语VideoSeek 从最开始的一个想法逐渐做成了一个完整的桌面应用有搜索能力有索引能力有预览能力有多页 UI有设置系统有远程公告有版本检查也有一套清晰得多的工程结构这类项目最有意思的地方就在于它不只是“技术能不能做出来”而是“能不能真的变成一个好用的工具”。如果你也在做本地 AI 工具、桌面应用或者也对视频检索方向感兴趣欢迎交流最后跪谢codex给我完善项目帮大忙了巨好用强烈推荐八、项目链接GitHubGitHub链接直接下载安装包点击下载