From b6a276cb8d7070f382944314b162cae5e248e61d Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sat, 2 May 2026 02:10:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E4=BA=A4=E4=BA=92=E6=8F=90=E7=A4=BA=E4=B8=8E=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=B1=9E=E6=80=A7=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能新增: - 新增 POST /api/ai/analyze-mask 后端接口,基于 mask polygon、bbox、points 和 score 返回置信度来源、面积、拓扑锚点和后端分析提示。 - 前端新增 analyzeMask API 封装,并在本体检查面板读取选中 mask 的后端几何属性和重新提取拓扑锚点结果。 - 右侧语义分类树点击分类时,会给当前选中 mask 换标签、更新 class 元数据,并将选中 mask 移到前端渲染最上层,方便继续编辑。 - 分割工作区画布新增上下文操作提示,覆盖多边形 Enter 完成、Esc 取消、首节点闭合、拖拽图形、点区域、SAM 点/框提示、区域合并/去除选择顺序和多边形编辑。 - AI 智能分割画布新增正向点、反向点、边界框选和视口控制的上下文提示。 - 自动传播交互收敛为参考帧加起止帧范围加单个“自动传播”按钮,默认使用当前参考帧全部 mask 作为 seed。 - 时间轴改为用浅蓝色进度条区段标记自动传播生成的帧,而不是已编辑帧竖线提示。 Bugfix: - AI 分割页无当前帧时移除外部演示背景图,改为明确空状态提示,避免误以为外部图片可参与真实推理。 - 工具栏魔法棒文案改为“打开 AI 智能分割”,避免误导为直接触发 SAM 推理。 - Canvas 底部当前图层信息改为显示真实选中 mask 标签和 annotation id,不再使用固定占位文本。 - 已保存标注回显时保留 mask metadata 中的传播来源、score 等字段,供时间轴和属性面板识别。 - 清理 server.ts 中遗留的 /api/login、/api/projects、/api/templates 内存 mock API,避免和 FastAPI 真实后端混淆。 测试: - 补充 analyze-mask 后端测试,覆盖后端几何属性和锚点返回。 - 补充 api.analyzeMask 前端契约测试,覆盖 normalized polygon、bbox、points 和 extract_skeleton payload。 - 补充本体面板测试,覆盖后端属性读取、自定义分类写回后端模板、选中 mask 换标签和置顶显示。 - 补充 Canvas 测试,覆盖上下文提示、多边形完成提示、布尔选择顺序提示、当前图层真实显示和编辑优先级。 - 补充 AI 分割测试,覆盖无帧空状态和提示工具上下文提示。 - 更新 Konva 测试 mock,支持拖动过程、stroke/dash/fillRule 等渲染断言。 文档: - 更新 README 和 AGENTS,说明 server.ts 不再保留业务 mock API。 - 更新 doc/02、doc/03、doc/04、doc/05、doc/07、doc/08、doc/09,记录后端属性分析、分类置顶显示、上下文提示、自动传播按钮、传播帧标记、测试覆盖和当前剩余限制。 --- AGENTS.md | 18 ++-- README.md | 10 +- backend/routers/ai.py | 79 ++++++++++++++ backend/schemas.py | 19 ++++ backend/tests/test_ai.py | 22 ++++ doc/02-current-implementation-map.md | 2 +- doc/03-frontend-element-audit.md | 35 +++---- doc/04-api-contracts.md | 6 +- doc/05-implementation-plan.md | 6 +- doc/07-current-requirements-freeze.md | 18 ++-- doc/08-current-design-freeze.md | 46 ++++---- doc/09-test-plan.md | 22 ++-- server.ts | 34 ------ src/components/AISegmentation.test.tsx | 24 +++++ src/components/AISegmentation.tsx | 47 +++++++-- src/components/CanvasArea.test.tsx | 71 ++++++++++++- src/components/CanvasArea.tsx | 117 +++++++++++++++++++-- src/components/FrameTimeline.test.tsx | 22 ++-- src/components/FrameTimeline.tsx | 33 +++--- src/components/OntologyInspector.test.tsx | 87 +++++++++++++-- src/components/OntologyInspector.tsx | 122 ++++++++++++++++++---- src/components/ToolsPalette.test.tsx | 2 +- src/components/ToolsPalette.tsx | 2 +- src/components/VideoWorkspace.test.tsx | 16 +-- src/components/VideoWorkspace.tsx | 52 ++------- src/lib/api.test.ts | 48 +++++++++ src/lib/api.ts | 48 +++++++++ src/test/setup.tsx | 19 ++++ 28 files changed, 796 insertions(+), 231 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 14caddc..2745a99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ - **项目名称**: `react-example`(`package.json` 中的 `name`) - **前端入口**: `src/main.tsx` → `src/App.tsx` -- **前端服务入口**: `server.ts`(Express + Vite 中间件 / 生产静态服务,并保留少量旧版 mock API) +- **前端服务入口**: `server.ts`(Express + Vite 中间件 / 生产静态服务;旧版 mock API 已清理) - **后端入口**: `backend/main.py`(FastAPI) - **默认前端地址**: `http://localhost:3000` - **默认后端地址**: `http://localhost:8000` @@ -49,7 +49,7 @@ ``` Seg_Server/ -├── server.ts # Express + Vite 前端入口;保留 /api/login、/api/projects、/api/templates mock +├── server.ts # Express + Vite 前端入口;不再提供旧版 /api mock ├── index.html # SPA HTML 入口 ├── vite.config.ts # Vite 配置;含 @/* 路径别名与 DISABLE_HMR 逻辑 ├── tsconfig.json # TypeScript 配置;@/* 映射到项目根目录 @@ -134,7 +134,7 @@ npm run build # Vite 预览 npm run preview -# 生产模式运行 server.ts,服务 dist/;仍保留 server.ts 中的旧版 mock API +# 生产模式运行 server.ts,服务 dist/ npm start # TypeScript 类型检查 @@ -222,10 +222,10 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload 3. 上传资源:视频走 `/api/media/upload`,只上传源文件并关联项目,不自动拆帧;DICOM 批量走 `/api/media/upload/dicom`。 4. 生成帧入队:用户在项目库点击“生成帧”,选择目标 FPS 后前端调用 `/api/media/parse`;后端创建 `ProcessingTask` 并投递 Celery,接口支持 `parse_fps`、`max_frames` 和 `target_width` 标准帧序列参数。 5. worker 执行:Celery worker 用 FFmpeg 优先拆视频帧,失败后用 OpenCV fallback,DICOM 使用 pydicom;视频帧按 `frame_%06d.jpg` 连续命名并记录 `timestamp_ms`、`source_frame_number` 和任务 `frame_sequence` 元数据。 -6. 帧展示:`VideoWorkspace.tsx` 调用 `/api/projects/{id}/frames`,`CanvasArea.tsx` 和 `FrameTimeline.tsx` 显示当前帧与时间轴缩略图;`FrameTimeline` 会根据当前项目帧内的 `masks` 在顶部进度条上用琥珀色竖线标出已有编辑/标注的帧,当前帧位置由播放进度条末端、时间提示和缩略图高亮表达;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。 -7. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、点区域和线段生成 polygon mask;多边形可按 Enter 或点击首节点闭合;绘制工具可在已有 mask 上继续落点;工具栏有“调整多边形”入口,点击 mask 可拖动/删除 polygon 顶点、通过边中点或双击边界插入新顶点,并能选择编辑多 polygon mask 的单个子区域;选中整块 mask 可用 Delete/Backspace 删除,已保存 mask 会同步后端删除;区域合并/去除会隐藏编辑手柄并显示已选数量,使用 `polygon-clipping` 做 union/difference,内含去除结果用 even-odd 规则渲染 hole;Zustand 维护 `maskHistory/maskFuture` 支持撤销/重做。 +6. 帧展示:`VideoWorkspace.tsx` 调用 `/api/projects/{id}/frames`,`CanvasArea.tsx` 和 `FrameTimeline.tsx` 显示当前帧与时间轴缩略图;`FrameTimeline` 会根据已保存标注回显到 `Mask.metadata` 的传播来源,把自动传播生成的帧在顶部进度条对应区段标为浅蓝色,当前帧位置由播放进度条末端、时间提示和缩略图高亮表达;前端 `Frame` 会保留后端返回的帧序列时间戳和源帧号。 +7. 手工标注:`CanvasArea.tsx` 支持多边形、矩形、圆、点区域和线段生成 polygon mask;多边形可按 Enter 或点击首节点闭合;绘制工具可在已有 mask 上继续落点;工具栏有“调整多边形”入口,点击 mask 后可按住顶点直接拖动并实时更新 polygon,也可删除 polygon 顶点、通过边中点或双击边界插入新顶点,并能选择编辑多 polygon mask 的单个子区域;选中整块 mask 可用 Delete/Backspace 删除,已保存 mask 会同步后端删除;区域合并/去除会隐藏编辑手柄并显示已选数量,第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,使用 `polygon-clipping` 做 union/difference,内含去除结果用 even-odd 规则渲染 hole;Zustand 维护 `maskHistory/maskFuture` 支持撤销/重做。 8. AI 分割:前端工具包括 SAM 2.1 变体选择、正向点、反向点和框选;工作区和 AI 页面都可点击已有提示点删除单点,AI 页面也可删除最近锚点、删除选中候选或清空本页锚点;这些删除入口会限制在当前提示点/本页 AI 候选范围内,避免误删工作区已有 mask。SAM 2.1 框选会建立候选 mask,后续正/反点通过 `interactive` prompt 携带原始框和累计点细化同一个候选 mask;AI 页面框选会先固化 `promptBox`,执行分割时只框选发送 `box` prompt,框选后继续加正/反点发送 `interactive` prompt;重复执行高精度分割会替换上一次 AI 页候选,只保留最新一个候选。包含反向点时工作区会传 `options.auto_filter_background=true` 和 `min_score=0.05`,如果后端过滤为空则移除旧候选 mask。后端 `ai.py` 期望按 `image_id`、`prompt_type`、`prompt_data`、`model` 和可选 `options` 调用 SAM registry。当前 registry 暴露 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`,并兼容 `sam2` 作为 tiny 别名;`model=sam3` 会被拒绝,`semantic` 文本提示也被禁用。SAM 2.1 支持点/框/interactive/自动分割和 video predictor 传播;多候选默认只采用最高分区域,避免重叠候选同时显示;AI 页面只渲染本页最新生成的候选 mask,不会把工作区已有 mask 带入 AI 画布;AI 页面生成的 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接改标签,推送到工作区会切到“调整多边形”并保留选择。`options.crop_to_prompt` 可对点/框/interactive prompt 做局部裁剪推理并回映射,`options.auto_filter_background` 可按分数和负向点过滤结果。 -9. 视频片段传播:工作区可选择“选中区域”或“当前帧全部”作为 seed,并用起止帧指定追踪范围;“传播全部可达”会把范围设为第 1 帧到最后 1 帧。前端会按 seed mask 和前/后方向顺序调用单 seed `POST /api/ai/propagate`,避免多个视频 tracker 并发抢占 GPU;后端按项目帧序列下载片段帧,当前使用所选 SAM 2.1 变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,并把后续帧结果保存为 `Annotation`。 +9. 视频片段传播:工作区以当前打开帧作为参考帧,使用该帧全部 mask 作为 seed,并用传播起始帧和传播结束帧指定追踪范围;前端只保留一个“自动传播”按钮,会按 seed mask 和前/后方向顺序调用单 seed `POST /api/ai/propagate`,避免多个视频 tracker 并发抢占 GPU;后端按项目帧序列下载片段帧,当前使用所选 SAM 2.1 变体的 `SAM2VideoPredictor.add_new_mask()` + `propagate_in_video()`,并把后续帧结果保存为 `Annotation`。 10. GT 导入:工作区“导入 GT Mask”调用 `/api/ai/import-gt-mask`;后端按非零像素值和连通域生成 polygon 标注,并用 distance transform 生成 seed point;前端回显 seed point,拖动后可归档更新。 11. 模板管理:`TemplateRegistry.tsx` 管理分类、颜色和 z-index;`OntologyInspector.tsx` 在工作区显示当前模板分类树。 12. 导出:后端支持 COCO JSON 和 PNG mask ZIP 导出;PNG ZIP 包含单标注 mask、按 zIndex 融合的语义 mask 和 `semantic_classes.json`。 @@ -242,11 +242,11 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload - 前端 `importGtMask()` 已对齐后端 `/api/ai/import-gt-mask`;工作区“导入 GT Mask”会导入后端生成的多类别标注和 seed point 并回显。 - 前端 `exportCoco()` 已对齐后端 `/api/export/{project_id}/coco`;前端 `exportMasks()` 已对齐后端 `/api/export/{project_id}/masks`;工作区导出按钮会先保存当前待归档 mask。 - 工作区“结构化归档保存”按钮已接入 `POST /api/ai/annotate` 和 `PATCH /api/ai/annotations/{id}`;加载工作区时会通过 `GET /api/ai/annotations` 回显已保存标注。 -- 工作区“传播片段”按钮已接入 `POST /api/ai/propagate`;当前启用所选 SAM 2.1 变体的视频 predictor,完成后刷新后端已保存标注。 +- 工作区“自动传播”按钮已接入 `POST /api/ai/propagate`;当前启用所选 SAM 2.1 变体的视频 predictor,完成后刷新后端已保存标注。 - 工作区“清空遮罩”会调用 `DELETE /api/ai/annotations/{id}` 删除当前帧已保存标注,并清空当前帧本地 mask。 - 项目状态已统一为 `pending`、`parsing`、`ready`、`error`;前端 `src/lib/api.ts` 会兼容归一化旧库中可能存在的 `Ready`、`Parsing`、`Error`。 - 项目库的视频导入与生成帧是两个独立动作:导入视频只上传源文件,生成帧按钮才会带 `parse_fps` 调用 `/api/media/parse`;工作区不会再因“有视频但无帧”自动创建拆帧任务。 -- `server.ts` 仍有旧版 `/api/login`、`/api/projects`、`/api/templates` mock;当前前端真实 API 调用主要走 FastAPI 的 `/api/auth/*`、`/api/projects`、`/api/templates` 等接口。 +- `server.ts` 不再提供旧版 `/api/login`、`/api/projects`、`/api/templates` mock;当前前端真实 API 调用走 FastAPI 的 `/api/auth/*`、`/api/projects`、`/api/templates` 等接口。 - `Dashboard.tsx` 初始统计、任务进度和活动日志来自 `GET /api/dashboard/overview`;任务进度来自 `processing_tasks` queued/running/success/failed/cancelled,处理中统计只计算 queued/running,支持取消 queued/running 任务、重试 failed/cancelled 任务和查看失败详情。Celery worker 通过 Redis pub/sub 的 `seg:progress` 频道推送细粒度进度,再由 FastAPI 广播到 `/ws/progress`;前端 WebSocket 客户端通过 `onopen/onclose/onerror` 更新连接状态,并定时发送 `ping` 心跳。 --- @@ -296,7 +296,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --reload - Axios 会附加 Bearer token,但后端大多数业务路由当前没有鉴权依赖。 - `backend/.env` 被 `.gitignore` 忽略;不要提交真实数据库、MinIO、Redis、模型路径等敏感配置。 - `start_services.sh` 中包含本机路径和 sudo 启动逻辑,迁移机器时要审查。 -- Express `server.ts` 的旧版 mock API 只适合开发/兼容场景,不能当生产鉴权或持久化方案。 +- Express `server.ts` 只负责前端开发/静态服务,不承担业务 API 或鉴权。 --- diff --git a/README.md b/README.md index 4f25656..c9f8a55 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ - **多媒体资产管理** — 支持视频(MP4/AVI/MOV)和 DICOM 医学影像上传;视频导入与生成帧分离,生成帧时选择目标 FPS,项目卡片可删除项目及其关联帧、标注和任务记录 - **AI 智能分割引擎** — 当前产品入口启用 SAM 2.1 四个变体(tiny/small/base+/large)选择;支持点分割(point)、框分割(box)、交互式正/反点细化、提示点单点删除、AI 候选单独删除、自动分割(auto)和 video predictor 传播,前端默认只采用最高分候选避免重叠备选同时显示 -- **交互式画布标注** — 基于 Konva 的高性能 Canvas,支持缩放/平移/手工多边形/矩形/圆/点/线、polygon 顶点拖动/删除、边中点插点、双击边界插点、区域合并/去除、选点/框选、撤销/重做,实时渲染 Mask 遮罩 +- **交互式画布标注** — 基于 Konva 的高性能 Canvas,支持缩放/平移/手工多边形/矩形/圆/点/线、polygon 顶点直接拖动/删除、边中点插点、双击边界插点、区域合并/去除、选点/框选、撤销/重做,实时渲染 Mask 遮罩 - **GT Mask 导入** — 工作区可导入 GT mask 图片,后端按非零像素值和连通域生成 polygon 标注并用 distance transform 写入 seed point;前端可回显和拖动 seed point - **本体字典管理** — 可配置的分类体系、颜色映射、图层优先级(z-index) -- **项目工作区** — 项目创建、帧浏览、多图层标注、已编辑帧提示、进度追踪 +- **项目工作区** — 项目创建、帧浏览、多图层标注、自动传播帧提示、进度追踪 - **数据导出** — 支持 COCO JSON 格式和 PNG Mask 批量导出;PNG ZIP 包含单标注 mask、按 z-index 融合的语义 mask 和类别映射 --- @@ -137,7 +137,7 @@ Seg_Server/ ├── public/ │ └── logo.png # 侧边栏 Logo 静态资源 ├── start_services.sh # 一键启动所有服务脚本 -├── server.ts # Express + Vite 前端入口(也保留少量旧版 mock API) +├── server.ts # Express + Vite 前端入口(不再提供旧版 mock API) ├── index.html # SPA HTML 入口 ├── vite.config.ts # Vite 构建配置 ├── package.json # npm 依赖与脚本 @@ -389,7 +389,7 @@ npm run build # 生产构建(输出到 dist/) npm run lint # TypeScript 类型检查 npm run test # Vitest watch 模式 npm run test:run # Vitest 单次运行 -npm start # Node.js 运行 server.ts(生产静态服务 / 旧版 mock API) +npm start # Node.js 运行 server.ts(生产静态服务) ``` ### 后端 @@ -467,7 +467,7 @@ pip install -e . --no-build-isolation - 工作区点选/框选会使用当前帧的数据库 `frame.id` 调用 `/api/ai/predict`。 - 工作区 SAM 2.1 交互式细化包含反向点时会启用后端背景过滤;若反向点排除了当前候选区域并返回空结果,前端会移除旧候选 mask。 - AI 页面只显示本页最新生成的 SAM 2.1 候选,不会把工作区已有 mask 带入 AI 画布;重复执行高精度分割会替换上一次 AI 页候选;新生成 mask 会写入全局 `masks` 并自动选中,右侧分类树可直接给生成结果换标签,“推送至工作区编辑”会切回工作区的多边形调整工具并保留选择。 -- 工作区传播功能会使用当前选中区域或当前帧全部区域作为 seed,按用户设置的起止帧向前/向后追踪;“传播全部可达”会覆盖项目第 1 帧到最后 1 帧。前端会按 seed 和方向顺序调用 `/api/ai/propagate`,并在完成后刷新已保存标注。 +- 工作区传播功能会使用当前打开参考帧的全部 mask 作为 seed,按用户设置的传播起始帧和传播结束帧向前/向后追踪;前端只保留一个“自动传播”按钮,会按 seed 和方向顺序调用 `/api/ai/propagate`,并在完成后刷新已保存标注。传播结果回显后,时间进度条会把自动传播生成的帧区段标为浅蓝色。 - 前端 `exportCoco()` 已对齐到 `/api/export/{projectId}/coco`。 - 工作区“导出 JSON 标注集”和“导出 PNG Mask ZIP”按钮已绑定下载流程;导出前会先保存当前待归档的前端 mask。 - 工作区“导入 GT Mask”按钮已绑定 `/api/ai/import-gt-mask`,导入后会刷新并回显已保存标注和 seed point。 diff --git a/backend/routers/ai.py b/backend/routers/ai.py index bd75338..413716d 100644 --- a/backend/routers/ai.py +++ b/backend/routers/ai.py @@ -15,6 +15,8 @@ from minio_client import download_file from models import Project, Frame, Template, Annotation from schemas import ( AiRuntimeStatus, + MaskAnalysisRequest, + MaskAnalysisResponse, PredictRequest, PredictResponse, PropagateRequest, @@ -78,6 +80,29 @@ def _polygon_bbox(polygon: list[list[float]]) -> list[float]: return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)] +def _polygon_area(polygon: list[list[float]]) -> float: + if len(polygon) < 3: + return 0.0 + total = 0.0 + for index, point in enumerate(polygon): + next_point = polygon[(index + 1) % len(polygon)] + total += _clamp01(point[0]) * _clamp01(next_point[1]) + total -= _clamp01(next_point[0]) * _clamp01(point[1]) + return abs(total) / 2.0 + + +def _analysis_anchors(polygons: list[list[list[float]]], points: list[list[float]] | None) -> list[list[float]]: + if points: + return [[_clamp01(point[0]), _clamp01(point[1])] for point in points if len(point) >= 2] + anchors: list[list[float]] = [] + for polygon in polygons: + if not polygon: + continue + step = max(1, len(polygon) // 12) + anchors.extend([[_clamp01(point[0]), _clamp01(point[1])] for point in polygon[::step]]) + return anchors[:32] + + def _frame_window( frames: list[Frame], source_position: int, @@ -389,6 +414,60 @@ def model_status(selected_model: str | None = None) -> dict: raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post( + "/analyze-mask", + response_model=MaskAnalysisResponse, + summary="Analyze mask geometry and prompt anchors", +) +def analyze_mask(payload: MaskAnalysisRequest, db: Session = Depends(get_db)) -> dict: + """Return backend-computed mask properties for the frontend inspector.""" + if payload.frame_id is not None: + frame = db.query(Frame).filter(Frame.id == payload.frame_id).first() + if not frame: + raise HTTPException(status_code=404, detail="Frame not found") + + mask_data = payload.mask_data or {} + polygons = mask_data.get("polygons") or [] + if not polygons: + raise HTTPException(status_code=400, detail="Mask analysis requires polygons") + + valid_polygons = [ + [[_clamp01(point[0]), _clamp01(point[1])] for point in polygon if len(point) >= 2] + for polygon in polygons + ] + valid_polygons = [polygon for polygon in valid_polygons if len(polygon) >= 3] + if not valid_polygons: + raise HTTPException(status_code=400, detail="Mask analysis requires at least one valid polygon") + + area = sum(_polygon_area(polygon) for polygon in valid_polygons) + bbox = payload.bbox or _polygon_bbox(valid_polygons[0]) + source = mask_data.get("source") + raw_score = mask_data.get("score") + confidence: float | None = None + confidence_source = "unavailable" + if isinstance(raw_score, (int, float)): + confidence = max(0.0, min(float(raw_score), 1.0)) + confidence_source = "model_score" + elif source: + confidence_source = "source_without_score" + else: + confidence_source = "manual_or_imported" + + anchors = _analysis_anchors(valid_polygons, payload.points) + message = "已从后端重新提取几何拓扑锚点" if payload.extract_skeleton else "已读取后端几何属性" + + return { + "confidence": confidence, + "confidence_source": confidence_source, + "topology_anchor_count": len(anchors), + "topology_anchors": anchors, + "area": area, + "bbox": bbox, + "source": source, + "message": message, + } + + @router.post( "/propagate", response_model=PropagateResponse, diff --git a/backend/schemas.py b/backend/schemas.py index 34deae6..0e7b606 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -190,6 +190,25 @@ class PredictResponse(BaseModel): scores: Optional[list[float]] = None +class MaskAnalysisRequest(BaseModel): + frame_id: Optional[int] = None + mask_data: dict[str, Any] + points: Optional[list[list[float]]] = None + bbox: Optional[list[float]] = None + extract_skeleton: bool = False + + +class MaskAnalysisResponse(BaseModel): + confidence: Optional[float] = None + confidence_source: str + topology_anchor_count: int + topology_anchors: list[list[float]] + area: float + bbox: Optional[list[float]] = None + source: Optional[str] = None + message: str + + class PropagationSeed(BaseModel): polygons: Optional[list[list[list[float]]]] = None bbox: Optional[list[float]] = None diff --git a/backend/tests/test_ai.py b/backend/tests/test_ai.py index 5d88359..ae7cfff 100644 --- a/backend/tests/test_ai.py +++ b/backend/tests/test_ai.py @@ -198,6 +198,28 @@ def test_model_status_rejects_disabled_sam3(client): assert "Unsupported model" in response.json()["detail"] +def test_analyze_mask_returns_backend_geometry_properties(client): + _, frame, _ = _create_project_and_frame(client) + + response = client.post("/api/ai/analyze-mask", json={ + "frame_id": frame["id"], + "mask_data": { + "polygons": [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3], [0.1, 0.3]]], + "source": "sam2.1_hiera_tiny", + "score": 0.87, + }, + "extract_skeleton": True, + }) + + assert response.status_code == 200 + body = response.json() + assert body["confidence"] == 0.87 + assert body["confidence_source"] == "model_score" + assert body["topology_anchor_count"] == 4 + assert body["area"] > 0 + assert body["message"] == "已从后端重新提取几何拓扑锚点" + + def test_propagate_saves_tracked_annotations(client, monkeypatch): project = client.post("/api/projects", json={"name": "Video Project"}).json() frames = [ diff --git a/doc/02-current-implementation-map.md b/doc/02-current-implementation-map.md index 6a2ad60..4cfd80b 100644 --- a/doc/02-current-implementation-map.md +++ b/doc/02-current-implementation-map.md @@ -9,7 +9,7 @@ - 前端服务:`server.ts` - 默认访问:`http://localhost:3000` -`server.ts` 的角色比较特殊:它既负责在开发模式下创建 Vite middleware,也在生产模式下服务 `dist/`。同时它还保留了旧版 mock API:`/api/login`、`/api/projects`、`/api/templates`。当前前端业务 API 主要不走这些 mock,而是走 `src/lib/api.ts` 指向的 FastAPI。 +`server.ts` 的角色比较特殊:它既负责在开发模式下创建 Vite middleware,也在生产模式下服务 `dist/`。当前旧版 `/api/login`、`/api/projects`、`/api/templates` mock 已清理;前端业务 API 走 `src/lib/api.ts` 指向的 FastAPI。 ### 后端入口 diff --git a/doc/03-frontend-element-audit.md b/doc/03-frontend-element-audit.md index 3f96bf0..ac43b4a 100644 --- a/doc/03-frontend-element-audit.md +++ b/doc/03-frontend-element-audit.md @@ -66,8 +66,7 @@ | “导出 JSON 标注集”按钮 | 真实可用 | 导出前会保存未归档 mask,然后调用 `exportCoco()` 下载 JSON | | “导出 PNG Mask ZIP”按钮 | 真实可用 | 导出前会保存未归档 mask,然后调用 `GET /api/export/{project_id}/masks` 下载 ZIP;后端同时包含单标注 mask、每帧语义融合 mask 和 `semantic_classes.json` | | “导入 GT Mask”按钮 | 真实可用 | 选择图片后调用 `POST /api/ai/import-gt-mask`,后端按非零像素值和连通域生成 polygon 标注与距离变换 seed point,再回显到工作区 | -| 传播对象/起止帧/按范围传播 | 真实可用 | 可选择“选中区域”或“当前帧全部”,并用起止帧定义从当前帧向前/向后的追踪范围;前端会按 seed mask 和方向顺序调用 `POST /api/ai/propagate`,当前启用 SAM 2 video predictor,完成后刷新已保存标注 | -| “传播全部可达”按钮 | 真实可用 | 一键把传播范围设为项目第 1 帧到最后 1 帧,并按当前传播对象把当前帧区域向前后所有可达帧传播 | +| 参考帧/起止帧/自动传播 | 真实可用 | 当前打开帧即参考帧,前端会使用该帧全部 mask 作为 seed;用户设置传播起始帧和传播结束帧后,单个“自动传播”按钮会按 seed mask 和前/后方向顺序调用 `POST /api/ai/propagate`,当前启用 SAM 2 video predictor,完成后刷新已保存标注 | | “结构化归档保存”按钮 | 真实可用 | 未保存 mask 写入 `POST /api/ai/annotate`;dirty mask 写入 `PATCH /api/ai/annotations/{id}`;保存成功后会重新拉取后端标注,并用 saved annotation 替换本次提交的 draft mask,避免仍显示未保存 | ## CanvasArea 画布 @@ -82,22 +81,23 @@ | 框选 | 真实可用 | UI 能画框,并把框坐标归一化后调用后端推理;结果需点击归档保存才持久化 | | AI 推理中提示 | 真实可用 | 请求期间会显示 | | 手工多边形/矩形/圆/点/线 | 真实可用 | 多边形点击取点后可按 Enter 完成,也可在三点后点击首节点闭合;矩形/圆/线拖拽生成 polygon;点工具生成小区域;绘制工具可在已有 mask 上继续落点;均写入 `Mask.segmentation`,可归档保存 | +| 画布上下文提示 | 真实可用 | 切换到多边形、矩形、圆、线、点、正/反向选点、框选、区域合并/去除、调整多边形等隐性操作工具时,画布左上角显示当前工具的完成/取消/选择顺序提示 | | Mask 渲染 | 真实可用 | 前端会把推理、手工绘制、GT 导入和已保存标注转成 Konva `pathData` 渲染 | -| Polygon 逐点编辑 / 删除 | 真实可用 | 点击 mask 后显示 polygon 顶点;拖动顶点会重算 `pathData/segmentation/bbox/area`,已保存 mask 标为 dirty;选中顶点后 Delete/Backspace 可删点但保留至少三点;选中 mask 但未选中顶点时 Delete/Backspace 删除整个 mask,已保存 mask 会同步调用后端删除 | +| Polygon 逐点编辑 / 删除 | 真实可用 | 点击 mask 后显示 polygon 顶点;按住顶点即可直接拖动并实时重算 `pathData/segmentation/bbox/area`,不需要先单击选中顶点,已保存 mask 标为 dirty;选中顶点后 Delete/Backspace 可删点但保留至少三点;选中 mask 但未选中顶点时 Delete/Backspace 删除整个 mask,已保存 mask 会同步调用后端删除 | | GT seed point 回显/编辑 | 真实可用 | 已保存标注的 `points` 会显示为黄色 seed 点;拖动后标记为 dirty,归档保存会更新后端 | -| 应用分类 | 真实可用 | Canvas 右下角按钮可将当前选择的模板分类应用到本帧 mask;右侧语义分类树点击分类时会优先改当前已选 mask;已保存 mask 会标为 dirty,归档保存时更新后端 | +| 应用分类 | 真实可用 | Canvas 右下角按钮可将当前选择的模板分类应用到本帧 mask;右侧语义分类树点击分类时会优先改当前已选 mask,并把已选 mask 移到前端渲染最上层方便继续编辑;已保存 mask 会标为 dirty,归档保存时更新后端 | | 清空遮罩 | 真实可用 | 工作区中会删除当前帧已保存标注并清空当前帧本地 mask | | 保存状态计数 | 真实可用 | 底部显示已保存、未保存、待更新数量 | -| 当前图层树文字 | Mock / UI-only | 固定显示 `OBJECT_VEHICLE_01` | +| 当前图层信息 | 真实可用 | 根据当前选中 mask 显示真实标签/后端 annotation id;未保存 mask 显示“未保存”,未选中时显示“未选择” | ## ToolsPalette 工具栏 | 元素 | 状态 | 说明 | |------|------|------| | 拖拽/选择 | 真实可用 | 控制 Canvas 是否可拖拽 | -| 调整多边形 | 真实可用 | 选中 polygon mask 后显示顶点和边中点;支持拖动顶点、点击边中点插点、双击边界按位置插点 | +| 调整多边形 | 真实可用 | 选中 polygon mask 后显示顶点和边中点;支持按住顶点直接拖动、点击边中点插点、双击边界按位置插点 | | 多边形/矩形/圆/点/线 | 真实可用 | 切换 activeTool 后由 `CanvasArea` 生成可保存的 polygon mask | -| 区域合并/去除 | 真实可用 | 选择工具后点击多个 mask,右下角显示已选数量和操作按钮;合并/去除模式会隐藏 polygon 编辑手柄,避免手柄抢占多选点击;使用 `polygon-clipping` 做 union / difference;合并会保留主 mask 并移除被合并 mask,去除会从主 mask 扣除后续选中 mask;内含扣除会保留 hole ring 并用 even-odd 规则渲染 | +| 区域合并/去除 | 真实可用 | 选择工具后点击多个 mask,右下角显示已选数量和操作按钮;合并/去除模式会隐藏 polygon 编辑手柄,避免手柄抢占多选点击;布尔选择态中第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,避免主区域和扣除区域看起来像随机阴影差异;使用 `polygon-clipping` 做 union / difference;合并会保留主 mask 并移除被合并 mask,去除会从主 mask 扣除后续选中 mask;内含扣除会保留 hole ring 并用 even-odd 规则渲染 | | 正向选点/反向选点/框选 | 部分可用 | 会影响 Canvas 交互,并能触发已对齐的 AI 推理接口;点击工作区内已有 SAM 提示点会优先删除该提示点并重新推理,不会冒泡成新增提示点或 mask 选择 | | 魔法棒 SAM 触发 | 部分可用 | 切到 AI 页面;不是直接执行推理 | | 撤销/重做 | 真实可用 | 绑定 Zustand `maskHistory/maskFuture`,支持工具栏按钮、AI 页按钮和 Canvas Ctrl+Z/Ctrl+Y | @@ -110,7 +110,7 @@ | 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)` | | 顶部 range 拖动 | 真实可用 | 改变当前帧 | | 具体时间显示 | 真实可用 | 根据项目 `parse_fps/original_fps` 显示当前时间和总时长,格式为 `mm:ss.cc` | -| 已编辑帧进度线 | 真实可用 | 根据当前项目帧内的 `masks` 计算有编辑/标注的帧,并在顶部进度条上覆盖琥珀色竖线;当前帧位置由播放进度条末端、时间提示和缩略图高亮表达,点击已编辑竖线可跳转到对应帧 | +| 自动传播帧进度条标记 | 真实可用 | 根据已保存标注回显的 `mask_data.source` / `propagated_from_frame_id` 识别自动传播生成的帧,并在顶部进度条对应帧区段覆盖浅蓝色;当前帧位置由播放进度条末端、时间提示和缩略图高亮表达 | | 播放/暂停 | 真实可用 | 当前代码按 `parse_fps/original_fps` 推进帧,最多 30fps | | 方向键切帧 | 真实可用 | 全局监听左右方向键切到上一帧/下一帧;焦点在 input、textarea、select 或 contentEditable 内时不会拦截 | @@ -119,11 +119,11 @@ | 元素 | 状态 | 说明 | |------|------|------| | 模板选择 | 部分可用 | 读取全局 templates,可切换 activeTemplateId | -| 分类树展示 / 换标签 | 真实可用 | 显示模板 classes 和本地 customClasses;点击分类会设为后续新 mask 的 activeClass,如果 Canvas 已选 mask,则同步更新已选 mask 的标签、颜色和 class 元数据 | -| 添加自定义分类 | 部分可用 | 只存在组件本地状态,不保存到后端 | -| 置信度条 | Mock / UI-only | 固定 `0.9412` | -| 拓扑锚点数量 | Mock / UI-only | 固定 `12 节点` | -| 重新提取骨架按钮 | Mock / UI-only | 无事件 | +| 分类树展示 / 换标签 | 真实可用 | 显示当前模板 classes;点击分类会设为后续新 mask 的 activeClass,如果 Canvas 已选 mask,则同步更新已选 mask 的标签、颜色和 class 元数据,并把已选 mask 移到前端渲染最上层 | +| 添加自定义分类 | 真实可用 | 需要先选择模板;新增分类通过 `PATCH /api/templates/{id}` 写入后端模板 `mapping_rules.classes`,并同步全局模板 store | +| 后端模型置信度 | 真实可用 | 选中 mask 后调用 `POST /api/ai/analyze-mask`,优先显示后端返回的模型分数;手工/导入 mask 无模型分数时显示“无模型分数” | +| 后端拓扑锚点数量 | 真实可用 | 选中 mask 后调用 `POST /api/ai/analyze-mask`,由后端根据 seed points 或 polygon 顶点采样返回锚点数量 | +| 重新提取拓扑锚点按钮 | 真实可用 | 调用 `POST /api/ai/analyze-mask` 并带 `extract_skeleton=true`,刷新后端几何锚点统计 | ## AISegmentation 独立 AI 页 @@ -132,19 +132,18 @@ | SAM 2.1 变体选择 / 模型状态 | 真实可用 | AI 页可选 tiny/small/base+/large,调用 `GET /api/ai/models/status?selected_model=` 展示所选变体和 GPU 状态;只有本地存在 checkpoint 的变体显示可用 | | 正向/反向点 | 真实可用 | 可在当前项目帧上加点并调用 AI 推理接口;AI 页中点击已有候选 mask 时也会继续添加当前正/反向提示点,点击已有提示点会删除该点;SAM 2.1 框选后会携带原始框和累计正/反点细化同一个候选 mask | | 边界框选 | 真实可用 | AI 页选择工具后可在画布拖拽蓝色虚线框;执行分割时会随 `/api/ai/predict` 发送 `box`,框选后继续添加正/反点会发送 interactive prompt | +| AI 画布上下文提示 | 真实可用 | 选择正向点、反向点、边界框选或视口控制时,画布左上角提示点击/拖拽、删除提示点和执行推理的操作方式 | | SAM 3 入口 | 当前禁用 | 因当前系统不提供文本提示,前端不再显示 SAM 3 模型选择、文本输入或 SAM 3 框选入口;后端 `model=sam3` 返回不支持 | | 语义文本输入 | 当前禁用 | AI 页不再提供文本语义输入;后端收到 `semantic` prompt 会返回 400 | | 参数开关 | 真实可用 | UI 展示为“局部专注模式(自动裁剪无锚区域)”和“严格除杂模式(自动清理干涉点)”,只是为了让用户更容易理解,不重命名内部字段;`cropMode` 会随 `/api/ai/predict` 发送 `crop_to_prompt`,后端对点/框 prompt 裁剪推理区域并回映射 polygon;`autoDeleteBg` 会发送 `auto_filter_background` 和 `min_score`,后端过滤低分结果和覆盖负向点的结果 | | 遮罩清晰度 | 真实可用 | 调节 AI 页候选 mask 的预览透明度,只影响本页显示,不改变 mask 几何、分类或保存数据 | | 执行高精度语义分割 | 真实可用 | 使用当前项目帧和所选 SAM 2.1 变体调用 `/api/ai/predict`;SAM 2.1 需要点/框提示且只采用最高分候选;AI 页只渲染本页最新候选,不显示工作区已有 mask,重复执行会替换上一次 AI 页候选而不是叠加;生成结果写入全局 masks 并自动选中,右侧分类树可立即换标签 | | 推送至工作区编辑 | 真实可用 | 切回工作区并把工具切到“调整多边形”,保留 AI 页选中的未保存 mask;工作区回显后端标注时不会覆盖这类 draft mask | -| 上传替换底图 | Mock / UI-only | 按钮无事件 | | 撤销/重做 | 真实可用 | 绑定全局 mask 历史栈 | | 删除最近锚点 | 真实可用 | 删除 AI 页最近一次放置的正/反向提示点,不影响已生成候选 mask 或工作区 mask | | 删除选中候选 | 真实可用 | 删除 AI 页当前选中的本页候选 mask;不会删除工作区已有 mask,Delete/Backspace 也遵循同一范围 | | 清空全体锚点 | 真实可用 | 清空 AI 页提示点和本页生成的候选 mask,不删除工作区已有 mask | -| 退档推送至工作区重组 | 部分可用 | 只切回工作区,共用 masks store,但没有保存/确认流程 | -| 背景图 | 部分可用 | 优先显示当前项目帧;没有项目帧时仍回退到 Unsplash 演示图 | +| 背景图 / 空状态 | 真实可用 | 优先显示当前项目帧;没有项目帧时显示空状态提示,不再回退到外部演示图片 | ## TemplateRegistry 模板库 @@ -158,10 +157,10 @@ | 拖拽排序 | 真实可用 | 重算 zIndex,保存时写后端 | | JSON 批量导入 | 部分可用 | 前端解析 JSON 并加入编辑态,保存后才落库 | | 载入腹腔镜 35 分类 | 真实可用 | 前端内置数据;后端也 seed 默认模板 | -| mapping rules | 部分可用 | 可存 `rules`,但无实际映射执行引擎 | +| mapping rules | 部分可用 | 可存 `rules`,但当前没有运行时映射执行引擎;适合后续用于导入外部标签、别名归一化或跨数据集类别映射 | ## 总体结论 当前前端真实可用的主链路是:登录、Dashboard 后端概览、项目列表、新建项目、上传视频/DICOM、显式生成帧、浏览帧、播放帧、工作区手工绘制、点/框 AI 推理、视频片段传播、GT mask 导入、标注保存/回显、COCO 导出、PNG mask ZIP 导出、模板 CRUD。 -当前最主要的 Mock 或未打通链路是:真正的文本语义分割已因无文本提示入口而暂时禁用;复杂洞结构编辑、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单和若干检查面板指标仍未落地。 +当前最主要的 Mock 或未打通链路是:真正的文本语义分割已因无文本提示入口而暂时禁用;复杂洞结构编辑、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单和 mapping rules 运行时映射执行引擎仍未落地。登录页“安全审计说明文字”仍只是 UI 文案。 diff --git a/doc/04-api-contracts.md b/doc/04-api-contracts.md index 9da33ab..51e3642 100644 --- a/doc/04-api-contracts.md +++ b/doc/04-api-contracts.md @@ -40,6 +40,7 @@ Authorization: Bearer | `predictMask(payload)` | `POST /api/ai/predict` | 对齐 | 前端发送 `image_id/prompt_type/prompt_data/model`,并把后端 `polygons` 转为 `masks[].pathData` | | `propagateMasks(payload)` | `POST /api/ai/propagate` | 对齐 | 当前帧 seed mask 向视频片段传播,并保存后续帧标注 | | `getAiModelStatus(selectedModel?)` | `GET /api/ai/models/status` | 对齐 | 返回 GPU 和四个 SAM 2.1 变体状态;`selected_model=sam3` 返回不支持 | +| `analyzeMask(mask, frame, options?)` | `POST /api/ai/analyze-mask` | 对齐 | 后端计算选中 mask 的置信度来源、拓扑锚点数量、面积和 bbox | | `getProjectAnnotations(projectId, frameId?)` | `GET /api/ai/annotations` | 对齐 | 前端加载工作区时用于回显已保存标注 | | `saveAnnotation(payload)` | `POST /api/ai/annotate` | 对齐 | 工作区归档保存当前项目未保存 mask | | `updateAnnotation(annotationId, payload)` | `PATCH /api/ai/annotations/{annotation_id}` | 对齐 | 工作区归档保存 dirty mask | @@ -78,6 +79,7 @@ Authorization: Bearer | POST | `/api/tasks/{task_id}/retry` | 重试失败或取消的后台任务 | | POST | `/api/ai/predict` | 当前启用 SAM 2 点/框/interactive 推理 | | POST | `/api/ai/propagate` | 当前启用 SAM 2 视频片段传播并保存标注 | +| POST | `/api/ai/analyze-mask` | 分析前端选中 mask 的后端几何属性和拓扑锚点 | | GET | `/api/ai/models/status` | GPU 和 SAM 模型状态 | | POST | `/api/ai/auto` | 自动分割 | | POST | `/api/ai/annotate` | 保存 AI 标注 | @@ -228,7 +230,7 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask,避免同 ### 视频片段传播请求体 -后端接口仍以单个 seed 为单位。工作区前端的“按范围传播/传播全部可达”会在本地根据当前帧、起止帧和传播对象,把多个 seed 或前后双向范围拆成多次顺序调用,避免同时启动多个视频 tracker。 +后端接口仍以单个 seed 为单位。工作区前端当前只提供一个“自动传播”按钮:当前打开帧作为参考帧,该帧全部 mask 作为 seed;用户设置传播起始帧和传播结束帧后,前端会在本地把多个 seed 或前后双向范围拆成多次顺序调用,避免同时启动多个视频 tracker。 单次调用示例: @@ -252,7 +254,7 @@ SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask,避免同 } ``` -SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2` 会兼容归一化为 tiny,`model=sam3` 当前不支持。响应会返回已创建的 `annotations`,保存的 `mask_data.source` 为 `_propagation`。 +SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2` 会兼容归一化为 tiny,`model=sam3` 当前不支持。响应会返回已创建的 `annotations`,保存的 `mask_data.source` 为 `_propagation`,前端回显时会把该字段保留到 `Mask.metadata`,用于把自动传播帧在时间进度条上标为浅蓝色。 ## 已完成的接口对齐 diff --git a/doc/05-implementation-plan.md b/doc/05-implementation-plan.md index 47b9500..79237ab 100644 --- a/doc/05-implementation-plan.md +++ b/doc/05-implementation-plan.md @@ -129,7 +129,7 @@ Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前 ## 阶段 7.5:视频片段传播(已完成基础闭环) -当前工作区传播功能会使用当前帧的选中 mask 或当前帧全部 mask 作为 seed,按用户设置的起止帧向前、向后或双向传播,并把结果写入后端标注表。“传播全部可达”会把范围设置为项目第 1 帧到最后 1 帧。 +当前工作区传播功能会使用当前打开参考帧的全部 mask 作为 seed,按用户设置的传播起始帧和传播结束帧向前、向后或双向传播,并把结果写入后端标注表。前端只保留一个“自动传播”按钮,减少传播对象选择带来的歧义。 已完成: @@ -138,7 +138,7 @@ Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前 3. SAM 2 路径使用官方 `SAM2VideoPredictor.add_new_mask()` 和 `propagate_in_video()`。 4. SAM 3 video tracker 路径已从当前产品入口禁用,相关 helper 仅保留作后续恢复参考。 5. 后端会跳过源帧,把传播结果保存到后续帧 `annotations`,并在完成后由前端刷新回显。 -6. 前端已经支持传播对象选择、起止帧范围和“传播全部可达”;多个 seed 或前后双向范围会拆成多次顺序调用单 seed 后端接口。 +6. 前端已经支持参考帧、起止帧范围和单按钮自动传播;多个 seed 或前后双向范围会拆成多次顺序调用单 seed 后端接口。 剩余建议: @@ -153,4 +153,4 @@ Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前 - SAM/GPU 状态已改为 `GET /api/ai/models/status` 驱动。 - 撤销/重做按钮已接全局 mask 历史栈。 - “重新提取内侧中轴树骨架”接真实接口,否则标为未实现。 -- AI 独立页不要固定 Unsplash 图,应从当前项目帧或上传文件进入。 +- AI 独立页已移除固定 Unsplash 演示图;没有当前项目帧时显示空状态。后续如果要支持独立图片分析,应接正式上传入口和项目/帧关联。 diff --git a/doc/07-current-requirements-freeze.md b/doc/07-current-requirements-freeze.md index 58ac972..96033e7 100644 --- a/doc/07-current-requirements-freeze.md +++ b/doc/07-current-requirements-freeze.md @@ -51,7 +51,7 @@ - 时间轴支持缩略图点击切帧、range 拖动切帧、键盘左右方向键切帧、播放/暂停顺序推进帧。 - 播放帧率使用项目 `parse_fps` 或 `original_fps`,限制在 1 到 30 FPS。 - 时间轴显示当前帧时间和总时长,时间基准使用项目 `parse_fps` 或 `original_fps`,格式为 `mm:ss.cc`。 -- 时间轴在顶部进度条上覆盖琥珀色竖线标记,基于当前项目帧内的 `masks` 标出已有编辑/标注的帧;当前帧位置由播放进度条末端、时间提示和缩略图高亮表达,点击已编辑竖线可跳转到对应帧。 +- 时间轴根据已保存标注回显的传播来源字段,把自动传播生成的帧在顶部进度条对应区段标为浅蓝色;不再使用竖线模式标记已编辑帧,当前帧位置由播放进度条末端、时间提示和缩略图高亮表达。 ## R5 工具栏 @@ -61,7 +61,7 @@ - 多边形、矩形、圆、点、线工具会在 Canvas 上生成可保存的 polygon mask。 - 多边形通过点击取点并按 Enter 完成,也支持三点后点击首节点闭合;矩形、圆、线通过拖拽生成;点工具生成小点区域。 - 绘制工具点击已有 mask 时应继续执行当前绘制动作,不应被 mask 选择逻辑吞掉。 -- 工具栏提供“调整多边形”工具,用户可以点击 mask 进入 polygon 顶点编辑态;拖动顶点会更新 mask 几何并把已保存 mask 标记为 dirty。 +- 工具栏提供“调整多边形”工具,用户可以点击 mask 进入 polygon 顶点编辑态;按住顶点即可直接拖动并实时更新 mask 几何,不需要先单击选中顶点,已保存 mask 会标记为 dirty。 - 顶点编辑态显示边中点插入手柄;点击边中点会在该边中间新增顶点。 - “调整多边形”工具下双击 polygon 边界时,会在最接近的线段上按双击位置新增顶点。 - 顶点编辑态下选中顶点后可用 Delete/Backspace 删除顶点,但不会让 polygon 少于三点。 @@ -69,7 +69,7 @@ - 撤销、重做绑定全局 `maskHistory/maskFuture`,支持工具栏按钮、AI 页按钮和 Canvas 快捷键。 - 区域合并工具支持多选当前帧 mask,并使用 polygon union 生成合并后的主 mask。 - 区域去除工具支持多选当前帧 mask,并从第一个选中的主 mask 中扣除后续选中 mask。 -- 区域合并/去除模式显示已选数量,并隐藏 polygon 编辑手柄以避免手柄抢占多选点击。 +- 区域合并/去除模式显示已选数量,并隐藏 polygon 编辑手柄以避免手柄抢占多选点击;第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓。 - 区域去除结果包含内洞时,前端保留 hole ring 并用 even-odd 规则渲染。 ## R6 AI 推理 @@ -98,9 +98,9 @@ - 工作区加载后端已保存标注时,必须保留当前项目帧里尚未保存的 AI/手工 draft mask,避免 AI 页推送到工作区的候选 mask 被异步回显流程覆盖。 - 语义文本提示 `semantic` 当前被后端禁用并返回 400。 - SAM 3 源码和历史测试保留,但不属于当前产品可用功能;前端不再展示 SAM 3 入口,后端 registry 不暴露 `sam3`。 -- 工作区传播功能允许选择传播对象:“选中区域”或“当前帧全部”;选中区域模式需要当前帧至少一个已选 mask,全部模式会使用当前帧所有 mask。 -- 工作区传播功能允许设置起止帧;前端以当前帧为 seed,只向起止范围内位于当前帧之前和之后的帧传播,源帧不重复保存。 -- 工作区“传播全部可达”会把起止帧设为项目第 1 帧到最后 1 帧,并按当前传播对象传播到所有可达前后帧。 +- 工作区传播功能以当前打开帧作为参考帧,并使用该帧全部 mask 作为 seed;用户不再选择“选中区域/当前帧全部”传播对象。 +- 工作区传播功能允许设置传播起始帧和传播结束帧;前端以当前参考帧为 seed,只向起止范围内位于参考帧之前和之后的帧传播,源帧不重复保存。 +- 工作区只保留一个“自动传播”按钮,点击后在指定范围内按前向/后向自动生成 mask。 - 前端复用单 seed 后端接口;多个 seed 或双向范围会被拆成多次顺序调用 `POST /api/ai/propagate`,避免并发抢占 GPU。 - `POST /api/ai/propagate` 当前支持四个 SAM 2.1 变体;兼容 `model=sam2` 并归一化为 tiny。SAM 2.1 使用官方 `SAM2VideoPredictor.add_new_mask()` 和 `propagate_in_video()`。 - 传播结果会写入后续帧 `annotations`,`mask_data.source` 标记为 `_propagation`,并保留 label、color 和 class 元数据。 @@ -136,11 +136,11 @@ ## R9 本体检查面板 - 工作区右侧可以选择模板。 -- 面板显示模板分类和组件本地自定义分类。 +- 面板显示模板分类;新增自定义分类会写入当前激活模板的后端 `mapping_rules.classes`。 - 用户可以选择具体分类;新 AI mask 会记录 `classId`、`className`、`classZIndex`,并在保存时写入 `mask_data.class`。 - 如果 Canvas 当前已经选中一个或多个 mask,点击语义分类树会把这些 mask 的 `label`、`color` 和 class 元数据改为该分类;已保存 mask 会进入 `dirty` 状态,归档保存时更新后端。 -- 添加自定义分类只存在组件本地状态,不保存到后端。 -- 置信度、拓扑锚点和重新提取骨架按钮当前为展示/占位。 +- 添加自定义分类需要先选择模板,保存时调用 `PATCH /api/templates/{id}` 并同步全局模板 store。 +- 选中 mask 后,置信度、拓扑锚点和重新提取拓扑锚点按钮调用 `POST /api/ai/analyze-mask`,不再显示固定占位值。 ## R10 Dashboard 与 WebSocket diff --git a/doc/08-current-design-freeze.md b/doc/08-current-design-freeze.md index ae60001..2ec2741 100644 --- a/doc/08-current-design-freeze.md +++ b/doc/08-current-design-freeze.md @@ -12,7 +12,7 @@ - FastAPI 后端 API。 - PostgreSQL、MinIO、Redis、SAM 2 等外部基础设施。SAM 3 相关源码保留,但当前产品入口禁用。 -开发时前端通过 `server.ts` 启动 Express + Vite middleware;后端通过 `backend/main.py` 启动 FastAPI。前端业务接口主要访问 FastAPI,不依赖 `server.ts` 中保留的旧 mock API。 +开发时前端通过 `server.ts` 启动 Express + Vite middleware;后端通过 `backend/main.py` 启动 FastAPI。前端业务接口访问 FastAPI,`server.ts` 不再保留旧版 `/api/*` mock。 ## 前端模块 @@ -30,8 +30,8 @@ | 工作区 | `src/components/VideoWorkspace.tsx` | 加载帧和模板,组织工具栏、Canvas、本体面板、时间轴 | | Canvas | `src/components/CanvasArea.tsx` | 显示帧、缩放平移、点/框提示、渲染 mask | | 工具栏 | `src/components/ToolsPalette.tsx` | 切换工具、跳转 AI 页面、触发 mask 撤销/重做 | -| 时间轴 | `src/components/FrameTimeline.tsx` | 帧导航、已编辑帧标记、左右方向键切帧、播放和当前/总时长显示 | -| 本体面板 | `src/components/OntologyInspector.tsx` | 模板选择、分类树、本地自定义分类 | +| 时间轴 | `src/components/FrameTimeline.tsx` | 帧导航、自动传播帧浅蓝区段标记、左右方向键切帧、播放和当前/总时长显示 | +| 本体面板 | `src/components/OntologyInspector.tsx` | 模板选择、分类树、后端自定义分类、mask 后端属性分析 | | AI 页面 | `src/components/AISegmentation.tsx` | 独立 AI 推理视图,使用当前项目帧 | | 模板库 | `src/components/TemplateRegistry.tsx` | 模板 CRUD、分类编辑、导入、排序 | @@ -103,7 +103,7 @@ 3. 帧数据映射为 store `Frame[]`,包含 `timestampMs` 和 `sourceFrameNumber`,供时间轴和后续视频传播使用。 4. 工作区调用 `GET /api/ai/annotations` 回显已保存标注时,会替换当前项目帧中的已保存 mask,但保留没有 `annotationId` 的未保存 draft mask;这保证 AI 页推送到工作区的候选 mask 不会被异步回显覆盖,并会在合并完成后恢复仍然存在的已选 mask id。 5. `CanvasArea` 会把全局 `selectedMaskIds` 中仍存在于当前帧的 id 同步回本地选区,避免帧初始化时的临时清空覆盖 AI 页推送过来的选中态。 -6. `FrameTimeline` 根据当前项目 `frames` 和全局 `masks` 计算有编辑/标注的帧,并在顶部时间进度条上覆盖可点击的琥珀色竖线;当前帧不额外渲染竖线,由播放进度条末端、时间提示和缩略图高亮表达。 +6. `FrameTimeline` 根据已保存标注回显到 `Mask.metadata` 的 `source` / `propagated_from_frame_id` 计算自动传播生成的帧,并在顶部时间进度条对应帧区段覆盖浅蓝色;当前帧不额外渲染竖线,由播放进度条末端、时间提示和缩略图高亮表达。 7. 当前帧传入 `CanvasArea`。 ### AI 点/框推理 @@ -125,7 +125,8 @@ 15. AI 页面候选 mask 删除只接受当前 `aiMaskIds` 范围内的已选 id;“删除选中候选”和 Delete/Backspace 都复用该范围过滤,避免删除工作区已有 mask。 16. AI 页面参数开关文案只做展示增强:“局部专注模式(自动裁剪无锚区域)”仍控制 `cropMode/crop_to_prompt`,“严格除杂模式(自动清理干涉点)”仍控制 `autoDeleteBg/auto_filter_background/min_score`。 17. AI 页面“遮罩清晰度”滑杆只调节候选 mask 的 Konva preview opacity,不写入 `Mask.segmentation`、分类元数据或后端 payload。 -18. Canvas 按当前帧过滤并渲染 mask。 +18. AI 画布左上角根据正向点、反向点、边界框选和视口控制显示上下文提示,说明点击/拖拽、删除提示点和执行推理的操作方式。 +19. Canvas 按当前帧过滤并渲染 mask。 19. 新 mask 会带上当前选择的模板分类元数据,包括 `classId`、`className`、`classZIndex`、`metadata.source=ai_segmentation` 和保存状态 `draft`。 20. 用户点击“结构化归档保存”后,前端将像素 `segmentation` 转成 normalized `mask_data.polygons`;未保存 mask 调用 `POST /api/ai/annotate`,dirty mask 调用 `PATCH /api/ai/annotations/{annotation_id}`;保存成功后本次提交的 draft mask id 会从本地保留列表中排除,并由后端 saved annotation 回显替换。 21. 工作区加载项目帧后通过 `GET /api/ai/annotations` 取回已保存标注并转成前端 mask。 @@ -133,31 +134,32 @@ ### 视频片段传播 -1. 用户在工作区选择传播对象:`selected` 表示当前帧已选 mask,`all` 表示当前帧所有 mask。 -2. 用户设置起止帧;“传播全部可达”会把起止帧设为 1 到项目总帧数。 -3. `VideoWorkspace` 以当前帧为 seed,将起止帧拆成 `backward` 和/或 `forward` 两段;只包含当前帧时不传播。 +1. 用户在工作区打开一帧作为参考帧;该帧全部 mask 都会作为传播 seed,不再提供传播对象下拉。 +2. 用户设置传播起始帧和传播结束帧,并点击唯一的“自动传播”按钮。 +3. `VideoWorkspace` 以当前参考帧为 seed,将起止帧拆成 `backward` 和/或 `forward` 两段;只包含当前帧时不传播。 4. `VideoWorkspace` 用 `buildAnnotationPayload()` 把每个 seed mask 转成 normalized polygon、bbox、label、color 和 class 元数据。 5. 前端对每个 seed、每个方向顺序调用 `POST /api/ai/propagate`,`include_source=false`、`save_annotations=true`;顺序调用是为了避免多个视频 tracker 并发抢占 GPU。 6. 后端按项目帧序列截取片段,下载对应帧到临时 `frame_%06d.jpg` 目录,保持当前帧在片段中的相对索引。 7. `model` 为任一 SAM 2.1 变体时,`sam2_engine` 使用对应 checkpoint/config 加载 `SAM2VideoPredictor.add_new_mask()` 注入 seed mask,再用 `propagate_in_video()` 传播。 8. `model=sam3` 当前不支持;SAM 3 video tracker 代码保留但没有接入产品路径。 9. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`,跳过源帧,`mask_data.source` 记录模型传播来源。 -8. 前端传播完成后重新调用 `GET /api/ai/annotations` 并回显新标注。 +10. 前端传播完成后重新调用 `GET /api/ai/annotations` 并回显新标注;`annotationToMask()` 会保留传播来源 metadata,供时间轴浅蓝色进度条区段显示。 ### 手工绘制与历史栈 1. 用户在 `ToolsPalette` 选择多边形、矩形、圆、点或线工具。 2. `CanvasArea` 将交互坐标转换成像素 polygon。 3. 多边形工具逐次记录节点,三点后点击首节点或按 Enter 时生成闭合 polygon。 -4. mask path 只在 `move`、`edit_polygon`、`area_merge` 和 `area_remove` 工具下拦截点击;绘制和 AI prompt 工具点击已有 mask 时继续冒泡给 Stage。 -5. 新 mask 写入 `pathData`、像素 `segmentation`、`bbox`、`area` 和当前模板分类元数据。 -6. `addMask()`、`setMasks()`、`updateMask()`、`clearMasks()` 会维护 `maskHistory/maskFuture`。 -7. 工具栏按钮、AI 页按钮和 Canvas Ctrl+Z/Ctrl+Y 调用 `undoMasks()` / `redoMasks()`。 +4. Canvas 左上角根据当前工具和操作阶段显示上下文提示;多边形提示会随已放置点数切换,明确 Enter 完成、Esc 取消和点击首节点闭合。 +5. mask path 只在 `move`、`edit_polygon`、`area_merge` 和 `area_remove` 工具下拦截点击;绘制和 AI prompt 工具点击已有 mask 时继续冒泡给 Stage。 +6. 新 mask 写入 `pathData`、像素 `segmentation`、`bbox`、`area` 和当前模板分类元数据。 +7. `addMask()`、`setMasks()`、`updateMask()`、`clearMasks()` 会维护 `maskHistory/maskFuture`。 +8. 工具栏按钮、AI 页按钮和 Canvas Ctrl+Z/Ctrl+Y 调用 `undoMasks()` / `redoMasks()`。 ### Polygon 逐点编辑 1. 用户选择“调整多边形”或“拖拽/选择”后点击 Canvas 上的 mask path,`CanvasArea` 记录 `selectedMaskId` 并显示该 mask 第一条 polygon 的顶点控制点和边中点插入手柄。 -2. 拖动顶点后,前端重算 `pathData`、像素 `segmentation`、`bbox`、`area`。 +2. 顶点 `mousedown/dragstart` 会立即设置当前顶点选择;拖动过程中通过 `dragMove` 实时重算 `pathData`、像素 `segmentation`、`bbox`、`area`,不需要先单击顶点再拖动。 3. 点击边中点手柄会在该边中点插入新顶点;在“调整多边形”工具下双击 polygon path 会在最接近的线段上按双击位置插入新顶点。 4. 如果 mask 已有 `annotationId`,编辑会把 `saveStatus` 标成 `dirty` 且 `saved=false`。 5. 归档保存时复用现有 `PATCH /api/ai/annotations/{annotation_id}` 链路,把更新后的 normalized polygon 写回后端。 @@ -168,10 +170,12 @@ 1. 用户选择 `area_merge` 或 `area_remove` 后,点击多个当前帧 mask 组成选择集。 2. 合并/去除模式隐藏 polygon 顶点和边中点编辑手柄,并在右下角显示已选数量;少于两个 mask 时操作按钮禁用。 -3. `CanvasArea` 把 `Mask.segmentation` 转为 `polygon-clipping` 的 MultiPolygon。 -4. `area_merge` 使用 union,更新第一个选中的主 mask,并从前端 store 移除后续被合并 mask;如果被移除 mask 已保存,会调用工作区传入的删除回调删除后端标注。 -5. `area_remove` 使用 difference,从第一个选中的主 mask 中扣除后续选中 mask,扣除对象本身保留;如果 difference 产生内洞,`segmentation` 保留外圈和 hole ring,渲染时使用 even-odd fill。 -6. 结果会重算 `pathData`、`segmentation`、`bbox`、`area`,已保存主 mask 会进入 dirty 状态并复用归档 PATCH 链路;带洞结果的面积按外圈减内洞计算。 +3. Canvas 左上角提示布尔选择顺序:第一个选中的是主区域,后续区域参与合并或扣除。 +4. 布尔选择态按选择顺序区分角色:第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓;所有已选区域填充透明度保持一致,避免被误解为阴影模式异常。 +5. `CanvasArea` 把 `Mask.segmentation` 转为 `polygon-clipping` 的 MultiPolygon。 +6. `area_merge` 使用 union,更新第一个选中的主 mask,并从前端 store 移除后续被合并 mask;如果被移除 mask 已保存,会调用工作区传入的删除回调删除后端标注。 +7. `area_remove` 使用 difference,从第一个选中的主 mask 中扣除后续选中 mask,扣除对象本身保留;如果 difference 产生内洞,`segmentation` 保留外圈和 hole ring,渲染时使用 even-odd fill。 +8. 结果会重算 `pathData`、`segmentation`、`bbox`、`area`,已保存主 mask 会进入 dirty 状态并复用归档 PATCH 链路;带洞结果的面积按外圈减内洞计算。 ### GT Mask 导入 @@ -197,7 +201,8 @@ 9. 工作区帧/标注异步加载完成后,`hydrateSavedAnnotations()` 会合并本地未保存 draft mask 和后端已保存 mask,不会用后端回显结果直接覆盖整个 `masks` store。 10. `OntologyInspector` 可以选择具体分类;选择结果进入全局 store,供 `CanvasArea` 和 `AISegmentation` 新建/更新 mask 时使用。 11. 如果 `selectedMaskIds` 中存在当前 store 的 mask,点击分类时会立即更新这些 mask 的 `templateId`、`classId`、`className`、`classZIndex`、`label` 和 `color`。 -12. 已保存 mask 被重新分类后进入 `dirty` 且 `saved=false`,继续复用工作区归档保存的 PATCH 链路。 +12. 同一次点击会把这些已选 mask 移动到前端 `masks` 数组末尾;`CanvasArea` 按数组顺序渲染,后渲染的 Path 显示在最上层,方便用户继续编辑刚换标签的区域。该显示置顶不改变模板 `zIndex` 或后端导出语义覆盖规则。 +13. 已保存 mask 被重新分类后进入 `dirty` 且 `saved=false`,继续复用工作区归档保存的 PATCH 链路。 ### 导出 @@ -251,5 +256,6 @@ - Dashboard 初始快照来自 `GET /api/dashboard/overview`;任务进度区由 `processing_tasks` queued/running/success/failed/cancelled 任务生成,处理中统计只计算 queued/running。 - 已保存标注支持通过“应用分类”、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑和区域合并/去除进入 dirty 状态并归档更新;选中整块 mask 可用 Delete/Backspace 删除并同步后端;复杂洞结构编辑尚未实现。 - SAM 3 文本语义分割已从当前产品路径中禁用;相关源码保留,恢复时需要重新接入前端入口、registry、状态接口和测试。 -- 自定义分类只存在本地组件状态。 +- 自定义分类通过 `PATCH /api/templates/{id}` 写入当前激活模板的 `mapping_rules.classes`。 +- 选中 mask 后,本体面板调用 `POST /api/ai/analyze-mask` 显示后端模型置信度、拓扑锚点数量、面积等属性;“重新提取拓扑锚点”会带 `extract_skeleton=true` 重新请求后端分析。 - GT mask 导入已完成多类别像素值拆分、contour、distance transform seed point 和前端 seed point 拖拽编辑;骨架提取、HDBSCAN 聚类和模板自动映射尚未实现。 diff --git a/doc/09-test-plan.md b/doc/09-test-plan.md index a9ad834..35ac5ef 100644 --- a/doc/09-test-plan.md +++ b/doc/09-test-plan.md @@ -17,12 +17,12 @@ | R1 登录与会话 | `src/components/Login.test.tsx`, `backend/tests/test_auth.py` | 成功登录、失败提示、后端 401 | | R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目卡片删除、DELETE 契约、后端 CRUD、删除级联、帧列表 | | R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、显式生成帧 FPS 选择、扩展名校验、自动建项目、关联项目、创建异步任务、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 | -| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、回显已保存标注时保留本地未保存 draft mask、缩略图/range/已编辑帧进度条竖线标记、当前帧由进度条末端和缩略图高亮表达/左右方向键切帧、播放、按项目 FPS 显示当前/总时长 | -| R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts` | 工具切换、调整多边形工具、AI 跳转、矩形/圆/线/点/多边形手工 mask 绘制、点工具在已有 mask 上落点、多边形 Enter/首节点闭合、polygon 顶点拖动/删除、边中点插点、双击边界按位置插点、整块 mask 删除、区域合并/去除、内含去除 hole 渲染、合并模式隐藏编辑手柄、工作区 SAM 提示点点击删除且不冒泡新增点、撤销/重做历史栈 | -| R6 AI 推理 | `src/lib/api.test.ts`, `src/components/CanvasArea.test.tsx`, `src/components/AISegmentation.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/components/ModelStatusBadge.test.tsx`, `backend/tests/test_ai.py`, `backend/tests/test_sam2_engine.py` | SAM 2.1 变体选择、点/框/interactive 契约、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、SAM 2.1 框选后正负点细化同一候选 mask、AI 页框选发送 box prompt、AI 页框选后加点发送 interactive prompt、AI 页重复执行替换旧候选、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可单点删除提示点并删除最近锚点、AI 页可删除选中候选且不删除工作区 mask、AI 页清空只移除本页候选、AI 页参数开关可读性文案且 options 字段不变、AI 页遮罩清晰度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页推送到工作区编辑保留选择、SAM 2.1 视频按选中/全量 seed 和起止帧范围传播、传播全部可达、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry | +| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、回显已保存标注时保留本地未保存 draft mask、缩略图/range/自动传播帧浅蓝进度条区段标记、当前帧由进度条末端和缩略图高亮表达/左右方向键切帧、播放、按项目 FPS 显示当前/总时长 | +| R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts` | 工具切换、调整多边形工具、AI 跳转、矩形/圆/线/点/多边形手工 mask 绘制、点工具在已有 mask 上落点、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合、polygon 顶点直接拖动/删除、边中点插点、双击边界按位置插点、整块 mask 删除、区域合并/去除、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 hole 渲染、合并模式隐藏编辑手柄、工作区 SAM 提示点点击删除且不冒泡新增点、撤销/重做历史栈 | +| R6 AI 推理 | `src/lib/api.test.ts`, `src/components/CanvasArea.test.tsx`, `src/components/AISegmentation.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/components/ModelStatusBadge.test.tsx`, `backend/tests/test_ai.py`, `backend/tests/test_sam2_engine.py` | SAM 2.1 变体选择、点/框/interactive 契约、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、SAM 2.1 框选后正负点细化同一候选 mask、AI 页框选发送 box prompt、AI 页框选后加点发送 interactive prompt、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、SAM 2.1 反向点启用背景过滤且空结果移除旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可单点删除提示点并删除最近锚点、AI 页可删除选中候选且不删除工作区 mask、AI 页清空只移除本页候选、AI 页参数开关可读性文案且 options 字段不变、AI 页遮罩清晰度只改预览 opacity、AI 页生成 mask 自动选中并可通过分类树换标签、AI 页推送到工作区编辑保留选择、SAM 2.1 视频以当前参考帧全部 mask 和起止帧范围自动传播、传播来源 metadata 回显、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry | | R7 标注保存 | `src/components/VideoWorkspace.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_ai.py` | 保存标注、保存后用后端 saved annotation 替换已提交 draft、加载回显、更新 dirty 标注、清空删除已保存标注、GT mask 多类别导入、seed point 回显/归一化、项目不存在、帧不存在 | | R8 模板库 | `src/components/TemplateRegistry.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_templates.py` | 前端模板加载/新建/编辑/删除、JSON 分类导入、mapping_rules 解包/打包、后端模板 CRUD | -| R9 本体检查面板 | `src/components/OntologyInspector.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts` | 模板选择、分类展示、具体分类选择、Canvas 选区同步、点击分类给已选 mask 换标签、自定义分类本地添加 | +| R9 本体检查面板 | `src/components/OntologyInspector.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_ai.py` | 模板选择、分类展示、具体分类选择、Canvas 选区同步、点击分类给已选 mask 换标签并移动到前端渲染最上层、自定义分类 PATCH 后端模板、选中 mask 后端属性分析、重新提取拓扑锚点 | | R10 Dashboard 与 WebSocket | `src/lib/api.test.ts`, `src/lib/websocket.test.ts`, `src/components/Dashboard.test.tsx`, `backend/tests/test_dashboard.py`, `backend/tests/test_main.py`, `backend/tests/test_progress_events.py`, `backend/tests/test_tasks.py` | 后端概览接口、任务表驱动进度区、最近完成任务保留显示、任务取消/重试/详情、cancelled 事件、Redis 进度事件 payload/发布、地址推导、消息订阅、连接状态回调、队列更新、heartbeat | | R11 导出 | `src/components/VideoWorkspace.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_export.py` | COCO/PNG 按钮下载、导出前自动保存、导出路径、JSON 结构、mask ZIP、zIndex 语义融合 | | R12 配置 | `src/lib/config.test.ts` | env 优先、hostname 推导、WS 推导 | @@ -35,13 +35,13 @@ | R1 | 登录页、默认开发凭证、token 写入、失败提示、后端 401 | `Login.test.tsx`, `test_auth.py` | 已覆盖 | | R2 | 项目列表/创建/选择、视频导入、DICOM 导入、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 | | R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、显式 FPS 生成帧、视频/DICOM 拆帧任务、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `test_media.py`, `test_tasks.py` | 已覆盖 | -| R4 | 工作区加载帧、无帧项目不自动解析、后端标注回显保留本地未保存 draft mask、Canvas 底图、缩略图/range/已编辑帧进度条竖线标记、当前帧由进度条末端和缩略图高亮表达/左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 | -| R5 | 工具切换、调整多边形入口、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 | -| R5 | 顶点编辑、边中点插点、双击边界按位置插点、顶点删除、整块删除、工作区 SAM 提示点删除优先级、撤销/重做、区域合并、区域去除、hole even-odd 渲染 | `CanvasArea.test.tsx`, `useStore.test.ts` | 已覆盖 | -| R6 | SAM 2.1 变体选择、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页框选/框选后加点、AI 页重复执行替换旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可删除提示点、AI 页可删除选中候选、AI 页清空只移除本页候选、AI 页遮罩清晰度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页推送到工作区编辑保留选择、SAM 2.1 视频按范围/全部可达传播、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.py`, `test_sam2_engine.py` | 已覆盖 | +| R4 | 工作区加载帧、无帧项目不自动解析、后端标注回显保留本地未保存 draft mask、Canvas 底图、缩略图/range/自动传播帧浅蓝进度条区段标记、当前帧由进度条末端和缩略图高亮表达/左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 | +| R5 | 工具切换、调整多边形入口、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制、多边形和布尔工具上下文提示 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 | +| R5 | 顶点直接拖动编辑、边中点插点、双击边界按位置插点、顶点删除、整块删除、工作区 SAM 提示点删除优先级、撤销/重做、区域合并、区域去除、布尔选择主区域黄色实线/扣除区域红色虚线、布尔选择顺序提示、hole even-odd 渲染 | `CanvasArea.test.tsx`, `useStore.test.ts` | 已覆盖 | +| R6 | SAM 2.1 变体选择、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页框选/框选后加点、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可删除提示点、AI 页可删除选中候选、AI 页清空只移除本页候选、AI 页遮罩清晰度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页推送到工作区编辑保留选择、SAM 2.1 视频按参考帧全部 mask 和范围自动传播、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.py`, `test_sam2_engine.py` | 已覆盖 | | R7 | 保存、保存后替换已提交 draft、查询、更新、删除标注、工作区回显、清空已保存标注、GT mask 导入和 seed point 回写 | `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `api.test.ts`, `test_ai.py` | 已覆盖 | | R8 | 模板加载、新建、编辑、删除、JSON 分类导入、mapping_rules 映射、后端 CRUD | `TemplateRegistry.test.tsx`, `api.test.ts`, `test_templates.py` | 已覆盖 | -| R9 | 模板选择、分类展示、分类选择、已选 mask 换标签、自定义本地分类、占位状态 | `OntologyInspector.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts` | 已覆盖 | +| R9 | 模板选择、分类展示、分类选择、已选 mask 换标签并置顶显示、自定义分类写入后端模板、后端属性分析、占位状态 | `OntologyInspector.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts`, `test_ai.py` | 已覆盖 | | R10 | Dashboard 概览、任务进度区、最近完成任务保留显示、活动日志、WebSocket progress/complete/error/status/cancelled、取消/重试/详情、连接状态回调、heartbeat | `Dashboard.test.tsx`, `websocket.test.ts`, `test_dashboard.py`, `test_main.py`, `test_progress_events.py`, `test_tasks.py` | 已覆盖 | | R11 | COCO/PNG ZIP 导出、导出前保存、路径和 JSON/ZIP 结构、zIndex 融合 | `VideoWorkspace.test.tsx`, `api.test.ts`, `test_export.py` | 已覆盖 | | R12 | API/WS 地址 env 优先和 hostname 推导 | `config.test.ts` | 已覆盖 | @@ -56,12 +56,12 @@ - R6:补充 `ModelStatusBadge.test.tsx` 中 SAM 3 不展示测试,避免禁用入口重新出现在前端。 - R6:补充后端 `selected_model=sam3` 拒绝测试和 semantic 禁用测试,避免后端继续暴露 SAM 3 产品能力。 - R6:补充 `POST /api/ai/propagate` 后端测试,验证 seed mask 传播结果会保存为后续帧标注并保留 class 元数据。 -- R6:补充 `propagateMasks()` API 封装和 `VideoWorkspace` 传播按钮测试,验证当前选中区域会发送到后端视频传播接口。 +- R6:补充 `propagateMasks()` API 封装和 `VideoWorkspace` 自动传播按钮测试,验证当前参考帧全部 mask 会按范围发送到后端视频传播接口。 - R6:`backend/tests/test_sam3_engine.py` 已标记跳过,仅作为历史保留实现的参考测试,不计入当前产品功能覆盖。 - R3:补充 `parseMedia()` 查询参数和后端拆帧任务 payload 测试,验证 `parse_fps`、`max_frames`、`target_width` 会进入任务。 - R3:补充 worker 注册标准帧序列测试,验证帧 `timestamp_ms`、`source_frame_number` 和 `result.frame_sequence` 元数据。 - R8:补充 `TemplateRegistry.test.tsx` 中模板编辑、删除测试,验证前端调用真实 API 封装并更新全局 store。 -- R9:补充 Canvas 选中 mask id 全局同步、本体树点击分类给已选 mask 换标签的测试,验证已保存 mask 会进入 dirty 状态。 +- R9:补充 Canvas 选中 mask id 全局同步、本体树点击分类给已选 mask 换标签并移到渲染最上层的测试,验证已保存 mask 会进入 dirty 状态。 ## 运行命令 diff --git a/server.ts b/server.ts index f20f3e7..b0afc05 100644 --- a/server.ts +++ b/server.ts @@ -8,40 +8,6 @@ async function startServer() { app.use(express.json()); - // In-memory data store for the backend - const dataStore = { - projects: [ - { id: 1, name: 'Autonomous_Nav_Cam_Left.mp4', frames: 1240, status: 'Ready', fps: 30, thumbnail: 'bg-zinc-800' }, - { id: 2, name: 'Store_Checkout_Aisle_1.mkv', frames: 450, status: 'Processing', fps: 15, thumbnail: 'bg-zinc-800' }, - { id: 3, name: 'Medical_Scans_Series_A', frames: 80, status: 'Ready', fps: '图像序列', thumbnail: 'bg-zinc-800' }, - { id: 4, name: 'Drone_Survey_Forest.mp4', frames: 3200, status: 'Ready', fps: 60, thumbnail: 'bg-zinc-800' }, - ], - templates: [ - { id: 1, name: 'Cityscapes_v2_Mapping', classes: 34, rules: 8 }, - { id: 2, name: 'Medical_Cell_Segment', classes: 4, rules: 2 }, - { id: 3, name: 'COCO_Panoptic_Base', classes: 133, rules: 12 }, - ] - }; - - // Auth endpoint - app.post("/api/login", (req, res) => { - const { username, password } = req.body; - if (username === "admin" && password === "123456") { - res.json({ token: "fake-jwt-token-for-admin" }); - } else { - res.status(401).json({ error: "Invalid credentials" }); - } - }); - - // Data endpoints - app.get("/api/projects", (req, res) => { - res.json(dataStore.projects); - }); - - app.get("/api/templates", (req, res) => { - res.json(dataStore.templates); - }); - // Vite middleware for development if (process.env.NODE_ENV !== "production") { const vite = await createViteServer({ diff --git a/src/components/AISegmentation.test.tsx b/src/components/AISegmentation.test.tsx index 737c0c4..5a526da 100644 --- a/src/components/AISegmentation.test.tsx +++ b/src/components/AISegmentation.test.tsx @@ -42,6 +42,30 @@ describe('AISegmentation', () => { expect(apiMock.getAiModelStatus).toHaveBeenCalledWith('sam2.1_hiera_tiny'); }); + it('does not render the legacy upload-replace-background mock button', () => { + render(); + + expect(screen.queryByText('上传替换底图')).not.toBeInTheDocument(); + }); + + it('shows an empty state instead of a demo image when no project frame is selected', () => { + useStore.setState({ frames: [] }); + + render(); + + expect(screen.getByText('请先在项目库选择项目并生成帧')).toBeInTheDocument(); + }); + + it('shows contextual guidance for prompt tools', () => { + render(); + + fireEvent.click(screen.getByText('正向选点')); + expect(screen.getByText(/点击目标内部添加正向点/)).toBeInTheDocument(); + + fireEvent.click(screen.getByText('边界框选')); + expect(screen.getByText(/按住并拖拽建立框选区域/)).toBeInTheDocument(); + }); + it('passes enabled inference parameters to the backend', async () => { apiMock.predictMask.mockResolvedValueOnce({ masks: [] }); render(); diff --git a/src/components/AISegmentation.tsx b/src/components/AISegmentation.tsx index 62bee73..d39d52e 100644 --- a/src/components/AISegmentation.tsx +++ b/src/components/AISegmentation.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useEffect } from 'react'; -import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Image as ImageIcon, Undo, Redo, Loader2, XCircle, Trash2 } from 'lucide-react'; +import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Undo, Redo, Loader2, XCircle, Trash2 } from 'lucide-react'; import { cn } from '../lib/utils'; import { Stage, Layer, Image as KonvaImage, Circle, Path, Group, Rect } from 'react-konva'; import useImage from 'use-image'; @@ -13,6 +13,7 @@ interface AISegmentationProps { type PromptPoint = { x: number; y: number; type: 'pos' | 'neg' }; type PromptBox = { x1: number; y1: number; x2: number; y2: number }; +type ToolHint = { title: string; body: string }; export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { const storeActiveTool = useStore((state) => state.activeTool); @@ -49,8 +50,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { const [boxCurrent, setBoxCurrent] = useState<{ x: number; y: number } | null>(null); const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }); const currentFrame = frames[currentFrameIndex] || null; - const previewUrl = currentFrame?.url || 'https://images.unsplash.com/photo-1549317661-bd32c8ce0be2?q=80&w=2070&auto=format&fit=crop'; - const [image] = useImage(previewUrl); + const [image] = useImage(currentFrame?.url || ''); const aiMaskIdSet = new Set(aiMaskIds); const frameMasks = currentFrame ? masks.filter((mask) => mask.frameId === currentFrame.id && aiMaskIdSet.has(mask.id)) @@ -59,6 +59,33 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { const modelCanInfer = selectedModelStatus?.available ?? true; const effectiveTool = storeActiveTool; + const toolHint = React.useMemo(() => { + if (!currentFrame) return null; + if (effectiveTool === 'point_pos') { + return { + title: '正向选点', + body: '点击目标内部添加正向点;点击已有提示点可删除。完成提示后点击“执行高精度语义分割”。', + }; + } + if (effectiveTool === 'point_neg') { + return { + title: '反向选点', + body: '点击不应包含的区域添加反向点;可和框选/正向点一起使用来细化结果。', + }; + } + if (effectiveTool === 'box_select') { + return { + title: promptBox ? '边界框已建立' : '边界框选', + body: promptBox + ? '当前框会随推理一起发送;也可以继续添加正向/反向点细化。重新拖拽会替换框。' + : '按住并拖拽建立框选区域,松开后保留框,再点击“执行高精度语义分割”。', + }; + } + if (effectiveTool === 'move') { + return { title: '视口控制', body: '拖拽移动画布,滚轮缩放;切回正向/反向点或框选后继续放置提示。' }; + } + return null; + }, [currentFrame, effectiveTool, promptBox]); const boxRect = React.useMemo(() => { const activeBox = boxStart && boxCurrent @@ -505,9 +532,6 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
- diff --git a/src/components/ToolsPalette.test.tsx b/src/components/ToolsPalette.test.tsx index 1426399..d425e16 100644 --- a/src/components/ToolsPalette.test.tsx +++ b/src/components/ToolsPalette.test.tsx @@ -37,7 +37,7 @@ describe('ToolsPalette', () => { const onTriggerAI = vi.fn(); render(); - fireEvent.click(screen.getByTitle('触发 SAM 推理 (Enter)')); + fireEvent.click(screen.getByTitle('打开 AI 智能分割')); expect(setActiveTool).toHaveBeenCalledWith('sam_trigger'); expect(onTriggerAI).toHaveBeenCalled(); diff --git a/src/components/ToolsPalette.tsx b/src/components/ToolsPalette.tsx index e7b4712..cfd344c 100644 --- a/src/components/ToolsPalette.tsx +++ b/src/components/ToolsPalette.tsx @@ -91,7 +91,7 @@ export function ToolsPalette({ setActiveTool('sam_trigger'); if (onTriggerAI) onTriggerAI(); }} - title="触发 SAM 推理 (Enter)" + title="打开 AI 智能分割" className={cn( "w-10 h-10 rounded-lg flex items-center justify-center transition-all", activeTool === 'sam_trigger' diff --git a/src/components/VideoWorkspace.test.tsx b/src/components/VideoWorkspace.test.tsx index 40c2501..a22a9a9 100644 --- a/src/components/VideoWorkspace.test.tsx +++ b/src/components/VideoWorkspace.test.tsx @@ -380,7 +380,7 @@ describe('VideoWorkspace', () => { ])); }); - it('propagates the selected current-frame mask through the configured frame range', async () => { + it('auto-propagates reference-frame masks through the configured frame range', async () => { apiMock.getProjectFrames.mockResolvedValueOnce([ { id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 }, { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 }, @@ -404,7 +404,6 @@ describe('VideoWorkspace', () => { useStore.setState({ aiModel: 'sam2.1_hiera_tiny', activeTemplateId: '2', - selectedMaskIds: ['mask-1'], masks: [{ id: 'mask-1', frameId: '10', @@ -417,7 +416,7 @@ describe('VideoWorkspace', () => { }); }); - fireEvent.click(screen.getByRole('button', { name: '按范围传播' })); + fireEvent.click(screen.getByRole('button', { name: '自动传播' })); await waitFor(() => expect(apiMock.propagateMasks).toHaveBeenCalledWith({ project_id: 1, @@ -437,10 +436,10 @@ describe('VideoWorkspace', () => { template_id: 2, }, })); - await waitFor(() => expect(screen.getByText('已传播 1 个 seed,处理 3 帧次,保存 2 个区域')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('已自动传播 1 个参考 mask,处理 3 帧次,保存 2 个区域')).toBeInTheDocument()); }); - it('propagates all current-frame masks to all reachable frames in both directions', async () => { + it('auto-propagates all reference-frame masks in both directions inside the selected range', async () => { apiMock.getProjectFrames.mockResolvedValueOnce([ { id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 }, { id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 }, @@ -502,8 +501,9 @@ describe('VideoWorkspace', () => { }); }); - fireEvent.change(screen.getByLabelText('传播对象'), { target: { value: 'all' } }); - fireEvent.click(screen.getByRole('button', { name: '传播全部可达' })); + fireEvent.change(screen.getByLabelText('传播起始帧'), { target: { value: '1' } }); + fireEvent.change(screen.getByLabelText('传播结束帧'), { target: { value: '3' } }); + fireEvent.click(screen.getByRole('button', { name: '自动传播' })); await waitFor(() => expect(apiMock.propagateMasks).toHaveBeenCalledTimes(4)); expect(apiMock.propagateMasks).toHaveBeenNthCalledWith(1, expect.objectContaining({ @@ -526,6 +526,6 @@ describe('VideoWorkspace', () => { max_frames: 2, seed: expect.objectContaining({ label: '肝脏' }), })); - await waitFor(() => expect(screen.getByText('已传播 2 个 seed,处理 8 帧次,保存 4 个区域')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('已自动传播 2 个参考 mask,处理 8 帧次,保存 4 个区域')).toBeInTheDocument()); }); }); diff --git a/src/components/VideoWorkspace.tsx b/src/components/VideoWorkspace.tsx index 1516593..d2a06b5 100644 --- a/src/components/VideoWorkspace.tsx +++ b/src/components/VideoWorkspace.tsx @@ -21,7 +21,6 @@ import { FrameTimeline } from './FrameTimeline'; import { ModelStatusBadge } from './ModelStatusBadge'; import type { Frame, Mask } from '../store/useStore'; -type PropagationTarget = 'selected' | 'all'; type PropagationDirection = 'forward' | 'backward'; export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }) { @@ -52,7 +51,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void const [isImportingGt, setIsImportingGt] = useState(false); const [isPropagating, setIsPropagating] = useState(false); const [statusMessage, setStatusMessage] = useState(''); - const [propagationTarget, setPropagationTarget] = useState('selected'); const [propagationStartFrame, setPropagationStartFrame] = useState(1); const [propagationEndFrame, setPropagationEndFrame] = useState(1); @@ -354,20 +352,16 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }; }, [activeTemplateId, currentFrame, currentProject?.id]); - const handlePropagateSegment = async (rangeOverride?: { startFrameNumber: number; endFrameNumber: number }) => { + const handleAutoPropagate = async () => { if (!currentProject?.id || !currentFrame?.id) return; - const currentFrameMasks = masks.filter((mask) => mask.frameId === currentFrame.id); - const selectedMasks = selectedMaskIds - .map((id) => currentFrameMasks.find((mask) => mask.id === id)) - .filter((mask): mask is Mask => Boolean(mask)); - const seedMasks = propagationTarget === 'all' ? currentFrameMasks : selectedMasks; + const seedMasks = masks.filter((mask) => mask.frameId === currentFrame.id); if (seedMasks.length === 0) { - setStatusMessage(propagationTarget === 'all' ? '当前帧没有可传播区域' : '请先选择一个或多个当前帧区域'); + setStatusMessage('请先在当前参考帧创建或保存至少一个 mask'); return; } - const startFrameNumber = clampFrameNumber(rangeOverride?.startFrameNumber ?? propagationStartFrame); - const endFrameNumber = clampFrameNumber(rangeOverride?.endFrameNumber ?? propagationEndFrame); + const startFrameNumber = clampFrameNumber(propagationStartFrame); + const endFrameNumber = clampFrameNumber(propagationEndFrame); const rangeStartIndex = Math.min(startFrameNumber, endFrameNumber) - 1; const rangeEndIndex = Math.max(startFrameNumber, endFrameNumber) - 1; const propagationDirections: Array<{ direction: PropagationDirection; maxFrames: number }> = []; @@ -397,7 +391,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void } setIsPropagating(true); - setStatusMessage(`${aiModel.toUpperCase()} 正在传播 ${seeds.length} 个区域到第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧...`); + setStatusMessage(`${aiModel.toUpperCase()} 正在以第 ${currentFrameNumber} 帧为参考,自动传播 ${seeds.length} 个 mask 到第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧...`); try { let createdCount = 0; let processedCount = 0; @@ -418,7 +412,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void } } await hydrateSavedAnnotations(currentProject.id, frames); - setStatusMessage(`已传播 ${seeds.length} 个 seed,处理 ${processedCount} 帧次,保存 ${createdCount} 个区域`); + setStatusMessage(`已自动传播 ${seeds.length} 个参考 mask,处理 ${processedCount} 帧次,保存 ${createdCount} 个区域`); } catch (err) { console.error('Propagation failed:', err); setStatusMessage('传播失败,请检查模型状态或后端日志'); @@ -427,16 +421,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void } }; - const handlePropagateAllReachable = () => { - if (totalFrames <= 1) { - setStatusMessage('当前项目没有可传播的前后帧'); - return; - } - setPropagationStartFrame(1); - setPropagationEndFrame(totalFrames); - void handlePropagateSegment({ startFrameNumber: 1, endFrameNumber: totalFrames }); - }; - return (
{/* Top Header / Status bar */} @@ -468,16 +452,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void {isImportingGt ? '导入中...' : '导入 GT Mask'}
- + 参考帧 {currentFrameNumber || 0} void />
-