罗广辉
12 hours ago 7cc239cee1a9af4e2e8a0f3d5b7a00a074b17214
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
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue";
 
import { artifactUrl, createMeasurementRun, readFileAsPayload, type MeasurementCase } 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 searchText = ref("");
const vectorFeatures = ref<GeoFeature[]>([]);
const measurementRows = ref<Record<string, string>[]>([]);
const currentCase = computed<MeasurementCase | undefined>(() => store.measurementCases[caseId.value] ?? Object.values(store.measurementCases)[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.measurementCases).map((item) => ({ value: item.id, label: item.label })));
const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase())));
 
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 = [];
  measurementRows.value = [];
  if (!currentCase.value || !selectedImage.value) return;
  const [vectorResponse, csvResponse] = await Promise.all([
    fetch(artifactUrl(`${currentCase.value.artifactRoot}/${selectedImage.value.vector_file}`), { cache: "no-store" }),
    fetch(artifactUrl(`${currentCase.value.artifactRoot}/${selectedImage.value.csv_file}`), { cache: "no-store" })
  ]);
  if (vectorResponse.ok) vectorFeatures.value = ((await vectorResponse.json()) as { features?: GeoFeature[] }).features ?? [];
  if (csvResponse.ok) {
    const lines = (await csvResponse.text()).trim().split(/\r?\n/);
    const headers = lines.shift()?.replace(/^\uFEFF/, "").split(",") ?? [];
    measurementRows.value = lines.filter(Boolean).map((line) => Object.fromEntries(headers.map((header, index) => [header, line.split(",")[index] ?? ""])));
  }
}
async function submitRun() {
  if (!files.value.length) { runError.value = "请至少选择一份标签 PNG 或 GeoTIFF 栅格。"; return; }
  running.value = true; runError.value = null;
  try {
    const rasters = await Promise.all(files.value.map(readFileAsPayload));
    const { run } = await createMeasurementRun(rasters);
    await store.loadMeasurement(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 () => { await store.loadMeasurement(); caseId.value = Object.keys(store.measurementCases)[0] ?? ""; syncSelection(); await loadVector(); });
</script>
 
<template>
  <ArtifactState :loading="store.loading" :error="store.error" />
  <section class="workspace-command measurement-command"><div><h2>新建空间测量运行</h2><p>上传标签栅格,在 CPU 上生成对象计数、面积/周长统计和 GeoAI GeoJSON。</p></div><a-button type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button></section>
  <section v-if="showRunForm" class="surface-section measurement-run-form"><a-alert type="info" show-icon message="单次最多 4 份 PNG/TIF/TIFF 标签栅格;普通 RGB 影像不能直接作为测量输入。" /><a-upload multiple accept=".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="measurement-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 measurement-images"><figure><figcaption>栅格测量预览</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.preview_file}`)" /></figure><figure><figcaption>矢量对象范围</figcaption><svg class="vector-preview" :viewBox="vectorViewBox" preserveAspectRatio="xMidYMid meet"><path v-for="(path, index) in vectorPaths" :key="index" :d="path" /></svg></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"><div class="section-heading"><div><h2>测量汇总</h2><p>{{ selectedImage.measurement_basis === "projected_crs" ? "有效投影 CRS,使用地图单位。" : "无有效投影 CRS,使用像素/坐标单位。" }}</p></div><a-tag color="green">{{ selectedImage.object_count }} 个对象</a-tag></div><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="对象计数">{{ selectedImage.object_count }}</a-descriptions-item><a-descriptions-item label="总面积">{{ selectedImage.total_area }} {{ selectedImage.area_unit }}</a-descriptions-item><a-descriptions-item label="总周长">{{ selectedImage.total_perimeter }} {{ selectedImage.length_unit }}</a-descriptions-item><a-descriptions-item label="矢量化">{{ selectedImage.vectorizer }}</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>对象明细</h2><a-table :data-source="measurementRows" :pagination="{ pageSize: 8 }" row-key="object_id" size="small" :scroll="{ x: 620 }"><a-table-column title="编号" data-index="object_id" key="object_id" /><a-table-column title="类别" data-index="class_name" key="class_name" /><a-table-column title="面积" data-index="area" key="area" align="right" /><a-table-column title="周长" data-index="perimeter" key="perimeter" align="right" /></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.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.processed_images }}</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}/${selectedImage.csv_file}`)" target="_blank"><FileOutlined />测量 CSV</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>
.measurement-run-form { display: grid; gap: 16px; margin-bottom: 24px; }
.measurement-workspace, .result-band { margin-bottom: 24px; }
.measurement-images figure { min-width: 0; }
.measurement-images :deep(.ant-image), .measurement-images :deep(img) { width: 100%; height: 360px; object-fit: contain; background: #171e1a; }
.vector-preview { width: 100%; height: 360px; border: 1px solid #d9d9d9; background: #f7f8f9; }
.vector-preview path { fill: rgba(23, 107, 80, 0.24); stroke: #176b50; stroke-width: 1.5; vector-effect: non-scaling-stroke; }
@media (max-width: 1199px) { .measurement-images :deep(img), .vector-preview { height: 300px; } }
</style>