2026-05-18-19-56-47 重构双视频同步与单帧对比
This commit is contained in:
157
backend/main.py
157
backend/main.py
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -101,6 +102,9 @@ def _save_frame_outputs(
|
|||||||
method: str,
|
method: str,
|
||||||
frame,
|
frame,
|
||||||
output,
|
output,
|
||||||
|
source_time: float | None = None,
|
||||||
|
result_time: float | None = None,
|
||||||
|
result_index: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
method_path = job_path / method
|
method_path = job_path / method
|
||||||
method_path.mkdir(exist_ok=True)
|
method_path.mkdir(exist_ok=True)
|
||||||
@@ -110,7 +114,7 @@ def _save_frame_outputs(
|
|||||||
cv2.imwrite(str(original_path), frame)
|
cv2.imwrite(str(original_path), frame)
|
||||||
cv2.imwrite(str(mask_path), output.mask)
|
cv2.imwrite(str(mask_path), output.mask)
|
||||||
cv2.imwrite(str(overlay_path), output.overlay)
|
cv2.imwrite(str(overlay_path), output.overlay)
|
||||||
return {
|
payload = {
|
||||||
"frame_index": frame_index,
|
"frame_index": frame_index,
|
||||||
"method": method,
|
"method": method,
|
||||||
"original_url": _public(original_path),
|
"original_url": _public(original_path),
|
||||||
@@ -118,6 +122,44 @@ def _save_frame_outputs(
|
|||||||
"overlay_url": _public(overlay_path),
|
"overlay_url": _public(overlay_path),
|
||||||
"metrics": output.metrics,
|
"metrics": output.metrics,
|
||||||
}
|
}
|
||||||
|
if source_time is not None:
|
||||||
|
payload["source_time"] = round(float(source_time), 4)
|
||||||
|
if result_time is not None:
|
||||||
|
payload["result_time"] = round(float(result_time), 4)
|
||||||
|
if result_index is not None:
|
||||||
|
payload["result_index"] = int(result_index)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _browser_video(raw_path: Path, final_path: Path) -> Path:
|
||||||
|
ffmpeg = shutil.which("ffmpeg")
|
||||||
|
if ffmpeg:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
ffmpeg,
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
str(raw_path),
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
"-preset",
|
||||||
|
"veryfast",
|
||||||
|
"-crf",
|
||||||
|
"20",
|
||||||
|
str(final_path),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
raw_path.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
raw_path.replace(final_path)
|
||||||
|
return final_path
|
||||||
|
|
||||||
|
|
||||||
def _process_image(job_path: Path, source: Path, method: str, sensitivity: float) -> dict[str, Any]:
|
def _process_image(job_path: Path, source: Path, method: str, sensitivity: float) -> dict[str, Any]:
|
||||||
@@ -126,13 +168,13 @@ def _process_image(job_path: Path, source: Path, method: str, sensitivity: float
|
|||||||
raise HTTPException(status_code=400, detail="无法读取图片")
|
raise HTTPException(status_code=400, detail="无法读取图片")
|
||||||
if method == "compare":
|
if method == "compare":
|
||||||
results = [
|
results = [
|
||||||
_save_frame_outputs(job_path, 0, item.method, frame, item)
|
_save_frame_outputs(job_path, 0, item.method, frame, item, 0.0, 0.0, 0)
|
||||||
for item in compare_frame(frame, None, sensitivity)
|
for item in compare_frame(frame, None, sensitivity)
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
output = segment_frame(frame, method, None, sensitivity)
|
output = segment_frame(frame, method, None, sensitivity)
|
||||||
results = [_save_frame_outputs(job_path, 0, method, frame, output)]
|
results = [_save_frame_outputs(job_path, 0, method, frame, output, 0.0, 0.0, 0)]
|
||||||
return {"kind": "image", "frames": results, "video_url": None}
|
return {"kind": "image", "frames": results, "video_url": None, "source_fps": 1.0, "result_fps": 1.0, "duration": 0.0}
|
||||||
|
|
||||||
|
|
||||||
def _selected_frame(index: int, stride: int, selected_count: int, max_frames: int) -> bool:
|
def _selected_frame(index: int, stride: int, selected_count: int, max_frames: int) -> bool:
|
||||||
@@ -156,7 +198,14 @@ def _process_video(
|
|||||||
frame_index = 0
|
frame_index = 0
|
||||||
selected_count = 0
|
selected_count = 0
|
||||||
writer = None
|
writer = None
|
||||||
|
result_fps = 8.0
|
||||||
|
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)
|
||||||
|
if source_fps <= 0:
|
||||||
|
source_fps = 12.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
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -171,16 +220,38 @@ def _process_video(
|
|||||||
if method == "compare":
|
if method == "compare":
|
||||||
outputs = compare_frame(frame, previous, sensitivity)
|
outputs = compare_frame(frame, previous, sensitivity)
|
||||||
for output in outputs:
|
for output in outputs:
|
||||||
frames.append(_save_frame_outputs(job_path, frame_index, output.method, frame, output))
|
frames.append(
|
||||||
|
_save_frame_outputs(
|
||||||
|
job_path,
|
||||||
|
frame_index,
|
||||||
|
output.method,
|
||||||
|
frame,
|
||||||
|
output,
|
||||||
|
frame_index / source_fps,
|
||||||
|
selected_count / result_fps,
|
||||||
|
selected_count,
|
||||||
|
)
|
||||||
|
)
|
||||||
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:
|
else:
|
||||||
video_output = segment_frame(frame, method, previous, sensitivity)
|
video_output = segment_frame(frame, method, previous, sensitivity)
|
||||||
frames.append(_save_frame_outputs(job_path, frame_index, method, frame, video_output))
|
frames.append(
|
||||||
|
_save_frame_outputs(
|
||||||
|
job_path,
|
||||||
|
frame_index,
|
||||||
|
method,
|
||||||
|
frame,
|
||||||
|
video_output,
|
||||||
|
frame_index / source_fps,
|
||||||
|
selected_count / result_fps,
|
||||||
|
selected_count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if writer is None:
|
if writer is None:
|
||||||
height, width = video_output.overlay.shape[:2]
|
height, width = video_output.overlay.shape[:2]
|
||||||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||||
writer = cv2.VideoWriter(str(video_path), fourcc, 8.0, (width, height))
|
writer = cv2.VideoWriter(str(raw_video_path), fourcc, result_fps, (width, height))
|
||||||
writer.write(video_output.overlay)
|
writer.write(video_output.overlay)
|
||||||
|
|
||||||
selected_count += 1
|
selected_count += 1
|
||||||
@@ -193,7 +264,39 @@ def _process_video(
|
|||||||
|
|
||||||
if not frames:
|
if not frames:
|
||||||
raise HTTPException(status_code=400, detail="视频没有可处理帧")
|
raise HTTPException(status_code=400, detail="视频没有可处理帧")
|
||||||
return {"kind": "video", "frames": frames, "video_url": _public(video_path) if video_path.exists() else None}
|
if raw_video_path.exists():
|
||||||
|
_browser_video(raw_video_path, video_path)
|
||||||
|
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,
|
||||||
|
"duration": duration,
|
||||||
|
"result_duration": round(len({frame["result_index"] for frame in frames}) / result_fps, 4) if frames else 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_video_frame(source: Path, frame_index: int) -> tuple[Any, Any | None, float]:
|
||||||
|
capture = cv2.VideoCapture(str(source))
|
||||||
|
if not capture.isOpened():
|
||||||
|
raise HTTPException(status_code=400, detail="无法读取视频")
|
||||||
|
source_fps = float(capture.get(cv2.CAP_PROP_FPS) or 0.0)
|
||||||
|
if source_fps <= 0:
|
||||||
|
source_fps = 12.0
|
||||||
|
frame_index = max(0, int(frame_index))
|
||||||
|
previous = None
|
||||||
|
if frame_index > 0:
|
||||||
|
capture.set(cv2.CAP_PROP_POS_FRAMES, frame_index - 1)
|
||||||
|
ok_prev, previous = capture.read()
|
||||||
|
if not ok_prev:
|
||||||
|
previous = None
|
||||||
|
capture.set(cv2.CAP_PROP_POS_FRAMES, frame_index)
|
||||||
|
ok, frame = capture.read()
|
||||||
|
capture.release()
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=400, detail="无法读取指定帧")
|
||||||
|
return frame, previous, source_fps
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/segment")
|
@app.post("/api/segment")
|
||||||
@@ -231,6 +334,44 @@ def segment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/compare-frame")
|
||||||
|
def compare_single_frame(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
frame_index: int = Form(0),
|
||||||
|
sensitivity: float = Form(0.56),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
ensure_dirs()
|
||||||
|
sensitivity = max(0.05, min(float(sensitivity), 0.95))
|
||||||
|
job_id = uuid.uuid4().hex[:12]
|
||||||
|
job_path = JOB_DIR / job_id
|
||||||
|
job_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
source = _save_upload(file, job_path)
|
||||||
|
suffix = source.suffix.lower()
|
||||||
|
if suffix in IMAGE_SUFFIXES:
|
||||||
|
frame = cv2.imread(str(source), cv2.IMREAD_COLOR)
|
||||||
|
previous = None
|
||||||
|
source_time = 0.0
|
||||||
|
elif suffix in VIDEO_SUFFIXES:
|
||||||
|
frame, previous, source_fps = _read_video_frame(source, frame_index)
|
||||||
|
source_time = int(frame_index) / source_fps
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="不支持的文件类型")
|
||||||
|
if frame is None:
|
||||||
|
raise HTTPException(status_code=400, detail="无法读取文件")
|
||||||
|
outputs = compare_frame(frame, previous, sensitivity)
|
||||||
|
frames = [
|
||||||
|
_save_frame_outputs(job_path, int(frame_index), output.method, frame, output, source_time, 0.0, 0)
|
||||||
|
for output in outputs
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"job_id": job_id,
|
||||||
|
"kind": "compare",
|
||||||
|
"frame_index": int(frame_index),
|
||||||
|
"source_time": round(float(source_time), 4),
|
||||||
|
"frames": frames,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def index() -> FileResponse:
|
def index() -> FileResponse:
|
||||||
return FileResponse(FRONTEND_DIR / "index.html")
|
return FileResponse(FRONTEND_DIR / "index.html")
|
||||||
|
|||||||
261
frontend/app.js
261
frontend/app.js
@@ -18,6 +18,9 @@ const videoPreview = document.querySelector("#videoPreview");
|
|||||||
const imagePreview = document.querySelector("#imagePreview");
|
const imagePreview = document.querySelector("#imagePreview");
|
||||||
const progressWrap = document.querySelector("#progressWrap");
|
const progressWrap = document.querySelector("#progressWrap");
|
||||||
const progressText = document.querySelector("#progressText");
|
const progressText = document.querySelector("#progressText");
|
||||||
|
const progressBar = document.querySelector("#progressBar");
|
||||||
|
const resultVideoPreview = document.querySelector("#resultVideoPreview");
|
||||||
|
const resultVideoEmpty = document.querySelector("#resultVideoEmpty");
|
||||||
const summaryStrip = document.querySelector("#summaryStrip");
|
const summaryStrip = document.querySelector("#summaryStrip");
|
||||||
const summaryJob = document.querySelector("#summaryJob");
|
const summaryJob = document.querySelector("#summaryJob");
|
||||||
const summaryFrames = document.querySelector("#summaryFrames");
|
const summaryFrames = document.querySelector("#summaryFrames");
|
||||||
@@ -34,23 +37,67 @@ const closeSourceDialog = document.querySelector("#closeSourceDialog");
|
|||||||
const sourceTitle = document.querySelector("#sourceTitle");
|
const sourceTitle = document.querySelector("#sourceTitle");
|
||||||
const sourceVideo = document.querySelector("#sourceVideo");
|
const sourceVideo = document.querySelector("#sourceVideo");
|
||||||
const sourceImage = document.querySelector("#sourceImage");
|
const sourceImage = document.querySelector("#sourceImage");
|
||||||
|
const openCompareButton = document.querySelector("#openCompareButton");
|
||||||
|
const controlCompareButton = document.querySelector("#controlCompareButton");
|
||||||
|
const compareDialog = document.querySelector("#compareDialog");
|
||||||
|
const closeCompareDialog = document.querySelector("#closeCompareDialog");
|
||||||
|
const compareGrid = document.querySelector("#compareGrid");
|
||||||
|
const compareTitle = document.querySelector("#compareTitle");
|
||||||
|
|
||||||
const methodLabels = new Map();
|
const methodLabels = new Map();
|
||||||
const methodDescriptions = new Map();
|
const methodDescriptions = new Map();
|
||||||
let selectedFile = null;
|
let selectedFile = null;
|
||||||
let currentObjectUrl = null;
|
let currentObjectUrl = null;
|
||||||
let lastFrames = [];
|
let lastFrames = [];
|
||||||
|
let lastResult = null;
|
||||||
|
let currentFrame = null;
|
||||||
|
let syncLock = false;
|
||||||
|
let progressTimers = [];
|
||||||
|
|
||||||
function setBusy(isBusy, text = "运行分割") {
|
function setBusy(isBusy, text = "运行分割") {
|
||||||
const button = form.querySelector(".primary");
|
const button = form.querySelector(".primary");
|
||||||
button.disabled = isBusy;
|
button.disabled = isBusy;
|
||||||
button.querySelector("span").textContent = isBusy ? "分割中" : text;
|
button.querySelector("span").textContent = isBusy ? "分割中" : text;
|
||||||
progressWrap.hidden = !isBusy;
|
|
||||||
if (isBusy) {
|
if (isBusy) {
|
||||||
progressText.textContent = "正在上传、抽帧并执行导丝分割";
|
progressWrap.hidden = false;
|
||||||
|
setProgress(8, "正在上传、抽帧并执行导丝分割");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setProgress(percent, text) {
|
||||||
|
progressBar.style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
||||||
|
progressText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleProgress() {
|
||||||
|
progressTimers.forEach((timer) => clearTimeout(timer));
|
||||||
|
progressTimers = [
|
||||||
|
setTimeout(() => setProgress(28, "正在读取视频并抽取关键帧"), 400),
|
||||||
|
setTimeout(() => setProgress(58, "正在生成导丝掩膜与叠加视频"), 1400),
|
||||||
|
setTimeout(() => setProgress(82, "正在整理结果帧与指标"), 2800),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishProgress() {
|
||||||
|
progressTimers.forEach((timer) => clearTimeout(timer));
|
||||||
|
progressTimers = [];
|
||||||
|
setProgress(100, "分割完成");
|
||||||
|
setTimeout(() => {
|
||||||
|
progressWrap.hidden = true;
|
||||||
|
progressBar.style.width = "0%";
|
||||||
|
}, 650);
|
||||||
|
}
|
||||||
|
|
||||||
|
function failProgress(message) {
|
||||||
|
progressTimers.forEach((timer) => clearTimeout(timer));
|
||||||
|
progressTimers = [];
|
||||||
|
setProgress(100, message || "分割失败");
|
||||||
|
setTimeout(() => {
|
||||||
|
progressWrap.hidden = true;
|
||||||
|
progressBar.style.width = "0%";
|
||||||
|
}, 900);
|
||||||
|
}
|
||||||
|
|
||||||
function setFile(file) {
|
function setFile(file) {
|
||||||
selectedFile = file;
|
selectedFile = file;
|
||||||
if (typeof DataTransfer !== "undefined") {
|
if (typeof DataTransfer !== "undefined") {
|
||||||
@@ -60,6 +107,7 @@ function setFile(file) {
|
|||||||
}
|
}
|
||||||
fileName.textContent = `${file.name} · ${(file.size / 1024 / 1024).toFixed(2)} MB`;
|
fileName.textContent = `${file.name} · ${(file.size / 1024 / 1024).toFixed(2)} MB`;
|
||||||
renderPreview(file);
|
renderPreview(file);
|
||||||
|
resetResultsForNewInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
function revokePreview() {
|
function revokePreview() {
|
||||||
@@ -125,6 +173,7 @@ async function loadMethods() {
|
|||||||
methodSelect.innerHTML = "";
|
methodSelect.innerHTML = "";
|
||||||
methodGrid.innerHTML = "";
|
methodGrid.innerHTML = "";
|
||||||
Object.entries(data.methods).forEach(([key, value]) => {
|
Object.entries(data.methods).forEach(([key, value]) => {
|
||||||
|
if (key === "compare") return;
|
||||||
methodLabels.set(key, value.label);
|
methodLabels.set(key, value.label);
|
||||||
methodDescriptions.set(key, value.description);
|
methodDescriptions.set(key, value.description);
|
||||||
|
|
||||||
@@ -154,7 +203,22 @@ function updateSummary(data) {
|
|||||||
summarySkeleton.textContent = Math.round(skeleton);
|
summarySkeleton.textContent = Math.round(skeleton);
|
||||||
summaryStrip.hidden = false;
|
summaryStrip.hidden = false;
|
||||||
resultCount.textContent = `${frames.length} 个结果`;
|
resultCount.textContent = `${frames.length} 个结果`;
|
||||||
jobMeta.textContent = `${data.kind === "video" ? "视频" : "图像"} · ${methodLabels.get(data.method) || data.method}`;
|
}
|
||||||
|
|
||||||
|
function resetResultsForNewInput() {
|
||||||
|
lastFrames = [];
|
||||||
|
lastResult = null;
|
||||||
|
currentFrame = null;
|
||||||
|
resultGrid.innerHTML = "";
|
||||||
|
resultVideoPreview.hidden = true;
|
||||||
|
resultVideoPreview.removeAttribute("src");
|
||||||
|
resultVideoEmpty.hidden = false;
|
||||||
|
emptyState.hidden = false;
|
||||||
|
emptyState.textContent = "已加载输入。点击左侧“运行分割”生成导丝掩膜和叠加结果。";
|
||||||
|
summaryStrip.hidden = true;
|
||||||
|
videoLink.hidden = true;
|
||||||
|
resultCount.textContent = "0 个结果";
|
||||||
|
setCompareEnabled(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDetail(frame) {
|
function openDetail(frame) {
|
||||||
@@ -174,6 +238,7 @@ function openDetail(frame) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderResults(data) {
|
function renderResults(data) {
|
||||||
|
lastResult = data;
|
||||||
lastFrames = data.frames || [];
|
lastFrames = data.frames || [];
|
||||||
resultGrid.innerHTML = "";
|
resultGrid.innerHTML = "";
|
||||||
emptyState.hidden = lastFrames.length > 0;
|
emptyState.hidden = lastFrames.length > 0;
|
||||||
@@ -181,10 +246,19 @@ function renderResults(data) {
|
|||||||
if (data.video_url) {
|
if (data.video_url) {
|
||||||
videoLink.href = data.video_url;
|
videoLink.href = data.video_url;
|
||||||
videoLink.setAttribute("download", "");
|
videoLink.setAttribute("download", "");
|
||||||
|
resultVideoPreview.preload = "metadata";
|
||||||
|
resultVideoPreview.src = data.video_url;
|
||||||
|
resultVideoPreview.load();
|
||||||
|
resultVideoPreview.hidden = false;
|
||||||
|
resultVideoEmpty.hidden = true;
|
||||||
|
} else {
|
||||||
|
resultVideoPreview.hidden = true;
|
||||||
|
resultVideoPreview.removeAttribute("src");
|
||||||
|
resultVideoEmpty.hidden = false;
|
||||||
}
|
}
|
||||||
updateSummary(data);
|
updateSummary(data);
|
||||||
|
|
||||||
lastFrames.forEach((frame) => {
|
lastFrames.forEach((frame, index) => {
|
||||||
const node = template.content.firstElementChild.cloneNode(true);
|
const node = template.content.firstElementChild.cloneNode(true);
|
||||||
node.querySelector(".method").textContent = methodLabels.get(frame.method) || frame.method;
|
node.querySelector(".method").textContent = methodLabels.get(frame.method) || frame.method;
|
||||||
node.querySelector(".frame-index").textContent = `帧 ${frame.frame_index}`;
|
node.querySelector(".frame-index").textContent = `帧 ${frame.frame_index}`;
|
||||||
@@ -192,12 +266,16 @@ function renderResults(data) {
|
|||||||
node.querySelector(".coverage").textContent = `${(frame.metrics.coverage * 100).toFixed(3)}%`;
|
node.querySelector(".coverage").textContent = `${(frame.metrics.coverage * 100).toFixed(3)}%`;
|
||||||
node.querySelector(".skeleton").textContent = frame.metrics.skeleton_length;
|
node.querySelector(".skeleton").textContent = frame.metrics.skeleton_length;
|
||||||
node.querySelector(".components").textContent = frame.metrics.components;
|
node.querySelector(".components").textContent = frame.metrics.components;
|
||||||
node.addEventListener("click", () => openDetail(frame));
|
node.dataset.frameIndex = frame.frame_index;
|
||||||
|
node.addEventListener("click", () => selectFrame(frame, index));
|
||||||
node.addEventListener("keydown", (event) => {
|
node.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Enter") openDetail(frame);
|
if (event.key === "Enter") selectFrame(frame, index);
|
||||||
});
|
});
|
||||||
resultGrid.appendChild(node);
|
resultGrid.appendChild(node);
|
||||||
});
|
});
|
||||||
|
if (lastFrames.length) {
|
||||||
|
selectFrame(lastFrames[0], 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSample() {
|
async function loadSample() {
|
||||||
@@ -240,7 +318,11 @@ function clearAll() {
|
|||||||
sourceImage.hidden = true;
|
sourceImage.hidden = true;
|
||||||
sourceVideo.removeAttribute("src");
|
sourceVideo.removeAttribute("src");
|
||||||
sourceImage.removeAttribute("src");
|
sourceImage.removeAttribute("src");
|
||||||
|
resultVideoPreview.hidden = true;
|
||||||
|
resultVideoPreview.removeAttribute("src");
|
||||||
|
resultVideoEmpty.hidden = false;
|
||||||
openSourceButton.hidden = true;
|
openSourceButton.hidden = true;
|
||||||
|
setCompareEnabled(false);
|
||||||
sourcePaneTitle.textContent = "查看原始视频";
|
sourcePaneTitle.textContent = "查看原始视频";
|
||||||
if (sourceDialog.open) sourceDialog.close();
|
if (sourceDialog.open) sourceDialog.close();
|
||||||
previewEmpty.hidden = false;
|
previewEmpty.hidden = false;
|
||||||
@@ -250,6 +332,146 @@ function clearAll() {
|
|||||||
videoLink.hidden = true;
|
videoLink.hidden = true;
|
||||||
resultCount.textContent = "0 个结果";
|
resultCount.textContent = "0 个结果";
|
||||||
jobMeta.textContent = "等待输入";
|
jobMeta.textContent = "等待输入";
|
||||||
|
lastFrames = [];
|
||||||
|
lastResult = null;
|
||||||
|
currentFrame = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCompareEnabled(enabled) {
|
||||||
|
openCompareButton.disabled = !enabled;
|
||||||
|
controlCompareButton.disabled = !enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFrame(frame, index = 0) {
|
||||||
|
currentFrame = frame;
|
||||||
|
[...resultGrid.querySelectorAll(".result-card")].forEach((node) => {
|
||||||
|
node.classList.toggle("is-selected", Number(node.dataset.frameIndex) === Number(frame.frame_index));
|
||||||
|
});
|
||||||
|
syncLock = true;
|
||||||
|
seekMedia(videoPreview, frame.source_time);
|
||||||
|
seekMedia(resultVideoPreview, frame.result_time);
|
||||||
|
syncLock = false;
|
||||||
|
setCompareEnabled(Boolean(selectedFile && frame));
|
||||||
|
resultCount.textContent = `${lastFrames.length} 个结果 · 当前帧 ${frame.frame_index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekMedia(media, time) {
|
||||||
|
if (!media || media.hidden || !Number.isFinite(time)) return;
|
||||||
|
if (Math.abs(media.currentTime - time) > 0.04) {
|
||||||
|
media.currentTime = Math.max(0, time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestFrameBy(source, time) {
|
||||||
|
if (!lastFrames.length) return null;
|
||||||
|
const key = source === "result" ? "result_time" : "source_time";
|
||||||
|
return lastFrames.reduce((best, frame) => {
|
||||||
|
const bestValue = Number.isFinite(best[key]) ? best[key] : 0;
|
||||||
|
const value = Number.isFinite(frame[key]) ? frame[key] : 0;
|
||||||
|
const bestDistance = Math.abs(bestValue - time);
|
||||||
|
const distance = Math.abs(value - time);
|
||||||
|
return distance < bestDistance ? frame : best;
|
||||||
|
}, lastFrames[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncVideos(source, time) {
|
||||||
|
if (syncLock || !lastFrames.length) return;
|
||||||
|
const frame = nearestFrameBy(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 === "result" && !videoPreview.hidden && Number.isFinite(frame.source_time)) {
|
||||||
|
seekMedia(videoPreview, frame.source_time);
|
||||||
|
}
|
||||||
|
syncLock = false;
|
||||||
|
[...resultGrid.querySelectorAll(".result-card")].forEach((node) => {
|
||||||
|
node.classList.toggle("is-selected", Number(node.dataset.frameIndex) === Number(frame.frame_index));
|
||||||
|
});
|
||||||
|
setCompareEnabled(Boolean(selectedFile && frame));
|
||||||
|
resultCount.textContent = `${lastFrames.length} 个结果 · 当前帧 ${frame.frame_index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMetricBlock(frame) {
|
||||||
|
const metrics = document.createElement("dl");
|
||||||
|
metrics.className = "metrics";
|
||||||
|
[
|
||||||
|
["覆盖率", `${(frame.metrics.coverage * 100).toFixed(3)}%`],
|
||||||
|
["骨架", frame.metrics.skeleton_length],
|
||||||
|
["连通域", frame.metrics.components],
|
||||||
|
].forEach(([label, value]) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
const dt = document.createElement("dt");
|
||||||
|
const dd = document.createElement("dd");
|
||||||
|
dt.textContent = label;
|
||||||
|
dd.textContent = value;
|
||||||
|
item.append(dt, dd);
|
||||||
|
metrics.appendChild(item);
|
||||||
|
});
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCompareFrames(frames) {
|
||||||
|
compareGrid.innerHTML = "";
|
||||||
|
frames.forEach((frame) => {
|
||||||
|
const card = document.createElement("article");
|
||||||
|
card.className = "compare-card";
|
||||||
|
|
||||||
|
const top = document.createElement("div");
|
||||||
|
top.className = "card-top";
|
||||||
|
const method = document.createElement("span");
|
||||||
|
method.className = "method";
|
||||||
|
method.textContent = methodLabels.get(frame.method) || frame.method;
|
||||||
|
const index = document.createElement("span");
|
||||||
|
index.className = "frame-index";
|
||||||
|
index.textContent = `帧 ${frame.frame_index}`;
|
||||||
|
top.append(method, index);
|
||||||
|
|
||||||
|
const figure = document.createElement("figure");
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.src = frame.overlay_url;
|
||||||
|
image.alt = `${method.textContent} 叠加结果`;
|
||||||
|
const caption = document.createElement("figcaption");
|
||||||
|
caption.textContent = "当前帧叠加视图";
|
||||||
|
figure.append(image, caption);
|
||||||
|
|
||||||
|
card.append(top, figure, createMetricBlock(frame));
|
||||||
|
compareGrid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCompareForCurrentFrame() {
|
||||||
|
if (!selectedFile || !currentFrame) return;
|
||||||
|
setCompareEnabled(false);
|
||||||
|
compareTitle.textContent = `当前帧 ${currentFrame.frame_index} 多方法对比`;
|
||||||
|
compareGrid.innerHTML = '<div class="compare-loading">正在生成当前帧多方法对比。</div>';
|
||||||
|
if (!compareDialog.open) compareDialog.showModal();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = new FormData();
|
||||||
|
payload.set("file", selectedFile);
|
||||||
|
payload.set("frame_index", currentFrame.frame_index);
|
||||||
|
payload.set("sensitivity", sensitivity.value);
|
||||||
|
const response = await fetch("/api/compare-frame", {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.detail || "多方法对比失败");
|
||||||
|
}
|
||||||
|
renderCompareFrames(data.frames || []);
|
||||||
|
} catch (error) {
|
||||||
|
compareGrid.innerHTML = "";
|
||||||
|
const message = document.createElement("div");
|
||||||
|
message.className = "compare-loading";
|
||||||
|
message.textContent = error.message || "多方法对比失败";
|
||||||
|
compareGrid.appendChild(message);
|
||||||
|
} finally {
|
||||||
|
setCompareEnabled(Boolean(selectedFile && currentFrame));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sensitivity.addEventListener("input", () => {
|
sensitivity.addEventListener("input", () => {
|
||||||
@@ -284,6 +506,25 @@ sampleButton.addEventListener("click", loadSample);
|
|||||||
clearButton.addEventListener("click", clearAll);
|
clearButton.addEventListener("click", clearAll);
|
||||||
closeDialog.addEventListener("click", () => detailDialog.close());
|
closeDialog.addEventListener("click", () => detailDialog.close());
|
||||||
closeSourceDialog.addEventListener("click", () => sourceDialog.close());
|
closeSourceDialog.addEventListener("click", () => sourceDialog.close());
|
||||||
|
closeCompareDialog.addEventListener("click", () => compareDialog.close());
|
||||||
|
openCompareButton.addEventListener("click", openCompareForCurrentFrame);
|
||||||
|
controlCompareButton.addEventListener("click", openCompareForCurrentFrame);
|
||||||
|
|
||||||
|
videoPreview.addEventListener("seeked", () => syncVideos("source", videoPreview.currentTime));
|
||||||
|
resultVideoPreview.addEventListener("seeked", () => syncVideos("result", resultVideoPreview.currentTime));
|
||||||
|
videoPreview.addEventListener("play", () => {
|
||||||
|
if (!syncLock && !resultVideoPreview.hidden) resultVideoPreview.play().catch(() => {});
|
||||||
|
});
|
||||||
|
resultVideoPreview.addEventListener("play", () => {
|
||||||
|
if (!syncLock && !videoPreview.hidden) videoPreview.play().catch(() => {});
|
||||||
|
});
|
||||||
|
videoPreview.addEventListener("pause", () => {
|
||||||
|
if (!syncLock && !resultVideoPreview.hidden) resultVideoPreview.pause();
|
||||||
|
});
|
||||||
|
resultVideoPreview.addEventListener("pause", () => {
|
||||||
|
if (!syncLock && !videoPreview.hidden) videoPreview.pause();
|
||||||
|
});
|
||||||
|
|
||||||
openSourceButton.addEventListener("click", () => {
|
openSourceButton.addEventListener("click", () => {
|
||||||
if (!selectedFile || !currentObjectUrl) return;
|
if (!selectedFile || !currentObjectUrl) return;
|
||||||
sourceVideo.hidden = true;
|
sourceVideo.hidden = true;
|
||||||
@@ -315,9 +556,15 @@ form.addEventListener("submit", async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
scheduleProgress();
|
||||||
|
setCompareEnabled(false);
|
||||||
|
currentFrame = null;
|
||||||
emptyState.hidden = false;
|
emptyState.hidden = false;
|
||||||
emptyState.textContent = "正在抽帧和分割,请稍候。";
|
emptyState.textContent = "正在抽帧和分割,请稍候。";
|
||||||
resultGrid.innerHTML = "";
|
resultGrid.innerHTML = "";
|
||||||
|
resultVideoPreview.hidden = true;
|
||||||
|
resultVideoPreview.removeAttribute("src");
|
||||||
|
resultVideoEmpty.hidden = false;
|
||||||
videoLink.hidden = true;
|
videoLink.hidden = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -332,9 +579,11 @@ form.addEventListener("submit", async (event) => {
|
|||||||
throw new Error(data.detail || "分割失败");
|
throw new Error(data.detail || "分割失败");
|
||||||
}
|
}
|
||||||
renderResults(data);
|
renderResults(data);
|
||||||
|
finishProgress();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emptyState.hidden = false;
|
emptyState.hidden = false;
|
||||||
emptyState.textContent = error.message;
|
emptyState.textContent = error.message;
|
||||||
|
failProgress(error.message || "分割失败");
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,9 @@
|
|||||||
<button class="primary" type="submit">
|
<button class="primary" type="submit">
|
||||||
<span>运行分割</span>
|
<span>运行分割</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="ghost compare-entry" id="controlCompareButton" type="button" disabled>
|
||||||
|
多方法对比
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section class="viewer">
|
<section class="viewer">
|
||||||
@@ -104,9 +107,9 @@
|
|||||||
<div class="pane-head">
|
<div class="pane-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Live Workspace</p>
|
<p class="eyebrow">Live Workspace</p>
|
||||||
<h2>预览与结果</h2>
|
<h2>预览与结果视频</h2>
|
||||||
</div>
|
</div>
|
||||||
<span id="resultCount">0 个结果</span>
|
<button class="ghost media-button" id="openCompareButton" type="button" disabled>多方法对比</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="progress-wrap" id="progressWrap" hidden>
|
<div class="progress-wrap" id="progressWrap" hidden>
|
||||||
@@ -114,6 +117,23 @@
|
|||||||
<p id="progressText">准备任务</p>
|
<p id="progressText">准备任务</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="result-video-stage">
|
||||||
|
<div class="preview-empty" id="resultVideoEmpty">运行分割后,这里会显示叠加结果视频。</div>
|
||||||
|
<video id="resultVideoPreview" controls muted hidden></video>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="workspace-pane frame-browser">
|
||||||
|
<div class="pane-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Frame Review</p>
|
||||||
|
<h2>预览与结果查看</h2>
|
||||||
|
</div>
|
||||||
|
<span id="resultCount">0 个结果</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="frame-browser-body">
|
||||||
<div class="summary-strip" id="summaryStrip" hidden>
|
<div class="summary-strip" id="summaryStrip" hidden>
|
||||||
<div><span>任务</span><strong id="summaryJob">-</strong></div>
|
<div><span>任务</span><strong id="summaryJob">-</strong></div>
|
||||||
<div><span>帧数</span><strong id="summaryFrames">-</strong></div>
|
<div><span>帧数</span><strong id="summaryFrames">-</strong></div>
|
||||||
@@ -123,8 +143,8 @@
|
|||||||
|
|
||||||
<div class="empty" id="emptyState">运行分割后,这里会显示原帧、叠加图、掩膜和指标。</div>
|
<div class="empty" id="emptyState">运行分割后,这里会显示原帧、叠加图、掩膜和指标。</div>
|
||||||
<div class="result-grid" id="resultGrid"></div>
|
<div class="result-grid" id="resultGrid"></div>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -168,6 +188,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog class="detail-dialog compare-dialog" id="compareDialog">
|
||||||
|
<div class="dialog-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Method Compare</p>
|
||||||
|
<h2 id="compareTitle">当前帧多方法对比</h2>
|
||||||
|
</div>
|
||||||
|
<button class="icon-button" id="closeCompareDialog" type="button" aria-label="关闭多方法对比">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="compare-grid" id="compareGrid"></div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<template id="resultCardTemplate">
|
<template id="resultCardTemplate">
|
||||||
<article class="result-card" tabindex="0">
|
<article class="result-card" tabindex="0">
|
||||||
<div class="card-top">
|
<div class="card-top">
|
||||||
|
|||||||
@@ -264,7 +264,14 @@ h3 {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost:hover,
|
.ghost:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: var(--faint);
|
||||||
|
border-color: var(--line-soft);
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost:not(:disabled):hover,
|
||||||
.method-option:hover,
|
.method-option:hover,
|
||||||
.result-card:hover {
|
.result-card:hover {
|
||||||
border-color: rgba(255, 209, 102, 0.62);
|
border-color: rgba(255, 209, 102, 0.62);
|
||||||
@@ -284,6 +291,18 @@ h3 {
|
|||||||
filter: grayscale(0.45);
|
filter: grayscale(0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compare-entry {
|
||||||
|
min-height: 46px;
|
||||||
|
border-color: rgba(56, 216, 184, 0.5);
|
||||||
|
color: var(--accent-2);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-entry:disabled {
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
.method-grid {
|
.method-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -419,6 +438,20 @@ input[type="range"] {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-video-stage {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 590px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(56, 216, 184, 0.035) 1px, transparent 1px),
|
||||||
|
linear-gradient(rgba(56, 216, 184, 0.035) 1px, transparent 1px),
|
||||||
|
#080a09;
|
||||||
|
background-size: 28px 28px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-empty,
|
.preview-empty,
|
||||||
.empty {
|
.empty {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -427,7 +460,8 @@ input[type="range"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#videoPreview,
|
#videoPreview,
|
||||||
#imagePreview {
|
#imagePreview,
|
||||||
|
#resultVideoPreview {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 590px;
|
max-height: 590px;
|
||||||
@@ -452,11 +486,11 @@ input[type="range"] {
|
|||||||
|
|
||||||
.progress-line span {
|
.progress-line span {
|
||||||
display: block;
|
display: block;
|
||||||
width: 12%;
|
width: 0%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
background: linear-gradient(90deg, var(--accent-2), var(--accent));
|
background: linear-gradient(90deg, var(--accent-2), var(--accent));
|
||||||
animation: pulse-progress 1.4s ease-in-out infinite alternate;
|
transition: width 320ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-wrap p {
|
.progress-wrap p {
|
||||||
@@ -464,15 +498,6 @@ input[type="range"] {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-progress {
|
|
||||||
from {
|
|
||||||
width: 18%;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
width: 92%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-strip {
|
.summary-strip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
@@ -515,7 +540,7 @@ input[type="range"] {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
max-height: 590px;
|
max-height: 520px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,6 +557,11 @@ input[type="range"] {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-card.is-selected {
|
||||||
|
border-color: rgba(56, 216, 184, 0.88);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(56, 216, 184, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.card-top {
|
.card-top {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
@@ -635,6 +665,62 @@ dd {
|
|||||||
width: min(1180px, calc(100vw - 36px));
|
width: min(1180px, calc(100vw - 36px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.frame-browser {
|
||||||
|
min-height: auto;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-browser-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-browser .summary-strip {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-browser .empty {
|
||||||
|
min-height: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-dialog {
|
||||||
|
width: min(1320px, calc(100vw - 36px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
max-height: min(72vh, 760px);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #111512;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-card img {
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-loading {
|
||||||
|
min-height: 220px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(16, 20, 17, 0.72);
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.source-stage {
|
.source-stage {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -683,6 +769,10 @@ dd {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.frame-browser .summary-strip {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.viewer-split {
|
.viewer-split {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -692,6 +782,7 @@ dd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-stage,
|
.preview-stage,
|
||||||
|
.result-video-stage,
|
||||||
.empty {
|
.empty {
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ def test_health_and_methods():
|
|||||||
samples = client.get("/api/samples")
|
samples = client.get("/api/samples")
|
||||||
assert samples.status_code == 200
|
assert samples.status_code == 200
|
||||||
assert "samples" in samples.json()
|
assert "samples" in samples.json()
|
||||||
|
if samples.json()["samples"]:
|
||||||
|
sample = samples.json()["samples"][0]
|
||||||
|
assert "version" in sample
|
||||||
|
assert "?v=" in sample["url"]
|
||||||
|
|
||||||
|
|
||||||
def test_segment_image(tmp_path: Path):
|
def test_segment_image(tmp_path: Path):
|
||||||
@@ -36,3 +40,47 @@ def test_segment_image(tmp_path: Path):
|
|||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert payload["kind"] == "image"
|
assert payload["kind"] == "image"
|
||||||
assert payload["frames"][0]["metrics"]["mask_pixels"] > 0
|
assert payload["frames"][0]["metrics"]["mask_pixels"] > 0
|
||||||
|
assert payload["frames"][0]["source_time"] == 0.0
|
||||||
|
assert payload["frames"][0]["result_time"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_video_and_compare_frame(tmp_path: Path):
|
||||||
|
video_path = tmp_path / "sample.mp4"
|
||||||
|
writer = cv2.VideoWriter(str(video_path), cv2.VideoWriter_fourcc(*"mp4v"), 12.0, (320, 220))
|
||||||
|
for index in range(18):
|
||||||
|
writer.write(make_frame(index, 320, 220))
|
||||||
|
writer.release()
|
||||||
|
|
||||||
|
with video_path.open("rb") as handle:
|
||||||
|
response = client.post(
|
||||||
|
"/api/segment",
|
||||||
|
files={"file": ("sample.mp4", handle, "video/mp4")},
|
||||||
|
data={"method": "fusion", "sensitivity": "0.58", "frame_stride": "6", "max_frames": "3"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["kind"] == "video"
|
||||||
|
assert payload["video_url"].endswith(".mp4")
|
||||||
|
assert payload["source_fps"] > 0
|
||||||
|
assert payload["result_fps"] == 8.0
|
||||||
|
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
|
||||||
|
|
||||||
|
with video_path.open("rb") as handle:
|
||||||
|
compare = client.post(
|
||||||
|
"/api/compare-frame",
|
||||||
|
files={"file": ("sample.mp4", handle, "video/mp4")},
|
||||||
|
data={"frame_index": str(payload["frames"][1]["frame_index"]), "sensitivity": "0.58"},
|
||||||
|
)
|
||||||
|
assert compare.status_code == 200
|
||||||
|
compare_payload = compare.json()
|
||||||
|
assert compare_payload["kind"] == "compare"
|
||||||
|
assert {frame["method"] for frame in compare_payload["frames"]} == {
|
||||||
|
"hessian_ridge",
|
||||||
|
"edge_morphology",
|
||||||
|
"temporal_difference",
|
||||||
|
"fusion",
|
||||||
|
}
|
||||||
|
|||||||
48
工程分析/实现方案-2026-05-18-19-56-47.md
Normal file
48
工程分析/实现方案-2026-05-18-19-56-47.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 实现方案
|
||||||
|
|
||||||
|
开始时间:2026-05-18-19-56-47
|
||||||
|
|
||||||
|
## 后端改造
|
||||||
|
|
||||||
|
1. 视频分割返回时间映射
|
||||||
|
- 在 `_process_video` 中读取源视频 `fps`、总帧数、时长。
|
||||||
|
- 每个返回帧增加:
|
||||||
|
- `source_time`:源视频中的时间。
|
||||||
|
- `result_time`:结果视频中的时间。
|
||||||
|
- `result_index`:结果视频内部帧序号。
|
||||||
|
- 返回 `source_fps`、`result_fps`、`duration`、`result_duration`。
|
||||||
|
|
||||||
|
2. 新增单帧多方法对比接口
|
||||||
|
- `POST /api/compare-frame`
|
||||||
|
- 参数:文件、`frame_index`、`sensitivity`。
|
||||||
|
- 后端定位到对应帧和上一帧,对该帧运行 `compare_frame`,返回多方法图片结果。
|
||||||
|
|
||||||
|
## 前端改造
|
||||||
|
|
||||||
|
1. 双视频区域
|
||||||
|
- 左侧:原始视频。
|
||||||
|
- 右侧:结果视频。
|
||||||
|
- 右侧标题改为“预览与结果视频”。
|
||||||
|
|
||||||
|
2. 视频同步
|
||||||
|
- 维护 `currentFrame` 和后端返回的时间映射。
|
||||||
|
- 点击下方帧列表时同时 seek 原始视频与结果视频。
|
||||||
|
- 用户 seek/play/pause 任一视频时同步另一个视频,使用锁避免递归触发。
|
||||||
|
|
||||||
|
3. 下方帧列表
|
||||||
|
- 新增“预览与结果查看”区域。
|
||||||
|
- 复用现有结果图片卡片;点击后设置当前帧并同步两个视频。
|
||||||
|
|
||||||
|
4. 多方法对比
|
||||||
|
- 控制面板中移除方法卡片里的“多方法对比”,改为运行按钮下方的独立按钮。
|
||||||
|
- 默认禁用。
|
||||||
|
- 分割完成且当前帧存在时启用。
|
||||||
|
- 点击后调用 `/api/compare-frame`,在弹窗中显示当前帧多方法对比。
|
||||||
|
|
||||||
|
5. 进度条
|
||||||
|
- 移除 CSS 无限动画。
|
||||||
|
- 使用 JS 设置固定阶段进度:准备、上传处理、生成结果、完成。
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
结果视频目前由抽样帧组成,因此同步采用返回的 `source_time` 与 `result_time` 映射,并在用户拖动时选择最近的分割帧。
|
||||||
42
工程分析/测试方案-2026-05-18-19-56-47.md
Normal file
42
工程分析/测试方案-2026-05-18-19-56-47.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 测试方案
|
||||||
|
|
||||||
|
开始时间:2026-05-18-19-56-47
|
||||||
|
|
||||||
|
## 自动化测试
|
||||||
|
|
||||||
|
- `pytest -q`
|
||||||
|
- 更新 API 测试:
|
||||||
|
- `/api/samples` 返回版本化样例。
|
||||||
|
- `/api/segment` 视频结果包含 `source_time` 和 `result_time`。
|
||||||
|
- `/api/compare-frame` 可以返回多方法对比结果。
|
||||||
|
|
||||||
|
## 运行验证
|
||||||
|
|
||||||
|
- `curl -s http://127.0.0.1:8001/api/health`
|
||||||
|
- 使用样例视频调用 `/api/segment`。
|
||||||
|
- 使用样例视频调用 `/api/compare-frame`。
|
||||||
|
- Chrome DevTools Protocol 打开页面、加载样例、运行分割,检查:
|
||||||
|
- 结果视频元素出现。
|
||||||
|
- 帧列表出现。
|
||||||
|
- 多方法对比按钮启用。
|
||||||
|
|
||||||
|
## 手工验证
|
||||||
|
|
||||||
|
1. 打开 `http://192.168.3.11:8001/`。
|
||||||
|
2. 点击“加载样例”。
|
||||||
|
3. 点击“运行分割”。
|
||||||
|
4. 右侧应显示“预览与结果视频”的视频播放器。
|
||||||
|
5. 下方“预览与结果查看”出现结果帧列表。
|
||||||
|
6. 点击任一结果帧,原始视频和结果视频同步跳转。
|
||||||
|
7. 点击“多方法对比”,查看当前帧的多方法结果。
|
||||||
|
|
||||||
|
## 执行结果
|
||||||
|
|
||||||
|
- `python3 -m compileall backend tests`:通过。
|
||||||
|
- `node --check frontend/app.js`:通过。
|
||||||
|
- `pytest -q`:5 passed。
|
||||||
|
- `curl http://127.0.0.1:8001/api/health`:服务返回 `ok`。
|
||||||
|
- `/api/segment` 使用内置样例视频:返回 H.264 结果视频、`source_time`、`result_time`、`result_index`。
|
||||||
|
- `/api/compare-frame` 使用内置样例视频第 24 帧:返回 4 种方法结果。
|
||||||
|
- Chrome headless 页面流程:加载样例、运行分割、结果视频展示、12 个结果帧、多方法对比 4 张卡片均通过。
|
||||||
|
- Chrome headless 双向同步验证:源视频 seek 到 2 秒后结果视频跳到最近处理帧 0.625 秒;结果视频 seek 到 0.375 秒后源视频跳到对应源帧 1.25 秒。
|
||||||
22
工程分析/经验记录.md
22
工程分析/经验记录.md
@@ -117,3 +117,25 @@ B. 产生问题原因:实测 Chrome 自动点击流程可以看到画面,视
|
|||||||
C. 解决问题方案:后端 `/api/samples` 返回带 `mtime_ns` 的版本化 URL;前端加载样例时追加时间戳,并使用 `fetch(..., { cache: "reload" })` 强制绕过旧缓存。
|
C. 解决问题方案:后端 `/api/samples` 返回带 `mtime_ns` 的版本化 URL;前端加载样例时追加时间戳,并使用 `fetch(..., { cache: "reload" })` 强制绕过旧缓存。
|
||||||
|
|
||||||
D. 后续如何避免问题:静态样例、模型文件、前端资源发生兼容性修复时,URL 必须版本化,避免用户浏览器继续使用旧缓存。
|
D. 后续如何避免问题:静态样例、模型文件、前端资源发生兼容性修复时,URL 必须版本化,避免用户浏览器继续使用旧缓存。
|
||||||
|
|
||||||
|
## 2026-05-18-19-56-47 双视频同步与当前帧多方法对比
|
||||||
|
|
||||||
|
### 1. 结果视频与原始视频时间轴不一致
|
||||||
|
|
||||||
|
A. 具体问题:结果视频由抽帧后的叠加帧组成,播放时长和原始视频不同;如果直接用同一个 `currentTime` 同步,会跳到错误画面。
|
||||||
|
|
||||||
|
B. 产生问题原因:后端按 `frame_stride` 抽取关键帧,结果视频用固定 `result_fps` 写出,源视频时间轴与结果视频时间轴天然不等长。
|
||||||
|
|
||||||
|
C. 解决问题方案:后端为每个结果帧返回 `source_time`、`result_time`、`result_index`;前端点击帧卡片或拖动任一视频时,使用最近结果帧的时间映射同步另一个视频。
|
||||||
|
|
||||||
|
D. 后续如何避免问题:凡是结果媒体来自抽样、降帧、裁剪或重编码,都必须在 API 中显式返回源时间与结果时间映射,前端不要假设两个视频时长一致。
|
||||||
|
|
||||||
|
### 2. 进度条循环动画造成任务状态误导
|
||||||
|
|
||||||
|
A. 具体问题:“正在上传、抽帧并执行导丝分割”的进度条一直往复跳动,看起来像任务卡住或没有真实进度。
|
||||||
|
|
||||||
|
B. 产生问题原因:旧样式使用 CSS 无限动画模拟进度,没有和前端请求阶段绑定。
|
||||||
|
|
||||||
|
C. 解决问题方案:移除无限动画,改为 JavaScript 按阶段设置固定进度:上传准备、抽帧、生成掩膜和叠加视频、整理结果、完成或失败。
|
||||||
|
|
||||||
|
D. 后续如何避免问题:耗时任务即使暂时没有后端实时进度,也要用单向推进的阶段式进度条,避免使用反复跳动的加载条表达处理进度。
|
||||||
|
|||||||
23
工程分析/需求分析-2026-05-18-19-56-47.md
Normal file
23
工程分析/需求分析-2026-05-18-19-56-47.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# 需求分析
|
||||||
|
|
||||||
|
开始时间:2026-05-18-19-56-47
|
||||||
|
|
||||||
|
## 用户目标
|
||||||
|
|
||||||
|
用户希望将网页端工作台调整为以视频对照为核心的交互:
|
||||||
|
|
||||||
|
1. “正在上传、抽帧并执行导丝分割”的进度条不要一直循环跳动。
|
||||||
|
2. “预览与结果”改为“预览与结果视频”,其中内容应是视频。
|
||||||
|
3. 下方增加“预览与结果查看”,放置当前图片帧列表;选择某一帧时,原始视频和结果视频跳到对应位置。
|
||||||
|
4. 原始视频和预览结果视频同步,调整一个另一个一起调整。
|
||||||
|
5. “多方法对比”移动到“运行分割”按钮下方;它只针对当前所在帧有效。运行后在结果视频右侧出现可用的“多方法对比”按钮,点击查看当前帧的多方法对比;原始视频右侧不要显示“视频 · 多方法对比”。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 运行分割时进度条为确定式推进,不再循环动画。
|
||||||
|
- 右侧面板标题为“预览与结果视频”,主内容是结果视频播放器。
|
||||||
|
- 下方有“预览与结果查看”帧列表。
|
||||||
|
- 点击帧列表项后,原始视频与结果视频跳到对应位置。
|
||||||
|
- 拖动或播放其中一个视频,另一个视频同步。
|
||||||
|
- 左侧控制面板中“多方法对比”位于“运行分割”按钮下方。
|
||||||
|
- 分割完成前,多方法对比按钮不可用;分割完成并选中帧后,结果视频右侧按钮亮起。
|
||||||
Reference in New Issue
Block a user