shuishen
7 hours ago 2ae460fc4a4c2419cf44329783d49a739e2a04ea
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
<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>