shuishen
2 days ago 4092854b0bea9e1fc02f29222c6f7cd876e565a5
feat:项目基础整理
17 files modified
4 files added
4 files deleted
204713 ■■■■■ changed files
.env.example 2 ●●●●● patch | view | raw | blame | history
PROJECT_CONTEXT.md 20 ●●●● patch | view | raw | blame | history
apps/workbench-console/.env.example 2 ●●●●● patch | view | raw | blame | history
apps/workbench-console/README.md 29 ●●●● patch | view | raw | blame | history
apps/workbench-console/src/api/artifacts.ts 133 ●●●● patch | view | raw | blame | history
apps/workbench-console/src/components/ObjectDetectionPanel.vue 51 ●●●● patch | view | raw | blame | history
apps/workbench-console/src/components/TrajectoryAnalysisPanel.vue 36 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/components/TrajectoryMap.vue 93 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/stores/artifacts.ts 64 ●●●● patch | view | raw | blame | history
apps/workbench-console/src/styles.css 82 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/views/OverviewView.vue 5 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/vite-env.d.ts 1 ●●●● patch | view | raw | blame | history
apps/workbench-console/vite.config.ts 3 ●●●● patch | view | raw | blame | history
baseData/no-fly-zone-Chn_xS2X.geojson 203348 ●●●●● patch | view | raw | blame | history
baseData/uom-fly-zone.bin-B8O-S8Cs.gzip patch | view | raw | blame | history
baseData/演示.xlsx patch | view | raw | blame | history
baseData/田墩-疫木识别(勿删).kmz patch | view | raw | blame | history
capabilities/01-object-detection/README.md 5 ●●●●● patch | view | raw | blame | history
capabilities/15-trajectory-analysis/README.md 26 ●●●●● patch | view | raw | blame | history
capabilities/15-trajectory-analysis/prepare_real_flight.py 419 ●●●●● patch | view | raw | blame | history
capabilities/15-trajectory-analysis/requirements.txt 1 ●●●● patch | view | raw | blame | history
capabilities/15-trajectory-analysis/run_trajectory_analysis.py 57 ●●●●● patch | view | raw | blame | history
capabilities/15-trajectory-analysis/tests/test_demo.py 2 ●●●●● patch | view | raw | blame | history
capabilities/15-trajectory-analysis/tests/test_prepare_real_flight.py 78 ●●●●● patch | view | raw | blame | history
scripts/serve_workbench_console.py 256 ●●●●● patch | view | raw | blame | history
.env.example
@@ -8,3 +8,5 @@
MODEL_API_BASE_URL=
MODEL_API_KEY=
# Optional public client-side map service token for the local console
VITE_TIANDITU_TOKEN=
PROJECT_CONTEXT.md
@@ -54,18 +54,19 @@
| pyproj | 3.7.2 |
| scikit-learn | 1.9.0 |
| matplotlib | 3.11.1 |
| openpyxl | 3.1.5 |
`geoai-py` is intentionally not installed in this environment because version 0.42.0 has no trajectory, tracking, or behavior-recognition API. The capability consumes timestamped track results that may originate from an upstream GeoAI detection/export workflow. `pip check` passes.
## Local Experiment Console
- Location: `apps/workbench-console/`
- Frontend: Vue 3 + Vite + Ant Design Vue + Pinia. Trajectory maps use Cesium `1.126.0` with a local grid base layer.
- Frontend: Vue 3 + Vite + Ant Design Vue + Pinia. Trajectory maps use Cesium `1.126.0`, public ArcGIS World Imagery, and optional TianDiTu image/label layers configured by `apps/workbench-console/.env.local` `VITE_TIANDITU_TOKEN`.
- Build: `Set-Location .\apps\workbench-console; npm install; npm run build`
- 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, read-only local UI for this workbench. It has no code, API, account, data-upload, or task-execution link to the two drone-product repositories.
- First release: overview plus live result pages for `01-object-detection` and `15-trajectory-analysis`; other capability pages show their registered status only.
- Scope: independent, read-only local UI for this workbench. It has no code, account, data-upload, or task-execution link to the two drone-product repositories. The map client directly requests public ArcGIS tiles and, only when configured, TianDiTu tiles.
- First release: overview plus live result pages for `01-object-detection` and `15-trajectory-analysis`; the trajectory page can switch between the real Tian Dun flight and synthetic validation cases. Other capability pages show their registered status only.
- File exposure: the local server permits only console assets, `shared/outputs`, and source images required for the object-detection comparison. It does not expose the rest of the repository.
## Capability Status
@@ -118,6 +119,7 @@
- Input contract: WGS84 timestamped CSV observations plus GeoJSON reference routes and zones, connected by a `*.case.json` manifest.
- Generated representative inputs: `shared/data/raw/15-trajectory-analysis`
- Inspectable outputs: `shared/outputs/15-trajectory-analysis`
- User-provided real source data is staged, unchanged, in `shared/data/raw/15-trajectory-analysis/tian-dun-demo-20260814/`: no-fly GeoJSON, flyable-area Gzip binary source, DJI WPMZ KMZ, and an actual-flight XLSX log. `prepare_real_flight.py` produces a single WGS84 case from all four sources at `shared/data/processed/15-trajectory-analysis/tian-dun-flight-19578/`. The Gzip was verified as 46,518 closed `int32 / 1e7` coordinate rings and yields two local flyable polygons for display only; the zone rule still recognizes only `zone_type=restricted`.
Measured validation:
@@ -125,13 +127,16 @@
- The difficult synthetic case has 3 tracks, drops 1 duplicate timestamp, and finds exactly one stop, one route deviation, one restricted-zone event, and one gathering event.
- Directory processing handled 5 tracks and 78 cleaned observations in about 2.8 seconds on CPU.
- Missing required CSV columns fail cleanly with exit code 2. Both 1400 x 980 PNG results were visually inspected.
- Real Tian Dun flight 19578: 233 observations became one WGS84 drone track; the 10-waypoint WPMZ route, one locally selected no-fly source area and two local flyable polygons were prepared and analyzed in 0.362 seconds. It produced two route-deviation runs (77 and 175 seconds) and one 464-second source-zone intersection, with no stop or gathering event. The static result map was visually inspected.
Current decisions and limitations:
- Keep the boundary explicit: the current Demo is C because it does not call `geoai-py`; using geospatial ecosystem libraries alone does not make it B. `geoai-py` is only a possible upstream source.
- This is rule-based behavior detection, not learned video action recognition, and it assumes track IDs already exist.
- Synthetic data verifies logic only. Do not claim real-world accuracy or batch-run operational data yet.
- Next decision: obtain one normal and one difficult anonymized real track set, manually label events, then measure false positives, misses, GPS sensitivity, and per-entity thresholds.
- The Tian Dun zone intersection is a spatial result against an unverified third-party source, not a violation conclusion. The reference route does not encode takeoff, return-to-home or landing, so its two deviation runs require flight-phase-aware validation.
- The flyable-area binary structure has been decoded, but its authority, publication time and operational semantics remain unverified; it is visual-only until that validation is complete.
- Next decision: obtain manual truth labels for this real flight and another difficult real flight, then measure false positives, misses, GPS sensitivity and phase-aware thresholds.
## Starting a New Capability
@@ -143,3 +148,10 @@
```
After completing verified work, update this snapshot and the capability README. Change `AGENTS.md` only when a long-lived rule changes.
## 2026-08-14 工作台更新
- 本地控制台已从只读结果页升级为独立实验工作台:能力页包含新建运行入口、服务端自动发现的案例库和结果工作区,仍不连接任何无人机产品。
- `scripts/serve_workbench_console.py` 仅监听 `127.0.0.1` 等回环地址,支持轨迹与目标检测的受限 JSON/Base64 上传 API;每次生成唯一运行目录,不覆盖原始数据或既有输出。
- 轨迹上传入口接受 XLSX、KMZ、禁飞区 GeoJSON 和可选适飞区 Gzip,预处理器已支持缺少适飞区时继续生成可运行 case。
- 目标检测页面默认并列展示原图与标注图;案例数据从输出目录扫描,历史多模型检测结果可以在同一案例库中选择。
- 轨迹汇总的 `normal`、`stop`、`route_deviation`、`restricted_zone`、`gathering` 标签在 UI 显示中文,原始英文值保留在 CSV/GeoJSON。
apps/workbench-console/.env.example
New file
@@ -0,0 +1,2 @@
# Optional public client-side token for TianDiTu image and label layers.
VITE_TIANDITU_TOKEN='e110584a27d506da2740edca951683f4'
apps/workbench-console/README.md
@@ -1,8 +1,9 @@
# GeoAI Workbench 本地实验控制台
这是一个完全独立于无人机产品的本地只读控制台。它只展示本工作区已有能力的
输入说明、运行元数据和结果工件,不上传数据、不调用外部 API,也不提供删除、
覆盖或启动算法的操作。
输入说明、运行元数据和结果工件,不上传数据,也不提供删除、覆盖或启动算法的
操作。地图会直接请求公开 ArcGIS 影像切片;配置天地图 token 后还会直接请求
天地图影像和注记切片。
## 启动
@@ -37,6 +38,18 @@
Vite 开发服务固定使用 `http://127.0.0.1:6174/apps/workbench-console/`,会通过本机代理读取 `6173` 的结果工件。
## 地图底图
Cesium 默认加载公开 ArcGIS World Imagery 底图。要叠加天地图影像和注记,在
`apps/workbench-console/.env.local` 中填写下列值后重新执行 `npm run build`:
```text
VITE_TIANDITU_TOKEN=<your-token>
```
可先参考同目录的 `.env.example`。`VITE_` 变量会被打包进本地浏览器代码,因此
只应填入适合客户端使用的天地图访问 token,不要填入其他服务密钥。
## 目录职责
- `apps/workbench-console/src/`:Vue 3 组件、路由、Pinia 状态与能力适配层。
@@ -44,5 +57,13 @@
- `scripts/serve_workbench_console.py`:只读本地文件服务;仅暴露构建后的控制台、已有输出和目标检测原图,不暴露仓库其余文件。
- `shared/outputs/`:能力原始输出;控制台不复制、不修改这些文件。
首版已接入 `01-object-detection` 和 `15-trajectory-analysis`。其他能力保留目录和
边界状态,待其首个 Demo 产出可检查工件后再接入。
首版已接入 `01-object-detection` 和 `15-trajectory-analysis`。田墩实飞案例会显示
实飞轨迹、计划航线、禁飞区与适飞区,并提供轨迹和区域范围的聚焦按钮。其他能力
保留目录和边界状态,待其首个 Demo 产出可检查工件后再接入。
## 本地运行工作台
控制台现支持可操作的本地实验运行,并保持与无人机产品完全独立。能力页面提供新建运行入口和可搜索扩展的案例库,案例由本地服务从运行目录自动发现,不再依赖前端硬编码 Tabs。
- 轨迹分析上传 `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`;服务只调用固定虚拟环境和能力脚本,不接受任意命令或任意路径。
apps/workbench-console/src/api/artifacts.ts
@@ -1,101 +1,46 @@
export interface Detection {
  class_id: number;
  class_name: string;
  confidence: number;
  bbox_xyxy: number[];
}
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[];
}
export interface TrajectoryEvent {
  event_id: string;
  event_type: string;
  track_ids: string[];
  start_time: string;
  end_time: string;
  duration_seconds: number;
}
export interface TrajectoryRun {
  case_count: number;
  track_count: number;
  event_count: number;
  dropped_duplicate_observations: number;
  elapsed_seconds: number;
  device: string;
  input: string;
  thresholds_by_case: Record<string, Record<string, number>>;
}
export interface TrajectorySummary {
  track_id: string;
  entity_type: string;
  point_count: string;
  distance_m: string;
  average_speed_mps: string;
  behavior_labels: string;
}
export interface Detection { class_id: number; class_name: string; confidence: number; bbox_xyxy: number[]; }
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 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; }
export interface TrajectoryCase { id: string; label: string; note: string; artifactRoot: string; showSpatialContext: boolean; showFlyableZones: boolean; createdAt: string; run: TrajectoryCaseRun; events: TrajectoryEvent[]; summary: TrajectorySummary[]; }
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; }
export interface UploadFilePayload { name: string; content: string; }
export const artifactUrl = (path: string) => `/${path.replace(/\\/g, "/").split("/").map(encodeURIComponent).join("/")}`;
async function getJson<T>(path: string): Promise<T> {
  const response = await fetch(artifactUrl(path), { cache: "no-store" });
  if (!response.ok) throw new Error(`无法读取 ${path} (${response.status})`);
  return response.json() as Promise<T>;
async function responseJson<T>(response: Response): Promise<T> {
  const payload = await response.json().catch(() => ({})) as { error?: string } & T;
  if (!response.ok) throw new Error(payload.error || `本地服务请求失败 (${response.status})`);
  return payload;
}
async function getJson<T>(path: string): Promise<T> { return responseJson<T>(await fetch(artifactUrl(path), { cache: "no-store" })); }
async function getText(path: string): Promise<string> { const response = await fetch(artifactUrl(path), { cache: "no-store" }); if (!response.ok) throw new Error(`无法读取 ${path} (${response.status})`); return response.text(); }
function parseCsv(text: string): TrajectorySummary[] { const lines = text.trim().split(/\r?\n/); const headers = lines.shift()?.replace(/^\uFEFF/, "").split(",") ?? []; return lines.filter(Boolean).map((line) => { const values = line.split(","); return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ""])) as unknown as TrajectorySummary; }); }
export async function loadTrajectoryArtifacts(): Promise<Record<string, TrajectoryCase>> {
  const { runs } = await getJson<{ runs: TrajectoryDefinition[] }>("api/trajectory/runs");
  const cases = await Promise.all(runs.map(async (definition) => {
    const [run, eventPayload, summary] = await Promise.all([getJson<TrajectoryCaseRun>(`${definition.artifactRoot}/run_metadata.json`), getJson<{ events: TrajectoryEvent[] }>(`${definition.artifactRoot}/events.json`), getText(`${definition.artifactRoot}/trajectory_summary.csv`)]);
    return { ...definition, run, events: eventPayload.events, summary: parseCsv(summary) } satisfies TrajectoryCase;
  }));
  return Object.fromEntries(cases.map((item) => [item.id, item]));
}
async function getText(path: string): Promise<string> {
  const response = await fetch(artifactUrl(path), { cache: "no-store" });
  if (!response.ok) throw new Error(`无法读取 ${path} (${response.status})`);
  return response.text();
export async function loadDetectionArtifacts(): Promise<Record<string, DetectionCase>> {
  const { runs } = await getJson<{ runs: DetectionDefinition[] }>("api/object-detection/runs");
  const cases = await Promise.all(runs.map(async (definition) => {
    const [run, payload] = await Promise.all([getJson<DetectionRun>(`${definition.artifactRoot}/run_metadata.json`), getJson<{ images: DetectionImage[] }>(`${definition.artifactRoot}/detections.json`)]);
    return { ...definition, run, images: payload.images } satisfies DetectionCase;
  }));
  return Object.fromEntries(cases.map((item) => [item.id, item]));
}
function parseCsv(text: string): TrajectorySummary[] {
  const lines = text.trim().split(/\r?\n/);
  const headers = lines.shift()?.replace(/^\uFEFF/, "").split(",") ?? [];
  return lines.filter(Boolean).map((line) => {
    const values = line.split(",");
    return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ""])) as unknown as TrajectorySummary;
  });
}
export async function loadDetectionArtifacts() {
  const [run, payload] = await Promise.all([
    getJson<DetectionRun>("shared/outputs/01-object-detection/run_metadata.json"),
    getJson<{ images: DetectionImage[] }>("shared/outputs/01-object-detection/detections.json")
  ]);
  return { run, images: payload.images };
}
export async function loadTrajectoryArtifacts() {
  const [run, difficultEvents, difficultSummary, normalSummary] = await Promise.all([
    getJson<TrajectoryRun>("shared/outputs/15-trajectory-analysis/run_metadata.json"),
    getJson<{ events: TrajectoryEvent[] }>("shared/outputs/15-trajectory-analysis/difficult/events.json"),
    getText("shared/outputs/15-trajectory-analysis/difficult/trajectory_summary.csv"),
    getText("shared/outputs/15-trajectory-analysis/normal/trajectory_summary.csv")
  ]);
  return {
    run,
    cases: {
      difficult: { events: difficultEvents.events, summary: parseCsv(difficultSummary) },
      normal: { events: [], summary: parseCsv(normalSummary) }
    }
  };
}
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 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/ObjectDetectionPanel.vue
@@ -1,37 +1,40 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { FileImageOutlined, InfoCircleOutlined } from "@ant-design/icons-vue";
import { artifactUrl } from "@/api/artifacts";
import { FileImageOutlined, InfoCircleOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue";
import { createDetectionRun, artifactUrl, readFileAsPayload } from "@/api/artifacts";
import ArtifactState from "@/components/ArtifactState.vue";
import { useArtifactStore } from "@/stores/artifacts";
const store = useArtifactStore();
const mode = ref<"annotated" | "original">("annotated");
const caseId = ref("");
const selectedName = ref("");
const selectedImage = computed(() => store.detectionImages.find((item) => item.file === selectedName.value) ?? store.detectionImages[0]);
const previewSource = computed(() => {
  if (!selectedImage.value) return "";
  const path = mode.value === "annotated"
    ? `shared/outputs/01-object-detection/${selectedImage.value.annotated_file}`
    : `shared/data/raw/01-object-detection/${selectedImage.value.file}`;
  return artifactUrl(path);
});
onMounted(async () => {
  await store.loadDetection();
  const mostDetections = [...store.detectionImages].sort((left, right) => right.detections.length - left.detections.length)[0];
  selectedName.value = mostDetections?.file ?? "";
});
const showRunForm = ref(false);
const files = ref<File[]>([]);
const running = ref(false);
const runError = ref<string | null>(null);
const searchText = ref("");
const currentCase = computed(() => store.detectionCases[caseId.value] ?? Object.values(store.detectionCases)[0]);
const selectedImage = computed(() => currentCase.value?.images.find((item) => item.file === selectedName.value) ?? currentCase.value?.images[0]);
const caseOptions = computed(() => Object.values(store.detectionCases).map((item) => ({ value: item.id, label: item.label })));
const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase())));
function syncSelection() { const candidate = currentCase.value?.images.reduce((best, item) => item.detections.length > (best?.detections.length ?? -1) ? item : best, undefined as typeof currentCase.value.images[number] | undefined); selectedName.value = candidate?.file ?? ""; }
function beforeUpload(file: File) { files.value = [...files.value, file]; return false; }
function removeFile(file: { name: string }) { files.value = files.value.filter((item) => item.name !== file.name); }
async function submitRun() { if (!files.value.length) { runError.value = "请至少选择一张 JPG、JPEG 或 PNG 图像。"; return; } running.value = true; runError.value = null; try { const images = await Promise.all(files.value.map(readFileAsPayload)); const { run } = await createDetectionRun(images); await store.loadDetection(true); caseId.value = run.id; syncSelection(); files.value = []; showRunForm.value = false; } catch (error) { runError.value = error instanceof Error ? error.message : "目标检测运行失败"; } finally { running.value = false; } }
onMounted(async () => { await store.loadDetection(); caseId.value = Object.keys(store.detectionCases)[0] ?? ""; syncSelection(); });
</script>
<template>
  <ArtifactState :loading="store.loading" :error="store.error" />
  <template v-if="store.detectionRun && selectedImage">
    <a-row :gutter="[18, 18]">
      <a-col :xs="24" :xl="17"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="store.detectionImages.map((item) => ({ value: item.file, label: `${item.file} · ${item.detections.length} 个候选` }))" /><a-segmented v-model:value="mode" :options="[{ label: '标注结果', value: 'annotated' }, { label: '原始影像', value: 'original' }]" /></div><a-image class="detection-image" :src="previewSource" :alt="`${selectedImage.file} 预览`" /><p class="image-caption">{{ selectedImage.width }} x {{ selectedImage.height }} 像素 · {{ selectedImage.detections.length }} 个候选</p></section></a-col>
      <a-col :xs="24" :xl="7"><section class="surface-section"><h2>本次运行</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="处理影像">{{ store.detectionRun.processed_images }} 张</a-descriptions-item><a-descriptions-item label="检测候选">{{ store.detectionRun.detection_count }} 个</a-descriptions-item><a-descriptions-item label="耗时">{{ store.detectionRun.elapsed_seconds }} 秒</a-descriptions-item><a-descriptions-item label="设备">{{ store.detectionRun.device }}</a-descriptions-item><a-descriptions-item label="模型">{{ store.detectionRun.model }}</a-descriptions-item><a-descriptions-item label="阈值">{{ store.detectionRun.confidence }}</a-descriptions-item></a-descriptions><a-divider /><a-space direction="vertical"><a-button type="link" :href="artifactUrl('shared/outputs/01-object-detection/detections.json')" target="_blank"><FileImageOutlined />检测 JSON</a-button><a-button type="link" :href="artifactUrl('shared/outputs/01-object-detection/run_metadata.json')" target="_blank"><InfoCircleOutlined />运行元数据</a-button></a-space></section></a-col>
    </a-row>
    <section class="surface-section"><div class="section-heading"><div><h2>当前影像检测明细</h2><p>边界框是 JPEG 像素坐标,不含可用地理坐标。</p></div></div><a-table :data-source="selectedImage.detections" :pagination="false" row-key="bbox_xyxy" size="small" :scroll="{ x: 680 }"><a-table-column title="类别" data-index="class_name" key="class_name" /><a-table-column title="置信度" key="confidence" align="right"><template #default="{ record }">{{ (record.confidence * 100).toFixed(1) }}%</template></a-table-column><a-table-column title="像素框 x1, y1, x2, y2" key="bbox"><template #default="{ record }">{{ record.bbox_xyxy.map((value: number) => value.toFixed(1)).join(', ') }}</template></a-table-column><template #emptyText>该影像没有达到当前阈值的候选目标。</template></a-table></section>
  <section class="workspace-command">
    <div><h2>新建检测运行</h2><p>上传少量代表性影像,CPU 基线会生成独立结果,不覆盖既有案例。</p></div>
    <a-button type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button>
  </section>
  <section v-if="showRunForm" class="surface-section run-form"><a-alert type="info" show-icon message="单次最多 12 张图像;当前基线识别人员和常见车辆,树木不在有效类别内。" /><a-upload class="run-upload" multiple accept=".jpg,.jpeg,.png" :file-list="files.map((file) => ({ uid: file.name, name: file.name, status: 'done' as const }))" :before-upload="beforeUpload" @remove="removeFile"><a-button><UploadOutlined />选择图像</a-button></a-upload><a-alert v-if="runError" type="error" show-icon :message="runError" /><a-button type="primary" :loading="running" :disabled="!files.length" @click="submitRun"><PlayCircleOutlined />开始检测</a-button></section>
  <template v-if="currentCase && selectedImage">
    <div class="detection-workspace">
    <a-row :gutter="[18, 18]"><a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col><a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="currentCase.images.map((item) => ({ value: item.file, label: `${item.file} · ${item.detections.length} 个候选` }))" /><span class="toolbar-note">{{ currentCase.note }}</span></div><div class="comparison-grid"><figure><figcaption>原始影像</figcaption><a-image class="detection-image" :src="artifactUrl(`${currentCase.inputRoot}/${selectedImage.file}`)" :alt="`${selectedImage.file} 原始影像`" /></figure><figure><figcaption>标注结果</figcaption><a-image class="detection-image" :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.annotated_file}`)" :alt="`${selectedImage.file} 标注结果`" /></figure></div><p class="image-caption">{{ selectedImage.width }} x {{ selectedImage.height }} 像素 · {{ selectedImage.detections.length }} 个候选</p></section></a-col></a-row>
    <a-row :gutter="[18, 18]"><a-col :xs="24" :xl="8"><section class="surface-section"><h2>本次运行</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="处理影像">{{ currentCase.run.processed_images }} 张</a-descriptions-item><a-descriptions-item label="检测候选">{{ currentCase.run.detection_count }} 个</a-descriptions-item><a-descriptions-item label="耗时">{{ currentCase.run.elapsed_seconds }} 秒</a-descriptions-item><a-descriptions-item label="设备">{{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item label="模型">{{ currentCase.run.model }}</a-descriptions-item></a-descriptions><a-divider /><a-space direction="vertical"><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/detections.json`)" target="_blank"><FileImageOutlined />检测 JSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><InfoCircleOutlined />运行元数据</a-button></a-space></section></a-col><a-col :xs="24" :xl="16"><section class="surface-section"><div class="section-heading"><div><h2>当前影像检测明细</h2><p>边界框为 JPEG 像素坐标,不含可用地理坐标。</p></div></div><a-table :data-source="selectedImage.detections" :pagination="false" row-key="bbox_xyxy" size="small" :scroll="{ x: 680 }"><a-table-column title="类别" data-index="class_name" key="class_name" /><a-table-column title="置信度" key="confidence" align="right"><template #default="{ record }">{{ (record.confidence * 100).toFixed(1) }}%</template></a-table-column><a-table-column title="像素框 x1, y1, x2, y2" key="bbox"><template #default="{ record }">{{ record.bbox_xyxy.map((value: number) => value.toFixed(1)).join(', ') }}</template></a-table-column><template #emptyText>该影像没有达到当前阈值的候选目标。</template></a-table></section></a-col></a-row>
    </div>
  </template>
</template>
apps/workbench-console/src/components/TrajectoryAnalysisPanel.vue
@@ -1,28 +1,34 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, ref } from "vue";
import { DownloadOutlined } from "@ant-design/icons-vue";
import { artifactUrl } from "@/api/artifacts";
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<"difficult" | "normal">("difficult");
const currentCase = computed(() => store.trajectoryCases[caseId.value] ?? { events: [], summary: [] });
const eventNames: Record<string, string> = { stop: "停留", route_deviation: "偏航", restricted_zone: "进入禁入区", gathering: "聚集" };
const caseNote = computed(() => caseId.value === "difficult" ? "含停留、偏航、禁入区、聚集与重复观测。" : "连续移动且贴合参考路线,预期无事件。" );
const difficultRules = computed(() => store.trajectoryRun?.thresholds_by_case.difficult ?? {});
onMounted(() => store.loadTrajectory());
const caseId = ref(""); const showRunForm = ref(false); const running = ref(false); const runError = ref<string | null>(null);
const searchText = ref("");
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] ?? ""; });
</script>
<template>
  <ArtifactState :loading="store.loading" :error="store.error" />
  <template v-if="store.trajectoryRun">
    <a-row :gutter="[18, 18]"><a-col :xs="24" :xl="17"><section class="surface-section"><div class="section-toolbar"><a-segmented v-model:value="caseId" :options="[{ label: '困难样本', value: 'difficult' }, { label: '正常样本', value: 'normal' }]" /><span class="toolbar-note">{{ caseNote }}</span></div><TrajectoryMap :case-id="caseId" /></section></a-col><a-col :xs="24" :xl="7"><section class="surface-section"><h2>本次运行</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="案例">{{ store.trajectoryRun.case_count }} 个</a-descriptions-item><a-descriptions-item label="轨迹">{{ store.trajectoryRun.track_count }} 条</a-descriptions-item><a-descriptions-item label="事件">{{ store.trajectoryRun.event_count }} 个</a-descriptions-item><a-descriptions-item label="重复清洗">{{ store.trajectoryRun.dropped_duplicate_observations }} 条</a-descriptions-item><a-descriptions-item label="耗时">{{ store.trajectoryRun.elapsed_seconds }} 秒</a-descriptions-item><a-descriptions-item label="设备">{{ store.trajectoryRun.device }}</a-descriptions-item></a-descriptions><a-divider /><h3>默认规则</h3><a-descriptions size="small" :column="1"><a-descriptions-item label="停留速度">{{ difficultRules.stop_speed_mps }} m/s</a-descriptions-item><a-descriptions-item label="偏航距离">{{ difficultRules.route_deviation_m }} m</a-descriptions-item><a-descriptions-item label="聚集距离">{{ difficultRules.gathering_radius_m }} m</a-descriptions-item></a-descriptions></section></a-col></a-row>
    <a-row :gutter="[18, 18]" class="result-row"><a-col :xs="24" :xl="10"><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="14"><section class="surface-section"><div class="section-heading"><div><h2>轨迹汇总</h2><p>聚类 ID 仅用于探索相似轨迹,不代表确认异常。</p></div></div><a-table :data-source="currentCase.summary" :pagination="false" row-key="track_id" size="small" :scroll="{ x: 760 }"><a-table-column title="轨迹" data-index="track_id" key="track_id" /><a-table-column title="对象" data-index="entity_type" key="entity_type" /><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="speed" align="right"><template #default="{ record }">{{ Number(record.average_speed_mps).toFixed(2) }} m/s</template></a-table-column><a-table-column title="行为标签" data-index="behavior_labels" key="behavior_labels" /></a-table></section></a-col></a-row>
    <section class="surface-section result-files"><a-space wrap><a-button v-for="file in ['events.json', 'events.geojson', 'trajectories.geojson', 'run_metadata.json']" :key="file" :href="artifactUrl(`shared/outputs/15-trajectory-analysis/${caseId}/${file}`)" target="_blank"><DownloadOutlined />{{ file }}</a-button></a-space></section>
  <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="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>
</template>
apps/workbench-console/src/components/TrajectoryMap.vue
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { AimOutlined, BorderOutlined } from "@ant-design/icons-vue";
import {
  ArcGisMapServerImageryProvider,
  Color,
  ColorMaterialProperty,
  ConstantProperty,
@@ -8,17 +10,21 @@
  GridImageryProvider,
  HeightReference,
  PointGraphics,
  UrlTemplateImageryProvider,
  Viewer
} from "cesium";
import { artifactUrl } from "@/api/artifacts";
const props = defineProps<{ caseId: "difficult" | "normal" }>();
const props = defineProps<{ artifactRoot: string; showSpatialContext: boolean; showFlyableZones: boolean }>();
const host = ref<HTMLElement>();
const mapError = ref<string | null>(null);
let viewer: Viewer | null = null;
let trajectorySource: GeoJsonDataSource | null = null;
let eventSource: GeoJsonDataSource | null = null;
let routeSource: GeoJsonDataSource | null = null;
let zoneSource: GeoJsonDataSource | null = null;
let flyableZoneSource: GeoJsonDataSource | null = null;
function eventColor(type: string) {
  if (type === "route_deviation") return Color.fromCssColorString("#9a5da8");
@@ -33,7 +39,15 @@
    mapError.value = null;
    if (trajectorySource) viewer.dataSources.remove(trajectorySource, true);
    if (eventSource) viewer.dataSources.remove(eventSource, true);
    const root = `shared/outputs/15-trajectory-analysis/${props.caseId}`;
    if (routeSource) viewer.dataSources.remove(routeSource, true);
    if (zoneSource) viewer.dataSources.remove(zoneSource, true);
    if (flyableZoneSource) viewer.dataSources.remove(flyableZoneSource, true);
    trajectorySource = null;
    eventSource = null;
    routeSource = null;
    zoneSource = null;
    flyableZoneSource = null;
    const root = props.artifactRoot;
    trajectorySource = await GeoJsonDataSource.load(artifactUrl(`${root}/trajectories.geojson`), { clampToGround: false });
    trajectorySource.entities.values.forEach((entity) => {
      if (entity.polyline) {
@@ -52,12 +66,78 @@
        heightReference: new ConstantProperty(HeightReference.NONE)
      });
    });
    if (props.showSpatialContext) {
      routeSource = await GeoJsonDataSource.load(artifactUrl(`${root}/reference_routes.geojson`), { clampToGround: false });
      routeSource.entities.values.forEach((entity) => {
        if (entity.polyline) {
          entity.polyline.width = new ConstantProperty(3);
          entity.polyline.material = new ColorMaterialProperty(Color.fromCssColorString("#444444"));
        }
      });
      zoneSource = await GeoJsonDataSource.load(artifactUrl(`${root}/zones.geojson`), { clampToGround: false });
      zoneSource.entities.values.forEach((entity) => {
        if (entity.polygon) {
          entity.polygon.material = new ColorMaterialProperty(Color.fromCssColorString("#e5c951").withAlpha(0.16));
          entity.polygon.outline = new ConstantProperty(true);
          entity.polygon.outlineColor = new ConstantProperty(Color.fromCssColorString("#9a7a00"));
        }
      });
      if (props.showFlyableZones) {
        flyableZoneSource = await GeoJsonDataSource.load(artifactUrl(`${root}/flyable_zones.geojson`), { clampToGround: false });
        flyableZoneSource.entities.values.forEach((entity) => {
          if (entity.polygon) {
            entity.polygon.material = new ColorMaterialProperty(Color.fromCssColorString("#36a269").withAlpha(0.22));
            entity.polygon.outline = new ConstantProperty(true);
            entity.polygon.outlineColor = new ConstantProperty(Color.fromCssColorString("#167344"));
          }
        });
      }
    }
    if (zoneSource) await viewer.dataSources.add(zoneSource);
    if (flyableZoneSource) await viewer.dataSources.add(flyableZoneSource);
    if (routeSource) await viewer.dataSources.add(routeSource);
    await viewer.dataSources.add(trajectorySource);
    await viewer.dataSources.add(eventSource);
    await viewer.flyTo(trajectorySource, { duration: 0 });
    await viewer.flyTo(props.showSpatialContext && zoneSource ? zoneSource : trajectorySource, { duration: 0 });
  } catch (error) {
    mapError.value = error instanceof Error ? error.message : "Cesium 地图加载失败";
  }
}
async function focusTrajectory() {
  if (viewer && trajectorySource) await viewer.flyTo(trajectorySource, { duration: 0.4 });
}
async function focusZones() {
  if (viewer && zoneSource) await viewer.flyTo(zoneSource, { duration: 0.4 });
}
async function addBaseLayers() {
  if (!viewer) return;
  viewer.imageryLayers.removeAll();
  try {
    const arcgis = await ArcGisMapServerImageryProvider.fromUrl(
      "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer"
    );
    viewer.imageryLayers.addImageryProvider(arcgis);
  } catch {
    viewer.imageryLayers.addImageryProvider(new GridImageryProvider({ cells: 8, glowWidth: 0 }));
  }
  const token = (import.meta.env.VITE_TIANDITU_TOKEN ?? "").trim();
  if (!token) return;
  const subdomains = ["0", "1", "2", "3", "4", "5", "6", "7"];
  viewer.imageryLayers.addImageryProvider(new UrlTemplateImageryProvider({
    url: `https://t{s}.tianditu.gov.cn/DataServer?T=img_w&x={x}&y={y}&l={z}&tk=${token}`,
    subdomains,
    maximumLevel: 18,
    credit: "Tianditu imagery"
  }));
  viewer.imageryLayers.addImageryProvider(new UrlTemplateImageryProvider({
    url: `https://t{s}.tianditu.gov.cn/DataServer?T=cva_w&x={x}&y={y}&l={z}&tk=${token}`,
    subdomains,
    maximumLevel: 18,
    credit: "Tianditu labels"
  }));
}
onMounted(async () => {
@@ -74,15 +154,14 @@
    selectionIndicator: false,
    timeline: false
  });
  viewer.imageryLayers.removeAll();
  viewer.imageryLayers.addImageryProvider(new GridImageryProvider({ cells: 8, glowWidth: 0 }));
  await addBaseLayers();
  await drawCase();
});
watch(() => props.caseId, drawCase);
watch(() => [props.artifactRoot, props.showSpatialContext, props.showFlyableZones], drawCase);
onBeforeUnmount(() => viewer?.destroy());
</script>
<template>
  <div class="trajectory-map"><div ref="host" class="cesium-host"></div><a-alert v-if="mapError" class="map-error" type="warning" show-icon :message="mapError" /></div>
  <div class="trajectory-map"><div ref="host" class="cesium-host"></div><div v-if="showSpatialContext" class="map-legend"><span><i class="legend-track"></i>实飞轨迹</span><span><i class="legend-route"></i>计划航线</span><span><i class="legend-restricted"></i>禁飞区</span><span v-if="showFlyableZones"><i class="legend-flyable"></i>适飞区</span></div><div v-if="showSpatialContext" class="map-tools"><a-tooltip title="聚焦轨迹"><a-button shape="circle" aria-label="聚焦轨迹" @click="focusTrajectory"><AimOutlined /></a-button></a-tooltip><a-tooltip title="查看禁飞区范围"><a-button shape="circle" aria-label="查看禁飞区范围" @click="focusZones"><BorderOutlined /></a-button></a-tooltip></div><a-alert v-if="mapError" class="map-error" type="warning" show-icon :message="mapError" /></div>
</template>
apps/workbench-console/src/stores/artifacts.ts
@@ -1,61 +1,15 @@
import { defineStore } from "pinia";
import { loadDetectionArtifacts, loadTrajectoryArtifacts, type DetectionCase, type TrajectoryCase } from "@/api/artifacts";
import {
  loadDetectionArtifacts,
  loadTrajectoryArtifacts,
  type DetectionImage,
  type DetectionRun,
  type TrajectoryRun,
  type TrajectorySummary,
  type TrajectoryEvent
} from "@/api/artifacts";
interface ArtifactState {
  detectionRun: DetectionRun | null;
  detectionImages: DetectionImage[];
  trajectoryRun: TrajectoryRun | null;
  trajectoryCases: Record<string, { events: TrajectoryEvent[]; summary: TrajectorySummary[] }>;
  loading: boolean;
  error: string | null;
}
interface ArtifactState { detectionCases: Record<string, DetectionCase>; trajectoryCases: Record<string, TrajectoryCase>; loading: boolean; error: string | null; }
export const useArtifactStore = defineStore("artifacts", {
  state: (): ArtifactState => ({
    detectionRun: null,
    detectionImages: [],
    trajectoryRun: null,
    trajectoryCases: {},
    loading: false,
    error: null
  }),
  actions: {
    async loadDetection() {
      if (this.detectionRun) return;
      this.loading = true;
      this.error = null;
      try {
        const result = await loadDetectionArtifacts();
        this.detectionRun = result.run;
        this.detectionImages = result.images;
      } catch (error) {
        this.error = error instanceof Error ? error.message : "目标检测结果读取失败";
      } finally {
        this.loading = false;
      }
  getters: {
    detectionRun: (state) => Object.values(state.detectionCases)[0]?.run ?? null,
    detectionImages: (state) => Object.values(state.detectionCases)[0]?.images ?? []
    },
    async loadTrajectory() {
      if (this.trajectoryRun) return;
      this.loading = true;
      this.error = null;
      try {
        const result = await loadTrajectoryArtifacts();
        this.trajectoryRun = result.run;
        this.trajectoryCases = result.cases;
      } catch (error) {
        this.error = error instanceof Error ? error.message : "轨迹分析结果读取失败";
      } finally {
        this.loading = false;
      }
    }
  state: (): ArtifactState => ({ detectionCases: {}, trajectoryCases: {}, 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; } }
  }
});
apps/workbench-console/src/styles.css
@@ -12,6 +12,82 @@
.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; }
.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; }.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; } }
@media (max-width: 640px) { .view-container { padding: 20px 14px 34px; }.page-header, .section-toolbar { 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; } }
.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; }
.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; }
.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; }
.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; }
.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-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; }
@media (max-width: 1199px) {
  .view-container { max-width: 100%; padding-inline: 26px; }
}
@media (max-width: 640px) {
  .view-container { padding: 20px 14px 34px; }
  .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; }
}
apps/workbench-console/src/views/OverviewView.vue
@@ -12,6 +12,7 @@
const store = useArtifactStore();
const router = useRouter();
const planned = computed(() => capabilities.filter((item) => item.status === "planned"));
const realTrajectory = computed(() => store.trajectoryCases["tian-dun-flight-19578"]);
onMounted(async () => Promise.all([store.loadDetection(), store.loadTrajectory()]));
function openCapability(id: string) { router.push({ name: "capability", params: { id } }); }
@@ -19,8 +20,8 @@
<template>
  <div class="view-container"><PageHeader eyebrow="WORKBENCH / OVERVIEW" title="实验结果一眼可见" description="独立于无人机产品的本地控制台。它只读取当前工作区已有结果,不上传数据,也不会触发算法运行。" /><ArtifactState :loading="store.loading" :error="store.error" />
    <a-row :gutter="[14, 14]" class="metric-row"><a-col :xs="12" :lg="6"><a-statistic title="能力目录" :value="capabilities.length" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="已验证 Demo" :value="verifiedCapabilities.length" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="最近检测结果" :value="store.detectionRun?.detection_count ?? 0" suffix="个" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="最近轨迹事件" :value="store.trajectoryRun?.event_count ?? 0" suffix="个" /></a-col></a-row>
    <section class="view-section"><div class="section-heading"><div><h2>已验证能力</h2><p>从实验结果进入,不需要再查找输出目录。</p></div></div><a-row :gutter="[18, 18]"><a-col :xs="24" :xl="12"><a-card class="capability-card" :body-style="{ padding: '0' }"><img class="card-media" :src="artifactUrl('shared/outputs/01-object-detection/annotated/DJI_20260810092727_0001_V_10.jpeg')" alt="目标检测标注图" /><div class="card-copy"><div class="card-topline"><div><h3>地物目标检测</h3><p>原图与标注图切换、检测框明细与运行参数。</p></div><a-tag color="green">已验证</a-tag></div><a-space wrap><a-tag color="gold">A:直接能力</a-tag><a-tag>{{ store.detectionRun?.processed_images ?? 0 }} 张影像</a-tag><a-tag>{{ store.detectionRun?.detection_count ?? 0 }} 个候选</a-tag></a-space><a-button type="primary" @click="openCapability('01-object-detection')"><EyeOutlined />查看实验结果</a-button></div></a-card></a-col><a-col :xs="24" :xl="12"><a-card class="capability-card" :body-style="{ padding: '0' }"><img class="card-media" :src="artifactUrl('shared/outputs/15-trajectory-analysis/difficult/analysis.png')" alt="轨迹分析图" /><div class="card-copy"><div class="card-topline"><div><h3>轨迹分析与行为识别</h3><p>Cesium 轨迹地图、规则事件和轨迹汇总。</p></div><a-tag color="green">已验证</a-tag></div><a-space wrap><a-tag color="gold">C:产品能力</a-tag><a-tag>{{ store.trajectoryRun?.track_count ?? 0 }} 条轨迹</a-tag><a-tag>{{ store.trajectoryRun?.event_count ?? 0 }} 个事件</a-tag></a-space><a-button type="primary" @click="openCapability('15-trajectory-analysis')"><EyeOutlined />查看实验结果</a-button></div></a-card></a-col></a-row></section>
    <a-row :gutter="[14, 14]" class="metric-row"><a-col :xs="12" :lg="6"><a-statistic title="能力目录" :value="capabilities.length" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="已验证 Demo" :value="verifiedCapabilities.length" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="最近检测结果" :value="store.detectionRun?.detection_count ?? 0" suffix="个" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="田墩轨迹事件" :value="realTrajectory?.run.event_count ?? 0" suffix="个" /></a-col></a-row>
    <section class="view-section"><div class="section-heading"><div><h2>已验证能力</h2><p>从实验结果进入,不需要再查找输出目录。</p></div></div><a-row :gutter="[18, 18]"><a-col :xs="24" :xl="12"><a-card class="capability-card" :body-style="{ padding: '0' }"><img class="card-media" :src="artifactUrl('shared/outputs/01-object-detection/annotated/DJI_20260810092727_0001_V_10.jpeg')" alt="目标检测标注图" /><div class="card-copy"><div class="card-topline"><div><h3>地物目标检测</h3><p>原图与标注图切换、检测框明细与运行参数。</p></div><a-tag color="green">已验证</a-tag></div><a-space wrap><a-tag color="gold">A:直接能力</a-tag><a-tag>{{ store.detectionRun?.processed_images ?? 0 }} 张影像</a-tag><a-tag>{{ store.detectionRun?.detection_count ?? 0 }} 个候选</a-tag></a-space><a-button type="primary" @click="openCapability('01-object-detection')"><EyeOutlined />查看实验结果</a-button></div></a-card></a-col><a-col :xs="24" :xl="12"><a-card class="capability-card" :body-style="{ padding: '0' }"><img class="card-media" :src="artifactUrl('shared/outputs/15-trajectory-analysis/tian-dun-flight-19578/tian-dun-flight-19578/analysis.png')" alt="田墩实飞轨迹分析图" /><div class="card-copy"><div class="card-topline"><div><h3>轨迹分析与行为识别</h3><p>田墩实飞、计划航线、区域图层和规则事件。</p></div><a-tag color="green">已验证</a-tag></div><a-space wrap><a-tag color="gold">C:产品能力</a-tag><a-tag>{{ realTrajectory?.run.track_count ?? 0 }} 条轨迹</a-tag><a-tag>{{ realTrajectory?.run.event_count ?? 0 }} 个事件</a-tag></a-space><a-button type="primary" @click="openCapability('15-trajectory-analysis')"><EyeOutlined />查看实验结果</a-button></div></a-card></a-col></a-row></section>
    <section class="view-section"><div class="section-heading"><div><h2>待实现能力</h2><p>首个 Demo 产生可检查工件后,可以按相同方式接入控制台。</p></div></div><a-row :gutter="[14, 14]"><a-col v-for="capability in planned.slice(0, 8)" :key="capability.id" :xs="24" :md="12" :xl="6"><a-card size="small" class="planned-card"><div class="card-topline"><h3>{{ capability.title }}</h3><a-tag>{{ capability.level }}</a-tag></div><p>{{ capability.note }}</p><a-button type="link" size="small" @click="openCapability(capability.id)"><ExperimentOutlined />查看状态</a-button></a-card></a-col></a-row></section>
  </div>
</template>
apps/workbench-console/src/vite-env.d.ts
New file
@@ -0,0 +1 @@
/// <reference types="vite/client" />
apps/workbench-console/vite.config.ts
@@ -33,7 +33,8 @@
    port: 6174,
    strictPort: true,
    proxy: {
      "/shared": "http://127.0.0.1:6173"
      "/shared": "http://127.0.0.1:6173",
      "/api": "http://127.0.0.1:6173"
    }
  }
});
baseData/no-fly-zone-Chn_xS2X.geojson
File was deleted
baseData/uom-fly-zone.bin-B8O-S8Cs.gzip
Binary files differ
baseData/演示.xlsx
Binary files differ
baseData/田墩-疫木识别(勿删).kmz
Binary files differ
capabilities/01-object-detection/README.md
@@ -99,3 +99,8 @@
- `DJI_20260713102047_0001_V_19.jpeg`:切片 YOLO 检出多个人员,但两种模型均未检出右上角红色汽车;该近景车顶外观仍需要更匹配的航拍数据或本项目样本微调。
- NWPU 会产生少量其他航拍类别误检,正式 Demo 只保留 `vehicle`;脚本还会过滤被高置信度整车框大部分包含的重复局部框。
- 树木不属于当前两个模型的有效类别,必须单独建设树冠检测/分割分支。
## 控制台运行入口
本地实验控制台可以直接上传最多 12 张 `JPG/JPEG/PNG` 进行一次 CPU 基线检测。每次运行会把原图保存到 `shared/data/raw/01-object-detection/runs/<run-id>/`,结果保存到 `shared/outputs/01-object-detection/runs/<run-id>/`,不会覆盖既有基线或用户原图。页面默认将同一影像的原图与标注图并列展示,并可在案例库中回看历史运行。
该入口调用固定的 `run_detection.py` 与 `01-object-detection` 虚拟环境,仍然只适用于人员和常见车辆的基线验证;树木、真实准确率、许可审查和生产批处理不在此入口的承诺范围内。
capabilities/15-trajectory-analysis/README.md
@@ -106,15 +106,41 @@
| pandas / GeoPandas / Shapely / scikit-learn | BSD 3-Clause 系列许可证 |
| PyProj | MIT;同时需遵守其随附 PROJ 数据/组件许可 |
| Matplotlib | Matplotlib License;随附字体等资产有各自许可证 |
| openpyxl 3.1.5 | MIT;仅用于读取用户提供的 XLSX 飞行日志 |
| Demo 数据 | 由本仓库脚本生成,无第三方数据集或模型权重 |
| 预训练模型/权重 | 无 |
以上是开发阶段记录,不等于已完成产品商用法务审查。
## 真实单例:田墩飞行任务 19578
用户提供的原始 XLSX、DJI WPMZ KMZ 和禁飞区 GeoJSON 保持在 `shared/data/raw/15-trajectory-analysis/tian-dun-demo-20260814/`,不会被脚本改写。使用以下命令生成可审计的处理输入,再运行单例分析:
```powershell
$py = '.\.venvs\15-trajectory-analysis\Scripts\python.exe'
& $py .\capabilities\15-trajectory-analysis\prepare_real_flight.py
& $py .\capabilities\15-trajectory-analysis\run_trajectory_analysis.py `
  --input .\shared\data\processed\15-trajectory-analysis\tian-dun-flight-19578\tian-dun-flight-19578.case.json `
  --output .\shared\outputs\15-trajectory-analysis\tian-dun-flight-19578
```
预处理器会将 Excel 的本地无时区时间按 `Asia/Shanghai` 解释后转换为 UTC,提取 `waylines.wpml` 中的 10 个 WGS84 航点,并从 2,300 个禁飞区源要素中按飞行/航线周边 5 km 筛选、合并为一个局部区域。点状区域按原始 `radius` 转为米制缓冲区;源数据的 `no-fly` 被映射为规则输入 `restricted`,只用于技术空间评估。适飞区 Gzip 已验证为 46,518 个闭合坐标环的 `int32 / 1e7` 流,田墩周边筛出 2 个适飞面;它只用于可视化,不参与禁入规则判定。
已验证的真实单例有 233 个观测点、1 条无人机轨迹,运行耗时 0.338 秒。输出报告 2 段偏航(77 秒、175 秒)和 1 个持续 464 秒的区域相交事件;没有停留或聚集事件。实飞点到计划线的最小/平均/最大距离为 0.0 / 221.0 / 540.8 m。两段偏航分别覆盖起飞接近计划线之前和自动返航/降落阶段,计划航线未表达这些飞行阶段,因此不能直接将其作为异常结论。区域相交仅代表与名为“三清山”的源区域(`no-fly-40001494`)发生空间相交,不是法规或运营违规认定。
处理输入位于 `shared/data/processed/15-trajectory-analysis/tian-dun-flight-19578/`;结果位于 `shared/outputs/15-trajectory-analysis/tian-dun-flight-19578/tian-dun-flight-19578/`,包括轨迹、计划航线、禁飞区、适飞区和事件 GeoJSON,以及 `analysis.png`。本地控制台的“田墩实飞”案例可直接查看这些结果;地图以 ArcGIS 影像为底图,并可在 `apps/workbench-console/.env.local` 配置 `VITE_TIANDITU_TOKEN` 叠加天地图影像和注记。
## 已知限制与下一步
- 2026-08-14 已归档一组用户提供的田墩真实原始资料至 `shared/data/raw/15-trajectory-analysis/tian-dun-demo-20260814/`:禁飞区 GeoJSON、适飞区 Gzip 二进制源文件、DJI WPMZ 航线 KMZ 与实际飞行 Excel。原文件保持未改名、未转换状态,当前尚未纳入分析结果。
- `prepare_real_flight.py` 已支持此批 XLSX、KMZ、禁飞 GeoJSON 与适飞区 Gzip 的单例准备。适飞区的二进制几何已验证为闭合坐标环,但其发布日期、授权方和运营语义仍未独立核验,当前只用于可视化,不参与规则判断。当前区域规则只识别 `zone_type=restricted`,不能把 `no_fly` 标签直接等同为已验证的禁入事件。
- 合成样本只能验证程序逻辑,不能证明真实场景准确率;目前没有可报告的真实误报率和漏报率。
- 聚集要求时间戳对齐,GPS 漂移、采样频率和轨迹断点会直接影响结果。
- 偏航依赖可信参考路线;禁入区依赖有效区域数据;不同人员、车辆、船舶和无人机需要分别标定阈值。
- 下一步应先选 1 个正常、1 个困难的脱敏实测轨迹,人工标注事件,再评估误报、漏报和阈值,而不是直接跑大目录。
- 若输入来自无人机视频,还需在此能力之前引入独立的多目标跟踪器,并验证 ID 切换问题。
## 控制台输入转换
本地实验控制台支持直接上传用户常见的 `XLSX` 飞行日志、`KMZ` DJI WPMZ 航线、禁飞区 `GeoJSON`,以及可选适飞区 `Gzip`。服务端会在固定的轨迹虚拟环境中调用 `prepare_real_flight.py` 和 `run_trajectory_analysis.py`,把文件转换成 Demo 的 `case.json`、WGS84 轨迹和 GeoJSON 输出。适飞区缺省时仍可分析,地图只是不显示适飞区图层。
每次运行使用新的 `trajectory-<UTC时间>-<随机串>` 编号,原始输入不会被覆盖;控制台案例库会自动扫描 `shared/outputs/15-trajectory-analysis`,行为标签在界面显示中文释义,结构化文件继续保存英文键值,便于程序处理。
capabilities/15-trajectory-analysis/prepare_real_flight.py
New file
@@ -0,0 +1,419 @@
"""Prepare one DJI flight log, WPMZ route and no-fly dataset for analysis."""
from __future__ import annotations
import argparse
import gzip
import hashlib
import json
import sys
import warnings
import xml.etree.ElementTree as element_tree
from datetime import UTC
from pathlib import Path
from zipfile import ZipFile
import geopandas as gpd
import numpy as np
import pandas as pd
from shapely import make_valid
from shapely.geometry import LineString, Polygon, mapping
from shapely.ops import unary_union
WGS84 = "EPSG:4326"
LOCAL_TIMEZONE = "Asia/Shanghai"
KML_NAMESPACE = "http://www.opengis.net/kml/2.2"
WPML_NAMESPACE = "http://www.dji.com/wpmz/1.0.5"
EXCEL_COLUMNS = {
    "mission_id": "\u98de\u884c\u4efb\u52a1ID",
    "latitude": "\u7eac\u5ea6",
    "longitude": "\u7ecf\u5ea6",
    "absolute_height_m": "\u7edd\u5bf9\u9ad8\u5ea6(m)",
    "relative_height_m": "\u5b9e\u65f6\u771f\u9ad8(m)",
    "timestamp": "\u521b\u5efa\u65f6\u95f4",
    "flight_phase": "\u98de\u884c\u7c7b\u578b",
}
def parse_args() -> argparse.Namespace:
    root = Path(__file__).resolve().parents[2]
    parser = argparse.ArgumentParser(description="Prepare one real DJI flight for trajectory analysis.")
    parser.add_argument(
        "--raw-dir",
        type=Path,
        default=root / "shared" / "data" / "raw" / "15-trajectory-analysis" / "tian-dun-demo-20260814",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=root / "shared" / "data" / "processed" / "15-trajectory-analysis" / "tian-dun-flight-19578",
    )
    parser.add_argument("--case-id", default="tian-dun-flight-19578")
    parser.add_argument("--overwrite", action="store_true", help="Update derived files in an existing output directory.")
    parser.add_argument(
        "--zone-search-radius-m",
        type=float,
        default=5000.0,
        help="Keep source no-fly areas intersecting this buffer around the flight and route.",
    )
    return parser.parse_args()
def discover_single(directory: Path, suffix: str) -> Path:
    matches = sorted(directory.glob(f"*{suffix}"))
    if len(matches) != 1:
        raise ValueError(f"Expected exactly one {suffix} file in {directory}, found {len(matches)}.")
    return matches[0]
def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()
def choose_metric_crs(longitude: float, latitude: float) -> str:
    zone = int((longitude + 180) // 6) + 1
    return f"EPSG:{(32600 if latitude >= 0 else 32700) + zone}"
def load_observations(path: Path) -> tuple[pd.DataFrame, dict[str, object]]:
    warnings.filterwarnings("ignore", message="Workbook contains no default style")
    source = pd.read_excel(path)
    missing = [column for column in EXCEL_COLUMNS.values() if column not in source.columns]
    if missing:
        raise ValueError(f"Flight workbook is missing columns: {', '.join(missing)}")
    missions = source[EXCEL_COLUMNS["mission_id"]].dropna().unique().tolist()
    if len(missions) != 1:
        raise ValueError(f"This single-flight preparer requires one mission ID, found: {missions}")
    mission_id = str(missions[0]).strip()
    timestamps = pd.to_datetime(source[EXCEL_COLUMNS["timestamp"]], errors="raise")
    if getattr(timestamps.dt, "tz", None) is None:
        timestamps = timestamps.dt.tz_localize(LOCAL_TIMEZONE, ambiguous="raise", nonexistent="raise")
    timestamps = timestamps.dt.tz_convert(UTC)
    longitude = pd.to_numeric(source[EXCEL_COLUMNS["longitude"]], errors="raise")
    latitude = pd.to_numeric(source[EXCEL_COLUMNS["latitude"]], errors="raise")
    valid = longitude.between(-180, 180) & latitude.between(-90, 90)
    if not valid.all():
        raise ValueError("Flight workbook contains longitude/latitude values outside WGS84 bounds.")
    track_id = f"drone-{mission_id}"
    observations = pd.DataFrame(
        {
            "track_id": track_id,
            "entity_type": "drone",
            "timestamp": timestamps.map(lambda value: value.strftime("%Y-%m-%dT%H:%M:%SZ")),
            "longitude": longitude.astype(float),
            "latitude": latitude.astype(float),
            "flight_phase": source[EXCEL_COLUMNS["flight_phase"]].astype(str),
            "absolute_height_m": pd.to_numeric(source[EXCEL_COLUMNS["absolute_height_m"]], errors="coerce"),
            "relative_height_m": pd.to_numeric(source[EXCEL_COLUMNS["relative_height_m"]], errors="coerce"),
            "source_row": range(2, len(source) + 2),
        }
    ).sort_values("timestamp", kind="stable")
    duplicates = int(observations.duplicated(["track_id", "timestamp"], keep="last").sum())
    return observations, {
        "source_row_count": int(len(source)),
        "prepared_row_count": int(len(observations)),
        "duplicate_timestamps_for_analyzer": duplicates,
        "mission_id": mission_id,
        "track_id": track_id,
        "input_time_interpretation": f"Naive timestamps interpreted as {LOCAL_TIMEZONE}, then converted to UTC.",
        "start_time_utc": observations["timestamp"].iloc[0],
        "end_time_utc": observations["timestamp"].iloc[-1],
    }
def extract_route(path: Path, track_id: str) -> tuple[dict[str, object], LineString]:
    with ZipFile(path) as archive:
        try:
            payload = archive.read("wpmz/waylines.wpml")
        except KeyError as exc:
            raise ValueError("KMZ does not contain wpmz/waylines.wpml.") from exc
    root = element_tree.fromstring(payload)
    namespaces = {"k": KML_NAMESPACE, "w": WPML_NAMESPACE}
    waypoints: list[tuple[int, list[float]]] = []
    for placemark in root.findall(".//k:Placemark", namespaces):
        coordinates = placemark.findtext("k:Point/k:coordinates", namespaces=namespaces)
        index = placemark.findtext("w:index", namespaces=namespaces)
        if coordinates is None or index is None:
            continue
        values = coordinates.strip().split(",")
        longitude, latitude = float(values[0]), float(values[1])
        if not (-180 <= longitude <= 180 and -90 <= latitude <= 90):
            raise ValueError("KMZ waypoint is outside WGS84 bounds.")
        waypoints.append((int(index), [longitude, latitude]))
    waypoints.sort(key=lambda item: item[0])
    coordinates = [item[1] for item in waypoints]
    if len(coordinates) < 2:
        raise ValueError("KMZ must contain at least two ordered waypoints.")
    line = LineString(coordinates)
    feature = {
        "type": "Feature",
        "properties": {
            "track_id": track_id,
            "source_format": "DJI WPMZ waylines.wpml",
            "waypoint_count": len(coordinates),
        },
        "geometry": mapping(line),
    }
    return feature, line
def build_zones(
    path: Path,
    context_wgs84: LineString,
    metric_crs: str,
    search_radius_m: float,
) -> tuple[list[dict[str, object]], dict[str, object]]:
    source = gpd.read_file(path)
    if source.crs is None:
        raise ValueError("No-fly GeoJSON has no declared CRS; it cannot be prepared safely.")
    metric = source.to_crs(metric_crs)
    context = gpd.GeoSeries([context_wgs84], crs=WGS84).to_crs(metric_crs).iloc[0].buffer(search_radius_m)
    grouped: dict[str, dict[str, object]] = {}
    skipped = 0
    selected_source_features = 0
    for row in metric.itertuples():
        geometry = row.geometry
        if geometry.geom_type == "Point":
            radius_value = pd.to_numeric(getattr(row, "radius", None), errors="coerce")
            radius = 0.0 if pd.isna(radius_value) else float(radius_value)
            if radius <= 0:
                skipped += 1
                continue
            effective_geometry = geometry.buffer(radius)
        elif geometry.geom_type in {"Polygon", "MultiPolygon"}:
            effective_geometry = geometry
        else:
            skipped += 1
            continue
        if not effective_geometry.intersects(context):
            continue
        selected_source_features += 1
        area_id = str(getattr(row, "area_id"))
        entry = grouped.setdefault(
            area_id,
            {
                "geometry": [],
                "name": str(getattr(row, "name", "")),
                "city": str(getattr(row, "city", "")),
                "level": getattr(row, "level", None),
                "height": getattr(row, "height", None),
                "source_feature_count": 0,
            },
        )
        entry["geometry"].append(effective_geometry)
        entry["source_feature_count"] = int(entry["source_feature_count"]) + 1
    features: list[dict[str, object]] = []
    for area_id, entry in sorted(grouped.items()):
        geometry = unary_union(entry["geometry"])
        geometry_wgs84 = gpd.GeoSeries([geometry], crs=metric_crs).to_crs(WGS84).iloc[0]
        features.append(
            {
                "type": "Feature",
                "properties": {
                    "zone_id": f"no-fly-{area_id}",
                    "zone_type": "restricted",
                    "source_area_id": area_id,
                    "source_name": entry["name"],
                    "source_city": entry["city"],
                    "source_level": entry["level"],
                    "source_height_m": entry["height"],
                    "source_feature_count": entry["source_feature_count"],
                    "mapping_note": "Source no-fly area mapped to restricted for this technical rule evaluation.",
                },
                "geometry": mapping(geometry_wgs84),
            }
        )
    return features, {
        "source_feature_count": int(len(source)),
        "selected_source_feature_count": selected_source_features,
        "selected_zone_count": len(features),
        "skipped_source_feature_count": skipped,
        "source_crs": str(source.crs),
        "zone_search_radius_m": search_radius_m,
        "mapping": "Polygon areas are retained; point areas are converted to metric-radius buffers; no-fly is mapped to restricted only for this technical rule evaluation.",
    }
def build_flyable_zones(
    path: Path,
    context_wgs84: LineString,
    metric_crs: str,
    search_radius_m: float,
) -> tuple[list[dict[str, object]], dict[str, object]]:
    """Decode the supplied closed-ring UOM coordinate stream near one flight."""
    values = np.frombuffer(gzip.decompress(path.read_bytes()), dtype="<i4")
    if values.size < 3:
        raise ValueError("Flyable-area Gzip is too small to contain a coordinate stream.")
    ring_count = int(values[-1])
    coordinate_values = values[:-1]
    if ring_count <= 0 or coordinate_values.size % 2:
        raise ValueError("Flyable-area Gzip has an invalid ring count or coordinate alignment.")
    coordinates = coordinate_values.reshape(-1, 2)
    context_metric = gpd.GeoSeries([context_wgs84], crs=WGS84).to_crs(metric_crs).iloc[0].buffer(search_radius_m)
    context = gpd.GeoSeries([context_metric], crs=metric_crs).to_crs(WGS84).iloc[0]
    minx, miny, maxx, maxy = context.bounds
    features: list[dict[str, object]] = []
    start = 0
    skipped = 0
    for ring_index in range(ring_count):
        end = start + 4
        while end <= len(coordinates) and not np.array_equal(coordinates[end - 1], coordinates[start]):
            end += 1
        if end > len(coordinates):
            raise ValueError(f"Flyable-area ring {ring_index} is not closed within the coordinate stream.")
        ring = coordinates[start:end].astype(np.float64) / 1e7
        start = end
        ring_minx, ring_miny = ring.min(axis=0)
        ring_maxx, ring_maxy = ring.max(axis=0)
        if ring_maxx < minx or ring_minx > maxx or ring_maxy < miny or ring_miny > maxy:
            continue
        polygon = Polygon(ring)
        if not polygon.is_valid:
            polygon = make_valid(polygon)
        if polygon.is_empty or polygon.geom_type not in {"Polygon", "MultiPolygon"} or not polygon.intersects(context):
            skipped += 1
            continue
        features.append(
            {
                "type": "Feature",
                "properties": {
                    "zone_id": f"flyable-bin-{ring_index:05d}",
                    "zone_type": "flyable",
                    "source_format": "UOM closed-ring coordinate stream (int32 / 1e7)",
                    "source_ring_index": ring_index,
                    "vertex_count": len(ring),
                },
                "geometry": mapping(polygon),
            }
        )
    if start > len(coordinates):
        raise ValueError("Flyable-area coordinate parser exceeded the available stream.")
    return features, {
        "source_ring_count": ring_count,
        "consumed_coordinate_pair_count": start,
        "selected_zone_count": len(features),
        "skipped_candidate_count": skipped,
        "zone_search_radius_m": search_radius_m,
        "decoder": "Closed rings parsed from little-endian int32 longitude/latitude values scaled by 1e7.",
        "limitation": "The supplied file contains geometry only; its publication time, authority and operational semantics remain unverified.",
    }
def write_json(path: Path, payload: dict[str, object]) -> None:
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def main() -> int:
    args = parse_args()
    raw_dir = args.raw_dir.resolve()
    output_dir = args.output.resolve()
    if args.zone_search_radius_m <= 0:
        print("Preparation failed: --zone-search-radius-m must be greater than zero.", file=sys.stderr)
        return 2
    if output_dir.exists() and not args.overwrite:
        print(f"Preparation failed: output already exists: {output_dir}", file=sys.stderr)
        return 2
    try:
        flight_path = discover_single(raw_dir / "tracks", ".xlsx")
        route_path = discover_single(raw_dir / "routes", ".kmz")
        zone_path = discover_single(raw_dir / "areas", ".geojson")
        flyable_matches = sorted((raw_dir / "areas").glob("*.gzip"))
        if len(flyable_matches) > 1:
            raise ValueError(f"Expected zero or one .gzip file in {raw_dir / 'areas'}, found {len(flyable_matches)}.")
        flyable_path = flyable_matches[0] if flyable_matches else None
        observations, observation_metadata = load_observations(flight_path)
        metric_crs = choose_metric_crs(float(observations["longitude"].mean()), float(observations["latitude"].mean()))
        route_feature, route = extract_route(route_path, str(observation_metadata["track_id"]))
        track_line = LineString(observations[["longitude", "latitude"]].to_numpy().tolist())
        context = unary_union([track_line, route])
        zones, zone_metadata = build_zones(zone_path, context, metric_crs, args.zone_search_radius_m)
        flyable_zones: list[dict[str, object]] = []
        flyable_metadata: dict[str, object] | None = None
        if flyable_path is not None:
            flyable_zones, flyable_metadata = build_flyable_zones(
                flyable_path, context, metric_crs, args.zone_search_radius_m
            )
        if not zones:
            raise ValueError("No source no-fly areas intersect the configured flight context buffer.")
        observations_metric = gpd.GeoSeries(
            gpd.points_from_xy(observations["longitude"], observations["latitude"]), crs=WGS84
        ).to_crs(metric_crs)
        route_metric = gpd.GeoSeries([route], crs=WGS84).to_crs(metric_crs).iloc[0]
        output_dir.mkdir(parents=True, exist_ok=args.overwrite)
        observations.to_csv(output_dir / "observations.csv", index=False, encoding="utf-8-sig")
        write_json(
            output_dir / "reference_routes.geojson",
            {
                "type": "FeatureCollection",
                "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}},
                "features": [route_feature],
            },
        )
        write_json(
            output_dir / "zones.geojson",
            {
                "type": "FeatureCollection",
                "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}},
                "features": zones,
            },
        )
        if flyable_path is not None:
            write_json(
                output_dir / "flyable_zones.geojson",
                {
                    "type": "FeatureCollection",
                    "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}},
                    "features": flyable_zones,
                },
            )
        manifest = {
            "case_id": args.case_id,
            "description": "One user-provided DJI flight, prepared from an XLSX log, WPMZ route and local no-fly-zone subset.",
            "crs": WGS84,
            "observations": "observations.csv",
            "reference_routes": "reference_routes.geojson",
            "zones": "zones.geojson",
        }
        if flyable_path is not None:
            manifest["flyable_zones"] = "flyable_zones.geojson"
        write_json(output_dir / f"{args.case_id}.case.json", manifest)
        metadata = {
            "case_id": args.case_id,
            "prepared_at": pd.Timestamp.now(tz=UTC).isoformat(),
            "raw_inputs": [
                {"path": str(path), "sha256": sha256(path)}
                for path in (flight_path, route_path, zone_path)
                if path is not None
            ] + ([{"path": str(flyable_path), "sha256": sha256(flyable_path)}] if flyable_path is not None else []),
            "observation_preparation": observation_metadata,
            "route_preparation": {
                "source_format": "DJI WPMZ waylines.wpml",
                "waypoint_count": route_feature["properties"]["waypoint_count"],
                "track_to_route_min_m": round(float(observations_metric.distance(route_metric).min()), 3),
                "track_to_route_mean_m": round(float(observations_metric.distance(route_metric).mean()), 3),
                "track_to_route_max_m": round(float(observations_metric.distance(route_metric).max()), 3),
            },
            "zone_preparation": zone_metadata,
            "flyable_zone_preparation": flyable_metadata,
            "crs": {"output": WGS84, "metric": metric_crs},
            "limitations": [
                "No-fly source semantics, publication time and operational authority have not been independently verified.",
                "Spatial intersection with a source no-fly area is a technical result, not a legal or operational violation conclusion.",
                "The reference route does not encode flight-phase semantics such as takeoff, return-to-home or landing.",
            ],
        }
        write_json(output_dir / "preparation_metadata.json", metadata)
    except (OSError, ValueError, KeyError, element_tree.ParseError, pd.errors.ParserError) as exc:
        print(f"Preparation failed: {exc}", file=sys.stderr)
        return 2
    print(f"Prepared 1 real flight case in {output_dir}")
    return 0
if __name__ == "__main__":
    raise SystemExit(main())
capabilities/15-trajectory-analysis/requirements.txt
@@ -2,3 +2,4 @@
geopandas>=1,<2
scikit-learn>=1.5,<2
matplotlib>=3.9,<4
openpyxl>=3.1,<4
capabilities/15-trajectory-analysis/run_trajectory_analysis.py
@@ -25,6 +25,7 @@
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
from matplotlib.patches import Patch
DEFAULT_THRESHOLDS = {
@@ -105,7 +106,7 @@
    return CRS.from_epsg(epsg)
def load_case(manifest_path: Path) -> tuple[dict[str, Any], pd.DataFrame, gpd.GeoDataFrame, gpd.GeoDataFrame, int, CRS]:
def load_case(manifest_path: Path) -> tuple[dict[str, Any], pd.DataFrame, gpd.GeoDataFrame, gpd.GeoDataFrame, gpd.GeoDataFrame | None, int, CRS]:
    manifest = load_json(manifest_path)
    for field in ("case_id", "crs", "observations", "reference_routes", "zones"):
        if not manifest.get(field):
@@ -142,6 +143,12 @@
        raise ValueError("Every track must contain at least two distinct timestamps.")
    routes = gpd.read_file(routes_path)
    zones = gpd.read_file(zones_path)
    flyable_zones: gpd.GeoDataFrame | None = None
    if manifest.get("flyable_zones"):
        flyable_path = resolve_input_path(manifest_path, str(manifest["flyable_zones"]))
        flyable_zones = gpd.read_file(flyable_path)
        if flyable_zones.crs is None:
            raise ValueError("Flyable-zone GeoJSON must declare a CRS.")
    if "track_id" not in routes.columns:
        raise ValueError("Reference route GeoJSON must contain a track_id property.")
    if "zone_id" not in zones.columns or "zone_type" not in zones.columns:
@@ -153,7 +160,15 @@
    if missing_routes:
        raise ValueError(f"Missing reference routes for: {', '.join(sorted(missing_routes))}")
    metric_crs = choose_metric_crs(float(frame["longitude"].mean()), float(frame["latitude"].mean()))
    return manifest, frame, routes.to_crs(metric_crs), zones.to_crs(metric_crs), duplicate_count, metric_crs
    return (
        manifest,
        frame,
        routes.to_crs(metric_crs),
        zones.to_crs(metric_crs),
        flyable_zones.to_crs(metric_crs) if flyable_zones is not None else None,
        duplicate_count,
        metric_crs,
    )
def true_runs(flags: list[bool], times: list[pd.Timestamp], max_gap: float) -> Iterable[tuple[int, int]]:
@@ -410,6 +425,7 @@
    tracks: dict[str, gpd.GeoDataFrame],
    routes_wgs84: gpd.GeoDataFrame,
    zones_wgs84: gpd.GeoDataFrame,
    flyable_zones_wgs84: gpd.GeoDataFrame | None,
    metadata: dict[str, Any],
) -> None:
    output_dir.mkdir(parents=True, exist_ok=True)
@@ -435,7 +451,22 @@
        for event in events
    ]
    write_geojson(output_dir / "events.geojson", event_features)
    render_map(output_dir / "analysis.png", tracks, routes_wgs84, zones_wgs84, events, manifest["case_id"])
    route_features = json.loads(routes_wgs84.to_json())["features"]
    write_geojson(output_dir / "reference_routes.geojson", route_features)
    zone_features = json.loads(zones_wgs84.to_json())["features"]
    write_geojson(output_dir / "zones.geojson", zone_features)
    if flyable_zones_wgs84 is not None:
        flyable_features = json.loads(flyable_zones_wgs84.to_json())["features"]
        write_geojson(output_dir / "flyable_zones.geojson", flyable_features)
    render_map(
        output_dir / "analysis.png",
        tracks,
        routes_wgs84,
        zones_wgs84,
        flyable_zones_wgs84,
        events,
        manifest["case_id"],
    )
    (output_dir / "run_metadata.json").write_text(
        json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8"
    )
@@ -457,17 +488,20 @@
    tracks: dict[str, gpd.GeoDataFrame],
    routes: gpd.GeoDataFrame,
    zones: gpd.GeoDataFrame,
    flyable_zones: gpd.GeoDataFrame | None,
    events: list[dict[str, Any]],
    case_id: str,
) -> None:
    figure, axis = plt.subplots(figsize=(10, 7), dpi=140)
    zones.plot(ax=axis, facecolor="#f0e442", edgecolor="#8c6d1f", alpha=0.28, linewidth=1.5)
    routes.plot(ax=axis, color="#666666", linestyle="--", linewidth=1.2)
    if flyable_zones is not None and not flyable_zones.empty:
        flyable_zones.plot(ax=axis, facecolor="#36a269", edgecolor="#167344", alpha=0.22, linewidth=1.3)
    palette = ["#0072b2", "#009e73", "#d55e00", "#cc79a7", "#56b4e9"]
    for index, (track_id, track) in enumerate(sorted(tracks.items())):
        color = palette[index % len(palette)]
        axis.plot(track.geometry.x, track.geometry.y, color=color, linewidth=2.2, marker="o", markersize=2.8, label=track_id)
        axis.annotate(track_id, (track.geometry.x.iloc[-1], track.geometry.y.iloc[-1]), fontsize=7, color=color)
    routes.plot(ax=axis, color="#444444", linestyle="--", linewidth=1.5, zorder=4, label="reference route")
    for event in events:
        event_type = event["event_type"]
        axis.scatter(
@@ -481,6 +515,11 @@
            zorder=5,
        )
    handles, labels = axis.get_legend_handles_labels()
    handles.append(Patch(facecolor="#f0e442", edgecolor="#8c6d1f", alpha=0.45, label="restricted area"))
    labels.append("restricted area")
    if flyable_zones is not None and not flyable_zones.empty:
        handles.append(Patch(facecolor="#36a269", edgecolor="#167344", alpha=0.45, label="flyable area"))
        labels.append("flyable area")
    present_event_types = {event["event_type"] for event in events}
    handles.extend(
        Line2D(
@@ -503,6 +542,13 @@
    axis.set_ylabel("Latitude (WGS84)")
    axis.grid(alpha=0.18)
    axis.ticklabel_format(useOffset=False)
    visible_geometries = [geometry for track in tracks.values() for geometry in track.geometry]
    visible_geometries.extend(geometry for geometry in routes.geometry if geometry is not None)
    bounds = gpd.GeoSeries(visible_geometries, crs="EPSG:4326").total_bounds
    width = max(bounds[2] - bounds[0], 0.0005)
    height = max(bounds[3] - bounds[1], 0.0005)
    axis.set_xlim(bounds[0] - width * 0.08, bounds[2] + width * 0.08)
    axis.set_ylim(bounds[1] - height * 0.08, bounds[3] + height * 0.08)
    figure.tight_layout()
    figure.savefig(path)
    plt.close(figure)
@@ -510,7 +556,7 @@
def process_manifest(manifest_path: Path, output_dir: Path) -> dict[str, Any]:
    started = time.perf_counter()
    manifest, frame, routes_metric, zones_metric, duplicates, metric_crs = load_case(manifest_path)
    manifest, frame, routes_metric, zones_metric, flyable_zones_metric, duplicates, metric_crs = load_case(manifest_path)
    thresholds = dict(DEFAULT_THRESHOLDS)
    for key, value in manifest.get("thresholds", {}).items():
        if key not in thresholds:
@@ -566,6 +612,7 @@
        tracks,
        routes_metric.to_crs("EPSG:4326"),
        zones_metric.to_crs("EPSG:4326"),
        flyable_zones_metric.to_crs("EPSG:4326") if flyable_zones_metric is not None else None,
        metadata,
    )
    return metadata
capabilities/15-trajectory-analysis/tests/test_demo.py
@@ -46,6 +46,8 @@
                    "events.json",
                    "trajectories.geojson",
                    "events.geojson",
                    "reference_routes.geojson",
                    "zones.geojson",
                    "analysis.png",
                    "run_metadata.json",
                ):
capabilities/15-trajectory-analysis/tests/test_prepare_real_flight.py
New file
@@ -0,0 +1,78 @@
from __future__ import annotations
import json
import gzip
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from zipfile import ZipFile
from openpyxl import Workbook
CAPABILITY_DIR = Path(__file__).resolve().parents[1]
PREPARER = CAPABILITY_DIR / "prepare_real_flight.py"
ANALYZER = CAPABILITY_DIR / "run_trajectory_analysis.py"
class PrepareRealFlightTests(unittest.TestCase):
    def test_prepares_a_runnable_single_flight_case(self) -> None:
        with tempfile.TemporaryDirectory() as temporary:
            root = Path(temporary)
            raw = root / "raw"
            (raw / "tracks").mkdir(parents=True)
            (raw / "routes").mkdir()
            (raw / "areas").mkdir()
            workbook = Workbook()
            sheet = workbook.active
            sheet.append([
                "\u98de\u884c\u7c7b\u578b", "\u98de\u884c\u4efb\u52a1ID", "\u7eac\u5ea6", "\u7ecf\u5ea6", "\u7edd\u5bf9\u9ad8\u5ea6(m)", "\u5b9e\u65f6\u771f\u9ad8(m)", "\u521b\u5efa\u65f6\u95f4"
            ])
            sheet.append(["\u822a\u7ebf\u98de\u884c", 42, 28.0, 118.0, 100.0, 30.0, "2026-01-01 08:00:00"])
            sheet.append(["\u822a\u7ebf\u98de\u884c", 42, 28.0001, 118.0001, 100.0, 30.0, "2026-01-01 08:00:10"])
            workbook.save(raw / "tracks" / "flight.xlsx")
            kmz = raw / "routes" / "route.kmz"
            wpml = """<?xml version='1.0' encoding='UTF-8'?><kml xmlns='http://www.opengis.net/kml/2.2' xmlns:wpml='http://www.dji.com/wpmz/1.0.5'><Document><Folder><Placemark><Point><coordinates>118.0,28.0</coordinates></Point><wpml:index>0</wpml:index></Placemark><Placemark><Point><coordinates>118.0001,28.0001</coordinates></Point><wpml:index>1</wpml:index></Placemark></Folder></Document></kml>"""
            with ZipFile(kmz, "w") as archive:
                archive.writestr("wpmz/waylines.wpml", wpml)
            zones = {
                "type": "FeatureCollection",
                "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}},
                "features": [{"type": "Feature", "properties": {"area_id": 1, "name": "test", "city": "test", "level": 2, "height": 120, "radius": 1000}, "geometry": {"type": "Point", "coordinates": [118.0, 28.0]}}],
            }
            (raw / "areas" / "zones.geojson").write_text(json.dumps(zones), encoding="utf-8")
            ring = [(118.0, 28.0), (118.001, 28.0), (118.001, 28.001), (118.0, 28.001), (118.0, 28.0)]
            encoded = [value for point in ring for value in (round(point[0] * 1e7), round(point[1] * 1e7))] + [1]
            (raw / "areas" / "flyable.gzip").write_bytes(gzip.compress(__import__("array").array("i", encoded).tobytes()))
            prepared = root / "prepared"
            result = subprocess.run(
                [sys.executable, str(PREPARER), "--raw-dir", str(raw), "--output", str(prepared), "--case-id", "sample"],
                capture_output=True,
                encoding="utf-8",
                text=True,
                check=False,
            )
            self.assertEqual(result.returncode, 0, result.stderr)
            observations = (prepared / "observations.csv").read_text(encoding="utf-8-sig")
            self.assertIn("flight_phase", observations)
            self.assertIn("2026-01-01T00:00:00Z", observations)
            zone_payload = json.loads((prepared / "zones.geojson").read_text(encoding="utf-8"))
            self.assertEqual(zone_payload["features"][0]["properties"]["zone_type"], "restricted")
            flyable_payload = json.loads((prepared / "flyable_zones.geojson").read_text(encoding="utf-8"))
            self.assertEqual(flyable_payload["features"][0]["properties"]["zone_type"], "flyable")
            analyzed = subprocess.run(
                [sys.executable, str(ANALYZER), "--input", str(prepared / "sample.case.json"), "--output", str(root / "outputs")],
                capture_output=True,
                encoding="utf-8",
                text=True,
                check=False,
            )
            self.assertEqual(analyzed.returncode, 0, analyzed.stderr)
            self.assertTrue((root / "outputs" / "sample" / "zones.geojson").is_file())
            self.assertTrue((root / "outputs" / "sample" / "flyable_zones.geojson").is_file())
if __name__ == "__main__":
    unittest.main()
scripts/serve_workbench_console.py
@@ -1,44 +1,275 @@
"""Serve the local GeoAI Workbench console from the repository root."""
"""Serve the local GeoAI Workbench console and its narrow local-run APIs."""
from __future__ import annotations
import argparse
import base64
import binascii
import json
import os
from pathlib import PurePosixPath
import re
import subprocess
import threading
from datetime import UTC, datetime
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.parse import unquote, urlsplit
from uuid import uuid4
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 6173
MAX_REQUEST_BYTES = 128 * 1024 * 1024
MAX_FILE_BYTES = 96 * 1024 * 1024
MAX_IMAGES_PER_RUN = 12
ALLOWED_PATH_PREFIXES = (
    "apps/workbench-console",
    "shared/outputs",
    "shared/data/raw/01-object-detection",
)
SAFE_FILE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
RUN_LOCK = threading.Lock()
class ApiError(ValueError):
    """A request error that can be shown to the local console user."""
def safe_file_name(value: str, expected_suffixes: set[str]) -> str:
    name = Path(value).name
    suffix = Path(name).suffix.lower()
    if suffix not in expected_suffixes:
        raise ApiError(f"Unsupported file type: {suffix or '(none)'}.")
    stem = SAFE_FILE_NAME.sub("_", Path(name).stem).strip("._") or "upload"
    return f"{stem[:80]}{suffix}"
def decode_upload(payload: dict[str, Any], expected_suffixes: set[str]) -> tuple[str, bytes]:
    if not isinstance(payload, dict) or not isinstance(payload.get("name"), str) or not isinstance(payload.get("content"), str):
        raise ApiError("Each uploaded file must include name and Base64 content.")
    name = safe_file_name(payload["name"], expected_suffixes)
    try:
        content = base64.b64decode(payload["content"], validate=True)
    except (binascii.Error, ValueError) as exc:
        raise ApiError(f"Invalid Base64 file content for {name}.") from exc
    if not content:
        raise ApiError(f"Uploaded file is empty: {name}.")
    if len(content) > MAX_FILE_BYTES:
        raise ApiError(f"Uploaded file exceeds {MAX_FILE_BYTES // (1024 * 1024)} MB: {name}.")
    return name, content
def make_run_id(prefix: str) -> str:
    return f"{prefix}-{datetime.now(UTC):%Y%m%d-%H%M%S}-{uuid4().hex[:6]}"
def relative_path(root: Path, path: Path) -> str:
    return path.relative_to(root).as_posix()
def load_json(path: Path) -> dict[str, Any]:
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return {}
    return payload if isinstance(payload, dict) else {}
def trajectory_runs(root: Path) -> list[dict[str, Any]]:
    output_root = root / "shared" / "outputs" / "15-trajectory-analysis"
    records: list[dict[str, Any]] = []
    for metadata_path in output_root.rglob("run_metadata.json"):
        artifact = metadata_path.parent
        if not (artifact / "trajectory_summary.csv").is_file() or not (artifact / "events.json").is_file():
            continue
        metadata = load_json(metadata_path)
        case_id = str(metadata.get("case_id") or artifact.name)
        is_real = case_id == "tian-dun-flight-19578"
        records.append(
            {
                "id": case_id,
                "label": "田墩实飞" if is_real else case_id,
                "note": "区域相交是来源数据的空间结果,不是违规结论。" if is_real else "本地实验运行结果,可继续查看结构化输出。",
                "artifactRoot": relative_path(root, artifact),
                "showSpatialContext": (artifact / "zones.geojson").is_file() and (artifact / "reference_routes.geojson").is_file(),
                "showFlyableZones": (artifact / "flyable_zones.geojson").is_file(),
                "createdAt": str(metadata.get("created_at") or ""),
            }
        )
    return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True)
def detection_runs(root: Path) -> list[dict[str, Any]]:
    output_root = root / "shared" / "outputs" / "01-object-detection"
    records: list[dict[str, Any]] = []
    for metadata_path in output_root.rglob("run_metadata.json"):
        artifact = metadata_path.parent
        if not (artifact / "detections.json").is_file():
            continue
        metadata = load_json(metadata_path)
        input_value = str(metadata.get("input_dir") or "")
        input_dir = Path(input_value) if input_value else root / "shared" / "data" / "raw" / "01-object-detection"
        try:
            input_root = relative_path(root, input_dir.resolve())
        except ValueError:
            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": "CPU 基线:人员与常见车辆;树木不在当前模型有效类别内。",
                "artifactRoot": relative_path(root, artifact),
                "inputRoot": input_root,
                "createdAt": str(metadata.get("created_at") or ""),
            }
        )
    return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True)
class WorkbenchConsoleHandler(SimpleHTTPRequestHandler):
    """Read-only static handler rooted at the workbench repository."""
    """Static UI plus fixed, local-only ingestion and experiment commands."""
    server_version = "GeoAIWorkbench/1.0"
    @property
    def root(self) -> Path:
        return Path(self.directory).resolve()
    def do_GET(self) -> None:  # noqa: N802 - inherited standard-library method name
        if urlsplit(self.path).path == "/":
        path = urlsplit(self.path).path
        if path == "/api/trajectory/runs":
            self.send_json(HTTPStatus.OK, {"runs": trajectory_runs(self.root)})
            return
        if path == "/api/object-detection/runs":
            self.send_json(HTTPStatus.OK, {"runs": detection_runs(self.root)})
            return
        if path == "/":
            self.send_response(HTTPStatus.FOUND)
            self.send_header("Location", "/apps/workbench-console/")
            self.end_headers()
            return
        super().do_GET()
    def do_POST(self) -> None:  # noqa: N802 - inherited standard-library method name
        path = urlsplit(self.path).path
        try:
            payload = self.read_json_body()
            if path == "/api/trajectory/runs":
                self.send_json(HTTPStatus.CREATED, {"run": self.create_trajectory_run(payload)})
                return
            if path == "/api/object-detection/runs":
                self.send_json(HTTPStatus.CREATED, {"run": self.create_detection_run(payload)})
                return
            self.send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown local API endpoint."})
        except ApiError as exc:
            self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
        except subprocess.TimeoutExpired:
            self.send_json(HTTPStatus.GATEWAY_TIMEOUT, {"error": "The local run exceeded its time limit; no existing result was overwritten."})
        except Exception as exc:  # pragma: no cover - defensive server boundary
            self.log_error("local run failed: %s", exc)
            self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Local run failed. Check the console terminal for details."})
    def do_OPTIONS(self) -> None:  # noqa: N802
        self.send_response(HTTPStatus.NO_CONTENT)
        self.send_header("Allow", "GET, POST, OPTIONS")
        self.end_headers()
    def read_json_body(self) -> dict[str, Any]:
        content_length = self.headers.get("Content-Length")
        if content_length is None or not content_length.isdigit():
            raise ApiError("A JSON request body with Content-Length is required.")
        size = int(content_length)
        if size <= 0 or size > MAX_REQUEST_BYTES:
            raise ApiError(f"Request must be between 1 byte and {MAX_REQUEST_BYTES // (1024 * 1024)} MB.")
        if "application/json" not in self.headers.get("Content-Type", ""):
            raise ApiError("Content-Type must be application/json.")
        try:
            payload = json.loads(self.rfile.read(size).decode("utf-8"))
        except (UnicodeDecodeError, json.JSONDecodeError) as exc:
            raise ApiError("Request body is not valid UTF-8 JSON.") from exc
        if not isinstance(payload, dict):
            raise ApiError("JSON request body must be an object.")
        return payload
    def run_command(self, command: list[str], timeout: int) -> None:
        completed = subprocess.run(command, cwd=self.root, capture_output=True, text=True, timeout=timeout, check=False)
        if completed.returncode:
            message = (completed.stderr or completed.stdout or "Unknown script error.").strip().splitlines()[-1]
            raise ApiError(f"Processing failed: {message[:600]}")
    def create_trajectory_run(self, payload: dict[str, Any]) -> dict[str, Any]:
        files = payload.get("files")
        if not isinstance(files, dict):
            raise ApiError("Trajectory request must contain a files object.")
        required = {
            "flight": {".xlsx"},
            "route": {".kmz"},
            "restricted": {".geojson"},
        }
        decoded = {key: decode_upload(files.get(key), suffixes) for key, suffixes in required.items()}
        flyable = decode_upload(files["flyable"], {".gzip"}) if files.get("flyable") else None
        run_id = make_run_id("trajectory")
        raw_root = self.root / "shared" / "data" / "raw" / "15-trajectory-analysis" / "runs" / run_id
        paths = {"flight": raw_root / "tracks" / decoded["flight"][0], "route": raw_root / "routes" / decoded["route"][0], "restricted": raw_root / "areas" / decoded["restricted"][0]}
        for key, path in paths.items():
            path.parent.mkdir(parents=True, exist_ok=True)
            path.write_bytes(decoded[key][1])
        if flyable:
            flyable_path = raw_root / "areas" / flyable[0]
            flyable_path.write_bytes(flyable[1])
        processed = self.root / "shared" / "data" / "processed" / "15-trajectory-analysis" / run_id
        output_parent = self.root / "shared" / "outputs" / "15-trajectory-analysis" / "runs" / run_id
        python = self.root / ".venvs" / "15-trajectory-analysis" / "Scripts" / "python.exe"
        if not python.is_file():
            raise ApiError("Trajectory virtual environment is unavailable. Run the capability setup first.")
        with RUN_LOCK:
            self.run_command([str(python), str(self.root / "capabilities" / "15-trajectory-analysis" / "prepare_real_flight.py"), "--raw-dir", str(raw_root), "--output", str(processed), "--case-id", run_id], 300)
            self.run_command([str(python), str(self.root / "capabilities" / "15-trajectory-analysis" / "run_trajectory_analysis.py"), "--input", str(processed / f"{run_id}.case.json"), "--output", str(output_parent)], 300)
        artifact = output_parent / run_id
        if not (artifact / "run_metadata.json").is_file():
            raise ApiError("Trajectory script finished without the expected result metadata.")
        return next(item for item in trajectory_runs(self.root) if item["id"] == run_id)
    def create_detection_run(self, payload: dict[str, Any]) -> dict[str, Any]:
        uploads = payload.get("images")
        if not isinstance(uploads, list) or not uploads:
            raise ApiError("Object-detection request must include at least one image.")
        if len(uploads) > MAX_IMAGES_PER_RUN:
            raise ApiError(f"A local run accepts at most {MAX_IMAGES_PER_RUN} images.")
        decoded = [decode_upload(item, {".jpg", ".jpeg", ".png"}) 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("detection")
        raw_root = self.root / "shared" / "data" / "raw" / "01-object-detection" / "runs" / run_id
        raw_root.mkdir(parents=True, exist_ok=False)
        for name, content in decoded:
            (raw_root / name).write_bytes(content)
        output = self.root / "shared" / "outputs" / "01-object-detection" / "runs" / run_id
        python = self.root / ".venvs" / "01-object-detection" / "Scripts" / "python.exe"
        if not python.is_file():
            raise ApiError("Object-detection virtual environment is unavailable. Run the capability setup first.")
        with RUN_LOCK:
            self.run_command([str(python), str(self.root / "capabilities" / "01-object-detection" / "run_detection.py"), "--input", str(raw_root), "--output", str(output)], 1200)
        if not (output / "run_metadata.json").is_file():
            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 send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
    def translate_path(self, path: str) -> str:
        """Expose only the static UI and artifacts required by the local console."""
        decoded_path = unquote(urlsplit(path).path).lstrip("/")
        requested = PurePosixPath(decoded_path)
        is_allowed = any(
            decoded_path == prefix or decoded_path.startswith(f"{prefix}/")
            for prefix in ALLOWED_PATH_PREFIXES
        )
        is_allowed = any(decoded_path == prefix or decoded_path.startswith(f"{prefix}/") for prefix in ALLOWED_PATH_PREFIXES)
        if ".." in requested.parts or not is_allowed:
            return os.fspath(Path(self.directory) / ".console-forbidden")
        if decoded_path == "apps/workbench-console" or decoded_path.startswith("apps/workbench-console/"):
@@ -71,13 +302,10 @@
    app_dir = root / "apps" / "workbench-console"
    if not (root / "shared").is_dir() or not (app_dir / "dist" / "index.html").is_file():
        raise SystemExit(f"Not a GeoAI Workbench root: {root}")
    handler = lambda *handler_args, **handler_kwargs: WorkbenchConsoleHandler(  # noqa: E731
        *handler_args, directory=os.fspath(root), **handler_kwargs
    )
    handler = lambda *handler_args, **handler_kwargs: WorkbenchConsoleHandler(*handler_args, directory=os.fspath(root), **handler_kwargs)  # noqa: E731
    server = ThreadingHTTPServer((args.host, args.port), handler)
    print(f"GeoAI Workbench console: http://{args.host}:{args.port}")
    print(f"Serving built console and read-only artifacts from: {root}")
    print("Local runs use fixed capability scripts and create a new run directory.")
    try:
        server.serve_forever()
    except KeyboardInterrupt: