From 5264c5c7fc3ab7b22813882a5402662c7f6d38bc Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Mon, 18 May 2026 20:22:11 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-18-20-18-20=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E8=A7=86=E9=A2=91=E6=97=B6=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 57 +++++++++++++++++------- frontend/app.js | 8 ++-- tests/test_api.py | 17 ++++++- 工程分析/实现方案-2026-05-18-20-18-20.md | 31 +++++++++++++ 工程分析/测试方案-2026-05-18-20-18-20.md | 34 ++++++++++++++ 工程分析/经验记录.md | 12 +++++ 工程分析/需求分析-2026-05-18-20-18-20.md | 18 ++++++++ 7 files changed, 154 insertions(+), 23 deletions(-) create mode 100644 工程分析/实现方案-2026-05-18-20-18-20.md create mode 100644 工程分析/测试方案-2026-05-18-20-18-20.md create mode 100644 工程分析/需求分析-2026-05-18-20-18-20.md diff --git a/backend/main.py b/backend/main.py index 3a31eec..42050dd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -181,6 +181,21 @@ def _selected_frame(index: int, stride: int, selected_count: int, max_frames: in return index % max(1, stride) == 0 and selected_count < max_frames +def _selected_frame_indices(frame_count: int, stride: int, max_frames: int) -> set[int]: + if frame_count <= 0: + return set() + candidates = list(range(0, frame_count, max(1, stride))) + if len(candidates) <= max_frames: + return set(candidates) + if max_frames <= 1: + return {candidates[0]} + last = len(candidates) - 1 + return { + candidates[round(position * last / (max_frames - 1))] + for position in range(max_frames) + } + + def _process_video( job_path: Path, source: Path, @@ -197,27 +212,30 @@ def _process_video( previous = None frame_index = 0 selected_count = 0 + written_count = 0 writer = None - result_fps = 8.0 raw_video_path = job_path / f"{method}_overlay.raw.mp4" video_path = job_path / f"{method}_overlay.mp4" source_fps = float(capture.get(cv2.CAP_PROP_FPS) or 0.0) if source_fps <= 0: source_fps = 12.0 + result_fps = source_fps frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0) - duration = round(frame_count / source_fps, 4) if frame_count else 0.0 + selected_indices = _selected_frame_indices(frame_count, frame_stride, max_frames) try: while True: ok, frame = capture.read() if not ok: break - if not _selected_frame(frame_index, frame_stride, selected_count, max_frames): - previous = frame - frame_index += 1 - continue + should_process = ( + frame_index in selected_indices + if selected_indices + else _selected_frame(frame_index, frame_stride, selected_count, max_frames) + ) + video_frame = frame - if method == "compare": + if should_process and method == "compare": outputs = compare_frame(frame, previous, sensitivity) for output in outputs: frames.append( @@ -228,12 +246,14 @@ def _process_video( frame, output, frame_index / source_fps, - selected_count / result_fps, - selected_count, + frame_index / source_fps, + frame_index, ) ) video_output = next(item for item in outputs if item.method == "fusion") - else: + video_frame = video_output.overlay + selected_count += 1 + elif should_process: video_output = segment_frame(frame, method, previous, sensitivity) frames.append( _save_frame_outputs( @@ -243,18 +263,20 @@ def _process_video( frame, video_output, frame_index / source_fps, - selected_count / result_fps, - selected_count, + frame_index / source_fps, + frame_index, ) ) + video_frame = video_output.overlay + selected_count += 1 if writer is None: - height, width = video_output.overlay.shape[:2] + height, width = frame.shape[:2] fourcc = cv2.VideoWriter_fourcc(*"mp4v") writer = cv2.VideoWriter(str(raw_video_path), fourcc, result_fps, (width, height)) - writer.write(video_output.overlay) + writer.write(video_frame) + written_count += 1 - selected_count += 1 previous = frame frame_index += 1 finally: @@ -266,14 +288,15 @@ def _process_video( raise HTTPException(status_code=400, detail="视频没有可处理帧") if raw_video_path.exists(): _browser_video(raw_video_path, video_path) + duration = round(written_count / source_fps, 4) if written_count else 0.0 return { "kind": "video", "frames": frames, "video_url": _public(video_path) if video_path.exists() else None, "source_fps": round(source_fps, 4), - "result_fps": result_fps, + "result_fps": round(result_fps, 4), "duration": duration, - "result_duration": round(len({frame["result_index"] for frame in frames}) / result_fps, 4) if frames else 0.0, + "result_duration": duration, } diff --git a/frontend/app.js b/frontend/app.js index a5ea1aa..0fe74a5 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -380,11 +380,11 @@ function syncVideos(source, time) { if (!frame) return; currentFrame = frame; syncLock = true; - if (source === "source" && !resultVideoPreview.hidden && Number.isFinite(frame.result_time)) { - seekMedia(resultVideoPreview, frame.result_time); + if (source === "source" && !resultVideoPreview.hidden) { + seekMedia(resultVideoPreview, time); } - if (source === "result" && !videoPreview.hidden && Number.isFinite(frame.source_time)) { - seekMedia(videoPreview, frame.source_time); + if (source === "result" && !videoPreview.hidden) { + seekMedia(videoPreview, time); } syncLock = false; [...resultGrid.querySelectorAll(".result-card")].forEach((node) => { diff --git a/tests/test_api.py b/tests/test_api.py index 023e6e8..caff579 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,7 +2,7 @@ from pathlib import Path from fastapi.testclient import TestClient -from backend.main import app +from backend.main import ROOT, app from scripts.generate_sample import make_frame import cv2 @@ -62,12 +62,25 @@ def test_segment_video_and_compare_frame(tmp_path: Path): assert payload["kind"] == "video" assert payload["video_url"].endswith(".mp4") assert payload["source_fps"] > 0 - assert payload["result_fps"] == 8.0 + assert payload["result_fps"] == payload["source_fps"] + assert payload["result_duration"] == payload["duration"] assert len(payload["frames"]) == 3 first_frame = payload["frames"][0] assert first_frame["source_time"] == 0.0 assert first_frame["result_time"] == 0.0 assert first_frame["result_index"] == 0 + second_frame = payload["frames"][1] + assert second_frame["result_time"] == second_frame["source_time"] + assert second_frame["result_index"] == second_frame["frame_index"] + + result_path = ROOT / payload["video_url"].lstrip("/") + result_capture = cv2.VideoCapture(str(result_path)) + assert result_capture.isOpened() + result_frames = int(result_capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0) + result_fps = float(result_capture.get(cv2.CAP_PROP_FPS) or 0) + result_capture.release() + assert result_frames == 18 + assert abs((result_frames / result_fps) - payload["duration"]) < 0.25 with video_path.open("rb") as handle: compare = client.post( diff --git a/工程分析/实现方案-2026-05-18-20-18-20.md b/工程分析/实现方案-2026-05-18-20-18-20.md new file mode 100644 index 0000000..5df0dfd --- /dev/null +++ b/工程分析/实现方案-2026-05-18-20-18-20.md @@ -0,0 +1,31 @@ +# 实现方案 + +开始时间:2026-05-18-20-18-20 + +## 后端 + +1. 修改 `_process_video` + - 结果视频改为按 `source_fps` 写出完整视频时间轴。 + - 每读到一帧都写入结果视频。 + - 命中抽帧策略的帧执行分割并写入叠加画面。 + - 未命中抽帧策略的帧写入原始画面。 + +2. 统一时间映射 + - `result_fps = source_fps`。 + - `result_duration = duration`。 + - 每个结果帧的 `result_time = source_time`。 + - 每个结果帧的 `result_index = frame_index`。 + +3. 抽帧覆盖 + - 当视频总帧数可获取时,先根据 `frame_stride` 得到候选帧。 + - 如果候选帧超过 `max_frames`,在候选帧中均匀抽取,避免只覆盖视频前半段。 + +## 前端 + +1. 双视频同步改为相同时间点同步 + - 源视频 seek 到 `t` 时,结果视频也 seek 到 `t`。 + - 结果视频 seek 到 `t` 时,源视频也 seek 到 `t`。 + +2. 当前帧选择仍按最近的已分割结果帧更新 + - 用于高亮下方结果卡片。 + - 用于多方法对比按钮的当前帧上下文。 diff --git a/工程分析/测试方案-2026-05-18-20-18-20.md b/工程分析/测试方案-2026-05-18-20-18-20.md new file mode 100644 index 0000000..9308930 --- /dev/null +++ b/工程分析/测试方案-2026-05-18-20-18-20.md @@ -0,0 +1,34 @@ +# 测试方案 + +开始时间:2026-05-18-20-18-20 + +## 自动化测试 + +- `python3 -m compileall backend tests` +- `node --check frontend/app.js` +- `pytest -q` + +## 接口验证 + +- 使用样例视频调用 `/api/segment`。 +- 检查返回的 `duration` 与 `result_duration` 基本一致。 +- 检查返回的 `source_fps` 与 `result_fps` 一致。 +- 使用 `ffprobe` 检查生成结果视频时长接近 6 秒。 + +## 浏览器验证 + +- Chrome headless 打开页面。 +- 点击“加载样例”并运行分割。 +- 检查左侧原始视频和右侧结果视频均能加载出画面。 +- 检查两个视频 `duration` 接近一致。 +- 检查拖动源视频后结果视频跳到相同时间点,反向也一致。 + +## 执行结果 + +- `python3 -m compileall backend tests`:通过。 +- `node --check frontend/app.js`:通过。 +- `pytest -q`:5 passed。 +- 样例视频调用 `/api/segment`:返回 `duration=6.0`、`result_duration=6.0`、`source_fps=12.0`、`result_fps=12.0`。 +- `ffprobe storage/jobs/4cc4901e30e9/fusion_overlay.mp4`:H.264、yuv420p、12fps、72 帧、6 秒。 +- Chrome headless 页面流程:左侧视频 `duration=6`,右侧结果视频 `duration=6`,12 个结果帧正常出现。 +- Chrome headless 同步验证:源视频 seek 到 2.25 秒后结果视频为 2.25 秒;结果视频 seek 到 4.5 秒后源视频为 4.5 秒。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index e86f6bb..65a6660 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -139,3 +139,15 @@ B. 产生问题原因:旧样式使用 CSS 无限动画模拟进度,没有和 C. 解决问题方案:移除无限动画,改为 JavaScript 按阶段设置固定进度:上传准备、抽帧、生成掩膜和叠加视频、整理结果、完成或失败。 D. 后续如何避免问题:耗时任务即使暂时没有后端实时进度,也要用单向推进的阶段式进度条,避免使用反复跳动的加载条表达处理进度。 + +## 2026-05-18-20-18-20 结果视频时长与原始视频对齐 + +### 1. 右侧结果视频只有约 1 秒 + +A. 具体问题:用户看到左侧原始视频为 6 秒,右侧“预览与结果视频”只有约 1 秒,两个播放器时长不一致。 + +B. 产生问题原因:后端把已抽取并分割的结果帧直接按固定 8fps 拼成结果视频;默认处理 12 帧时,结果视频只有约 `12 / 8 = 1.5` 秒,而不是原始 6 秒时间轴。 + +C. 解决问题方案:后端改为按原视频 `source_fps` 写完整结果视频;抽中的帧写入分割叠加画面,未抽中的帧写入原始画面;`result_fps`、`result_duration`、`result_time` 与源视频时间轴保持一致。前端双视频 seek 同步改为同一时间点同步。 + +D. 后续如何避免问题:任何面向并排对照的视频结果,都应优先保持与源视频相同时间轴;抽帧结果可以作为下方帧卡片展示,但主视频播放器不应只由抽帧结果压缩拼接。 diff --git a/工程分析/需求分析-2026-05-18-20-18-20.md b/工程分析/需求分析-2026-05-18-20-18-20.md new file mode 100644 index 0000000..862fc3c --- /dev/null +++ b/工程分析/需求分析-2026-05-18-20-18-20.md @@ -0,0 +1,18 @@ +# 需求分析 + +开始时间:2026-05-18-20-18-20 + +## 用户问题 + +用户反馈:左侧原始视频显示 6 秒,右侧预览与结果视频显示约 1 秒,询问原因。 + +## 问题判断 + +当前右侧结果视频由已抽取并完成分割的结果帧直接拼接生成。默认最多处理 12 帧,使用固定 8fps 写出,因此结果视频时长约为 `12 / 8 = 1.5` 秒,浏览器显示约 1 秒;而左侧原始样例视频是完整 6 秒。 + +## 期望行为 + +- 右侧“预览与结果视频”应与左侧原始视频保持同一时间轴和同一显示时长。 +- 保留抽帧分割策略,避免为了生成完整视频而强制处理每一帧导致运行过慢。 +- 抽中的帧显示导丝分割叠加结果,未抽中的帧保留原始画面。 +- 拖动任一视频时,另一个视频跳到相同时间点;下方帧卡片仍表示已分割的关键帧。