17 files modified
4 files added
4 files deleted
| | |
| | | MODEL_API_BASE_URL= |
| | | MODEL_API_KEY= |
| | | |
| | | # Optional public client-side map service token for the local console |
| | | VITE_TIANDITU_TOKEN= |
| | |
| | | | 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 |
| | |
| | | - 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: |
| | | |
| | |
| | | - 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 |
| | | |
| | |
| | | ``` |
| | | |
| | | 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。 |
| New file |
| | |
| | | # Optional public client-side token for TianDiTu image and label layers. |
| | | VITE_TIANDITU_TOKEN='e110584a27d506da2740edca951683f4' |
| | |
| | | # GeoAI Workbench 本地实验控制台 |
| | | |
| | | 这是一个完全独立于无人机产品的本地只读控制台。它只展示本工作区已有能力的 |
| | | 输入说明、运行元数据和结果工件,不上传数据、不调用外部 API,也不提供删除、 |
| | | 覆盖或启动算法的操作。 |
| | | 输入说明、运行元数据和结果工件,不上传数据,也不提供删除、覆盖或启动算法的 |
| | | 操作。地图会直接请求公开 ArcGIS 影像切片;配置天地图 token 后还会直接请求 |
| | | 天地图影像和注记切片。 |
| | | |
| | | ## 启动 |
| | | |
| | |
| | | |
| | | 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 状态与能力适配层。 |
| | |
| | | - `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`;服务只调用固定虚拟环境和能力脚本,不接受任意命令或任意路径。 |
| | |
| | | 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); }); } |
| | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | <script setup lang="ts"> |
| | | import { onBeforeUnmount, onMounted, ref, watch } from "vue"; |
| | | import { AimOutlined, BorderOutlined } from "@ant-design/icons-vue"; |
| | | import { |
| | | ArcGisMapServerImageryProvider, |
| | | Color, |
| | | ColorMaterialProperty, |
| | | ConstantProperty, |
| | |
| | | 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"); |
| | |
| | | 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) { |
| | |
| | | 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 () => { |
| | |
| | | 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> |
| | |
| | | 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; } } |
| | | } |
| | | }); |
| | |
| | | .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; } |
| | | } |
| | |
| | | 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 } }); } |
| | |
| | | |
| | | <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> |
| New file |
| | |
| | | /// <reference types="vite/client" /> |
| | |
| | | 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" |
| | | } |
| | | } |
| | | }); |
| | |
| | | - `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` 虚拟环境,仍然只适用于人员和常见车辆的基线验证;树木、真实准确率、许可审查和生产批处理不在此入口的承诺范围内。 |
| | |
| | | | 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`,行为标签在界面显示中文释义,结构化文件继续保存英文键值,便于程序处理。 |
| New file |
| | |
| | | """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()) |
| | |
| | | geopandas>=1,<2 |
| | | scikit-learn>=1.5,<2 |
| | | matplotlib>=3.9,<4 |
| | | openpyxl>=3.1,<4 |
| | |
| | | matplotlib.use("Agg") |
| | | import matplotlib.pyplot as plt |
| | | from matplotlib.lines import Line2D |
| | | from matplotlib.patches import Patch |
| | | |
| | | |
| | | DEFAULT_THRESHOLDS = { |
| | |
| | | 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): |
| | |
| | | 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: |
| | |
| | | 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]]: |
| | |
| | | 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) |
| | |
| | | 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" |
| | | ) |
| | |
| | | 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( |
| | |
| | | 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( |
| | |
| | | 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) |
| | |
| | | |
| | | 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: |
| | |
| | | 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 |
| | |
| | | "events.json", |
| | | "trajectories.geojson", |
| | | "events.geojson", |
| | | "reference_routes.geojson", |
| | | "zones.geojson", |
| | | "analysis.png", |
| | | "run_metadata.json", |
| | | ): |
| New file |
| | |
| | | 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() |
| | |
| | | """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/"): |
| | |
| | | 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: |