shuishen
2 days ago 91e2fe47a57cd39b612d54177b548e4ec3432783
feat:语义分割demo处理
18 files modified
6 files added
1445 ■■■■ changed files
PROJECT_CONTEXT.md 47 ●●●● patch | view | raw | blame | history
apps/workbench-console/README.md 5 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/api/artifacts.ts 16 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/components/SemanticMappingPanel.vue 112 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/components/TrajectoryAnalysisPanel.vue 104 ●●●● patch | view | raw | blame | history
apps/workbench-console/src/data/capabilities.ts 2 ●●● patch | view | raw | blame | history
apps/workbench-console/src/stores/artifacts.ts 44 ●●●● patch | view | raw | blame | history
apps/workbench-console/src/styles.css 235 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/views/CapabilityView.vue 2 ●●●●● patch | view | raw | blame | history
capabilities/02-semantic-mapping/README.md 103 ●●●●● patch | view | raw | blame | history
capabilities/02-semantic-mapping/configs/task-catalog.json 54 ●●●●● patch | view | raw | blame | history
capabilities/02-semantic-mapping/requirements.txt 6 ●●●●● patch | view | raw | blame | history
capabilities/02-semantic-mapping/run_semantic_segmentation.py 172 ●●●●● patch | view | raw | blame | history
capabilities/02-semantic-mapping/tests/test_semantic_segmentation.py 42 ●●●●● patch | view | raw | blame | history
capabilities/GEOAI_PY_DIRECT_CAPABILITIES.md 94 ●●●●● patch | view | raw | blame | history
capabilities/README.md 31 ●●●●● patch | view | raw | blame | history
scripts/serve_workbench_console.py 82 ●●●●● patch | view | raw | blame | history
solutions/mining-inspection/01-person-safety/README.md 17 ●●●●● patch | view | raw | blame | history
solutions/mining-inspection/01-person-safety/solution.json 15 ●●●● patch | view | raw | blame | history
solutions/mining-inspection/02-drainage-blockage/README.md 29 ●●●● patch | view | raw | blame | history
solutions/mining-inspection/02-drainage-blockage/solution.json 11 ●●●● patch | view | raw | blame | history
solutions/mining-inspection/README.md 90 ●●●● patch | view | raw | blame | history
solutions/mining-inspection/requirements-map.json 96 ●●●●● patch | view | raw | blame | history
tests/test_serve_workbench_console.py 36 ●●●●● patch | view | raw | blame | history
PROJECT_CONTEXT.md
@@ -41,6 +41,20 @@
`pip check` passes in this environment.
Dedicated semantic-mapping environment:
`E:\AllWorkProject\geoai-workbench\.venvs\02-semantic-mapping`
| Component | Version |
| --- | --- |
| Python | 3.12.10 |
| geoai-py | 0.42.0 |
| rasterio | 1.5.1 |
| geopandas | 1.1.4 |
| OpenCV | 5.0.0 |
The environment reuses the compatible package set from the verified object-detection environment after the isolated setup exceeded the dependency-download timeout. It has its own Python 3.12 interpreter and `pip check` passes.
Dedicated trajectory-analysis environment:
`E:\AllWorkProject\geoai-workbench\.venvs\15-trajectory-analysis`
@@ -66,8 +80,9 @@
- Start command: `py -3.12 .\scripts\serve_workbench_console.py`
- URL: `http://127.0.0.1:6173` (built console); Vite development is `http://127.0.0.1:6174/apps/workbench-console/`. Only `6xxx` ports are accepted.
- Scope: independent local experiment workbench for this repository. It has no code, account, or product API link to the two drone-product repositories. The map client directly requests public ArcGIS tiles and, only when configured, TianDiTu tiles.
- Current workflow: `01-object-detection` and `15-trajectory-analysis` support new local runs, searchable case libraries, and visual result workspaces. The server creates a new run ID for every submission, only accepts allowlisted input types, and calls fixed capability scripts in fixed virtual environments.
- Current workflow: `01-object-detection`, `02-semantic-mapping`, and `15-trajectory-analysis` support new local runs, searchable case libraries, and visual result workspaces. Semantic mapping loads its controlled task catalog, shows source/raster comparison, a GeoJSON polygon preview, class proportions, and downloads. The server creates a new run ID for every submission, only accepts allowlisted input types and enabled tasks, and calls fixed capability scripts in fixed virtual environments.
- File exposure: the static handler permits only console assets, `shared/outputs`, and source images required for result comparison. Upload APIs write new raw and processed run directories but do not expose the rest of the repository.
- Styling: `apps/workbench-console/src/styles.css` is organized into design variables, application shell, shared workbench components, capability workspaces, maps, and responsive rules. New capability pages must use a scoped workspace class and shared spacing variables instead of global override patches.
## Capability Status
@@ -75,16 +90,38 @@
| --- | --- | --- |
| `00-change-detection` | Existing product capability confirmed; local Demo not implemented | The current system's orthophoto change detection already uses GeoAI. |
| `01-object-detection` | In progress; runnable people and vehicle experiments | Tiled YOLO helps people; GeoAI NWPU substantially improves top-down vehicles; tree detection is not implemented. |
| `02-semantic-mapping` | Runnable CPU baseline verified | B capability: deterministic RGB/HSV segmentation plus `geoai.masks_to_vector`; raster and vector outputs are inspectable, but this is not a trained GeoAI model. |
| `15-trajectory-analysis` | Runnable CPU Demo verified | C capability consuming timestamped tracks: spatial metrics, DBSCAN and explicit behavior rules; not a built-in `geoai-py` function. |
| `02` through `14`, `16` through `18` | Directory and initial README only | No verified local Demo yet. Start each one through `$geoai-capability-builder`. |
| `03` through `14`, `16` through `18` | Directory and initial README only | No verified local Demo yet. Start each one through `$geoai-capability-builder`. |
## Semantic Mapping Snapshot
- Script: `capabilities/02-semantic-mapping/run_semantic_segmentation.py`.
- Boundary: B. `geoai-py 0.42.0` supplies class-wise `masks_to_vector`; deterministic RGB/HSV rules supply the first offline segmentation mask because no general pretrained weight is bundled.
- Classes: `other`, `vegetation`, `water`, and `impervious`. Outputs are mask PNG, overlay PNG, mask GeoTIFF, class GeoJSON, and `run_metadata.json`.
- Validation input: two existing real 3840 x 2160 drone JPEGs copied to `shared/data/raw/02-semantic-mapping/validation-20260817/`. Both are ordinary non-georeferenced images, so their vector output is in pixel coordinates.
- Measured result: two images completed on CPU in 15.738 seconds. The vegetation-heavy image produced 1,707,028 vegetation pixels; the difficult road image produced 8,167,327 impervious pixels. Both used `geoai.masks_to_vector`, and raster/vector artifacts plus both overlays were inspected.
- Honest limitation: blue roofs and other blue objects can be false water, and lighting/material similarity can change all rule classes. There is no manual ground truth, so no accuracy, IoU, precision, or recall is claimed.
- Task catalog: `capabilities/02-semantic-mapping/configs/task-catalog.json`. Only `color_baseline` is selectable; project classes such as drainage blockage are recorded but disabled until data, labels, weights, licenses, and acceptance thresholds are available.
- Console: `GET /api/semantic-mapping/tasks` and `GET/POST /api/semantic-mapping/runs`, at most six allowlisted images per run. The server validates `taskId` before creating a run directory. Raw, processed, and output paths are separate. Run `semantic-20260817-063122-5f20ef` verified a real upload and persisted `color_baseline` / `通用颜色规则基线` in structured metadata.
- Next decision: choose one business class, obtain normal and difficult georeferenced GeoTIFFs plus manual masks, then license and evaluate a lightweight pretrained model through `geoai.semantic_segmentation` before any batch or product use.
## 项目算法方案层
- 新增 `solutions/`:它是 C 类项目算法编排层,引用 `capabilities/` 中已验证的原子能力,不复制模型实现。
- 新增 `solutions/mining-inspection/`:对应瑶岗仙二期巡查需求,当前只建立两个试点骨架:`01-person-safety` 和 `02-drainage-blockage`。
- 两个试点均为 `scaffold`,没有运行脚本、模型下载或产品告警接入;它们明确记录了引用能力、数据前提、阻塞条件、预期输出和非目标。
- `solutions/mining-inspection/requirements-map.json` 将需求文档中的 8 条记录归一化为 7 个方案域;“排洪沟巡查”和“排洪沟堵塞识别”合并,避免重复模型、标注和规则。
- 七个方案域为:人员与个体防护、爆破警戒热成像、排洪沟与箱涵堵塞、边坡变化、竖井巡查、索道巡查、尾矿库异常筛查。优先级、就绪度和 A/B/C 实现边界分别记录,不能相互替代。
- 当前两个专项目录 `01-person-safety` 和 `02-drainage-blockage` 仍为 `scaffold`,没有运行脚本、模型下载或产品告警接入;其余方案域保留在需求映射和方案索引中。
- 项目数据流必须区分模型观察、空间/测量分析、事件候选和平台动作。安全帽以人员与安全帽检测及实例关联为主,属性分类只作为可选辅助。
- 爆破区热成像人员识别是受数据条件限制的 P1,不等同于 RGB 人员检测的成熟度;当前颜色规则基线不是排洪沟模型,RGB 浑浊或颜色异常也不能证明矿浆渗漏。
- `shared/configs/solution-result.schema.json` 规定项目算法输出必须区分模型观察结果与供人工复核的事件候选。
- 其余矿山需求先保留在 `solutions/mining-inspection/README.md` 索引中,待基础能力和现场数据满足条件后再创建专项目录。
## geoai-py 能力目录
- `capabilities/GEOAI_PY_DIRECT_CAPABILITIES.md` 已按 `geoai-py 0.42.0` 实际导出接口整理能力边界。
- 算法人员交付模型时,必须同时提供权重/许可证、输入波段与 CRS、类别或掩膜规范、推理参数、输出样例和评估结果。
- 当前对象检测环境已具备 Transformers、timm、Lightning 和 OpenCV;`torchange`、`omniwatermask`、`rfdetr`、`terratorch`、`segment-geospatial`、`detectron2` 和 `onnxruntime` 尚未安装。
- 目标检测、分割、分类、变化检测、水体/树冠/深度/嵌入等是算法产出可被 GeoAI 工作流承接的方向;电子围栏、告警、无人机控制、工单和调度仍属于项目产品能力。
## Object Detection Snapshot
apps/workbench-console/README.md
@@ -57,7 +57,7 @@
- `scripts/serve_workbench_console.py`:本地控制台服务和受限运行 API;仅暴露构建后的控制台、结果工件与必要原图,且只调用固定虚拟环境和能力脚本。
- `shared/outputs/`:能力原始输出;控制台不复制、不修改这些文件。
首版已接入 `01-object-detection` 和 `15-trajectory-analysis`。田墩实飞案例会显示
首版已接入 `01-object-detection`、`02-semantic-mapping` 和 `15-trajectory-analysis`。田墩实飞案例会显示
实飞轨迹、计划航线、禁飞区与适飞区,并提供轨迹和区域范围的聚焦按钮。其他能力
保留目录和边界状态,待其首个 Demo 产出可检查工件后再接入。
## 本地运行工作台
@@ -66,4 +66,5 @@
- 轨迹分析上传 `XLSX`、`KMZ`、禁飞区 `GeoJSON` 和可选适飞区 `Gzip`,原始、处理和输出分别归档在 `shared/data/raw`、`shared/data/processed`、`shared/outputs`。
- 目标检测上传最多 12 张 `JPG/JPEG/PNG`,原图和标注图默认并列对比,每次生成独立运行编号。
- 本地 API 为 `GET/POST /api/trajectory/runs` 和 `GET/POST /api/object-detection/runs`,仅监听回环地址,端口必须是 `6xxx`;服务只调用固定虚拟环境和能力脚本,不接受任意命令或任意路径。
- 语义分割上传最多 6 张 `JPG/JPEG/PNG/TIF/TIFF`,任务预设从能力目录动态读取;当前只有通用颜色流程可运行,排洪沟、边坡、竖井和尾矿库任务在模型与真值齐备前保持禁用。结果展示原图、栅格叠加、GeoJSON 矢量预览、类别比例和可下载工件。
- 本地 API 为 `GET/POST /api/trajectory/runs`、`GET/POST /api/object-detection/runs`、`GET/POST /api/semantic-mapping/runs` 和只读的 `GET /api/semantic-mapping/tasks`,仅监听回环地址,端口必须是 `6xxx`;服务只调用固定虚拟环境和能力脚本,不接受任意命令或任意路径。
apps/workbench-console/src/api/artifacts.ts
@@ -2,6 +2,10 @@
export interface DetectionImage { file: string; annotated_file: string; width: number; height: number; detections: Detection[]; }
export interface DetectionRun { detection_count: number; processed_images: number; elapsed_seconds: number; device: string; model: string; confidence: number; input_dir: string; notes: string[]; created_at?: string; }
export interface DetectionCase { id: string; label: string; note: string; artifactRoot: string; inputRoot: string; createdAt: string; run: DetectionRun; images: DetectionImage[]; }
export interface SemanticImage { file: string; width: number; height: number; mask_file: string; overlay_file: string; raster_file: string; vector_file: string; class_pixel_counts: Record<string, number>; georeferenced: boolean; vectorizer: string; elapsed_seconds: number; }
export interface SemanticRun { capability: string; classification: string; task_id?: string; task_name?: string; created_at?: string; geoai_version: string; method: string; model: string; device: string; thresholds: Record<string, number>; input_count: number; processed_images: number; elapsed_seconds: number; images: SemanticImage[]; classes: Array<{ id: number; key: string; label: string; color: number[] }>; limitations: string[]; input_dir?: string; raw_input_dir?: string; }
export interface SemanticCase { id: string; label: string; note: string; artifactRoot: string; inputRoot: string; rawInputRoot: string; createdAt: string; run: SemanticRun; }
export interface SemanticTask { id: string; name: string; status: string; selectable: boolean; input_sensor: string[]; classes: string[]; project_use?: string; required_evidence?: string[]; prohibited_claim?: string; }
export interface TrajectoryEvent { event_id: string; event_type: string; track_ids: string[]; start_time: string; end_time: string; duration_seconds: number; }
export interface TrajectoryCaseRun { case_id: string; input_count: number; track_count: number; event_count: number; dropped_duplicate_observations: number; elapsed_seconds: number; device: string; input: string; thresholds: Record<string, number>; created_at?: string; }
export interface TrajectorySummary { track_id: string; entity_type: string; point_count: string; distance_m: string; average_speed_mps: string; behavior_labels: string; }
@@ -9,6 +13,7 @@
interface CaseDefinition { id: string; label: string; note: string; artifactRoot: string; createdAt: string; }
interface TrajectoryDefinition extends CaseDefinition { showSpatialContext: boolean; showFlyableZones: boolean; }
interface DetectionDefinition extends CaseDefinition { inputRoot: string; }
interface SemanticDefinition extends CaseDefinition { inputRoot: string; rawInputRoot: string; }
export interface UploadFilePayload { name: string; content: string; }
export const artifactUrl = (path: string) => `/${path.replace(/\\/g, "/").split("/").map(encodeURIComponent).join("/")}`;
@@ -40,7 +45,18 @@
  return Object.fromEntries(cases.map((item) => [item.id, item]));
}
export async function loadSemanticArtifacts(): Promise<Record<string, SemanticCase>> {
  const { runs } = await getJson<{ runs: SemanticDefinition[] }>("api/semantic-mapping/runs");
  const cases = await Promise.all(runs.map(async (definition) => ({ ...definition, run: await getJson<SemanticRun>(`${definition.artifactRoot}/run_metadata.json`) } satisfies SemanticCase)));
  return Object.fromEntries(cases.map((item) => [item.id, item]));
}
export async function loadSemanticTasks(): Promise<SemanticTask[]> {
  return (await getJson<{ tasks: SemanticTask[] }>("api/semantic-mapping/tasks")).tasks;
}
async function postRun<T>(path: string, body: object): Promise<T> { return responseJson<T>(await fetch(artifactUrl(path), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })); }
export async function createTrajectoryRun(files: { flight: UploadFilePayload; route: UploadFilePayload; restricted: UploadFilePayload; flyable?: UploadFilePayload }) { return postRun<{ run: TrajectoryDefinition }>("api/trajectory/runs", { files }); }
export async function createDetectionRun(images: UploadFilePayload[]) { return postRun<{ run: DetectionDefinition }>("api/object-detection/runs", { images }); }
export async function createSemanticRun(images: UploadFilePayload[], taskId: string) { return postRun<{ run: SemanticDefinition }>("api/semantic-mapping/runs", { images, taskId }); }
export function readFileAsPayload(file: File): Promise<UploadFilePayload> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error(`无法读取 ${file.name}`)); reader.onload = () => { const value = String(reader.result ?? ""); resolve({ name: file.name, content: value.slice(value.indexOf(",") + 1) }); }; reader.readAsDataURL(file); }); }
apps/workbench-console/src/components/SemanticMappingPanel.vue
New file
@@ -0,0 +1,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>
apps/workbench-console/src/components/TrajectoryAnalysisPanel.vue
@@ -1,34 +1,114 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, ref } from "vue";
import { DownloadOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue";
import { artifactUrl, createTrajectoryRun, readFileAsPayload } from "@/api/artifacts";
import ArtifactState from "@/components/ArtifactState.vue";
import { useArtifactStore } from "@/stores/artifacts";
const TrajectoryMap = defineAsyncComponent(() => import("@/components/TrajectoryMap.vue"));
const store = useArtifactStore();
const caseId = ref(""); const showRunForm = ref(false); const running = ref(false); const runError = ref<string | null>(null);
const caseId = ref("");
const searchText = ref("");
const showRunForm = ref(false);
const running = ref(false);
const runError = ref<string | null>(null);
const inputs = ref<{ flight?: File; route?: File; restricted?: File; flyable?: File }>({});
const currentCase = computed(() => store.trajectoryCases[caseId.value] ?? Object.values(store.trajectoryCases)[0]);
const caseOptions = computed(() => Object.values(store.trajectoryCases).map((item) => ({ label: item.label, value: item.id })));
const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase())));
const eventNames: Record<string, string> = { normal: "正常", stop: "停留", route_deviation: "偏航", restricted_zone: "进入禁入区", gathering: "聚集" };
const currentRules = computed(() => currentCase.value?.run.thresholds ?? {});
const outputFiles = computed(() => { const base = ["events.json", "events.geojson", "trajectories.geojson", "reference_routes.geojson", "zones.geojson", "run_metadata.json"]; return currentCase.value?.showFlyableZones ? [...base.slice(0, 5), "flyable_zones.geojson", "run_metadata.json"] : base; });
function beforeUpload(key: keyof typeof inputs.value) { return (file: File) => { inputs.value = { ...inputs.value, [key]: file }; return false; }; }
function clearInput(key: keyof typeof inputs.value) { return () => { const copy = { ...inputs.value }; delete copy[key]; inputs.value = copy; }; }
function behaviorTags(value: string) { return (value || "normal").split("|").filter(Boolean).map((item) => ({ raw: item, label: eventNames[item] || item })); }
async function submitRun() { const { flight, route, restricted, flyable } = inputs.value; if (!flight || !route || !restricted) { runError.value = "请提供实际飞行 XLSX、规划航线 KMZ 和禁飞区 GeoJSON。"; return; } running.value = true; runError.value = null; try { const [flightPayload, routePayload, restrictedPayload, flyablePayload] = await Promise.all([readFileAsPayload(flight), readFileAsPayload(route), readFileAsPayload(restricted), flyable ? readFileAsPayload(flyable) : Promise.resolve(undefined)]); const { run } = await createTrajectoryRun({ flight: flightPayload, route: routePayload, restricted: restrictedPayload, ...(flyablePayload ? { flyable: flyablePayload } : {}) }); await store.loadTrajectory(true); caseId.value = run.id; inputs.value = {}; showRunForm.value = false; } catch (error) { runError.value = error instanceof Error ? error.message : "轨迹分析运行失败"; } finally { running.value = false; } }
onMounted(async () => { await store.loadTrajectory(); caseId.value = Object.keys(store.trajectoryCases)[0] ?? ""; });
const outputFiles = computed(() => {
  const base = ["events.json", "events.geojson", "trajectories.geojson", "reference_routes.geojson", "zones.geojson", "run_metadata.json"];
  return currentCase.value?.showFlyableZones ? [...base.slice(0, 5), "flyable_zones.geojson", "run_metadata.json"] : base;
});
function beforeUpload(key: keyof typeof inputs.value) {
  return (file: File) => {
    inputs.value = { ...inputs.value, [key]: file };
    return false;
  };
}
function clearInput(key: keyof typeof inputs.value) {
  return () => {
    const copy = { ...inputs.value };
    delete copy[key];
    inputs.value = copy;
  };
}
function behaviorTags(value: string) {
  return (value || "normal").split("|").filter(Boolean).map((item) => ({ raw: item, label: eventNames[item] || item }));
}
async function submitRun() {
  const { flight, route, restricted, flyable } = inputs.value;
  if (!flight || !route || !restricted) {
    runError.value = "请提供实际飞行 XLSX、规划航线 KMZ 和禁飞区 GeoJSON。";
    return;
  }
  running.value = true;
  runError.value = null;
  try {
    const [flightPayload, routePayload, restrictedPayload, flyablePayload] = await Promise.all([
      readFileAsPayload(flight),
      readFileAsPayload(route),
      readFileAsPayload(restricted),
      flyable ? readFileAsPayload(flyable) : Promise.resolve(undefined)
    ]);
    const { run } = await createTrajectoryRun({ flight: flightPayload, route: routePayload, restricted: restrictedPayload, ...(flyablePayload ? { flyable: flyablePayload } : {}) });
    await store.loadTrajectory(true);
    caseId.value = run.id;
    inputs.value = {};
    showRunForm.value = false;
  } catch (error) {
    runError.value = error instanceof Error ? error.message : "轨迹分析运行失败";
  } finally {
    running.value = false;
  }
}
onMounted(async () => {
  await store.loadTrajectory();
  caseId.value = Object.keys(store.trajectoryCases)[0] ?? "";
});
</script>
<template>
  <ArtifactState :loading="store.loading" :error="store.error" />
  <section class="workspace-command"><div><h2>新建轨迹分析</h2><p>上传原始航线与飞行日志后,控制台会自动转换为分析输入并保存为新的本地运行。</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="适飞区为可选图层,只用于地图展示;禁飞区相交是技术空间结果,不是违规结论。" /><div class="upload-grid"><div><label>实际飞行轨迹(XLSX)</label><a-upload accept=".xlsx" :max-count="1" :before-upload="beforeUpload('flight')" @remove="clearInput('flight')"><a-button><UploadOutlined />{{ inputs.flight?.name || "选择 XLSX" }}</a-button></a-upload></div><div><label>规划航线(KMZ)</label><a-upload accept=".kmz" :max-count="1" :before-upload="beforeUpload('route')" @remove="clearInput('route')"><a-button><UploadOutlined />{{ inputs.route?.name || "选择 KMZ" }}</a-button></a-upload></div><div><label>禁飞区(GeoJSON)</label><a-upload accept=".geojson,.json" :max-count="1" :before-upload="beforeUpload('restricted')" @remove="clearInput('restricted')"><a-button><UploadOutlined />{{ inputs.restricted?.name || "选择 GeoJSON" }}</a-button></a-upload></div><div><label>适飞区(可选 Gzip)</label><a-upload accept=".gzip,.gz" :max-count="1" :before-upload="beforeUpload('flyable')" @remove="clearInput('flyable')"><a-button><UploadOutlined />{{ inputs.flyable?.name || "选择 Gzip" }}</a-button></a-upload></div></div><a-alert v-if="runError" type="error" show-icon :message="runError" /><a-button type="primary" :loading="running" @click="submitRun"><PlayCircleOutlined />开始轨迹分析</a-button></section>
  <template v-if="currentCase"><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">{{ 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"><span class="toolbar-note">{{ currentCase.note }}</span></div><TrajectoryMap :artifact-root="currentCase.artifactRoot" :show-spatial-context="currentCase.showSpatialContext" :show-flyable-zones="currentCase.showFlyableZones" /></section></a-col></a-row>
    <a-row :gutter="[18, 18]" class="result-row"><a-col :xs="24" :xl="8"><section class="surface-section"><h2>本次运行</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="轨迹">{{ currentCase.run.track_count }} 条</a-descriptions-item><a-descriptions-item label="事件">{{ currentCase.run.event_count }} 个</a-descriptions-item><a-descriptions-item label="重复清洗">{{ currentCase.run.dropped_duplicate_observations }} 条</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><a-divider /><h3>默认规则</h3><a-descriptions size="small" :column="1"><a-descriptions-item label="停留速度">{{ currentRules.stop_speed_mps }} m/s</a-descriptions-item><a-descriptions-item label="偏航距离">{{ currentRules.route_deviation_m }} m</a-descriptions-item><a-descriptions-item label="聚集距离">{{ currentRules.gathering_radius_m }} m</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="7"><section class="surface-section"><div class="section-heading"><div><h2>事件记录</h2><p>{{ currentCase.events.length }} 个规则事件</p></div></div><a-empty v-if="!currentCase.events.length" description="当前案例未触发规则事件" /><a-timeline v-else><a-timeline-item v-for="event in currentCase.events" :key="event.event_id" :color="event.event_type === 'gathering' ? 'green' : event.event_type === 'restricted_zone' ? 'gold' : event.event_type === 'route_deviation' ? 'purple' : 'red'"><strong>{{ eventNames[event.event_type] || event.event_type }}</strong><p>{{ event.track_ids.join('、') }}</p><small>{{ new Date(event.start_time).toLocaleString('zh-CN', { hour12: false }) }} · {{ event.duration_seconds }} 秒</small></a-timeline-item></a-timeline></section></a-col><a-col :xs="24" :xl="9"><section class="surface-section"><div class="section-heading"><div><h2>轨迹汇总</h2><p>行为标签以中文展示;英文值仍保留在结构化输出中。</p></div></div><a-table :data-source="currentCase.summary" :pagination="false" row-key="track_id" size="small" :scroll="{ x: 720 }"><a-table-column title="轨迹" data-index="track_id" key="track_id" /><a-table-column title="点数" data-index="point_count" key="point_count" align="right" /><a-table-column title="距离" key="distance" align="right"><template #default="{ record }">{{ Number(record.distance_m).toFixed(1) }} m</template></a-table-column><a-table-column title="行为标签" key="behavior_labels"><template #default="{ record }"><a-space wrap><a-tag v-for="tag in behaviorTags(record.behavior_labels)" :key="tag.raw" :color="tag.raw === 'normal' ? 'green' : 'gold'">{{ tag.label }}</a-tag></a-space></template></a-table-column></a-table></section></a-col></a-row>
  <section class="workspace-command">
    <div><h2>新建轨迹分析</h2><p>上传原始航线与飞行日志后,控制台会自动转换为分析输入并保存为新的本地运行。</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="适飞区为可选图层,只用于地图展示;禁飞区相交是技术空间结果,不是违规结论。" />
    <div class="upload-grid">
      <div><label>实际飞行轨迹(XLSX)</label><a-upload accept=".xlsx" :max-count="1" :before-upload="beforeUpload('flight')" @remove="clearInput('flight')"><a-button><UploadOutlined />{{ inputs.flight?.name || "选择 XLSX" }}</a-button></a-upload></div>
      <div><label>规划航线(KMZ)</label><a-upload accept=".kmz" :max-count="1" :before-upload="beforeUpload('route')" @remove="clearInput('route')"><a-button><UploadOutlined />{{ inputs.route?.name || "选择 KMZ" }}</a-button></a-upload></div>
      <div><label>禁飞区(GeoJSON)</label><a-upload accept=".geojson" :max-count="1" :before-upload="beforeUpload('restricted')" @remove="clearInput('restricted')"><a-button><UploadOutlined />{{ inputs.restricted?.name || "选择 GeoJSON" }}</a-button></a-upload></div>
      <div><label>适飞区(可选 Gzip)</label><a-upload accept=".gzip" :max-count="1" :before-upload="beforeUpload('flyable')" @remove="clearInput('flyable')"><a-button><UploadOutlined />{{ inputs.flyable?.name || "选择 Gzip" }}</a-button></a-upload></div>
    </div>
    <a-alert v-if="runError" type="error" show-icon :message="runError" />
    <a-button type="primary" :loading="running" @click="submitRun"><PlayCircleOutlined />开始轨迹分析</a-button>
  </section>
  <div v-if="currentCase" class="trajectory-workspace">
    <a-row :gutter="[18, 18]" class="workspace-top-row">
      <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">{{ item.label }}</a-list-item></template></a-list></section></a-col>
      <a-col :xs="24" :xl="19"><section class="surface-section map-panel"><div class="section-toolbar"><span class="toolbar-note">{{ currentCase.note }}</span></div><TrajectoryMap :artifact-root="currentCase.artifactRoot" :show-spatial-context="currentCase.showSpatialContext" :show-flyable-zones="currentCase.showFlyableZones" /></section></a-col>
    </a-row>
    <a-row :gutter="[18, 18]" class="result-row">
      <a-col :xs="24" :xl="8"><section class="surface-section"><h2>本次运行</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="轨迹">{{ currentCase.run.track_count }} 条</a-descriptions-item><a-descriptions-item label="事件">{{ currentCase.run.event_count }} 个</a-descriptions-item><a-descriptions-item label="重复清洗">{{ currentCase.run.dropped_duplicate_observations }} 条</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><a-divider /><h3>默认规则</h3><a-descriptions size="small" :column="1"><a-descriptions-item label="停留速度">{{ currentRules.stop_speed_mps }} m/s</a-descriptions-item><a-descriptions-item label="偏航距离">{{ currentRules.route_deviation_m }} m</a-descriptions-item><a-descriptions-item label="聚集距离">{{ currentRules.gathering_radius_m }} m</a-descriptions-item></a-descriptions></section></a-col>
      <a-col :xs="24" :xl="8"><section class="surface-section"><div class="section-heading"><div><h2>事件记录</h2><p>{{ currentCase.events.length }} 个规则事件</p></div></div><a-empty v-if="!currentCase.events.length" description="当前案例未触发规则事件" /><a-timeline v-else><a-timeline-item v-for="event in currentCase.events" :key="event.event_id" :color="event.event_type === 'gathering' ? 'green' : event.event_type === 'restricted_zone' ? 'gold' : event.event_type === 'route_deviation' ? 'purple' : 'red'"><strong>{{ eventNames[event.event_type] || event.event_type }}</strong><p>{{ event.track_ids.join('、') }}</p><small>{{ new Date(event.start_time).toLocaleString('zh-CN', { hour12: false }) }} · {{ event.duration_seconds }} 秒</small></a-timeline-item></a-timeline></section></a-col>
      <a-col :xs="24" :xl="8"><section class="surface-section"><div class="section-heading"><div><h2>轨迹汇总</h2><p>行为标签以中文展示;英文值仍保留在结构化输出中。</p></div></div><a-table :data-source="currentCase.summary" :pagination="false" row-key="track_id" size="small" :scroll="{ x: 720 }"><a-table-column title="轨迹" data-index="track_id" key="track_id" /><a-table-column title="点数" data-index="point_count" key="point_count" align="right" /><a-table-column title="距离" key="distance" align="right"><template #default="{ record }">{{ Number(record.distance_m).toFixed(1) }} m</template></a-table-column><a-table-column title="行为标签" key="behavior_labels"><template #default="{ record }"><a-space wrap><a-tag v-for="tag in behaviorTags(record.behavior_labels)" :key="tag.raw" :color="tag.raw === 'normal' ? 'green' : 'gold'">{{ tag.label }}</a-tag></a-space></template></a-table-column></a-table></section></a-col>
    </a-row>
    <section class="surface-section result-files"><a-space wrap><a-button v-for="file in outputFiles" :key="file" :href="artifactUrl(`${currentCase.artifactRoot}/${file}`)" target="_blank"><DownloadOutlined />{{ file }}</a-button></a-space></section>
  </template>
  </div>
</template>
apps/workbench-console/src/data/capabilities.ts
@@ -11,7 +11,7 @@
export const capabilities: CapabilityRecord[] = [
  { id: "00-change-detection", title: "变化检测", level: "A", status: "existing", note: "现有产品能力已确认;本地 Demo 尚未实现。" },
  { id: "01-object-detection", title: "地物目标检测", level: "A", status: "verified", note: "人员与车辆实验可运行,含标注影像和结构化检测结果。" },
  { id: "02-semantic-mapping", title: "语义制图", level: "A", status: "planned", note: "等待首个本地 Demo。" },
  { id: "02-semantic-mapping", title: "语义分割", level: "B", status: "verified", note: "CPU 颜色规则基线,展示栅格掩膜、叠加图和 GeoAI 矢量结果。" },
  { id: "03-attribute-classification", title: "属性分类", level: "A", status: "planned", note: "等待首个本地 Demo。" },
  { id: "04-spatial-measurement", title: "空间测量", level: "B", status: "planned", note: "等待首个本地 Demo。" },
  { id: "05-3d-pointcloud", title: "三维点云", level: "B", status: "planned", note: "等待首个本地 Demo。" },
apps/workbench-console/src/stores/artifacts.ts
@@ -1,15 +1,49 @@
import { defineStore } from "pinia";
import { loadDetectionArtifacts, loadTrajectoryArtifacts, type DetectionCase, type TrajectoryCase } from "@/api/artifacts";
interface ArtifactState { detectionCases: Record<string, DetectionCase>; trajectoryCases: Record<string, TrajectoryCase>; loading: boolean; error: string | null; }
import {
  loadDetectionArtifacts,
  loadSemanticArtifacts,
  loadTrajectoryArtifacts,
  type DetectionCase,
  type SemanticCase,
  type TrajectoryCase
} from "@/api/artifacts";
interface ArtifactState {
  detectionCases: Record<string, DetectionCase>;
  trajectoryCases: Record<string, TrajectoryCase>;
  semanticCases: Record<string, SemanticCase>;
  loading: boolean;
  error: string | null;
}
export const useArtifactStore = defineStore("artifacts", {
  getters: {
    detectionRun: (state) => Object.values(state.detectionCases)[0]?.run ?? null,
    detectionImages: (state) => Object.values(state.detectionCases)[0]?.images ?? []
  },
  state: (): ArtifactState => ({ detectionCases: {}, trajectoryCases: {}, loading: false, error: null }),
  state: (): ArtifactState => ({ detectionCases: {}, trajectoryCases: {}, semanticCases: {}, loading: false, error: null }),
  actions: {
    async loadDetection(force = false) { if (!force && Object.keys(this.detectionCases).length) return; this.loading = true; this.error = null; try { this.detectionCases = await loadDetectionArtifacts(); } catch (error) { this.error = error instanceof Error ? error.message : "目标检测结果读取失败"; } finally { this.loading = false; } },
    async loadTrajectory(force = false) { if (!force && Object.keys(this.trajectoryCases).length) return; this.loading = true; this.error = null; try { this.trajectoryCases = await loadTrajectoryArtifacts(); } catch (error) { this.error = error instanceof Error ? error.message : "轨迹分析结果读取失败"; } finally { this.loading = false; } }
    async loadDetection(force = false) {
      if (!force && Object.keys(this.detectionCases).length) return;
      this.loading = true; this.error = null;
      try { this.detectionCases = await loadDetectionArtifacts(); }
      catch (error) { this.error = error instanceof Error ? error.message : "目标检测结果读取失败"; }
      finally { this.loading = false; }
    },
    async loadTrajectory(force = false) {
      if (!force && Object.keys(this.trajectoryCases).length) return;
      this.loading = true; this.error = null;
      try { this.trajectoryCases = await loadTrajectoryArtifacts(); }
      catch (error) { this.error = error instanceof Error ? error.message : "轨迹分析结果读取失败"; }
      finally { this.loading = false; }
    },
    async loadSemantic(force = false) {
      if (!force && Object.keys(this.semanticCases).length) return;
      this.loading = true; this.error = null;
      try { this.semanticCases = await loadSemanticArtifacts(); }
      catch (error) { this.error = error instanceof Error ? error.message : "语义分割结果读取失败"; }
      finally { this.loading = false; }
    }
  }
});
apps/workbench-console/src/styles.css
@@ -1,93 +1,176 @@
:root { color: #1b2620; background: #f3f5f1; font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif; font-synthesis: none; }
:root {
  --wb-bg: #f3f5f1;
  --wb-surface: #ffffff;
  --wb-border: #d9e0da;
  --wb-text: #202b25;
  --wb-muted: #66716b;
  --wb-green: #176b50;
  --wb-green-soft: #eaf3ed;
  --wb-sidebar: #1e2923;
  --wb-space-1: 8px;
  --wb-space-2: 16px;
  --wb-space-3: 24px;
  --wb-panel-padding: 18px;
  --wb-shadow: 0 9px 25px rgba(20, 43, 30, 0.06);
  color: #1b2620;
  background: var(--wb-bg);
  font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif;
  font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: #f3f5f1; }
body { min-width: 320px; margin: 0; background: var(--wb-bg); }
/* Application shell */
.application-frame { min-height: 100vh; }
.application-sider { position: sticky !important; top: 0; height: 100vh; background: #1e2923 !important; border-right: 1px solid #152019; }
.application-sider { position: sticky !important; top: 0; height: 100vh; background: var(--wb-sidebar) !important; border-right: 1px solid #152019; }
.content-layout { min-width: 0; background: var(--wb-bg) !important; }
.brand { display: flex; align-items: center; gap: 10px; height: 82px; padding: 18px 20px; color: #f4f9f5; cursor: pointer; }
.brand-mark { display: grid; width: 34px; height: 34px; place-items: center; background: #dcf0e4; color: #195d43; font-size: 12px; font-weight: 800; }
.brand strong, .brand small { display: block; }.brand strong { font-size: 14px; letter-spacing: 0; }.brand small { color: #aac0b0; font-size: 11px; }
.navigation-menu { height: calc(100vh - 150px); overflow-y: auto; background: #1e2923 !important; border-inline-end: 0 !important; }.navigation-menu .ant-menu-item-group-title { color: #9eb2a4 !important; font-size: 11px; font-weight: 700; letter-spacing: 0; }.navigation-menu .ant-menu-item { margin-inline: 0 !important; width: 100% !important; border-radius: 0 !important; }.navigation-menu .ant-menu-item-selected { background: #31443a !important; }.navigation-menu .ant-menu-item::after { border-inline-end-color: #79c99e !important; }
.sider-status { position: absolute; right: 18px; bottom: 18px; left: 18px; display: flex; gap: 9px; padding-top: 14px; color: #dce8e0; border-top: 1px solid #42534a; }.sider-status .anticon { margin-top: 4px; color: #79c99e; }.sider-status strong, .sider-status small { display: block; }.sider-status strong { font-size: 12px; }.sider-status small { color: #a9bcb0; font-size: 11px; }
.content-layout { background: #f3f5f1 !important; }.view-container { width: min(1460px, 100%); margin: 0 auto; padding: 34px 42px 58px; }.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; }.eyebrow { margin: 0 0 6px; color: #176b50; font-size: 12px; font-weight: 700; letter-spacing: 0; }.page-header h1 { margin: 0 0 8px; color: #19231e; font-size: 29px; line-height: 1.2; letter-spacing: 0; }.page-description { max-width: 800px; margin: 0; color: #66716b; }.local-tag { margin: 2px 0 0 !important; border-radius: 3px !important; }
.loading-block { display: flex; gap: 9px; align-items: center; justify-content: center; min-height: 150px; color: #66716b; }.metric-row { margin-bottom: 30px; }.metric-row .ant-statistic { min-height: 106px; padding: 17px; background: #ffffff; border: 1px solid #d9e0da; box-shadow: 0 9px 25px rgba(20, 43, 30, 0.06); }.metric-row .ant-statistic-content { color: #1c392b; font-size: 29px; }.view-section { margin-top: 32px; }.section-heading { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 14px; }.section-heading h2, .surface-section h2 { margin: 0 0 4px; color: #202b25; font-size: 18px; letter-spacing: 0; }.section-heading p, .surface-section p { margin: 0; color: #66716b; font-size: 12px; }
.capability-card { overflow: hidden; border-radius: 4px !important; border-color: #d9e0da !important; box-shadow: 0 9px 25px rgba(20, 43, 30, 0.07) !important; }.card-media { display: block; width: 100%; height: 260px; object-fit: cover; background: #e0e6e1; }.card-copy { padding: 18px; }.card-topline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }.card-topline h3 { margin: 0 0 6px; color: #202b25; font-size: 16px; letter-spacing: 0; }.card-topline p, .planned-card p { color: #66716b; }.card-copy .ant-space { display: flex; min-height: 40px; align-content: flex-start; margin-top: 14px; }.card-copy .ant-btn { margin-top: 8px; }.planned-card { height: 100%; border-radius: 4px !important; border-color: #d9e0da !important; }.planned-card p { min-height: 38px; margin: 7px 0 8px; font-size: 12px; }
.surface-section { margin-bottom: 18px; padding: 20px; background: #ffffff; border: 1px solid #d9e0da; box-shadow: 0 9px 25px rgba(20, 43, 30, 0.06); }.section-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 13px; }.section-toolbar .ant-select { width: min(65%, 540px); }.detection-image { display: block; width: 100%; min-height: 390px; max-height: 660px; overflow: hidden; background: #232e28; text-align: center; }.detection-image .ant-image-img { width: 100%; max-height: 660px; object-fit: contain; }.image-caption { padding-top: 9px; }.surface-section .ant-descriptions { font-size: 12px; }.surface-section .ant-descriptions-item-label { color: #66716b; }.surface-section h3 { margin: 0 0 8px; font-size: 14px; }.result-row { margin-top: 0; }.result-files { margin-bottom: 0; }.toolbar-note { color: #66716b; font-size: 12px; text-align: right; }
.workspace-command { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin: 0 0 18px; padding: 16px 20px; background: #eaf3ed; border: 1px solid #c9ddcf; }.workspace-command h2 { margin: 0 0 4px; font-size: 16px; }.workspace-command p { margin: 0; color: #66716b; font-size: 12px; }.run-form { display: grid; gap: 16px; }.run-form .ant-alert { margin: 0; }.run-upload { display: block; }.upload-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }.upload-grid label { display: block; margin-bottom: 7px; color: #66716b; font-size: 12px; }.run-library { min-height: 100%; }.run-library h2 { margin-bottom: 12px; }.run-item { display: block !important; padding: 9px 10px !important; color: #425148; cursor: pointer; border: 0 !important; }.run-item.active { color: #176b50; background: #e6f2ea; font-weight: 700; }.comparison-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }.comparison-grid figure { min-width: 0; margin: 0; }.comparison-grid figcaption { margin-bottom: 7px; color: #425148; font-size: 12px; font-weight: 700; }.comparison-grid .detection-image { min-height: 280px; height: 430px; }.comparison-grid .detection-image .ant-image-img { height: 430px; }
.trajectory-map { position: relative; height: 510px; overflow: hidden; background: #1b2620; }.cesium-host { width: 100%; height: 100%; }.map-error { position: absolute; right: 14px; bottom: 14px; left: 14px; }.map-legend { position: absolute; bottom: 14px; left: 14px; display: flex; gap: 10px; flex-wrap: wrap; max-width: calc(100% - 28px); padding: 7px 9px; color: #f8fbf9; background: rgba(20, 32, 25, 0.82); border: 1px solid rgba(224, 239, 229, 0.45); font-size: 12px; }.map-legend span { display: inline-flex; gap: 5px; align-items: center; white-space: nowrap; }.map-legend i { display: inline-block; width: 12px; height: 12px; border: 1px solid rgba(255, 255, 255, 0.85); }.legend-track { background: #176b50; }.legend-route { background: #444444; }.legend-restricted { background: #e5c951; }.legend-flyable { background: #36a269; }.map-tools { position: absolute; top: 12px; left: 12px; display: flex; gap: 6px; }.map-tools .ant-btn { border-color: #b8c9bd; color: #1f4834; }.cesium-viewer-bottom { display: none !important; }.cesium-viewer-toolbar { top: 10px; right: 10px; }.ant-empty { margin: 70px 0; }.ant-empty-description p { color: #66716b; }
@media (max-width: 991px) { .application-sider { position: static !important; height: auto; }.navigation-menu { height: auto; max-height: 290px; }.sider-status { display: none; }.view-container { padding: 25px 22px 42px; }.detection-image { min-height: 270px; }.trajectory-map { height: 440px; }.upload-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) { .view-container { padding: 20px 14px 34px; }.page-header, .section-toolbar, .workspace-command { align-items: stretch; flex-direction: column; }.page-header h1 { font-size: 24px; }.local-tag { align-self: flex-start; }.section-toolbar .ant-select { width: 100%; }.toolbar-note { text-align: left; }.surface-section { padding: 14px; }.trajectory-map { height: 380px; }.card-media { height: 205px; }.upload-grid, .comparison-grid { grid-template-columns: 1fr; }.comparison-grid .detection-image, .comparison-grid .detection-image .ant-image-img { height: 300px; } }
/* Layout refinement: keep the workbench dense, aligned, and readable at desktop widths. */
.content-layout { min-width: 0; }
.view-container { width: 100%; max-width: 1680px; padding: 28px 32px 48px; }
.page-header { margin-bottom: 22px; }
.page-header h1 { font-size: 27px; }
.metric-row { margin-bottom: 24px; }
.metric-row .ant-statistic { min-height: 96px; padding: 15px 16px; }
.metric-row .ant-statistic-title { margin-bottom: 8px; font-size: 12px; }
.metric-row .ant-statistic-content { font-size: 26px; }
.brand strong, .brand small, .sider-status strong, .sider-status small { display: block; }
.brand strong { font-size: 14px; letter-spacing: 0; }
.brand small { color: #aac0b0; font-size: 11px; }
.navigation-menu { height: calc(100vh - 150px); overflow-y: auto; background: var(--wb-sidebar) !important; border-inline-end: 0 !important; }
.navigation-menu .ant-menu-item-group-title { color: #9eb2a4 !important; font-size: 11px; font-weight: 700; letter-spacing: 0; }
.navigation-menu .ant-menu-item { width: 100% !important; margin-inline: 0 !important; border-radius: 0 !important; }
.navigation-menu .ant-menu-item-selected { background: #31443a !important; }
.navigation-menu .ant-menu-item::after { border-inline-end-color: #79c99e !important; }
.sider-status { position: absolute; right: 18px; bottom: 18px; left: 18px; display: flex; gap: 9px; padding-top: 14px; color: #dce8e0; border-top: 1px solid #42534a; }
.sider-status .anticon { margin-top: 4px; color: #79c99e; }
.sider-status strong { font-size: 12px; }
.sider-status small { color: #a9bcb0; font-size: 11px; }
/* Page primitives */
.view-container { width: 100%; max-width: 1680px; margin: 0 auto; padding: 28px 32px 48px; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--wb-space-3); margin-bottom: 22px; }
.eyebrow { margin: 0 0 6px; color: var(--wb-green); font-size: 12px; font-weight: 700; letter-spacing: 0; }
.page-header h1 { margin: 0 0 var(--wb-space-1); color: #19231e; font-size: 27px; line-height: 1.2; letter-spacing: 0; }
.page-description { max-width: 800px; margin: 0; color: var(--wb-muted); }
.local-tag { margin: 2px 0 0 !important; border-radius: 3px !important; }
.loading-block { display: flex; align-items: center; justify-content: center; gap: 9px; min-height: 150px; color: var(--wb-muted); }
.view-section { margin-top: 26px; }
.section-heading { margin-bottom: 12px; }
.surface-section { margin-bottom: 16px; padding: 17px 18px; }
.surface-section h2 { font-size: 17px; }
.section-toolbar { min-height: 32px; margin-bottom: 10px; }
.run-library { min-height: 0; height: auto; align-self: flex-start; }
.run-library .ant-input-search { margin-bottom: 8px; }
.section-heading { display: flex; justify-content: space-between; gap: var(--wb-space-2); margin-bottom: 12px; }
.section-heading h2, .surface-section h2 { margin: 0 0 4px; color: var(--wb-text); font-size: 17px; letter-spacing: 0; }
.section-heading p, .surface-section p { margin: 0; color: var(--wb-muted); font-size: 12px; }
/* Shared cards and overview */
.surface-section { padding: var(--wb-panel-padding); background: var(--wb-surface); border: 1px solid var(--wb-border); box-shadow: var(--wb-shadow); }
.metric-row { margin-bottom: var(--wb-space-3); }
.metric-row .ant-statistic { min-height: 96px; padding: 15px 16px; background: var(--wb-surface); border: 1px solid var(--wb-border); box-shadow: var(--wb-shadow); }
.metric-row .ant-statistic-title { margin-bottom: var(--wb-space-1); font-size: 12px; }
.metric-row .ant-statistic-content { color: #1c392b; font-size: 26px; }
.capability-card, .planned-card { border-color: var(--wb-border) !important; border-radius: 4px !important; }
.capability-card { overflow: hidden; box-shadow: var(--wb-shadow) !important; }
.card-media { display: block; width: 100%; height: 260px; object-fit: cover; background: #e0e6e1; }
.card-copy { padding: 18px; }
.card-topline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.card-topline h3 { margin: 0 0 6px; color: var(--wb-text); font-size: 16px; letter-spacing: 0; }
.card-topline p, .planned-card p { color: var(--wb-muted); }
.card-copy .ant-space { display: flex; min-height: 40px; margin-top: 14px; align-content: flex-start; }
.card-copy .ant-btn { margin-top: var(--wb-space-1); }
.planned-card { height: 100%; }
.planned-card p { min-height: 38px; margin: 7px 0 var(--wb-space-1); font-size: 12px; }
/* Shared workbench controls */
.workspace-command { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin: 0 0 var(--wb-space-2); padding: 14px 17px; background: var(--wb-green-soft); border: 1px solid #c9ddcf; }
.workspace-command h2 { margin: 0 0 4px; font-size: 16px; }
.workspace-command p { margin: 0; color: var(--wb-muted); font-size: 12px; }
.run-form, .semantic-run-form { display: grid; gap: 13px; margin-bottom: var(--wb-space-3); }
.run-form .ant-alert { margin: 0; }
.upload-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.upload-grid label { display: block; margin-bottom: 7px; color: var(--wb-muted); font-size: 12px; }
.section-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 32px; margin-bottom: 10px; }
.section-toolbar .ant-select { width: min(65%, 540px); }
.toolbar-note { color: var(--wb-muted); font-size: 12px; text-align: right; }
.run-library { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: hidden; }
.run-library h2 { margin-bottom: 12px; }
.run-library .ant-input-search { margin-bottom: var(--wb-space-1); }
.run-library .ant-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.run-library .ant-list-item { min-height: 34px; }
.run-item { padding: 7px 9px !important; }
.trajectory-map { height: 500px; }
.result-row { align-items: flex-start; }
.result-row > .ant-col-xl-7,
.result-row > .ant-col-xl-8,
.result-row > .ant-col-xl-9 { flex: 0 0 33.333333% !important; max-width: 33.333333% !important; }
.result-row .surface-section { height: auto; min-height: 100%; }
.result-files { display: flex; align-items: center; min-height: 54px; padding: 10px 14px; }
.result-files .ant-space { row-gap: 8px !important; column-gap: 8px !important; }
.run-item { display: block !important; padding: 7px 9px !important; color: #425148; cursor: pointer; border: 0 !important; }
.run-item.active { color: var(--wb-green); background: #e6f2ea; font-weight: 700; }
/* Workspace row layout */
.workspace-top-row, .semantic-workspace, .detection-workspace > .ant-row { align-items: stretch; margin-bottom: var(--wb-space-3); }
.workspace-top-row > .ant-col, .semantic-workspace > .ant-col, .detection-workspace > .ant-row > .ant-col, .trajectory-workspace .result-row > .ant-col { display: flex; }
.workspace-top-row > .ant-col > .surface-section, .semantic-workspace > .ant-col > .surface-section, .detection-workspace > .ant-row > .ant-col > .surface-section, .trajectory-workspace .result-row > .ant-col > .surface-section { width: 100%; margin-bottom: 0; }
.trajectory-workspace .result-row { align-items: stretch; margin-bottom: var(--wb-space-3); }
.trajectory-workspace .result-row > .ant-col > .surface-section { flex: 1 1 auto; }
.trajectory-workspace .result-files { display: flex; align-items: center; min-height: 54px; margin: 0; padding: 10px 14px; }
.result-files .ant-space { row-gap: var(--wb-space-1) !important; column-gap: var(--wb-space-1) !important; }
.result-files .ant-btn { height: 31px; padding-inline: 10px; font-size: 12px; }
.surface-section .ant-divider { margin: 16px 0; }
.surface-section .ant-table-wrapper { margin-top: 8px; }
.result-band { margin-bottom: var(--wb-space-3); }
.result-band.ant-row { align-items: stretch; }
.result-band.ant-row > .ant-col { display: flex; }
.result-band.ant-row > .ant-col > .surface-section { width: 100%; margin-bottom: 0; }
/* Object detection workspace */
.comparison-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.comparison-grid figure { min-width: 0; margin: 0; }
.comparison-grid figcaption { margin-bottom: 7px; padding: 0 2px; color: #425148; font-size: 12px; font-weight: 700; }
.detection-image { display: block; width: 100%; min-height: 280px; overflow: hidden; background: #232e28; text-align: center; }
.detection-image .ant-image-img { width: 100%; object-fit: contain; }
.comparison-grid .detection-image, .comparison-grid .detection-image .ant-image-img { height: 400px; }
.image-caption { padding-top: 9px; }
.detection-workspace > .ant-row + .ant-row > .ant-col > .surface-section { flex: 1 1 auto; }
/* Trajectory workspace and map */
.map-panel { display: flex; flex-direction: column; }
.trajectory-map { position: relative; height: 500px; overflow: hidden; background: #1b2620; }
.cesium-host { width: 100%; height: 100%; }
.map-error { position: absolute; right: 14px; bottom: 14px; left: 14px; }
.map-legend { position: absolute; bottom: 14px; left: 14px; display: flex; flex-wrap: wrap; gap: 10px; max-width: calc(100% - 28px); padding: 7px 9px; color: #f8fbf9; background: rgba(20, 32, 25, 0.82); border: 1px solid rgba(224, 239, 229, 0.45); font-size: 12px; }
.map-legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.map-legend i { display: inline-block; width: 12px; height: 12px; border: 1px solid rgba(255, 255, 255, 0.85); }
.legend-track { background: #176b50; }
.legend-route { background: #444444; }
.legend-restricted { background: #e5c951; }
.legend-flyable { background: #36a269; }
.map-tools { position: absolute; top: 12px; left: 12px; display: flex; gap: 6px; }
.map-tools .ant-btn { border-color: #b8c9bd; color: #1f4834; }
.cesium-viewer-bottom { display: none !important; }
.cesium-viewer-toolbar { top: 10px; right: 10px; }
/* Shared result content */
.surface-section .ant-descriptions { font-size: 12px; }
.surface-section .ant-descriptions-item-label { color: var(--wb-muted); }
.surface-section h3 { margin: 0 0 var(--wb-space-1); font-size: 14px; }
.surface-section .ant-divider { margin: var(--wb-space-2) 0; }
.surface-section .ant-table-wrapper { margin-top: var(--wb-space-1); }
.surface-section .ant-table-thead > tr > th { padding: 9px 10px; font-size: 12px; }
.surface-section .ant-table-tbody > tr > td { padding: 8px 10px; font-size: 12px; }
.surface-section .ant-table-tbody > tr > td { padding: var(--wb-space-1) 10px; font-size: 12px; }
.surface-section .ant-timeline { margin-top: 12px; }
.surface-section .ant-timeline-item { padding-bottom: 17px; }
.comparison-grid { gap: 12px; }
.comparison-grid figcaption { padding: 0 2px; }
.comparison-grid .detection-image, .comparison-grid .detection-image .ant-image-img { height: 400px; }
.workspace-command { margin-bottom: 16px; padding: 14px 17px; }
.run-form { gap: 13px; }
.upload-grid { gap: 12px; }
.ant-empty { margin: 70px 0; }
.ant-empty-description p { color: var(--wb-muted); }
/* Responsive layout */
@media (max-width: 1199px) {
  .view-container { max-width: 100%; padding-inline: 26px; }
  .view-container { padding-inline: 26px; }
  .workspace-top-row > .ant-col, .semantic-workspace > .ant-col, .detection-workspace > .ant-row > .ant-col, .trajectory-workspace .result-row > .ant-col { display: block; }
  .run-library { min-height: 0; overflow: visible; }
  .run-library .ant-list { overflow: visible; }
  .trajectory-map { height: 440px; }
  .upload-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 991px) {
  .application-sider { position: static !important; height: auto; }
  .navigation-menu { height: auto; max-height: 290px; }
  .sider-status { display: none; }
}
@media (max-width: 640px) {
  .view-container { padding: 20px 14px 34px; }
  .page-header, .section-toolbar, .workspace-command { align-items: stretch; flex-direction: column; }
  .page-header h1 { font-size: 24px; }
  .local-tag { align-self: flex-start; }
  .section-toolbar .ant-select { width: 100%; }
  .toolbar-note { text-align: left; }
  .surface-section { padding: 14px; }
  .trajectory-map { height: 390px; }
  .result-files { align-items: flex-start; }
}
/* Equal-height composition within each result band. */
.run-library { height: 100%; min-height: 100%; align-self: stretch; display: flex; flex-direction: column; }
.run-library .ant-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.result-row { align-items: stretch !important; }
.result-row > .ant-col { display: flex; }
.result-row > .ant-col > .surface-section { width: 100%; min-height: 100%; margin-bottom: 0; }
.result-files { margin-top: 24px !important; }
/* Object detection has an explicit two-row rhythm: comparison first, details second. */
.detection-workspace > .ant-row:first-child > .ant-col > .surface-section { margin-bottom: 0; }
.detection-workspace > .ant-row + .ant-row { align-items: stretch !important; margin-top: 24px; }
.detection-workspace > .ant-row + .ant-row > .ant-col { display: flex; }
.detection-workspace > .ant-row + .ant-row > .ant-col > .surface-section { width: 100%; height: 100%; min-height: 0; margin-bottom: 0; }
.result-row { margin-top: 8px !important; }
/* The trajectory sidebar must match the visible map card, not the row's trailing margin. */
@media (min-width: 1200px) {
  .ant-row:has(.trajectory-map) .run-library { height: 578px; min-height: 578px; }
}
@media (max-width: 1199px) {
  .run-library { height: auto; min-height: 0; }
  .run-library .ant-list { overflow: visible; }
  .result-row { align-items: stretch !important; }
  .trajectory-map { height: 380px; }
  .card-media { height: 205px; }
  .upload-grid, .comparison-grid { grid-template-columns: 1fr; }
  .comparison-grid .detection-image, .comparison-grid .detection-image .ant-image-img { height: 300px; }
  .trajectory-workspace .result-files { align-items: flex-start; }
}
apps/workbench-console/src/views/CapabilityView.vue
@@ -5,6 +5,7 @@
import PageHeader from "@/components/PageHeader.vue";
import ObjectDetectionPanel from "@/components/ObjectDetectionPanel.vue";
import TrajectoryAnalysisPanel from "@/components/TrajectoryAnalysisPanel.vue";
import SemanticMappingPanel from "@/components/SemanticMappingPanel.vue";
import { capabilityById } from "@/data/capabilities";
const route = useRoute();
@@ -16,6 +17,7 @@
    <PageHeader :eyebrow="`CAPABILITY ${capability.id} / ${capability.level}`" :title="capability.title" :description="capability.note" />
    <ObjectDetectionPanel v-if="capability.id === '01-object-detection'" />
    <TrajectoryAnalysisPanel v-else-if="capability.id === '15-trajectory-analysis'" />
    <SemanticMappingPanel v-else-if="capability.id === '02-semantic-mapping'" />
    <a-empty v-else description="等待第一个可验证 Demo"><template #description><h2>尚无本地结果工件</h2><p>该能力已登记在目录中,但尚未产生可视化结果、结构化输出或运行元数据。</p></template></a-empty>
  </div>
  <div v-else class="view-container"><a-result status="404" title="未找到能力页面" sub-title="请从左侧目录选择已登记的能力。" /></div>
capabilities/02-semantic-mapping/README.md
@@ -1,6 +1,101 @@
# Semantic Mapping
# 语义分割
- 输入:正射影像切片和可选标注。
- 输出:分割掩膜、GeoJSON 图层和面积统计。
- 首个 Demo:建筑或水体二选一,使用小区域和轻量预训练模型。
状态:首个 CPU Demo 已验证(2026-08-17)。
## 能力边界
当前 Demo 归为 **B(GeoAI 与生态库组合)**,不是 `geoai-py` 内置的预训练语义分割模型。
- `geoai-py 0.42.0` 提供训练/推理 API,但通用 `semantic_segmentation` 需要用户提供匹配的模型权重。
- 首版为了可离线、可重复验收,使用明确的 RGB/HSV 颜色规则区分 `vegetation`、`water`、`impervious` 和 `other`。
- 每个类别的二值掩膜通过 `geoai.masks_to_vector` 转为多边形;Rasterio、GeoPandas、OpenCV 和 Pillow 承担栅格、矢量和图像处理。
这是一条可解释的工程基线,不是训练模型精度基准。蓝色屋顶、蓝色车辆和阴影会造成误分,当前没有人工真值,不能报告 IoU、精确率或召回率。
## 任务类别与控制台选择
任务目录见 [`configs/task-catalog.json`](./configs/task-catalog.json)。控制台类别选择必须
来自这个目录,并且只允许 `selectable=true` 的任务实际运行;项目需要的类别不能通过
前端任意填写后交给不支持它的算法。
| 任务 | 类别 | 当前状态 |
| --- | --- | --- |
| `color_baseline` | other、vegetation、water、impervious | 可运行,只验证工作流,准确率未测 |
| `drainage_blockage` | drainage_channel、culvert_inlet、blockage_material、blockage | planned,需项目掩膜、纯负样本和模型 |
| `slope_change_region` | stable、surface_change、collapse_candidate、crack_region | planned,需配准多期影像和变化真值 |
| `shaft_structure` | shaft_opening、guard、walkway、blockage、visible_damage | blocked,检测范围未明确 |
| `tailings_anomaly` | water_or_slurry_region、spectral_anomaly、temporal_change | blocked,RGB 异常不得直接判定矿浆渗漏 |
这里的 `blockage_material` 是可见物观察,`blockage` 是结合沟道空间关系、覆盖比例和
人工规则后的项目标签;两者不能混为同一个模型类别。
## Demo 契约
输入:单个文件或目录,允许 `JPG/JPEG/PNG/TIF/TIFF`。普通图片没有 CRS;GeoTIFF 只有在含有效 CRS 和仿射变换时才保留地理坐标。
每张影像输出:
| 文件 | 内容 |
| --- | --- |
| `*.mask.png` | 类别编号栅格,0=其他、1=植被、2=水体、3=不透水面 |
| `*.overlay.png` | 可直接检查的原图与类别颜色叠加图 |
| `*.mask.tif` | 单波段 GeoTIFF;普通图片使用像素坐标 |
| `*.segments.geojson` | GeoAI 矢量化的类别多边形 |
| `run_metadata.json` | 版本、方法、阈值、设备、输入数、耗时、类别像素统计和限制 |
脚本拒绝写入非空输出目录,避免覆盖既有结果。
## 环境与运行
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\setup.ps1 -Capability 02-semantic-mapping
$py = '.\.venvs\02-semantic-mapping\Scripts\python.exe'
& $py .\capabilities\02-semantic-mapping\run_semantic_segmentation.py `
  --input .\shared\data\raw\02-semantic-mapping\validation-20260817 `
  --output .\shared\outputs\02-semantic-mapping\my-run
```
本机环境为 Python 3.12.10、`geoai-py 0.42.0`、Rasterio 1.5.1、GeoPandas 1.1.4 和 OpenCV 5.0.0,CPU 运行,`pip check` 通过。
## 验收集与实测
验收输入位于 `shared/data/raw/02-semantic-mapping/validation-20260817/`,来自仓库内已有真实无人机影像的只读副本:
- 正常样例 `DJI_20260713102047_0001_V_19.jpeg`:植被明显,同时包含道路、蓝色屋顶、车辆和阴影。
- 困难样例 `DJI_20260728113653_0001_V_50.jpeg`:几乎全部是不透水道路与人行道,用于检查低类别多样性和小面积误分。
两张图均为 3840 x 2160、无地理参考。CPU 总耗时 15.738 秒,两个结果均实际使用 `geoai.masks_to_vector`,PNG、GeoTIFF、GeoJSON 和元数据均非空并已视觉检查。
正常样例将 1,707,028 个像素标为植被;可见树冠大体连续,但蓝色屋顶被误标为水体。困难样例有 8,167,327 个像素标为不透水面,仅有少量植被/水体误分。该结果只证明数据流与输出契约可用,不证明真实业务精度。
自动验收:
```powershell
$env:PYTHONPATH = (Resolve-Path '.\src')
.\.venvs\02-semantic-mapping\Scripts\python.exe -m unittest discover -s .\capabilities\02-semantic-mapping\tests -v
.\.venvs\02-semantic-mapping\Scripts\python.exe -m unittest discover -s .\tests -v
```
## 本地实验控制台
打开 <http://127.0.0.1:6173/#/capability/02-semantic-mapping>。页面采用“新建运行 -> 案例库 -> 结果工作区”,支持最多 6 张影像上传,并展示原图/栅格叠加、矢量多边形预览、类别像素比例和下载工件。
服务端每次生成新的 `semantic-<UTC时间>-<随机值>`:原始文件位于 `shared/data/raw/02-semantic-mapping/runs/<run-id>/`,处理输入位于 `shared/data/processed/02-semantic-mapping/<run-id>/`,结果位于 `shared/outputs/02-semantic-mapping/runs/<run-id>/`。固定 API 为 `GET/POST /api/semantic-mapping/runs`,只调用固定 Python 3.12 环境和固定脚本。
## 许可与下一步
| 项目 | 当前记录 |
| --- | --- |
| `geoai-py 0.42.0` | MIT |
| Rasterio | BSD 3-Clause;GDAL 等随附组件需分别遵守其许可 |
| GeoPandas / pandas / Shapely | BSD 3-Clause 系列 |
| OpenCV | Apache-2.0 |
| Pillow | HPND |
| 模型权重 | 无 |
| 验收影像 | 用户项目内已有影像副本,授权范围未在本 Demo 中扩展 |
下一步应优先选择项目任务 `drainage_blockage`,准备沟道、箱涵口、堵塞物、畅通和
“有杂物但未堵塞”的人工标注;再选择有独立许可证记录的轻量预训练权重,通过
`geoai.semantic_segmentation` 对比本颜色基线。没有真值前不开放该任务为控制台可运行
选项、不批量运行目录,也不建议产品接入。
capabilities/02-semantic-mapping/configs/task-catalog.json
New file
@@ -0,0 +1,54 @@
{
  "capability": "02-semantic-mapping",
  "selection_rule": "Only tasks with selectable=true may be offered as runnable console options.",
  "tasks": [
    {
      "id": "color_baseline",
      "name": "通用颜色规则基线",
      "status": "verified_workflow_only",
      "selectable": true,
      "input_sensor": ["RGB"],
      "classes": ["other", "vegetation", "water", "impervious"],
      "implementation": "deterministic RGB/HSV rules plus geoai.masks_to_vector",
      "accuracy_status": "unmeasured",
      "project_use": "验证大图、栅格、矢量和元数据流程,不作为瑶岗仙业务类别模型"
    },
    {
      "id": "drainage_blockage",
      "name": "排洪沟与箱涵堵塞",
      "status": "planned",
      "selectable": false,
      "input_sensor": ["RGB", "preferred georeferenced orthomosaic"],
      "classes": ["drainage_channel", "culvert_inlet", "blockage_material", "blockage"],
      "required_evidence": ["项目人工掩膜", "纯负样本", "严重度规则", "模型权重与许可证"]
    },
    {
      "id": "slope_change_region",
      "name": "边坡变化区域",
      "status": "planned",
      "selectable": false,
      "input_sensor": ["registered multitemporal orthomosaic", "optional DSM or point cloud"],
      "classes": ["stable", "surface_change", "collapse_candidate", "crack_region"],
      "required_evidence": ["同尺度配准多期数据", "变化真值", "工程测量精度", "变化阈值"]
    },
    {
      "id": "shaft_structure",
      "name": "竖井设施与堵塞区域",
      "status": "blocked_by_scope",
      "selectable": false,
      "input_sensor": ["close-range RGB", "optional 3D"],
      "classes": ["shaft_opening", "guard", "walkway", "blockage", "visible_damage"],
      "required_evidence": ["检测部位定义", "损坏定义", "近景正常和异常样本"]
    },
    {
      "id": "tailings_anomaly",
      "name": "尾矿库水体或矿浆异常筛查",
      "status": "blocked_by_evidence",
      "selectable": false,
      "input_sensor": ["multispectral", "thermal", "fixed-view RGB for comparison only"],
      "classes": ["water_or_slurry_region", "spectral_anomaly", "temporal_change"],
      "required_evidence": ["真实渗漏样本", "传感器与波段规范", "现场复核标签"],
      "prohibited_claim": "RGB 颜色或浑浊异常不得直接表述为矿浆渗漏已确认"
    }
  ]
}
capabilities/02-semantic-mapping/requirements.txt
@@ -1,7 +1,5 @@
-r ../../requirements/base.txt
# GeoAI vectorization and raster metadata for the segmentation output.
geoai-py>=0.42,<0.43
rasterio>=1.4,<2
geopandas>=1,<2
torch>=2.4,<3
torchvision>=0.19,<1
segmentation-models-pytorch>=0.4,<1
capabilities/02-semantic-mapping/run_semantic_segmentation.py
New file
@@ -0,0 +1,172 @@
"""Run a small, inspectable CPU semantic-mapping baseline.
The first capability version deliberately uses deterministic RGB/HSV rules so it
can run without downloading model weights.  GeoAI's ``masks_to_vector`` is used
for class-wise polygon extraction, while the metadata keeps the model boundary
explicit: this is a B capability, not a built-in GeoAI segmentation model.
"""
from __future__ import annotations
import argparse
import json
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import cv2
import geopandas as gpd
import numpy as np
import pandas as pd
import rasterio
from PIL import Image
from rasterio.features import shapes
from rasterio.transform import Affine
from shapely.geometry import shape
CLASS_INFO = {
    0: {"key": "other", "label": "其他", "color": (80, 80, 80)},
    1: {"key": "vegetation", "label": "植被", "color": (40, 180, 70)},
    2: {"key": "water", "label": "水体", "color": (35, 130, 220)},
    3: {"key": "impervious", "label": "不透水面", "color": (220, 150, 55)},
}
SUPPORTED_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
def classify_rgb(rgb: np.ndarray) -> np.ndarray:
    """Classify RGB pixels with conservative, explainable colour rules."""
    rgb_f = rgb.astype(np.float32)
    r, g, b = [rgb_f[..., i] for i in range(3)]
    hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV)
    saturation = hsv[..., 1].astype(np.float32)
    value = hsv[..., 2].astype(np.float32)
    result = np.zeros(rgb.shape[:2], dtype=np.uint8)
    green = (g > r * 1.04) & (g > b * 1.04) & (g > 45) & (saturation > 45)
    blue = (b > r * 1.08) & (b > g * 1.02) & (b > 45) & (saturation > 35)
    gray = (np.max(rgb_f, axis=2) - np.min(rgb_f, axis=2) < 38) & (value > 45)
    result[green] = 1
    result[blue & ~green] = 2
    result[gray & ~green & ~blue] = 3
    return result
def _raster_profile(input_path: Path, width: int, height: int) -> tuple[Affine, str | None, bool]:
    if input_path.suffix.lower() not in {".tif", ".tiff"}:
        return Affine.identity(), None, False
    try:
        with rasterio.open(input_path) as src:
            transform = src.transform
            crs = src.crs.to_string() if src.crs else None
            return transform, crs, bool(src.crs and src.transform != Affine.identity())
    except rasterio.errors.RasterioIOError:
        return Affine.identity(), None, False
def _geojson_from_mask(mask: np.ndarray, transform: Affine, crs: str | None, class_id: int) -> gpd.GeoDataFrame:
    records: list[dict[str, Any]] = []
    binary = (mask == class_id).astype(np.uint8)
    for geometry, value in shapes(binary, mask=binary.astype(bool), transform=transform):
        if value != 1:
            continue
        polygon = shape(geometry)
        if polygon.area < 100:
            continue
        records.append({"geometry": polygon, "class_id": class_id, "class_key": CLASS_INFO[class_id]["key"], "class_label": CLASS_INFO[class_id]["label"], "pixel_area": float(polygon.area)})
    return gpd.GeoDataFrame(records, geometry="geometry", crs=crs)
def vectorize_with_geoai(mask: np.ndarray, transform: Affine, crs: str | None, output_path: Path) -> bool:
    """Use GeoAI's class-wise mask utility, then preserve class properties."""
    try:
        from geoai import masks_to_vector
        frames: list[gpd.GeoDataFrame] = []
        output_path.parent.mkdir(parents=True, exist_ok=True)
        for class_id in (1, 2, 3):
            temp_path = output_path.with_name(f".{output_path.stem}-{class_id}.tif")
            binary = np.where(mask == class_id, 255, 0).astype(np.uint8)
            with rasterio.open(temp_path, "w", driver="GTiff", height=binary.shape[0], width=binary.shape[1], count=1, dtype="uint8", transform=transform, crs=crs, nodata=0) as dst:
                dst.write(binary, 1)
            try:
                frame = masks_to_vector(str(temp_path), min_object_area=100, simplify_tolerance=1.0)
                if not frame.empty:
                    frame = frame.assign(class_id=class_id, class_key=CLASS_INFO[class_id]["key"], class_label=CLASS_INFO[class_id]["label"])
                    frames.append(frame[["geometry", "class_id", "class_key", "class_label", "confidence"]])
            finally:
                temp_path.unlink(missing_ok=True)
        merged = gpd.GeoDataFrame(pd_concat(frames), geometry="geometry", crs=crs)
        merged.to_file(output_path, driver="GeoJSON")
        return True
    except Exception:
        fallback = gpd.GeoDataFrame(pd_concat([_geojson_from_mask(mask, transform, crs, class_id) for class_id in (1, 2, 3)]), geometry="geometry", crs=crs)
        fallback.to_file(output_path, driver="GeoJSON")
        return False
def pd_concat(frames: list[gpd.GeoDataFrame]) -> gpd.GeoDataFrame:
    if not frames:
        return gpd.GeoDataFrame({"geometry": []}, geometry="geometry")
    return gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), geometry="geometry", crs=frames[0].crs)
def process_image(input_path: Path, output_dir: Path) -> dict[str, Any]:
    started = time.perf_counter()
    with Image.open(input_path) as image:
        rgb = np.asarray(image.convert("RGB"))
    height, width = rgb.shape[:2]
    mask = classify_rgb(rgb)
    transform, crs, georeferenced = _raster_profile(input_path, width, height)
    stem = input_path.stem
    mask_path = output_dir / f"{stem}.mask.png"
    overlay_path = output_dir / f"{stem}.overlay.png"
    raster_path = output_dir / f"{stem}.mask.tif"
    vector_path = output_dir / f"{stem}.segments.geojson"
    Image.fromarray(mask).save(mask_path)
    palette = np.zeros((256, 3), dtype=np.uint8)
    for class_id, info in CLASS_INFO.items():
        palette[class_id] = info["color"]
    color_mask = palette[mask]
    overlay = (rgb.astype(np.float32) * 0.52 + color_mask.astype(np.float32) * 0.48).clip(0, 255).astype(np.uint8)
    Image.fromarray(overlay).save(overlay_path, quality=92)
    with rasterio.open(raster_path, "w", driver="GTiff", height=height, width=width, count=1, dtype="uint8", transform=transform, crs=crs, nodata=0) as dst:
        dst.write(mask, 1)
    used_geoai = vectorize_with_geoai(mask, transform, crs, vector_path)
    counts = {info["key"]: int(np.count_nonzero(mask == class_id)) for class_id, info in CLASS_INFO.items()}
    return {"file": input_path.name, "width": width, "height": height, "mask_file": mask_path.name, "overlay_file": overlay_path.name, "raster_file": raster_path.name, "vector_file": vector_path.name, "class_pixel_counts": counts, "georeferenced": georeferenced, "vectorizer": "geoai.masks_to_vector" if used_geoai else "rasterio.features.shapes fallback", "elapsed_seconds": round(time.perf_counter() - started, 3)}
def collect_inputs(input_path: Path) -> list[Path]:
    if input_path.is_file():
        candidates = [input_path]
    elif input_path.is_dir():
        candidates = sorted(item for item in input_path.iterdir() if item.is_file())
    else:
        raise SystemExit(f"Input path does not exist: {input_path}")
    inputs = [item for item in candidates if item.suffix.lower() in SUPPORTED_SUFFIXES]
    if not inputs:
        raise SystemExit("No JPG, PNG, or GeoTIFF inputs were found.")
    return inputs
def main() -> int:
    parser = argparse.ArgumentParser(description="Run the CPU semantic-mapping baseline.")
    parser.add_argument("--input", type=Path, default=Path("shared/data/raw/01-object-detection"))
    parser.add_argument("--output", type=Path, default=Path("shared/outputs/02-semantic-mapping"))
    args = parser.parse_args()
    inputs = collect_inputs(args.input)
    if args.output.exists() and any(args.output.iterdir()):
        raise SystemExit(f"Output directory is not empty: {args.output}. Use a new run directory.")
    args.output.mkdir(parents=True, exist_ok=True)
    started = time.perf_counter()
    images = [process_image(item, args.output) for item in inputs]
    metadata = {"capability": "02-semantic-mapping", "classification": "B", "created_at": datetime.now(UTC).isoformat(), "geoai_version": __import__("importlib.metadata").metadata.version("geoai-py"), "method": "deterministic RGB/HSV semantic baseline", "model": "classical-color-baseline", "device": "CPU", "thresholds": {"vegetation_green_ratio": 1.04, "water_blue_ratio": 1.08, "gray_channel_range": 38, "minimum_vector_area_pixels": 100}, "input_dir": args.input.as_posix(), "raw_input_dir": args.input.as_posix(), "input_count": len(images), "processed_images": len(images), "elapsed_seconds": round(time.perf_counter() - started, 3), "images": images, "classes": [info | {"id": class_id} for class_id, info in CLASS_INFO.items()], "limitations": ["这是可解释的颜色规则基线,不是经过训练的通用分割模型。", "普通 JPG/PNG 没有 CRS 时,GeoJSON 使用像素坐标;只有有效 GeoTIFF 地理参考才会保留地理坐标。", "复杂光照、阴影、材质相近区域可能误分;当前没有人工标注,因此不报告精度。"]}
    (args.output / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
    print(json.dumps(metadata, ensure_ascii=False, indent=2))
    return 0
if __name__ == "__main__":
    raise SystemExit(main())
capabilities/02-semantic-mapping/tests/test_semantic_segmentation.py
New file
@@ -0,0 +1,42 @@
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = ROOT / "capabilities" / "02-semantic-mapping" / "run_semantic_segmentation.py"
class SemanticSegmentationTests(unittest.TestCase):
    def test_small_rgb_input_produces_raster_vector_and_metadata(self):
        with tempfile.TemporaryDirectory() as temp:
            work = Path(temp)
            image = Image.new("RGB", (96, 64), (80, 80, 80))
            pixels = image.load()
            for y in range(20):
                for x in range(32):
                    pixels[x, y] = (35, 180, 55)
            for y in range(20, 44):
                for x in range(32, 72):
                    pixels[x, y] = (30, 100, 210)
            source = work / "sample.png"
            image.save(source)
            output = work / "out"
            result = subprocess.run([sys.executable, str(SCRIPT), "--input", str(source), "--output", str(output)], capture_output=True, text=True)
            self.assertEqual(result.returncode, 0, result.stderr)
            metadata = json.loads((output / "run_metadata.json").read_text(encoding="utf-8"))
            self.assertEqual(metadata["input_count"], 1)
            record = metadata["images"][0]
            for key in ("mask_file", "overlay_file", "raster_file", "vector_file"):
                self.assertTrue((output / record[key]).is_file(), key)
            self.assertGreater(record["class_pixel_counts"]["vegetation"], 0)
            self.assertGreater(record["class_pixel_counts"]["water"], 0)
if __name__ == "__main__":
    unittest.main()
capabilities/GEOAI_PY_DIRECT_CAPABILITIES.md
New file
@@ -0,0 +1,94 @@
# geoai-py 直接能力目录
版本基准:`geoai-py 0.42.0`(当前项目对象检测环境)。
本文只整理 `opengeos/geoai` 已有模块、接口和官方工作流,不把项目侧的风险规则、
工单、航线或算法组合误称为 `geoai-py` 内置能力。
## 先理解“直接使用”的含义
算法人员交付一个模型,不等于产品已经可以直接使用。真正可接入需要同时交付:
1. 模型权重或模型仓库地址,以及单独的许可证说明。
2. 输入格式、波段顺序、坐标系、分辨率、切片尺寸和归一化方式。
3. 类别清单、阈值、NMS/后处理参数和版本号。
4. 固定的推理入口与输出样例。
5. 一组正常、困难、误报和漏报样本,用于验收。
`geoai-py` 主要负责数据读取、切片、模型调用、地理结果写出和可视化;它不会
自动拥有“安全帽、钢丝绳断丝、矿浆渗漏”等项目领域模型。
## A 类:可以由 geoai-py 直接承接的算法产出
| 能力 | 主要接口/模块 | 算法人员可以交付什么 | geoai-py 可直接产出 | 当前使用边界 |
| --- | --- | --- | --- | --- |
| 目标检测 | `multiclass_detection`、`object_detection`、`object_detection_batch`、`RF-DETR`、`ObjectDetector` | 检测模型、类别表、阈值和切片参数 | 框、类别、置信度、实例掩膜、GeoTIFF/GeoDataFrame/GeoJSON | 内置 NWPU 模型主要是航空目标;人员、安全帽、垃圾等要专项模型。 |
| 语义分割 | `semantic_segmentation`、`semantic_segmentation_batch`、`train_segmentation_model`、`timm_semantic_segmentation` | 分割模型、类别映射、掩膜标签 | 像素类别掩膜、矢量图斑、面积统计 | 沟道、水体、堵塞物和边坡需要项目样本或适配模型。 |
| 实例分割 | `instance_segmentation`、`instance_segmentation_batch`、`GroundedSAM`、`CLIPSegmentation` | 实例分割模型或点/文本提示 | 每个目标的掩膜、类别和实例编号 | Grounded/SAM 类能力依赖额外模型与许可,不能当作零配置模型。 |
| 影像分类 | `classify_image`、`classify_images`、`train_classifier`、`image_classification` | 分类模型、标签和类别定义 | 类别、概率、批量分类结果 | 适合正常/异常、损坏等级等整图或切片分类。 |
| 变化检测 | `ChangeStarDetection`、`changestar_detect`、变化检测模块 | 双期模型、配准影像、变化阈值 | 变化栅格、变化图斑、置信度和矢量结果 | 双期影像必须配准、同坐标系;当前项目正射变化检测已有产品基础。 |
| 水体分割 | `segment_water` | 多光谱 GeoTIFF、波段顺序和水体样本 | 水体掩膜 GeoTIFF、水体 GeoJSON、多边形面积 | 需要 RGB+NIR 等四波段;普通 JPEG 不能直接替代多光谱输入。 |
| 树冠高度 | `canopy_height_estimation`、`CanopyHeightEstimation` | RGB 影像和对应模型权重 | 每像素树冠高度栅格,单位为米 | 这是树高估计,不是单木框选;模型权重较大,精度需现场验证。 |
| 深度估计 | `depth_estimation`、`AutoGeoModel` | 深度模型和 GeoTIFF | 深度 GeoTIFF | 通常是相对深度,不能直接当作测绘高程或工程位移。 |
| 像素回归 | `train_pixel_regressor`、`predict_raster`、`evaluate_regression` | 有目标栅格的回归训练集 | 连续值栅格 | 可用于高度、指数或风险分值,但裂缝宽度等工程量仍需标定。 |
| 特征嵌入与相似度 | `extract_patch_embeddings`、`extract_pixel_embeddings`、`cluster_embeddings`、`embedding_similarity`、`train_embedding_classifier` | 嵌入模型、样本库和相似度/聚类规则 | 特征向量、聚类、相似图、异常候选 | 适合样本稀缺的异常筛查,不能直接代替项目告警规则。 |
| 遥感基础模型 | `PrithviProcessor`、`UniverSatProcessor`、`DINOv3GeoProcessor`、`FOUNDATION_MODELS` | 基础模型权重和下游任务头 | 特征、分类/分割/回归推理结果 | 多数需要额外模型、波段和依赖;更适合作为训练或迁移学习底座。 |
## B 类:库有接口,但必须组合或补充依赖
| 方向 | 已有支持 | 还需要补充 |
| --- | --- | --- |
| 大图处理与地理输出 | GeoTIFF 切片、拼接、掩膜矢量化、框转 GeoJSON、Raster/Vector 工具 | 有效 CRS、仿射变换、统一坐标系和业务字段。 |
| 零样本/提示式识别 | Grounding DINO、OWL、Moondream、CLIP、SAM 等封装入口 | 外部模型、显存/CPU 评估、提示词、领域验证和许可。 |
| 质量与异常筛查 | 掩膜清理、栅格清理、云掩膜、超分辨率、嵌入聚类 | 业务质量规则、阈值和人工复核。 |
| 三维/连续值分析 | 深度估计、树冠高度、像素回归以及点云数据读写/下载辅助 | 摄影测量或 LiDAR、控制点、测量精度和三维重建流程。 |
| ONNX/部署 | `export_to_onnx`、ONNX 分割/分类 | `onnxruntime`、算子兼容、性能压测和部署环境。 |
## C 类:不能直接由 geoai-py 产出
下列结果需要现有产品后端、规则引擎或无人机系统配合:
- 电子围栏、危险区域包含/距离规则和告警等级。
- 自动悬停、喊话、返航、复飞和航线调整。
- 告警推送、工单派发、责任人、处置时限和闭环状态。
- 轨迹跟踪、行为识别、集群调度和飞行资源编排。
- 资产健康评分、预测性维护、知识图谱和汇报报表。
`geoai-py` 的 `Pipeline` 是代码级步骤编排工具,不等同于矿山巡检项目的业务
流程编排或无人机任务调度。
## 与瑶岗仙项目需求的直接对应关系
| 项目算法 | 可直接承接的库能力 | 必须由项目侧补充的部分 |
| --- | --- | --- |
| 人员安全识别 | 人员/安全帽目标检测、可选裁剪分类、GeoJSON 输出 | 安全帽专项模型、人员-安全帽关联、危险区规则、告警流程。 |
| 索道安全感知 | 检测、分割、异常筛查、像素回归 | 钢丝绳/卡扣缺陷模型、近景采集、三维测量和阈值。 |
| 爆破警戒热成像 | 目标检测接口、空间结果输出 | 热成像模型、电子围栏、自动悬停/喊话。 |
| 边坡监测 | 变化检测、分割、深度/回归、矢量统计 | 配准和测量精度、三维重建、位移/裂缝阈值。 |
| 排洪沟/堵塞 | 语义/实例分割、目标检测、变化检测、面积统计 | 沟道/箱涵口类别、堵塞物掩膜、纯负样本、严重度规则。 |
| 尾矿库矿浆渗漏 | 水体分割、分类、异常/变化检测 | 多光谱/热成像、真实渗漏样本、现场复核;RGB 浑浊不能直接证明渗漏。 |
| 竖井堵塞及损坏 | 检测、实例分割、异常筛查、测量 | 设施部位定义、近景样本和三维模型。 |
## 当前环境的可用性提醒
在当前 `01-object-detection` 环境中,已确认:
- 已安装:PyTorch、Rasterio、GeoPandas、Transformers、timm、Lightning、OpenCV。
- 未安装:`torchange`、`omniwatermask`、`rfdetr`、`terratorch`、`segment-geospatial`、`detectron2`、`onnxruntime`。
- 因此当前可以继续验证目标检测及其地理输出;ChangeStar、水体分割、RF-DETR、部分基础模型和 ONNX 部署需要先单独建环境或补充依赖。
## 给算法人员的交付清单
算法人员交付后,项目侧至少需要拿到以下内容,才能接入 `geoai-py` 或项目方案层:
| 交付项 | 最低要求 |
| --- | --- |
| 模型文件 | 权重路径/仓库、版本、许可证、适用输入。 |
| 数据规范 | JPG/PNG/GeoTIFF、波段顺序、CRS、GSD、切片和归一化。 |
| 标签规范 | 类别 ID/名称、框或掩膜格式、背景/无效值定义。 |
| 推理参数 | 置信度、NMS、最小面积、变化阈值或回归单位。 |
| 输出样例 | 至少一张正常样本、一张困难样本和对应 JSON/GeoJSON/栅格。 |
| 评估结果 | 样本数量、漏报、误报、IoU/F1/mAP 或回归误差、运行耗时。 |
这份目录用于判断“算法人员交付的模型能否被现有 GeoAI 工作流承接”,不替代
具体能力的 Demo、数据验收和商业授权审查。
capabilities/README.md
@@ -14,6 +14,37 @@
判断一个方向是否属于 GeoAI 项目,要看它是否处理地理空间数据并调用 AI 或
空间分析;判断它是否是 `geoai-py` 的能力,则必须以该仓库当前源码和文档为准。
## 实现边界与项目阶段是两个维度
项目需求不能直接拿 A/B/C 当作业务分类。A/B/C 只说明某个结果由哪一层实现:
| 项目阶段 | 典型结果 | 常见实现边界 |
| --- | --- | --- |
| 模型观察 | 框、类别、掩膜、变化栅格、异常分数 | A 或 B |
| 空间与测量 | 相交、距离、面积、长度、位移、覆盖比例 | B;若只是消费结果的服务也可能是 C |
| 事件候选 | 疑似未戴安全帽、疑似堵塞、疑似边坡变化 | C |
| 平台动作 | 告警、喊话、工单、复飞、任务调度 | C |
同一个目录如果横跨多个边界,README 必须逐段说明。例如 `12-quality-control-refly`
中的质量指标计算可属于 B,而自动复飞建议和任务下发属于 C,不能用一个总标签掩盖差异。
## 项目类别不是任意标签
“人员、安全帽、沟道、堵塞物、裂缝、矿浆渗漏”等类别只有在模型权重、类别映射、
输入传感器和标注规范一致时才能成为可运行选项。控制台应选择登记过的任务预设,
不能把用户填写的任意名称交给不支持该类别的通用模型。
- 安全帽优先建模为人员与安全帽的检测/实例及关联;`03-attribute-classification`
  只作为人员裁剪图上的可选二次分类,不替代安全帽目标和关联真值。
- 排洪沟需要沟道/箱涵口与堵塞物的项目掩膜;当前 `02-semantic-mapping` 颜色基线
  仅验证栅格到矢量的数据流,不代表已经支持排洪沟类别。
- RGB 水色或浑浊只能作为异常观察,不能直接输出“矿浆渗漏已确认”。
当前 `geoai-py 0.42.0` 的直接能力目录见
[`GEOAI_PY_DIRECT_CAPABILITIES.md`](./GEOAI_PY_DIRECT_CAPABILITIES.md)。该目录区分
了库可以直接承接的模型产出、需要额外依赖或空间规则的组合能力,以及必须由产品
后端实现的业务能力。
官方依据:
- https://github.com/opengeos/geoai
scripts/serve_workbench_console.py
@@ -24,10 +24,12 @@
MAX_REQUEST_BYTES = 128 * 1024 * 1024
MAX_FILE_BYTES = 96 * 1024 * 1024
MAX_IMAGES_PER_RUN = 12
MAX_SEGMENTATION_IMAGES_PER_RUN = 6
ALLOWED_PATH_PREFIXES = (
    "apps/workbench-console",
    "shared/outputs",
    "shared/data/raw/01-object-detection",
    "shared/data/raw/02-semantic-mapping",
)
SAFE_FILE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
RUN_LOCK = threading.Lock()
@@ -129,6 +131,37 @@
    return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True)
def semantic_runs(root: Path) -> list[dict[str, Any]]:
    output_root = root / "shared" / "outputs" / "02-semantic-mapping"
    records: list[dict[str, Any]] = []
    for metadata_path in output_root.rglob("run_metadata.json"):
        artifact = metadata_path.parent
        metadata = load_json(metadata_path)
        if metadata.get("capability") != "02-semantic-mapping" or not isinstance(metadata.get("images"), list):
            continue
        run_id = artifact.name if artifact != output_root else "baseline"
        records.append(
            {
                "id": run_id,
                "label": "语义分割基线" if run_id == "baseline" else run_id,
                "note": f"{metadata.get('task_name') or '通用颜色规则基线'},输出栅格掩膜与 GeoAI 矢量结果。",
                "artifactRoot": relative_path(root, artifact),
                "inputRoot": str(metadata.get("input_dir") or "shared/data/processed/02-semantic-mapping"),
                "rawInputRoot": str(metadata.get("raw_input_dir") or "shared/data/raw/02-semantic-mapping"),
                "createdAt": str(metadata.get("created_at") or ""),
            }
        )
    return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True)
def semantic_tasks(root: Path) -> list[dict[str, Any]]:
    catalog = load_json(root / "capabilities" / "02-semantic-mapping" / "configs" / "task-catalog.json")
    tasks = catalog.get("tasks")
    if not isinstance(tasks, list):
        return []
    return [item for item in tasks if isinstance(item, dict) and isinstance(item.get("id"), str)]
class WorkbenchConsoleHandler(SimpleHTTPRequestHandler):
    """Static UI plus fixed, local-only ingestion and experiment commands."""
@@ -146,6 +179,12 @@
        if path == "/api/object-detection/runs":
            self.send_json(HTTPStatus.OK, {"runs": detection_runs(self.root)})
            return
        if path == "/api/semantic-mapping/runs":
            self.send_json(HTTPStatus.OK, {"runs": semantic_runs(self.root)})
            return
        if path == "/api/semantic-mapping/tasks":
            self.send_json(HTTPStatus.OK, {"tasks": semantic_tasks(self.root)})
            return
        if path == "/":
            self.send_response(HTTPStatus.FOUND)
            self.send_header("Location", "/apps/workbench-console/")
@@ -162,6 +201,9 @@
                return
            if path == "/api/object-detection/runs":
                self.send_json(HTTPStatus.CREATED, {"run": self.create_detection_run(payload)})
                return
            if path == "/api/semantic-mapping/runs":
                self.send_json(HTTPStatus.CREATED, {"run": self.create_semantic_run(payload)})
                return
            self.send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown local API endpoint."})
        except ApiError as exc:
@@ -257,6 +299,46 @@
            raise ApiError("Detection script finished without the expected result metadata.")
        return next(item for item in detection_runs(self.root) if item["id"] == run_id)
    def create_semantic_run(self, payload: dict[str, Any]) -> dict[str, Any]:
        task_id = str(payload.get("taskId") or "color_baseline")
        task = next((item for item in semantic_tasks(self.root) if item["id"] == task_id), None)
        if task is None:
            raise ApiError(f"Unknown semantic-mapping task: {task_id}.")
        if task.get("selectable") is not True:
            raise ApiError(f"Semantic-mapping task is not runnable yet: {task_id}.")
        uploads = payload.get("images")
        if not isinstance(uploads, list) or not uploads:
            raise ApiError("Semantic-mapping request must include at least one image.")
        if len(uploads) > MAX_SEGMENTATION_IMAGES_PER_RUN:
            raise ApiError(f"A semantic-mapping run accepts at most {MAX_SEGMENTATION_IMAGES_PER_RUN} images.")
        decoded = [decode_upload(item, {".jpg", ".jpeg", ".png", ".tif", ".tiff"}) for item in uploads]
        if len({name.casefold() for name, _ in decoded}) != len(decoded):
            raise ApiError("Uploaded image names must be unique within one run.")
        run_id = make_run_id("semantic")
        raw_root = self.root / "shared" / "data" / "raw" / "02-semantic-mapping" / "runs" / run_id
        processed_root = self.root / "shared" / "data" / "processed" / "02-semantic-mapping" / run_id
        raw_root.mkdir(parents=True, exist_ok=False)
        processed_root.mkdir(parents=True, exist_ok=False)
        for name, content in decoded:
            (raw_root / name).write_bytes(content)
            (processed_root / name).write_bytes(content)
        output = self.root / "shared" / "outputs" / "02-semantic-mapping" / "runs" / run_id
        python = self.root / ".venvs" / "02-semantic-mapping" / "Scripts" / "python.exe"
        if not python.is_file():
            raise ApiError("Semantic-mapping virtual environment is unavailable. Run the capability setup first.")
        with RUN_LOCK:
            self.run_command([str(python), str(self.root / "capabilities" / "02-semantic-mapping" / "run_semantic_segmentation.py"), "--input", str(processed_root), "--output", str(output)], 900)
        metadata_path = output / "run_metadata.json"
        if not metadata_path.is_file():
            raise ApiError("Semantic-mapping script finished without the expected result metadata.")
        metadata = load_json(metadata_path)
        metadata["task_id"] = task_id
        metadata["task_name"] = str(task.get("name") or task_id)
        metadata["input_dir"] = relative_path(self.root, processed_root)
        metadata["raw_input_dir"] = relative_path(self.root, raw_root)
        metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
        return next(item for item in semantic_runs(self.root) if item["id"] == run_id)
    def send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
solutions/mining-inspection/01-person-safety/README.md
@@ -2,24 +2,27 @@
## 业务目标
从无人机巡检影像中识别人员,判断是否佩戴安全帽,并结合危险区域或禁入区域生成
从 RGB 无人机巡检影像中识别人员与安全帽,建立人员-安全帽关联,并结合危险区域或禁入区域生成
人工复核用的安全事件候选。
## 方案分类
- A 类直接能力:人员和安全帽目标检测、影像切片、结果导出。
- B 类组合能力:小目标切片、坐标转换、人员与区域的空间关系判断。
- A 类直接能力:人员和安全帽目标检测/实例分割、影像切片、结果导出;人员裁剪属性分类仅作为可选辅助。
- B 类组合能力:小目标切片、人员-安全帽关联、坐标转换、人员与区域的空间关系判断。
- C 类项目编排:未佩戴安全帽/进入危险区域的事件规则、告警等级和平台处置。
## 当前成熟度
`01-object-detection` 已验证普通 RGB 无人机影像中的人员候选检测,但尚未具备
安全帽类别模型,也未具备地理坐标和危险区域规则。因此本方案当前仅为可追溯的
安全帽类别模型、人员-安全帽关联真值,也未具备地理坐标和危险区域规则。因此本方案当前仅为可追溯的
编排骨架,不可用于正式告警。
爆破警戒热成像属于独立传感器任务,不与本 RGB 方案共用“已验证”状态。热成像人员
模型、温度/灰度规范和警戒区齐备后,再按 `person_thermal_guard` 任务单独验收。
## 必需输入
- 正常、未佩戴和遮挡情况下的人员/安全帽原始图片或视频。
- 正常、未佩戴、遮挡和纯负样本中的人员/安全帽原始图片或视频。
- 图片对应的拍摄高度、GSD 和相机信息。
- 危险区域或禁入区域 GeoJSON;若只需影像告警,可暂不提供。
- 告警等级、允许漏报/误报范围和人工复核流程。
@@ -27,11 +30,11 @@
## 预期输出
- 带人员和安全帽框的标注图。
- `observations`:人员、安全帽、位置、置信度和关联关系。
- `observations`:人员实例、安全帽实例、位置、置信度和人员-安全帽关联关系。
- `events`:疑似未佩戴安全帽、疑似进入危险区域等事件候选及命中规则。
- `artifacts`:标注图、JSON,以及有地理坐标时的 GeoJSON。
## 验收前提
先使用固定的真实正负样本集,分别统计人员检出率、安全帽识别率、误报、漏报和
先使用固定的真实正负样本集,分别统计人员检出率、安全帽检出率、人员-安全帽关联准确率、误报、漏报和
单图/目录处理耗时;未完成这项评估前不得接入正式安全告警。
solutions/mining-inspection/01-person-safety/solution.json
@@ -6,14 +6,15 @@
  "capability_references": [
    {
      "id": "01-object-detection",
      "role": "人员与安全帽候选检测",
      "role": "人员与安全帽候选检测/实例分割",
      "status": "partial",
      "note": "当前仅验证人员候选;安全帽类别尚未实现。"
    },
    {
      "id": "03-attribute-classification",
      "role": "安全帽佩戴状态判定",
      "status": "planned"
      "role": "可选:对人员或头部裁剪图做二次属性分类",
      "status": "optional_planned",
      "note": "不能替代安全帽目标检测和人员-安全帽关联真值。"
    },
    {
      "id": "06-spatial-reasoning",
@@ -29,12 +30,12 @@
  "inputs": [
    "无人机 RGB 图片或视频帧",
    "可选:有效坐标系的正射影像与危险区域 GeoJSON",
    "安全帽类别样本与业务告警规则"
    "安全帽类别、人员-安全帽关联标注与业务告警规则"
  ],
  "pipeline": [
    "影像质量检查与切片",
    "人员/安全帽目标检测",
    "人员与安全帽关联",
    "人员/安全帽目标检测或实例分割",
    "人员-安全帽关联与可选裁剪分类",
    "可选:空间围栏判断",
    "风险规则计算与人工复核事件输出"
  ],
@@ -45,7 +46,7 @@
    "artifacts"
  ],
  "blocking_conditions": [
    "缺少安全帽正负样本",
    "缺少安全帽正负样本和人员-安全帽关联真值",
    "缺少危险区域边界或像素区域定义",
    "缺少告警阈值与人工复核流程"
  ],
solutions/mining-inspection/02-drainage-blockage/README.md
@@ -5,6 +5,9 @@
识别排洪沟、箱涵口及其周边的杂草、淤泥、砂石、建筑垃圾或漂浮物等疑似堵塞物,
结合沟道边界、堵塞面积和严重度规则,生成汛期巡检的维护事件候选。
报告中的“排洪沟巡查”和“排洪沟堵塞识别”在本工作区合并为这一方案:两者共用
沟道/箱涵口类别、堵塞物标注、面积测量与严重度规则,避免重复训练和维护。
## 方案分类
- A 类直接能力:语义/实例分割、目标检测、变化检测、影像切片和结果导出。
@@ -13,14 +16,30 @@
## 当前成熟度
`02-semantic-mapping`、`04-spatial-measurement` 和 `07-risk-rule-engine` 尚未实现
本地 Demo。因此该方案不运行模型,只固化后续需要连接的能力、数据和结果协议。
`02-semantic-mapping` 已验证通用颜色规则的栅格、GeoTIFF 和 GeoJSON 数据流,
但它没有沟道、箱涵口或堵塞物项目模型,也没有人工真值;因此这里只能将它标为
`partial`。`04-spatial-measurement` 和 `07-risk-rule-engine` 尚无项目 Demo。
本方案仍是编排骨架,不得把颜色基线结果表述为排洪沟识别结果。
## 项目任务类别
控制台后续应选择任务预设 `drainage_blockage`,而不是任意填写类别。最低标签建议为:
| 类别键 | 含义 | 建议标注 |
| --- | --- | --- |
| `drainage_channel` | 可过水的沟道主体 | 语义掩膜 |
| `culvert_inlet` | 箱涵口、格栅口等关键入口 | 实例掩膜或框 |
| `blockage_material` | 杂草、淤泥、砂石、垃圾、漂浮物等可见物 | 实例/语义掩膜及材料子类 |
| `blockage` | 由物体、沟道关系和覆盖比例形成的堵塞区域 | 人工复核掩膜/事件标签 |
`blockage_material` 是模型观察;`blockage` 需要结合沟道相交、覆盖比例和业务阈值,
不能只因画面中出现杂草或垃圾就直接判定堵塞。
## 必需输入
- 排洪沟和箱涵口的 RGB 原图,优先提供正射 GeoTIFF。
- 沟道、箱涵口和巡检范围的 GeoJSON 或测绘边界。
- 正常、轻度、中度、重度堵塞的历史照片及人工标注。
- 畅通、轻度、中度、重度以及“有杂物但未堵塞”的历史照片与人工掩膜。
- 堵塞面积、长度、覆盖比例或箱涵口遮挡比例等严重度口径。
## 预期输出
@@ -32,5 +51,5 @@
## 验收前提
应固定一组正常与不同严重度堵塞样本,分别评估沟道分割、堵塞检出、严重度分级、
面积误差、漏报、误报和单次巡检处理时长。
应固定一组正常、纯负与不同严重度堵塞样本,分别评估沟道/箱涵口 IoU、堵塞物
检出、堵塞事件精确率/召回率、严重度分级、面积误差、漏报、误报和单次巡检处理时长。
solutions/mining-inspection/02-drainage-blockage/solution.json
@@ -7,7 +7,8 @@
    {
      "id": "02-semantic-mapping",
      "role": "沟道、箱涵口和疑似堵塞区域分割",
      "status": "planned"
      "status": "partial",
      "note": "通用颜色基线已验证栅格到矢量流程,但不支持沟道、箱涵口或堵塞物项目类别。"
    },
    {
      "id": "01-object-detection",
@@ -34,12 +35,12 @@
  "inputs": [
    "无人机 RGB 图片或正射 GeoTIFF",
    "沟道、箱涵口和巡检范围边界",
    "堵塞正负样本、严重度标注与业务阈值"
    "畅通、堵塞、纯负样本,沟道/箱涵口/堵塞物掩膜、严重度标注与业务阈值"
  ],
  "pipeline": [
    "影像质量检查与正射/切片准备",
    "沟道和箱涵口分割",
    "堵塞物检测或异常区域分割",
    "沟道和箱涵口分割/实例提取",
    "堵塞物检测或异常区域分割,并区分有杂物但未堵塞的负例",
    "多期变化和面积/长度测量",
    "严重度规则计算与人工复核事件输出"
  ],
@@ -51,7 +52,7 @@
  ],
  "blocking_conditions": [
    "缺少沟道和箱涵口边界",
    "缺少不同严重度的历史堵塞样本",
    "缺少畅通、纯负和不同严重度的历史堵塞样本与掩膜",
    "缺少堵塞分级和告警阈值"
  ],
  "out_of_scope": [
solutions/mining-inspection/README.md
@@ -1,35 +1,75 @@
# 矿山巡检项目算法方案
# 瑶岗仙二期矿山巡检算法方案
本目录对应《瑶岗仙二期巡查需求分析表》中的矿山巡检需求。每一项方案以无人机
影像或地理空间数据为输入,组合 GeoAI 原子能力、空间判断和业务规则,输出可供
平台展示和人工复核的事件候选。
本目录以《瑶岗仙二期项目算法需求与 GeoAI 能力匹配分析报告》为需求来源。项目方案
组合 `capabilities/` 中的原子能力、空间计算和业务规则,输出供平台展示与人工复核的
事件候选;这里不复制模型实现,也不直接控制无人机或派发工单。
## 需求映射
结构化映射见 [`requirements-map.json`](./requirements-map.json)。报告原始 8 项中,
“排洪沟巡查”和“排洪沟堵塞识别”共用同一设施、样本、模型和严重度规则,因此规范化
为 **7 个方案域**,不重复建设两套能力。
| 需求算法 | 主要组合能力 | 当前状态 |
## 两套分类不能混用
| 分类维度 | 回答的问题 | 示例 |
| --- | --- | --- |
| 人员安全识别 | `01-object-detection` + `03-attribute-classification` + `06-spatial-reasoning` + `07-risk-rule-engine` | 已创建试点骨架 |
| 索道安全感知 | 缺陷分割 + 异常检测 + 三维测量 + 资产健康 | 待明确检测对象和采集方式 |
| 爆破警戒热成像巡查 | 热成像人员检测 + 空间围栏 + 风险规则 | 待取得热成像样本和警戒区 |
| 边坡监测 | `00-change-detection` + 三维点云 + 空间测量 | 待修正需求描述并提供样本 |
| 排洪沟巡查 | `02-semantic-mapping` + 检测 + 测量 + 风险规则 | 已创建堵塞识别试点骨架 |
| 尾矿库矿浆渗漏识别 | 水体分割/分类 + 异常/变化检测 | 待取得真实渗漏样本和传感器条件 |
| 竖井堵塞及损坏识别 | 检测/分割 + 异常检测 + 三维测量 | 待界定设施范围与损坏定义 |
| 排洪沟堵塞识别 | `02-semantic-mapping` + `00-change-detection` + `04-spatial-measurement` + `07-risk-rule-engine` | 与排洪沟巡查共建 |
| A/B/C 实现边界 | 结果由 `geoai-py` 直接产生、生态组合产生,还是产品服务产生? | 分割掩膜=A;面积与多期统计=B;告警与工单=C |
| 项目处理阶段 | 当前结果属于模型观察、空间计算、事件候选,还是平台动作? | `person`、围栏相交、疑似闯入、喊话 |
| P1/P2/P3 优先级 | 在数据、传感器、规则齐备程度下,先验证什么? | 排洪沟=P1;索道近景缺陷=P3 |
| readiness 成熟度 | 现在能运行到哪一步,还缺什么证据? | `partial`、`blocked_by_data` |
## 统一处理链路
A/B/C 不表示准确率或成熟度。技术上属于 A 类的接口,如果没有项目模型、权重和真值,
仍然可能处于 `blocked`;技术契合度高也不等于可以优先上线。
## 规范化需求映射
| 方案域 | 原始需求 | 主要原子能力 | 优先级 | 当前判断 |
| --- | --- | --- | --- | --- |
| 人员与个体防护 | 人员安全识别 | `01` 检测 + 可选 `03` 裁剪分类 + `06/07` | P1 | 已有人候选 Demo;无安全帽模型、关联真值和区域规则 |
| 爆破警戒热成像 | 爆破警戒热成像巡查 | 热成像人员检测 + `06/07` | P1-条件就绪 | 无热成像样本和警戒区,不能复用 RGB 模型后直接验收 |
| 排洪沟与箱涵堵塞 | 排洪沟巡查、排洪沟堵塞识别 | `02` 分割 + `01` 检测 + `00/04/07` | P1 | 通用颜色分割流程已通;项目类别、模型和真值未完成 |
| 边坡变化与形变 | 边坡监测 | `00` 变化 + `05` 三维 + `04/09` | P2 | 先修正重复需求描述,补同尺度多期数据和工程精度 |
| 竖井设施巡查 | 竖井堵塞及损坏识别 | `01/02/09` + `04/05` | P2 | 先界定井口、拦网、栈道、井壁等检测部位 |
| 索道部件巡查 | 索道安全感知 | `01/02/09` + `04/05/13` | P3 | 全景航拍不支持断丝结论;先确定部件和近景采集方案 |
| 尾矿库异常筛查 | 尾矿库矿浆渗漏识别 | `02/03/00/09` + 可选 `08` | P3 | RGB 颜色异常不等于渗漏,需多光谱/热成像和现场证据 |
## 统一结果分层
```text
无人机采集 / 正射影像 / 热成像 / 三维数据
    -> 原子能力:检测、分割、分类、变化、测量、异常
    -> 空间判断:位置、围栏、面积、距离、归属
    -> 业务规则:阈值、严重等级、事件建议
    -> 结果协议:标注图、JSON、GeoJSON、事件候选、运行元数据
采集数据
  -> 模型观察 observations:person、helmet、blockage_mask、surface_change
  -> 空间与测量 analysis:围栏相交、面积、长度、覆盖比例、位移
  -> 事件候选 events:疑似未戴安全帽、疑似堵塞、疑似边坡变化
  -> 平台动作 actions:告警、喊话、工单、复飞、任务调度
```
## 方案实施规则
- `observations` 只能陈述模型看到了什么,不直接写“违规”“事故”或“渗漏已确认”。
- `analysis` 必须记录 CRS、单位、阈值和计算方法;普通 JPEG 只能报告像素坐标/面积。
- `events` 必须给出命中规则、证据工件和人工复核状态。
- `actions` 属于产品权限、审计和安全责任范围,不在原子能力 Demo 中自动执行。
- 先验证原子能力,再连接方案步骤;禁止用未验证模型直接批处理项目数据。
- 事件是算法候选,除非业务明确授权,不应直接表述为事故、违规或最终安全结论。
- JPEG 输出为像素坐标;需要地图定位、电子围栏或面积计算时,必须提供有效坐标系的 GeoTIFF/GeoJSON/测绘数据。
- 自动悬停、喊话、告警推送、工单和飞行任务调整属于产品集成范围,不在本目录直接实现。
## 类别选择规则
控制台的“类别选择”应是**任务预设**,而不是允许任意填写类别名称。每个可选任务必须
绑定模型/算法版本、类别表、输入传感器、标注规范、许可证和验收状态。没有模型和真值的
项目类别可以登记为 `planned`,但不能伪装成可运行选项。
当前优先任务预设:
| 任务预设 | 最低类别 | 输出类型 | 状态 |
| --- | --- | --- | --- |
| `person_ppe_rgb` | person、helmet | 框/实例与关联关系 | 人员 partial;安全帽 planned |
| `person_thermal_guard` | thermal_person | 框/热点区域 | blocked_by_data |
| `drainage_blockage` | channel、culvert_inlet、blockage_material、blockage | 多类掩膜、图斑 | planned;通用颜色基线不等同于该任务 |
| `slope_change` | stable、surface_change、collapse_candidate、crack_region | 多期变化栅格/图斑 | existing product only / local planned |
| `tailings_anomaly` | water_or_slurry_region、spectral_anomaly | 掩膜、变化与异常分数 | blocked_by_evidence |
## 阶段门槛
1. **G0 需求可验收**:目标对象、传感器、拍摄距离/GSD、区域和业务阈值明确。
2. **G1 数据可评估**:有正常、异常、困难、纯负样本和独立测试集,来源与许可已记录。
3. **G2 原子能力通过**:固定输入可重复运行,输出结构化,报告漏报、误报、耗时和限制。
4. **G3 方案事件通过**:空间计算、严重度规则与人工复核一致,不把模型观察当最终结论。
5. **G4 产品闭环评审**:权限、审计、性能、告警、工单和无人机动作经过产品与安全评审。
当前只保留两个试点目录:`01-person-safety` 和 `02-drainage-blockage`。其余方案在满足
G0/G1 前只登记在需求映射中,不创建空模型目录,也不承诺精度或上线时间。
solutions/mining-inspection/requirements-map.json
New file
@@ -0,0 +1,96 @@
{
  "source_document": "reference_article/瑶岗仙二期项目算法需求与GeoAI能力匹配分析报告.docx",
  "source_item_count": 8,
  "normalized_solution_domain_count": 7,
  "normalization_notes": [
    "排洪沟巡查与排洪沟堵塞识别合并为同一方案域,避免重复模型、标注和规则。",
    "A/B/C 表示实现边界,不表示项目优先级或成熟度。",
    "模型观察、空间计算、事件规则和平台动作必须分别记录。"
  ],
  "solution_domains": [
    {
      "id": "person-safety",
      "name": "人员与个体防护识别",
      "source_items": ["人员安全识别"],
      "priority": "P1",
      "readiness": "partial",
      "sensor": ["RGB"],
      "capability_references": ["01-object-detection", "03-attribute-classification", "06-spatial-reasoning", "07-risk-rule-engine"],
      "model_observations": ["person", "helmet"],
      "event_candidates": ["suspected_missing_helmet", "suspected_restricted_area_entry"],
      "blocking_conditions": ["缺少安全帽正负样本", "缺少人员与安全帽关联真值", "缺少危险区域和事件阈值"]
    },
    {
      "id": "blast-zone-thermal-guard",
      "name": "爆破警戒热成像巡查",
      "source_items": ["爆破警戒热成像巡查"],
      "priority": "P1-conditional",
      "readiness": "blocked_by_data",
      "sensor": ["thermal", "optional RGB"],
      "capability_references": ["01-object-detection", "06-spatial-reasoning", "07-risk-rule-engine"],
      "model_observations": ["thermal_person_candidate"],
      "event_candidates": ["suspected_person_in_blast_zone"],
      "blocking_conditions": ["缺少原始热成像样本", "缺少热成像人员标注", "缺少警戒区定义", "缺少允许漏报和误报边界"]
    },
    {
      "id": "drainage-blockage",
      "name": "排洪沟与箱涵堵塞巡查",
      "source_items": ["排洪沟巡查", "排洪沟堵塞识别"],
      "priority": "P1",
      "readiness": "partial",
      "sensor": ["RGB", "preferred georeferenced orthomosaic"],
      "capability_references": ["02-semantic-mapping", "01-object-detection", "00-change-detection", "04-spatial-measurement", "07-risk-rule-engine"],
      "model_observations": ["drainage_channel", "culvert_inlet", "blockage_material", "blockage_mask"],
      "event_candidates": ["suspected_drainage_blockage"],
      "blocking_conditions": ["缺少沟道和箱涵边界", "缺少堵塞掩膜和严重度样本", "缺少覆盖比例或面积阈值"]
    },
    {
      "id": "slope-monitoring",
      "name": "边坡变化与形变监测",
      "source_items": ["边坡监测"],
      "priority": "P2",
      "readiness": "needs_requirement_correction",
      "sensor": ["registered orthomosaic", "DSM or point cloud"],
      "capability_references": ["00-change-detection", "05-3d-pointcloud", "04-spatial-measurement", "09-anomaly-detection"],
      "model_observations": ["surface_change", "collapse_candidate", "crack_region", "displacement_measurement"],
      "event_candidates": ["suspected_slope_change"],
      "blocking_conditions": ["需求描述疑似复制错误", "缺少同尺度配准多期数据", "缺少工程测量精度和告警阈值"]
    },
    {
      "id": "shaft-inspection",
      "name": "竖井堵塞与设施损坏巡查",
      "source_items": ["竖井堵塞及损坏识别"],
      "priority": "P2",
      "readiness": "blocked_by_scope",
      "sensor": ["close-range RGB", "optional 3D"],
      "capability_references": ["01-object-detection", "02-semantic-mapping", "09-anomaly-detection", "04-spatial-measurement", "05-3d-pointcloud"],
      "model_observations": ["blockage", "damaged_guard", "damaged_walkway", "structural_anomaly"],
      "event_candidates": ["suspected_shaft_blockage", "suspected_shaft_damage"],
      "blocking_conditions": ["检测部位未界定", "损坏定义未确定", "缺少近景正常和异常样本"]
    },
    {
      "id": "cableway-inspection",
      "name": "索道部件与外观缺陷巡查",
      "source_items": ["索道安全感知"],
      "priority": "P3",
      "readiness": "blocked_by_capture_method",
      "sensor": ["close-range RGB", "optional 3D"],
      "capability_references": ["01-object-detection", "02-semantic-mapping", "09-anomaly-detection", "04-spatial-measurement", "05-3d-pointcloud", "13-asset-health-diagnosis"],
      "model_observations": ["bucket", "clamp", "tower_component", "visible_defect"],
      "event_candidates": ["suspected_cableway_defect"],
      "blocking_conditions": ["检测对象未确定", "常规全景不能支持断丝识别", "缺少近景航线和异常样本"]
    },
    {
      "id": "tailings-leakage",
      "name": "尾矿库矿浆渗漏与水体异常筛查",
      "source_items": ["尾矿库矿浆渗漏识别"],
      "priority": "P3",
      "readiness": "blocked_by_evidence",
      "sensor": ["multispectral", "thermal", "fixed-view RGB for comparison only"],
      "capability_references": ["02-semantic-mapping", "03-attribute-classification", "00-change-detection", "09-anomaly-detection", "08-spatiotemporal-forecasting"],
      "model_observations": ["water_or_slurry_region", "color_or_spectral_anomaly", "temporal_change"],
      "event_candidates": ["suspected_tailings_leakage"],
      "blocking_conditions": ["缺少真实渗漏样本", "传感器条件未确认", "RGB 浑浊不能直接证明矿浆渗漏", "缺少现场复核标签"]
    }
  ]
}
tests/test_serve_workbench_console.py
@@ -1,6 +1,8 @@
from __future__ import annotations
import importlib.util
import io
import sys
import unittest
from pathlib import Path
@@ -33,6 +35,40 @@
            Path(handler.translate_path("/shared/data/raw/01-object-detection/sample.jpeg")),
            ROOT / "shared" / "data" / "raw" / "01-object-detection" / "sample.jpeg",
        )
        self.assertEqual(
            Path(handler.translate_path("/shared/data/raw/02-semantic-mapping/sample.tif")),
            ROOT / "shared" / "data" / "raw" / "02-semantic-mapping" / "sample.tif",
        )
    def test_upload_name_is_sanitized_and_extension_is_allowlisted(self) -> None:
        self.assertEqual(MODULE.safe_file_name("../../unsafe name.JPG", {".jpg"}), "unsafe_name.jpg")
        with self.assertRaises(MODULE.ApiError):
            MODULE.safe_file_name("image.exe", {".jpg", ".png"})
    def test_oversized_request_is_rejected_before_reading_body(self) -> None:
        handler = self.make_handler()
        handler.headers = {"Content-Length": str(MODULE.MAX_REQUEST_BYTES + 1), "Content-Type": "application/json"}
        handler.rfile = io.BytesIO(b"")
        with self.assertRaises(MODULE.ApiError):
            handler.read_json_body()
    def test_script_failure_becomes_a_useful_api_error(self) -> None:
        handler = self.make_handler()
        with self.assertRaisesRegex(MODULE.ApiError, "Processing failed"):
            handler.run_command([sys.executable, "-c", "raise SystemExit(2)"], timeout=10)
    def test_semantic_validation_run_is_discovered(self) -> None:
        runs = MODULE.semantic_runs(ROOT)
        self.assertTrue(any(item["id"] == "validation-20260817" for item in runs))
    def test_semantic_task_catalog_only_enables_verified_baseline(self) -> None:
        tasks = MODULE.semantic_tasks(ROOT)
        self.assertEqual(len(tasks), 5)
        selectable = [item["id"] for item in tasks if item.get("selectable") is True]
        self.assertEqual(selectable, ["color_baseline"])
        handler = self.make_handler()
        with self.assertRaisesRegex(MODULE.ApiError, "not runnable yet"):
            handler.create_semantic_run({"taskId": "drainage_blockage", "images": []})
    def test_blocks_repository_files_and_encoded_traversal(self) -> None:
        handler = self.make_handler()