shuishen
2 days ago 91e2fe47a57cd39b612d54177b548e4ec3432783
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
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue";
 
import { artifactUrl, createSemanticRun, loadSemanticTasks, readFileAsPayload, type SemanticTask } from "@/api/artifacts";
import ArtifactState from "@/components/ArtifactState.vue";
import { useArtifactStore } from "@/stores/artifacts";
 
interface GeoFeature { properties?: Record<string, unknown>; geometry?: { type: string; coordinates: unknown }; }
 
const store = useArtifactStore();
const caseId = ref("");
const selectedName = ref("");
const files = ref<File[]>([]);
const showRunForm = ref(false);
const running = ref(false);
const runError = ref<string | null>(null);
const taskId = ref("color_baseline");
const tasks = ref<SemanticTask[]>([]);
const searchText = ref("");
const vectorFeatures = ref<GeoFeature[]>([]);
const currentCase = computed(() => store.semanticCases[caseId.value] ?? Object.values(store.semanticCases)[0]);
const selectedImage = computed(() => currentCase.value?.run.images.find((item) => item.file === selectedName.value) ?? currentCase.value?.run.images[0]);
const caseOptions = computed(() => Object.values(store.semanticCases).map((item) => ({ value: item.id, label: item.label })));
const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase())));
const taskOptions = computed(() => tasks.value.map((item) => ({ value: item.id, label: `${item.name} · ${item.status}`, disabled: !item.selectable })));
const classRows = computed(() => {
  const total = selectedImage.value ? selectedImage.value.width * selectedImage.value.height : 0;
  return currentCase.value?.run.classes.map((item) => ({ ...item, pixels: selectedImage.value?.class_pixel_counts[item.key] ?? 0, percent: total ? ((selectedImage.value?.class_pixel_counts[item.key] ?? 0) / total) * 100 : 0 })) ?? [];
});
 
function rings(feature: GeoFeature): number[][][] {
  const geometry = feature.geometry;
  if (!geometry) return [];
  if (geometry.type === "Polygon") return geometry.coordinates as number[][][];
  if (geometry.type === "MultiPolygon") return (geometry.coordinates as number[][][][]).flat();
  return [];
}
const allRings = computed(() => vectorFeatures.value.flatMap(rings));
const vectorViewBox = computed(() => {
  const points = allRings.value.flat();
  if (!points.length) return "0 0 1 1";
  const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]);
  const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys);
  return `${minX} ${minY} ${Math.max(maxX - minX, 1)} ${Math.max(maxY - minY, 1)}`;
});
const vectorPaths = computed(() => allRings.value.map((ring) => ring.map((point, index) => `${index ? "L" : "M"}${point[0]},${point[1]}`).join(" ") + " Z"));
 
function syncSelection() { selectedName.value = currentCase.value?.run.images[0]?.file ?? ""; }
function beforeUpload(file: File) { files.value = [...files.value, file]; return false; }
function removeFile(file: { name: string }) { files.value = files.value.filter((item) => item.name !== file.name); }
async function loadVector() {
  vectorFeatures.value = [];
  if (!currentCase.value || !selectedImage.value) return;
  const response = await fetch(artifactUrl(`${currentCase.value.artifactRoot}/${selectedImage.value.vector_file}`), { cache: "no-store" });
  if (response.ok) vectorFeatures.value = ((await response.json()) as { features?: GeoFeature[] }).features ?? [];
}
async function submitRun() {
  if (!files.value.length) { runError.value = "请至少选择一张 JPG、PNG 或 GeoTIFF 影像。"; return; }
  running.value = true; runError.value = null;
  try {
    const images = await Promise.all(files.value.map(readFileAsPayload));
    const { run } = await createSemanticRun(images, taskId.value);
    await store.loadSemantic(true); caseId.value = run.id; syncSelection(); files.value = []; showRunForm.value = false;
  } catch (error) { runError.value = error instanceof Error ? error.message : "语义分割运行失败"; }
  finally { running.value = false; }
}
 
watch([caseId, selectedName], loadVector);
onMounted(async () => { tasks.value = await loadSemanticTasks(); await store.loadSemantic(); caseId.value = Object.keys(store.semanticCases)[0] ?? ""; syncSelection(); await loadVector(); });
</script>
 
<template>
  <ArtifactState :loading="store.loading" :error="store.error" />
  <section class="workspace-command">
    <div><h2>新建语义分割运行</h2><p>上传少量代表影像,在 CPU 上生成栅格掩膜、叠加图和 GeoJSON 矢量。</p></div>
    <a-button type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button>
  </section>
  <section v-if="showRunForm" class="surface-section semantic-run-form">
    <a-alert type="info" show-icon message="单次最多 6 张 JPG/JPEG/PNG/GeoTIFF;当前是颜色规则基线,不代表通用模型精度。" />
    <a-form layout="vertical"><a-form-item label="任务预设"><a-select v-model:value="taskId" :options="taskOptions" /></a-form-item></a-form>
    <a-upload multiple accept=".jpg,.jpeg,.png,.tif,.tiff" :file-list="files.map((file) => ({ uid: file.name, name: file.name, status: 'done' as const }))" :before-upload="beforeUpload" @remove="removeFile"><a-button><UploadOutlined />选择影像</a-button></a-upload>
    <a-alert v-if="runError" type="error" show-icon :message="runError" />
    <a-button type="primary" :loading="running" :disabled="!files.length" @click="submitRun"><PlayCircleOutlined />开始分割</a-button>
  </section>
 
  <template v-if="currentCase && selectedImage">
    <a-row :gutter="[18, 18]" class="semantic-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()">{{ item.label }}</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 }} x {{ selectedImage.height }} 像素</span></div><div class="comparison-grid semantic-images"><figure><figcaption>原始影像</figcaption><a-image :src="artifactUrl(`${currentCase.rawInputRoot}/${selectedImage.file}`)" /></figure><figure><figcaption>栅格分割叠加</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.overlay_file}`)" /></figure></div></section></a-col>
    </a-row>
 
    <a-row :gutter="[18, 18]" class="result-band">
      <a-col :xs="24" :xl="12"><section class="surface-section vector-panel"><div class="section-heading"><div><h2>矢量结果预览</h2><p>{{ selectedImage.georeferenced ? "保留输入 CRS 与仿射变换" : "普通影像,坐标为像素坐标" }}</p></div><a-tag>{{ vectorFeatures.length }} 个要素</a-tag></div><svg class="vector-preview" :viewBox="vectorViewBox" preserveAspectRatio="xMidYMid meet"><path v-for="(path, index) in vectorPaths" :key="index" :d="path" /></svg></section></a-col>
      <a-col :xs="24" :xl="12"><section class="surface-section"><h2>类别面积比例</h2><a-table :data-source="classRows" :pagination="false" row-key="key" size="small"><a-table-column title="类别" key="label"><template #default="{ record }"><span class="class-swatch" :style="{ background: `rgb(${record.color.join(',')})` }" />{{ record.label }}</template></a-table-column><a-table-column title="像素数" data-index="pixels" key="pixels" align="right" /><a-table-column title="占比" key="percent" align="right"><template #default="{ record }">{{ record.percent.toFixed(1) }}%</template></a-table-column></a-table></section></a-col>
    </a-row>
 
    <section class="surface-section result-band"><div class="section-heading"><div><h2>运行与下载</h2><p>{{ currentCase.note }}</p></div></div><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 5 }"><a-descriptions-item label="任务预设">{{ currentCase.run.task_name ?? "通用颜色规则基线" }}</a-descriptions-item><a-descriptions-item label="分类边界">{{ currentCase.run.classification }}</a-descriptions-item><a-descriptions-item label="设备">{{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item label="GeoAI">{{ currentCase.run.geoai_version }}</a-descriptions-item><a-descriptions-item label="耗时">{{ currentCase.run.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.raster_file}`)" target="_blank"><FileImageOutlined />栅格 GeoTIFF</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.vector_file}`)" target="_blank"><FileOutlined />矢量 GeoJSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
  </template>
</template>
 
<style scoped>
.semantic-run-form { display: grid; gap: 16px; margin-bottom: 24px; }
.semantic-workspace, .result-band { margin-bottom: 24px; }
.semantic-images figure { min-width: 0; }
.semantic-images :deep(.ant-image), .semantic-images :deep(img) { width: 100%; }
.vector-panel { min-height: 420px; }
.vector-preview { width: 100%; height: 320px; border: 1px solid #d9d9d9; background: #f7f8f9; }
.vector-preview path { fill: rgba(32, 158, 92, 0.28); stroke: #1677ff; stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.class-swatch { display: inline-block; width: 14px; height: 14px; margin-right: 8px; border-radius: 2px; vertical-align: -2px; }
@media (max-width: 1199px) { .vector-panel { min-height: auto; } }
</style>