<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>
|