<script setup lang="ts">
|
import { computed, onMounted, ref } from "vue";
|
import { DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue";
|
|
import { artifactUrl, createAnomalyRun, loadAnomalyJob, uploadAnomalyFile, type AnomalyCase } from "@/api/artifacts";
|
import ArtifactState from "@/components/ArtifactState.vue";
|
import { useArtifactStore } from "@/stores/artifacts";
|
|
const store = useArtifactStore();
|
const caseId = ref("");
|
const selectedName = ref("");
|
const referenceFiles = ref<File[]>([]);
|
const inputFiles = ref<File[]>([]);
|
const tileSize = ref(256);
|
const stride = ref(128);
|
const thresholdQuantile = ref(0.995);
|
const randomState = ref(42);
|
const showRunForm = ref(false);
|
const running = ref(false);
|
const reusingCaseId = ref("");
|
const runStage = ref("");
|
const runError = ref<string | null>(null);
|
const searchText = ref("");
|
|
const currentCase = computed<AnomalyCase | undefined>(() => store.anomalyCases[caseId.value] ?? Object.values(store.anomalyCases)[0]);
|
const selectedImage = computed(() => currentCase.value?.run.images.find((item) => item.file === selectedName.value) ?? currentCase.value?.run.images[0]);
|
const candidates = computed(() => selectedImage.value && currentCase.value ? currentCase.value.candidates[selectedImage.value.file] ?? [] : []);
|
const candidateRows = computed(() => candidates.value.map((item) => item.properties));
|
const caseOptions = computed(() => Object.values(store.anomalyCases).map((item) => ({ value: item.id, label: item.label })));
|
const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase())));
|
const methodLabel: Record<string, string> = { rule_only: "仅规则", isolation_only: "仅 Isolation Forest", agreement: "两方法一致" };
|
const featureLabel: Record<string, string> = {
|
rgb_mean_r: "红色均值", rgb_mean_g: "绿色均值", rgb_mean_b: "蓝色均值",
|
rgb_std_r: "红色波动", rgb_std_g: "绿色波动", rgb_std_b: "蓝色波动",
|
hsv_mean_h: "色相均值", hsv_mean_s: "饱和度均值", hsv_mean_v: "亮度均值",
|
hsv_std_h: "色相波动", hsv_std_s: "饱和度波动", hsv_std_v: "亮度波动",
|
gray_entropy: "灰度熵", edge_density: "边缘密度", laplacian_variance: "清晰度", dark_ratio: "暗像素比例", bright_ratio: "亮像素比例",
|
local_appearance_change: "局部外观变化", local_structure_change: "局部结构变化"
|
};
|
|
function addFile(target: "reference" | "input", file: File) {
|
const list = target === "reference" ? referenceFiles : inputFiles;
|
if (!list.value.some((item) => item.name.toLowerCase() === file.name.toLowerCase())) list.value = [...list.value, file];
|
return false;
|
}
|
function removeFile(target: "reference" | "input", name: string) {
|
const list = target === "reference" ? referenceFiles : inputFiles;
|
list.value = list.value.filter((item) => item.name !== name);
|
}
|
function syncSelection() { selectedName.value = currentCase.value?.run.images[0]?.file ?? ""; }
|
const delay = (milliseconds: number) => new Promise((resolve) => globalThis.setTimeout(resolve, milliseconds));
|
|
async function loadCaseFile(root: string, name: string) {
|
const response = await fetch(artifactUrl(`${root}/${name}`), { cache: "no-store" });
|
if (!response.ok) throw new Error(`无法读取案例原图 ${name}(HTTP ${response.status})`);
|
const blob = await response.blob();
|
return new File([blob], name, { type: blob.type || "application/octet-stream" });
|
}
|
|
async function reuseCaseInputs(id: string) {
|
const source = store.anomalyCases[id];
|
if (!source || reusingCaseId.value) return;
|
reusingCaseId.value = id;
|
runError.value = null;
|
try {
|
const [references, inputs] = await Promise.all([
|
Promise.all(source.run.reference_images.map((item) => loadCaseFile(source.referenceRoot, item.file))),
|
Promise.all(source.run.images.map((item) => loadCaseFile(source.inputRoot, item.file)))
|
]);
|
referenceFiles.value = references;
|
inputFiles.value = inputs;
|
tileSize.value = source.run.parameters.tile_size;
|
stride.value = source.run.parameters.stride;
|
thresholdQuantile.value = source.run.parameters.threshold_quantile;
|
randomState.value = source.run.parameters.random_state;
|
caseId.value = id;
|
syncSelection();
|
showRunForm.value = true;
|
} catch (error) {
|
showRunForm.value = true;
|
runError.value = error instanceof Error ? error.message : "案例输入恢复失败";
|
} finally {
|
reusingCaseId.value = "";
|
}
|
}
|
|
async function submitRun() {
|
if (!referenceFiles.value.length || !inputFiles.value.length) { runError.value = "请至少选择一张正常参考影像和一张待检测影像。"; return; }
|
if (stride.value > tileSize.value) { runError.value = "步长不能大于窗口尺寸。"; return; }
|
running.value = true; runError.value = null;
|
try {
|
runStage.value = "正在上传原始影像…";
|
const references = [];
|
const inputs = [];
|
for (const file of referenceFiles.value) references.push(await uploadAnomalyFile(file, "reference"));
|
for (const file of inputFiles.value) inputs.push(await uploadAnomalyFile(file, "input"));
|
runStage.value = "已提交,正在本机 CPU 后台检测…";
|
const { job } = await createAnomalyRun(
|
{ reference: references, input: inputs },
|
{ tileSize: tileSize.value, stride: stride.value, thresholdQuantile: thresholdQuantile.value, randomState: randomState.value }
|
);
|
let state = job;
|
while (state.status === "queued" || state.status === "running") { await delay(1200); state = await loadAnomalyJob(job.id); }
|
if (state.status === "failed") throw new Error(state.error || "异常检测后台任务失败");
|
runStage.value = "正在加载结果…";
|
await store.loadAnomaly(true);
|
caseId.value = state.runId;
|
syncSelection();
|
referenceFiles.value = []; inputFiles.value = []; showRunForm.value = false;
|
} catch (error) { runError.value = error instanceof Error ? error.message : "异常检测运行失败"; }
|
finally { running.value = false; runStage.value = ""; }
|
}
|
|
onMounted(async () => { await store.loadAnomaly(); caseId.value = Object.keys(store.anomalyCases)[0] ?? ""; syncSelection(); });
|
</script>
|
|
<template>
|
<ArtifactState :loading="store.loading" :error="store.error" />
|
<section class="workspace-command anomaly-command">
|
<div><h2>新建异常检测运行</h2><p>固定机位且尺寸一致时自动进行同位置比较,再与 Isolation Forest 结果对照。</p></div>
|
<a-button type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button>
|
</section>
|
|
<section v-if="showRunForm" class="surface-section anomaly-run-form">
|
<a-alert type="warning" show-icon message="建议上传 3~6 张固定机位、相同尺寸的正常图;系统会自动启用同位置规则。机位或尺寸不一致时回退为全局比较;候选不是业务告警。" />
|
<div class="anomaly-upload-grid">
|
<div class="upload-group">
|
<div><strong>正常参考影像</strong><span>1~6 张;合计至少产生 50 个窗口</span></div>
|
<a-upload multiple accept=".jpg,.jpeg,.png,.tif,.tiff" :show-upload-list="false" :before-upload="(file: File) => addFile('reference', file)"><a-button><UploadOutlined />选择参考影像</a-button></a-upload>
|
<div class="file-tags"><a-tag v-for="file in referenceFiles" :key="file.name" closable @close="removeFile('reference', file.name)">{{ file.name }}</a-tag><span v-if="!referenceFiles.length">未选择</span></div>
|
</div>
|
<div class="upload-group">
|
<div><strong>待检测影像</strong><span>1~6 张;每张分别生成结果</span></div>
|
<a-upload multiple accept=".jpg,.jpeg,.png,.tif,.tiff" :show-upload-list="false" :before-upload="(file: File) => addFile('input', file)"><a-button><UploadOutlined />选择待检测影像</a-button></a-upload>
|
<div class="file-tags"><a-tag v-for="file in inputFiles" :key="file.name" closable @close="removeFile('input', file.name)">{{ file.name }}</a-tag><span v-if="!inputFiles.length">未选择</span></div>
|
</div>
|
</div>
|
<div class="anomaly-parameters">
|
<label>窗口尺寸<a-input-number v-model:value="tileSize" :min="128" :max="1024" :step="64" /></label>
|
<label>步长<a-input-number v-model:value="stride" :min="32" :max="tileSize" :step="32" /></label>
|
<label>阈值分位数<a-input-number v-model:value="thresholdQuantile" :min="0.9" :max="0.9999" :step="0.001" :precision="4" /></label>
|
<label>随机种子<a-input-number v-model:value="randomState" :min="0" :max="2147483647" /></label>
|
</div>
|
<a-alert v-if="runError" type="error" show-icon :message="runError" />
|
<a-button type="primary" :loading="running" :disabled="!referenceFiles.length || !inputFiles.length" @click="submitRun"><PlayCircleOutlined />开始检测</a-button>
|
<span v-if="running" class="run-stage">{{ runStage }}</span>
|
</section>
|
|
<template v-if="currentCase && selectedImage">
|
<a-row :gutter="[18, 18]" class="anomaly-workspace">
|
<a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><a-button class="reuse-case-button" type="link" size="small" :loading="reusingCaseId === item.value" :disabled="Boolean(reusingCaseId) || running" @click.stop="reuseCaseInputs(item.value)">复用输入</a-button></a-list-item></template></a-list></section></a-col>
|
<a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="currentCase.run.images.map((item) => ({ value: item.file, label: item.file }))" /><span>{{ selectedImage.width }} × {{ selectedImage.height }} · {{ selectedImage.tile_count }} 窗口</span></div><div class="comparison-grid anomaly-primary"><figure><figcaption>待检测原图</figcaption><a-image :src="artifactUrl(`${currentCase.inputRoot}/${selectedImage.file}`)" /></figure><figure><figcaption>两方法对比结果</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.overlay_file}`)" /></figure></div><div class="anomaly-legend"><span class="rule">仅规则</span><span class="isolation">仅 Isolation Forest</span><span class="agreement">两方法一致</span></div></section></a-col>
|
</a-row>
|
|
<section class="surface-section result-band"><div class="section-heading"><div><h2>正常参考输入</h2><p>模型只从这些影像的窗口学习“正常”视觉分布。</p></div><a-tag>{{ currentCase.run.reference_tile_count }} 个参考窗口</a-tag></div><div class="reference-grid"><figure v-for="reference in currentCase.run.reference_images" :key="reference.file"><a-image :src="artifactUrl(`${currentCase.referenceRoot}/${reference.file}`)" /><figcaption>{{ reference.file }} · {{ reference.tile_count }} 窗口</figcaption></figure></div></section>
|
|
<a-row :gutter="[18, 18]" class="metric-row anomaly-metrics"><a-col :xs="12" :lg="6"><a-statistic title="候选区" :value="selectedImage.candidate_count" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="规则覆盖率" :value="(selectedImage.rule_anomaly_coverage * 100).toFixed(2)" suffix="%" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="Isolation 覆盖率" :value="(selectedImage.isolation_anomaly_coverage * 100).toFixed(2)" suffix="%" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="单图耗时" :value="selectedImage.elapsed_seconds" suffix="秒" /></a-col></a-row>
|
|
<a-row :gutter="[18, 18]" class="result-band anomaly-secondary"><a-col :xs="24" :xl="12"><section class="surface-section"><h2>规则异常热力图</h2><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.rule_heatmap_file}`)" /></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>Isolation Forest 热力图</h2><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.isolation_heatmap_file}`)" /></section></a-col></a-row>
|
|
<section class="surface-section result-band"><div class="section-heading"><div><h2>候选区明细</h2><p>原因特征表示规则评分贡献最大项,不是业务原因诊断。</p></div><a-tag>{{ selectedImage.georeferenced ? selectedImage.crs : "像素坐标" }}</a-tag></div><a-table :data-source="candidateRows" :pagination="{ pageSize: 8 }" row-key="feature_id" size="small" :scroll="{ x: 760 }"><a-table-column title="编号" data-index="feature_id" key="feature_id" /><a-table-column title="方法" data-index="method" key="method"><template #default="{ text }">{{ methodLabel[text] || text }}</template></a-table-column><a-table-column title="主要特征" data-index="reason_feature" key="reason_feature"><template #default="{ text }">{{ featureLabel[text] || text || '—' }}</template></a-table-column><a-table-column title="规则分数" data-index="max_rule_score" key="max_rule_score" align="right" /><a-table-column title="Isolation 分数" data-index="max_isolation_score" key="max_isolation_score" align="right" /><a-table-column title="面积 (px)" data-index="area_pixels" key="area_pixels" align="right" /></a-table></section>
|
|
<section class="surface-section result-band anomaly-files"><div class="section-heading"><div><h2>运行与下载</h2><p>{{ currentCase.note }}</p></div></div><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 6 }"><a-descriptions-item label="分类边界">{{ currentCase.run.classification }}</a-descriptions-item><a-descriptions-item label="规则模式">{{ currentCase.run.parameters.rule_mode_selected === 'aligned' ? '同位置' : '全局' }}</a-descriptions-item><a-descriptions-item label="设备">{{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item label="参考影像">{{ currentCase.run.reference_count }}</a-descriptions-item><a-descriptions-item label="参考窗口">{{ currentCase.run.reference_tile_count }}</a-descriptions-item><a-descriptions-item label="总耗时">{{ currentCase.run.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions><a-space wrap><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.mask_file}`)" download><FileImageOutlined />编码掩膜 GeoTIFF</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.vector_file}`)" download><FileOutlined />候选 GeoJSON</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.tiles_file}`)" download><FileOutlined />窗口特征 CSV</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" download><DownloadOutlined />运行元数据</a-button></a-space></section>
|
<a-alert class="anomaly-limit" type="warning" show-icon message="结果表示相对正常参考影像的视觉离群,只是人工复核候选,不能直接形成堵塞、损坏、渗漏或生产告警结论。" />
|
</template>
|
<a-empty v-else-if="!store.loading" description="尚无异常检测案例"><template #description><p>点击“上传并运行”,选择正常参考影像和待检测影像。</p></template></a-empty>
|
</template>
|
|
<style scoped>
|
.anomaly-run-form { display: grid; gap: 16px; margin-bottom: 24px; }
|
.anomaly-upload-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
.upload-group { display: grid; align-content: start; gap: 12px; padding: 16px; border: 1px solid var(--wb-border); background: #f7f9f7; }
|
.upload-group > div:first-child { display: grid; gap: 4px; }
|
.upload-group span, .run-stage { color: var(--wb-muted); font-size: 12px; }
|
.file-tags { min-height: 30px; }
|
.file-tags :deep(.ant-tag) { margin-bottom: 6px; }
|
.anomaly-parameters { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
.anomaly-parameters label { display: grid; gap: 7px; color: var(--wb-muted); font-size: 12px; font-weight: 700; }
|
.anomaly-parameters :deep(.ant-input-number) { width: 100%; }
|
.anomaly-workspace, .result-band { margin-bottom: 24px; }
|
.anomaly-workspace > :deep(.ant-col), .anomaly-secondary > :deep(.ant-col) { display: flex; }
|
.anomaly-workspace .surface-section, .anomaly-secondary .surface-section { width: 100%; }
|
.anomaly-workspace .run-library { align-self: flex-start; max-height: clamp(430px, calc(100vh - 260px), 660px); }
|
.run-item { display: flex !important; align-items: center; gap: 8px; }
|
.run-item-label { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.reuse-case-button { flex: 0 0 auto; padding-inline: 4px; }
|
.anomaly-primary :deep(.ant-image), .anomaly-primary :deep(img) { width: 100%; }
|
.anomaly-primary :deep(img) { height: 390px; object-fit: contain; background: #18221d; }
|
.anomaly-secondary :deep(.ant-image), .anomaly-secondary :deep(img) { width: 100%; }
|
.anomaly-secondary :deep(img) { height: 330px; object-fit: contain; background: #18221d; }
|
.reference-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; }
|
.reference-grid figure { min-width: 0; margin: 0; }
|
.reference-grid :deep(.ant-image), .reference-grid :deep(img) { width: 100%; }
|
.reference-grid :deep(img) { height: 150px; object-fit: cover; background: #18221d; }
|
.reference-grid figcaption { margin-top: 6px; overflow: hidden; color: var(--wb-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
.anomaly-legend { display: flex; flex-wrap: wrap; gap: 18px; margin-top: 12px; color: var(--wb-muted); font-size: 12px; }
|
.anomaly-legend span::before { display: inline-block; width: 10px; height: 10px; margin-right: 6px; content: ""; }
|
.anomaly-legend .rule::before { background: #ff9f1c; }.anomaly-legend .isolation::before { background: #2196f3; }.anomaly-legend .agreement::before { background: #e53935; }
|
.anomaly-limit { margin-bottom: 24px; }
|
@media (max-width: 1199px) { .anomaly-workspace > :deep(.ant-col), .anomaly-secondary > :deep(.ant-col) { display: block; } .anomaly-workspace .run-library { max-height: none; } .anomaly-parameters { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
@media (max-width: 760px) { .anomaly-upload-grid, .anomaly-primary, .anomaly-parameters, .reference-grid { grid-template-columns: minmax(0, 1fr); } .section-heading { flex-wrap: wrap; } .anomaly-primary :deep(img), .anomaly-secondary :deep(img) { height: 270px; } }
|
</style>
|