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