| | |
| | | <script setup lang="ts"> |
| | | import { computed, onMounted, ref } from "vue"; |
| | | import { FileImageOutlined, InfoCircleOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue"; |
| | | import { createDetectionRun, artifactUrl, readFileAsPayload } from "@/api/artifacts"; |
| | | import { createDetectionRun, artifactUrl, readFileAsPayload, type ComputeDevice } from "@/api/artifacts"; |
| | | import ArtifactState from "@/components/ArtifactState.vue"; |
| | | import RunDeletionControl from "@/components/RunDeletionControl.vue"; |
| | | import { useArtifactStore } from "@/stores/artifacts"; |
| | | |
| | | const store = useArtifactStore(); |
| | |
| | | const selectedName = ref(""); |
| | | const showRunForm = ref(false); |
| | | const files = ref<File[]>([]); |
| | | const device = ref<ComputeDevice>("auto"); |
| | | const running = ref(false); |
| | | const runError = ref<string | null>(null); |
| | | const searchText = ref(""); |
| | |
| | | const caseOptions = computed(() => Object.values(store.detectionCases).map((item) => ({ value: item.id, label: item.label }))); |
| | | const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase()))); |
| | | function syncSelection() { const candidate = currentCase.value?.images.reduce((best, item) => item.detections.length > (best?.detections.length ?? -1) ? item : best, undefined as typeof currentCase.value.images[number] | undefined); selectedName.value = candidate?.file ?? ""; } |
| | | async function removeRun() { await store.loadDetection(true); caseId.value = Object.keys(store.detectionCases)[0] ?? ""; syncSelection(); } |
| | | 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 submitRun() { if (!files.value.length) { runError.value = "请至少选择一张 JPG、JPEG 或 PNG 图像。"; return; } running.value = true; runError.value = null; try { const images = await Promise.all(files.value.map(readFileAsPayload)); const { run } = await createDetectionRun(images); await store.loadDetection(true); caseId.value = run.id; syncSelection(); files.value = []; showRunForm.value = false; } catch (error) { runError.value = error instanceof Error ? error.message : "目标检测运行失败"; } finally { running.value = false; } } |
| | | async function submitRun() { if (!files.value.length) { runError.value = "请至少选择一张 JPG、JPEG 或 PNG 图像。"; return; } running.value = true; runError.value = null; try { const images = await Promise.all(files.value.map(readFileAsPayload)); const { run } = await createDetectionRun(images, device.value); await store.loadDetection(true); caseId.value = run.id; syncSelection(); files.value = []; showRunForm.value = false; } catch (error) { runError.value = error instanceof Error ? error.message : "目标检测运行失败"; } finally { running.value = false; } } |
| | | onMounted(async () => { await store.loadDetection(); caseId.value = Object.keys(store.detectionCases)[0] ?? ""; syncSelection(); }); |
| | | </script> |
| | | |
| | | <template> |
| | | <ArtifactState :loading="store.loading" :error="store.error" /> |
| | | <section class="workspace-command"> |
| | | <div><h2>新建检测运行</h2><p>上传少量代表性影像,CPU 基线会生成独立结果,不覆盖既有案例。</p></div> |
| | | <div><h2>新建检测运行</h2><p>上传少量代表性影像,任务会自动优先使用本机可用 GPU,未通过检测时回退 CPU,并生成独立结果。</p></div> |
| | | <a-button type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button> |
| | | </section> |
| | | <section v-if="showRunForm" class="surface-section run-form"><a-alert type="info" show-icon message="单次最多 12 张图像;当前基线识别人员和常见车辆,树木不在有效类别内。" /><a-upload class="run-upload" multiple accept=".jpg,.jpeg,.png" :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> |
| | | <section v-if="showRunForm" class="surface-section run-form"><a-alert type="info" show-icon message="单次最多 12 张图像;当前基线识别人员和常见车辆,树木不在有效类别内。" /><div class="resolution-control"><label for="detection-device">计算设备</label><a-select id="detection-device" v-model:value="device"><a-select-option value="auto">自动(可用 GPU 否则 CPU)</a-select-option><a-select-option value="cpu">CPU</a-select-option><a-select-option value="cuda">GPU CUDA</a-select-option></a-select><span>GPU 仅用于模型推理;显式选择 CUDA 时,探测失败会直接提示。</span></div><a-upload class="run-upload" multiple accept=".jpg,.jpeg,.png" :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"> |
| | | <div class="detection-workspace"> |
| | | <a-row :gutter="[18, 18]"><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.images.map((item) => ({ value: item.file, label: `${item.file} · ${item.detections.length} 个候选` }))" /><span class="toolbar-note">{{ currentCase.note }}</span></div><div class="comparison-grid"><figure><figcaption>原始影像</figcaption><a-image class="detection-image" :src="artifactUrl(`${currentCase.inputRoot}/${selectedImage.file}`)" :alt="`${selectedImage.file} 原始影像`" /></figure><figure><figcaption>标注结果</figcaption><a-image class="detection-image" :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.annotated_file}`)" :alt="`${selectedImage.file} 标注结果`" /></figure></div><p class="image-caption">{{ selectedImage.width }} x {{ selectedImage.height }} 像素 · {{ selectedImage.detections.length }} 个候选</p></section></a-col></a-row> |
| | | <a-row :gutter="[18, 18]"><a-col :xs="24" :xl="8"><section class="surface-section"><h2>本次运行</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="处理影像">{{ currentCase.run.processed_images }} 张</a-descriptions-item><a-descriptions-item label="检测候选">{{ currentCase.run.detection_count }} 个</a-descriptions-item><a-descriptions-item label="耗时">{{ currentCase.run.elapsed_seconds }} 秒</a-descriptions-item><a-descriptions-item label="设备">{{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item label="模型">{{ currentCase.run.model }}</a-descriptions-item></a-descriptions><a-divider /><a-space direction="vertical"><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/detections.json`)" target="_blank"><FileImageOutlined />检测 JSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><InfoCircleOutlined />运行元数据</a-button></a-space></section></a-col><a-col :xs="24" :xl="16"><section class="surface-section"><div class="section-heading"><div><h2>当前影像检测明细</h2><p>边界框为 JPEG 像素坐标,不含可用地理坐标。</p></div></div><a-table :data-source="selectedImage.detections" :pagination="false" row-key="bbox_xyxy" size="small" :scroll="{ x: 680 }"><a-table-column title="类别" data-index="class_name" key="class_name" /><a-table-column title="置信度" key="confidence" align="right"><template #default="{ record }">{{ (record.confidence * 100).toFixed(1) }}%</template></a-table-column><a-table-column title="像素框 x1, y1, x2, y2" key="bbox"><template #default="{ record }">{{ record.bbox_xyxy.map((value: number) => value.toFixed(1)).join(', ') }}</template></a-table-column><template #emptyText>该影像没有达到当前阈值的候选目标。</template></a-table></section></a-col></a-row> |
| | | <a-row :gutter="[18, 18]"><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><RunDeletionControl capability="01-object-detection" :run-id="item.value" :label="item.label" @removed="removeRun" /></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.images.map((item) => ({ value: item.file, label: `${item.file} · ${item.detections.length} 个候选` }))" /><span class="toolbar-note">{{ currentCase.note }}</span></div><div class="comparison-grid"><figure><figcaption>原始影像</figcaption><a-image class="detection-image" :src="artifactUrl(`${currentCase.inputRoot}/${selectedImage.file}`)" :alt="`${selectedImage.file} 原始影像`" /></figure><figure><figcaption>标注结果</figcaption><a-image class="detection-image" :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.annotated_file}`)" :alt="`${selectedImage.file} 标注结果`" /></figure></div><p class="image-caption">{{ selectedImage.width }} x {{ selectedImage.height }} 像素 · {{ selectedImage.detections.length }} 个候选</p></section></a-col></a-row> |
| | | <a-row :gutter="[18, 18]"><a-col :xs="24" :xl="8"><section class="surface-section"><h2>本次运行</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="处理影像">{{ currentCase.run.processed_images }} 张</a-descriptions-item><a-descriptions-item label="检测候选">{{ currentCase.run.detection_count }} 个</a-descriptions-item><a-descriptions-item label="耗时">{{ currentCase.run.elapsed_seconds }} 秒</a-descriptions-item><a-descriptions-item label="请求 / 实际设备">{{ currentCase.run.requested_device ?? "既有记录" }} / {{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item v-if="currentCase.run.execution?.fallback_used" label="CPU 回退原因">{{ currentCase.run.execution.fallback_reason }}</a-descriptions-item><a-descriptions-item label="模型">{{ currentCase.run.model }}</a-descriptions-item></a-descriptions><a-divider /><a-space direction="vertical"><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/detections.json`)" target="_blank"><FileImageOutlined />检测 JSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><InfoCircleOutlined />运行元数据</a-button></a-space></section></a-col><a-col :xs="24" :xl="16"><section class="surface-section"><div class="section-heading"><div><h2>当前影像检测明细</h2><p>边界框为 JPEG 像素坐标,不含可用地理坐标。</p></div></div><a-table :data-source="selectedImage.detections" :pagination="false" row-key="bbox_xyxy" size="small" :scroll="{ x: 680 }"><a-table-column title="类别" data-index="class_name" key="class_name" /><a-table-column title="置信度" key="confidence" align="right"><template #default="{ record }">{{ (record.confidence * 100).toFixed(1) }}%</template></a-table-column><a-table-column title="像素框 x1, y1, x2, y2" key="bbox"><template #default="{ record }">{{ record.bbox_xyxy.map((value: number) => value.toFixed(1)).join(', ') }}</template></a-table-column><template #emptyText>该影像没有达到当前阈值的候选目标。</template></a-table></section></a-col></a-row> |
| | | </div> |
| | | </template> |
| | | </template> |