2026-05-18-20-18-20 对齐结果视频时长
This commit is contained in:
@@ -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
|
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(
|
def _process_video(
|
||||||
job_path: Path,
|
job_path: Path,
|
||||||
source: Path,
|
source: Path,
|
||||||
@@ -197,27 +212,30 @@ def _process_video(
|
|||||||
previous = None
|
previous = None
|
||||||
frame_index = 0
|
frame_index = 0
|
||||||
selected_count = 0
|
selected_count = 0
|
||||||
|
written_count = 0
|
||||||
writer = None
|
writer = None
|
||||||
result_fps = 8.0
|
|
||||||
raw_video_path = job_path / f"{method}_overlay.raw.mp4"
|
raw_video_path = job_path / f"{method}_overlay.raw.mp4"
|
||||||
video_path = job_path / f"{method}_overlay.mp4"
|
video_path = job_path / f"{method}_overlay.mp4"
|
||||||
source_fps = float(capture.get(cv2.CAP_PROP_FPS) or 0.0)
|
source_fps = float(capture.get(cv2.CAP_PROP_FPS) or 0.0)
|
||||||
if source_fps <= 0:
|
if source_fps <= 0:
|
||||||
source_fps = 12.0
|
source_fps = 12.0
|
||||||
|
result_fps = source_fps
|
||||||
frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
|
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:
|
try:
|
||||||
while True:
|
while True:
|
||||||
ok, frame = capture.read()
|
ok, frame = capture.read()
|
||||||
if not ok:
|
if not ok:
|
||||||
break
|
break
|
||||||
if not _selected_frame(frame_index, frame_stride, selected_count, max_frames):
|
should_process = (
|
||||||
previous = frame
|
frame_index in selected_indices
|
||||||
frame_index += 1
|
if selected_indices
|
||||||
continue
|
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)
|
outputs = compare_frame(frame, previous, sensitivity)
|
||||||
for output in outputs:
|
for output in outputs:
|
||||||
frames.append(
|
frames.append(
|
||||||
@@ -228,12 +246,14 @@ def _process_video(
|
|||||||
frame,
|
frame,
|
||||||
output,
|
output,
|
||||||
frame_index / source_fps,
|
frame_index / source_fps,
|
||||||
selected_count / result_fps,
|
frame_index / source_fps,
|
||||||
selected_count,
|
frame_index,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
video_output = next(item for item in outputs if item.method == "fusion")
|
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)
|
video_output = segment_frame(frame, method, previous, sensitivity)
|
||||||
frames.append(
|
frames.append(
|
||||||
_save_frame_outputs(
|
_save_frame_outputs(
|
||||||
@@ -243,18 +263,20 @@ def _process_video(
|
|||||||
frame,
|
frame,
|
||||||
video_output,
|
video_output,
|
||||||
frame_index / source_fps,
|
frame_index / source_fps,
|
||||||
selected_count / result_fps,
|
frame_index / source_fps,
|
||||||
selected_count,
|
frame_index,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
video_frame = video_output.overlay
|
||||||
|
selected_count += 1
|
||||||
|
|
||||||
if writer is None:
|
if writer is None:
|
||||||
height, width = video_output.overlay.shape[:2]
|
height, width = frame.shape[:2]
|
||||||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||||
writer = cv2.VideoWriter(str(raw_video_path), fourcc, result_fps, (width, height))
|
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
|
previous = frame
|
||||||
frame_index += 1
|
frame_index += 1
|
||||||
finally:
|
finally:
|
||||||
@@ -266,14 +288,15 @@ def _process_video(
|
|||||||
raise HTTPException(status_code=400, detail="视频没有可处理帧")
|
raise HTTPException(status_code=400, detail="视频没有可处理帧")
|
||||||
if raw_video_path.exists():
|
if raw_video_path.exists():
|
||||||
_browser_video(raw_video_path, video_path)
|
_browser_video(raw_video_path, video_path)
|
||||||
|
duration = round(written_count / source_fps, 4) if written_count else 0.0
|
||||||
return {
|
return {
|
||||||
"kind": "video",
|
"kind": "video",
|
||||||
"frames": frames,
|
"frames": frames,
|
||||||
"video_url": _public(video_path) if video_path.exists() else None,
|
"video_url": _public(video_path) if video_path.exists() else None,
|
||||||
"source_fps": round(source_fps, 4),
|
"source_fps": round(source_fps, 4),
|
||||||
"result_fps": result_fps,
|
"result_fps": round(result_fps, 4),
|
||||||
"duration": duration,
|
"duration": duration,
|
||||||
"result_duration": round(len({frame["result_index"] for frame in frames}) / result_fps, 4) if frames else 0.0,
|
"result_duration": duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -380,11 +380,11 @@ function syncVideos(source, time) {
|
|||||||
if (!frame) return;
|
if (!frame) return;
|
||||||
currentFrame = frame;
|
currentFrame = frame;
|
||||||
syncLock = true;
|
syncLock = true;
|
||||||
if (source === "source" && !resultVideoPreview.hidden && Number.isFinite(frame.result_time)) {
|
if (source === "source" && !resultVideoPreview.hidden) {
|
||||||
seekMedia(resultVideoPreview, frame.result_time);
|
seekMedia(resultVideoPreview, time);
|
||||||
}
|
}
|
||||||
if (source === "result" && !videoPreview.hidden && Number.isFinite(frame.source_time)) {
|
if (source === "result" && !videoPreview.hidden) {
|
||||||
seekMedia(videoPreview, frame.source_time);
|
seekMedia(videoPreview, time);
|
||||||
}
|
}
|
||||||
syncLock = false;
|
syncLock = false;
|
||||||
[...resultGrid.querySelectorAll(".result-card")].forEach((node) => {
|
[...resultGrid.querySelectorAll(".result-card")].forEach((node) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from backend.main import app
|
from backend.main import ROOT, app
|
||||||
from scripts.generate_sample import make_frame
|
from scripts.generate_sample import make_frame
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
@@ -62,12 +62,25 @@ def test_segment_video_and_compare_frame(tmp_path: Path):
|
|||||||
assert payload["kind"] == "video"
|
assert payload["kind"] == "video"
|
||||||
assert payload["video_url"].endswith(".mp4")
|
assert payload["video_url"].endswith(".mp4")
|
||||||
assert payload["source_fps"] > 0
|
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
|
assert len(payload["frames"]) == 3
|
||||||
first_frame = payload["frames"][0]
|
first_frame = payload["frames"][0]
|
||||||
assert first_frame["source_time"] == 0.0
|
assert first_frame["source_time"] == 0.0
|
||||||
assert first_frame["result_time"] == 0.0
|
assert first_frame["result_time"] == 0.0
|
||||||
assert first_frame["result_index"] == 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:
|
with video_path.open("rb") as handle:
|
||||||
compare = client.post(
|
compare = client.post(
|
||||||
|
|||||||
31
工程分析/实现方案-2026-05-18-20-18-20.md
Normal file
31
工程分析/实现方案-2026-05-18-20-18-20.md
Normal file
@@ -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. 当前帧选择仍按最近的已分割结果帧更新
|
||||||
|
- 用于高亮下方结果卡片。
|
||||||
|
- 用于多方法对比按钮的当前帧上下文。
|
||||||
34
工程分析/测试方案-2026-05-18-20-18-20.md
Normal file
34
工程分析/测试方案-2026-05-18-20-18-20.md
Normal file
@@ -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 秒。
|
||||||
12
工程分析/经验记录.md
12
工程分析/经验记录.md
@@ -139,3 +139,15 @@ B. 产生问题原因:旧样式使用 CSS 无限动画模拟进度,没有和
|
|||||||
C. 解决问题方案:移除无限动画,改为 JavaScript 按阶段设置固定进度:上传准备、抽帧、生成掩膜和叠加视频、整理结果、完成或失败。
|
C. 解决问题方案:移除无限动画,改为 JavaScript 按阶段设置固定进度:上传准备、抽帧、生成掩膜和叠加视频、整理结果、完成或失败。
|
||||||
|
|
||||||
D. 后续如何避免问题:耗时任务即使暂时没有后端实时进度,也要用单向推进的阶段式进度条,避免使用反复跳动的加载条表达处理进度。
|
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. 后续如何避免问题:任何面向并排对照的视频结果,都应优先保持与源视频相同时间轴;抽帧结果可以作为下方帧卡片展示,但主视频播放器不应只由抽帧结果压缩拼接。
|
||||||
|
|||||||
18
工程分析/需求分析-2026-05-18-20-18-20.md
Normal file
18
工程分析/需求分析-2026-05-18-20-18-20.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 需求分析
|
||||||
|
|
||||||
|
开始时间:2026-05-18-20-18-20
|
||||||
|
|
||||||
|
## 用户问题
|
||||||
|
|
||||||
|
用户反馈:左侧原始视频显示 6 秒,右侧预览与结果视频显示约 1 秒,询问原因。
|
||||||
|
|
||||||
|
## 问题判断
|
||||||
|
|
||||||
|
当前右侧结果视频由已抽取并完成分割的结果帧直接拼接生成。默认最多处理 12 帧,使用固定 8fps 写出,因此结果视频时长约为 `12 / 8 = 1.5` 秒,浏览器显示约 1 秒;而左侧原始样例视频是完整 6 秒。
|
||||||
|
|
||||||
|
## 期望行为
|
||||||
|
|
||||||
|
- 右侧“预览与结果视频”应与左侧原始视频保持同一时间轴和同一显示时长。
|
||||||
|
- 保留抽帧分割策略,避免为了生成完整视频而强制处理每一帧导致运行过慢。
|
||||||
|
- 抽中的帧显示导丝分割叠加结果,未抽中的帧保留原始画面。
|
||||||
|
- 拖动任一视频时,另一个视频跳到相同时间点;下方帧卡片仍表示已分割的关键帧。
|
||||||
Reference in New Issue
Block a user