2026-05-18-19-06-50 构建网页端分割工作台
This commit is contained in:
193
frontend/app.js
193
frontend/app.js
@@ -1,21 +1,79 @@
|
||||
const form = document.querySelector("#segmentForm");
|
||||
const methodSelect = document.querySelector("#method");
|
||||
const methodGrid = document.querySelector("#methodGrid");
|
||||
const sensitivity = document.querySelector("#sensitivity");
|
||||
const sensitivityValue = document.querySelector("#sensitivityValue");
|
||||
const fileInput = document.querySelector("#file");
|
||||
const fileName = document.querySelector("#fileName");
|
||||
const dropZone = document.querySelector("#dropZone");
|
||||
const sampleButton = document.querySelector("#sampleButton");
|
||||
const clearButton = document.querySelector("#clearButton");
|
||||
const resultGrid = document.querySelector("#resultGrid");
|
||||
const emptyState = document.querySelector("#emptyState");
|
||||
const videoLink = document.querySelector("#videoLink");
|
||||
const health = document.querySelector("#health");
|
||||
const template = document.querySelector("#resultCardTemplate");
|
||||
const previewEmpty = document.querySelector("#previewEmpty");
|
||||
const videoPreview = document.querySelector("#videoPreview");
|
||||
const imagePreview = document.querySelector("#imagePreview");
|
||||
const progressWrap = document.querySelector("#progressWrap");
|
||||
const progressText = document.querySelector("#progressText");
|
||||
const summaryStrip = document.querySelector("#summaryStrip");
|
||||
const summaryJob = document.querySelector("#summaryJob");
|
||||
const summaryFrames = document.querySelector("#summaryFrames");
|
||||
const summaryCoverage = document.querySelector("#summaryCoverage");
|
||||
const summarySkeleton = document.querySelector("#summarySkeleton");
|
||||
const resultCount = document.querySelector("#resultCount");
|
||||
const jobMeta = document.querySelector("#jobMeta");
|
||||
const detailDialog = document.querySelector("#detailDialog");
|
||||
const closeDialog = document.querySelector("#closeDialog");
|
||||
|
||||
const methodLabels = new Map();
|
||||
const methodDescriptions = new Map();
|
||||
let selectedFile = null;
|
||||
let currentObjectUrl = null;
|
||||
let lastFrames = [];
|
||||
|
||||
function setBusy(isBusy) {
|
||||
const button = form.querySelector("button");
|
||||
function setBusy(isBusy, text = "运行分割") {
|
||||
const button = form.querySelector(".primary");
|
||||
button.disabled = isBusy;
|
||||
button.querySelector("span").textContent = isBusy ? "分割中" : "开始分割";
|
||||
button.querySelector("span").textContent = isBusy ? "分割中" : text;
|
||||
progressWrap.hidden = !isBusy;
|
||||
if (isBusy) {
|
||||
progressText.textContent = "正在上传、抽帧并执行导丝分割";
|
||||
}
|
||||
}
|
||||
|
||||
function setFile(file) {
|
||||
selectedFile = file;
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(file);
|
||||
fileInput.files = transfer.files;
|
||||
fileName.textContent = `${file.name} · ${(file.size / 1024 / 1024).toFixed(2)} MB`;
|
||||
renderPreview(file);
|
||||
}
|
||||
|
||||
function revokePreview() {
|
||||
if (currentObjectUrl) {
|
||||
URL.revokeObjectURL(currentObjectUrl);
|
||||
currentObjectUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPreview(file) {
|
||||
revokePreview();
|
||||
currentObjectUrl = URL.createObjectURL(file);
|
||||
previewEmpty.hidden = true;
|
||||
videoPreview.hidden = true;
|
||||
imagePreview.hidden = true;
|
||||
if (file.type.startsWith("video/")) {
|
||||
videoPreview.src = currentObjectUrl;
|
||||
videoPreview.hidden = false;
|
||||
} else {
|
||||
imagePreview.src = currentObjectUrl;
|
||||
imagePreview.hidden = false;
|
||||
}
|
||||
jobMeta.textContent = `已选择 ${file.name}`;
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
@@ -31,53 +89,171 @@ async function loadHealth() {
|
||||
}
|
||||
}
|
||||
|
||||
function activateMethod(key) {
|
||||
methodSelect.value = key;
|
||||
[...methodGrid.querySelectorAll(".method-option")].forEach((node) => {
|
||||
node.classList.toggle("is-active", node.dataset.method === key);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMethods() {
|
||||
const response = await fetch("/api/methods");
|
||||
const data = await response.json();
|
||||
methodSelect.innerHTML = "";
|
||||
methodGrid.innerHTML = "";
|
||||
Object.entries(data.methods).forEach(([key, value]) => {
|
||||
methodLabels.set(key, value.label);
|
||||
methodDescriptions.set(key, value.description);
|
||||
|
||||
const option = document.createElement("option");
|
||||
option.value = key;
|
||||
option.textContent = value.label;
|
||||
if (key === "fusion") option.selected = true;
|
||||
methodSelect.appendChild(option);
|
||||
|
||||
const card = document.createElement("button");
|
||||
card.type = "button";
|
||||
card.className = "method-option";
|
||||
card.dataset.method = key;
|
||||
card.innerHTML = `<strong>${value.label}</strong><span>${value.description}</span>`;
|
||||
card.addEventListener("click", () => activateMethod(key));
|
||||
methodGrid.appendChild(card);
|
||||
});
|
||||
activateMethod("fusion");
|
||||
}
|
||||
|
||||
function updateSummary(data) {
|
||||
const frames = data.frames || [];
|
||||
const coverage = frames.reduce((sum, frame) => sum + frame.metrics.coverage, 0) / Math.max(1, frames.length);
|
||||
const skeleton = frames.reduce((sum, frame) => sum + frame.metrics.skeleton_length, 0) / Math.max(1, frames.length);
|
||||
summaryJob.textContent = data.job_id;
|
||||
summaryFrames.textContent = frames.length;
|
||||
summaryCoverage.textContent = `${(coverage * 100).toFixed(3)}%`;
|
||||
summarySkeleton.textContent = Math.round(skeleton);
|
||||
summaryStrip.hidden = false;
|
||||
resultCount.textContent = `${frames.length} 个结果`;
|
||||
jobMeta.textContent = `${data.kind === "video" ? "视频" : "图像"} · ${methodLabels.get(data.method) || data.method}`;
|
||||
}
|
||||
|
||||
function openDetail(frame) {
|
||||
document.querySelector("#detailMethod").textContent = methodLabels.get(frame.method) || frame.method;
|
||||
document.querySelector("#detailTitle").textContent = `帧 ${frame.frame_index} 分割详情`;
|
||||
document.querySelector("#detailOriginal").src = frame.original_url;
|
||||
document.querySelector("#detailOverlay").src = frame.overlay_url;
|
||||
document.querySelector("#detailMask").src = frame.mask_url;
|
||||
const metrics = document.querySelector("#detailMetrics");
|
||||
metrics.innerHTML = `
|
||||
<div><dt>覆盖率</dt><dd>${(frame.metrics.coverage * 100).toFixed(3)}%</dd></div>
|
||||
<div><dt>掩膜像素</dt><dd>${frame.metrics.mask_pixels}</dd></div>
|
||||
<div><dt>骨架长度</dt><dd>${frame.metrics.skeleton_length}</dd></div>
|
||||
<div><dt>连通域</dt><dd>${frame.metrics.components}</dd></div>
|
||||
`;
|
||||
detailDialog.showModal();
|
||||
}
|
||||
|
||||
function renderResults(data) {
|
||||
lastFrames = data.frames || [];
|
||||
resultGrid.innerHTML = "";
|
||||
emptyState.hidden = true;
|
||||
emptyState.hidden = lastFrames.length > 0;
|
||||
videoLink.hidden = !data.video_url;
|
||||
if (data.video_url) {
|
||||
videoLink.href = data.video_url;
|
||||
videoLink.setAttribute("download", "");
|
||||
}
|
||||
updateSummary(data);
|
||||
|
||||
data.frames.forEach((frame) => {
|
||||
lastFrames.forEach((frame) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.querySelector(".method").textContent = methodLabels.get(frame.method) || frame.method;
|
||||
node.querySelector(".frame-index").textContent = `帧 ${frame.frame_index}`;
|
||||
node.querySelector(".overlay").src = frame.overlay_url;
|
||||
node.querySelector(".mask").src = frame.mask_url;
|
||||
node.querySelector(".coverage").textContent = `${(frame.metrics.coverage * 100).toFixed(3)}%`;
|
||||
node.querySelector(".skeleton").textContent = frame.metrics.skeleton_length;
|
||||
node.querySelector(".components").textContent = frame.metrics.components;
|
||||
node.addEventListener("click", () => openDetail(frame));
|
||||
node.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") openDetail(frame);
|
||||
});
|
||||
resultGrid.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSample() {
|
||||
sampleButton.disabled = true;
|
||||
sampleButton.textContent = "加载中";
|
||||
try {
|
||||
const response = await fetch("/api/samples");
|
||||
const data = await response.json();
|
||||
const sample = data.samples.find((item) => item.kind === "video") || data.samples[0];
|
||||
if (!sample) throw new Error("未找到样例文件");
|
||||
const blobResponse = await fetch(sample.url);
|
||||
const blob = await blobResponse.blob();
|
||||
const file = new File([blob], sample.name, { type: sample.kind === "video" ? "video/mp4" : "image/png" });
|
||||
setFile(file);
|
||||
} finally {
|
||||
sampleButton.disabled = false;
|
||||
sampleButton.textContent = "加载样例";
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
selectedFile = null;
|
||||
fileInput.value = "";
|
||||
fileName.textContent = "支持 mp4、avi、png、jpg、tiff";
|
||||
revokePreview();
|
||||
videoPreview.removeAttribute("src");
|
||||
imagePreview.removeAttribute("src");
|
||||
videoPreview.hidden = true;
|
||||
imagePreview.hidden = true;
|
||||
previewEmpty.hidden = false;
|
||||
resultGrid.innerHTML = "";
|
||||
emptyState.hidden = false;
|
||||
summaryStrip.hidden = true;
|
||||
videoLink.hidden = true;
|
||||
resultCount.textContent = "0 个结果";
|
||||
jobMeta.textContent = "等待输入";
|
||||
}
|
||||
|
||||
sensitivity.addEventListener("input", () => {
|
||||
sensitivityValue.textContent = Number(sensitivity.value).toFixed(2);
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
const file = fileInput.files[0];
|
||||
fileName.textContent = file ? file.name : "支持 mp4、avi、png、jpg、tiff";
|
||||
if (file) setFile(file);
|
||||
});
|
||||
|
||||
["dragenter", "dragover"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.add("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
["dragleave", "drop"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.remove("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (event) => {
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) setFile(file);
|
||||
});
|
||||
|
||||
sampleButton.addEventListener("click", loadSample);
|
||||
clearButton.addEventListener("click", clearAll);
|
||||
closeDialog.addEventListener("click", () => detailDialog.close());
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const file = fileInput.files[0] || selectedFile;
|
||||
if (!file) {
|
||||
emptyState.hidden = false;
|
||||
emptyState.textContent = "请先选择文件或加载样例。";
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
emptyState.hidden = false;
|
||||
emptyState.textContent = "正在抽帧和分割,请稍候。";
|
||||
@@ -86,6 +262,7 @@ form.addEventListener("submit", async (event) => {
|
||||
|
||||
try {
|
||||
const payload = new FormData(form);
|
||||
payload.set("file", file);
|
||||
const response = await fetch("/api/segment", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
|
||||
Reference in New Issue
Block a user