Compare commits
13 Commits
docker-dep
...
docker-dep
| Author | SHA1 | Date | |
|---|---|---|---|
| 384822d3ea | |||
| 0ca1fed9d4 | |||
| a71c622df4 | |||
| 80e62596ee | |||
| bde9b24b88 | |||
| c3d2f4da06 | |||
| 079ae0d012 | |||
| 2ce5131d22 | |||
| 7187fb260f | |||
| abd8c73812 | |||
| d369674906 | |||
| 09f6137a8f | |||
| 12c263f0f6 |
15
.env
@@ -1,15 +1,15 @@
|
|||||||
# Copy this file to .env before running docker compose.
|
# Copy this file to .env before running docker compose.
|
||||||
# XXXX LAN access: set PUBLIC_HOST to the machine IP, for example 192.168.3.11.
|
# XXXX LAN access: set PUBLIC_HOST to the machine IP, for example 192.168.3.11.
|
||||||
# XXXX Public-domain access through frpc/frps + NPM: set PUBLIC_HOST to the external frontend host, for example seg.example.com.
|
# XXXX Public-domain access through frpc/frps + NPM: set PUBLIC_HOST to the external frontend host, for example seg.example.com.
|
||||||
PUBLIC_HOST=192.168.3.11
|
PUBLIC_HOST=seg.huijutec.cn
|
||||||
|
|
||||||
# XXXX Frontend build-time API/WebSocket endpoints.
|
# XXXX Frontend build-time API/WebSocket endpoints.
|
||||||
# LAN default can stay empty because the frontend infers http://<browser-host>:8000.
|
# LAN default can stay empty because the frontend infers http://<browser-host>:8000.
|
||||||
# Public-domain example:
|
# Public-domain example:
|
||||||
# VITE_API_BASE_URL=https://seg-api.example.com
|
# VITE_API_BASE_URL=https://seg-api.example.com
|
||||||
# VITE_WS_PROGRESS_URL=wss://seg-api.example.com/ws/progress
|
# VITE_WS_PROGRESS_URL=wss://seg-api.example.com/ws/progress
|
||||||
VITE_API_BASE_URL=
|
VITE_API_BASE_URL=https://seg-api.huijutec.cn
|
||||||
VITE_WS_PROGRESS_URL=
|
VITE_WS_PROGRESS_URL=wss://seg-api.huijutec.cn/ws/progress
|
||||||
|
|
||||||
FRONTEND_PORT=3000
|
FRONTEND_PORT=3000
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
@@ -24,10 +24,11 @@ MINIO_ACCESS_KEY=minioadmin
|
|||||||
MINIO_SECRET_KEY=minioadmin
|
MINIO_SECRET_KEY=minioadmin
|
||||||
|
|
||||||
# XXXX Browser-facing MinIO endpoint used to generate image/frame presigned URLs.
|
# XXXX Browser-facing MinIO endpoint used to generate image/frame presigned URLs.
|
||||||
# LAN example: 192.168.3.11:9000 and MINIO_SECURE=false
|
# LAN example: 192.168.3.11:9000 with MINIO_SECURE=false
|
||||||
# Public-domain example: seg-minio.example.com and MINIO_SECURE=true
|
# Public-domain example: seg-minio.example.com with MINIO_PUBLIC_SECURE=true
|
||||||
MINIO_PUBLIC_ENDPOINT=192.168.3.11:9000
|
MINIO_PUBLIC_ENDPOINT=seg-minio.huijutec.cn
|
||||||
MINIO_SECURE=false
|
MINIO_SECURE=false
|
||||||
|
MINIO_PUBLIC_SECURE=true
|
||||||
|
|
||||||
# Local directory containing SAM 2.1 checkpoints.
|
# Local directory containing SAM 2.1 checkpoints.
|
||||||
# Keep this relative path so the whole Seg_Server_Docker folder can be moved.
|
# Keep this relative path so the whole Seg_Server_Docker folder can be moved.
|
||||||
@@ -36,7 +37,7 @@ SAM_MODELS_DIR=./models
|
|||||||
# XXXX Must include every browser origin that will open the frontend.
|
# XXXX Must include every browser origin that will open the frontend.
|
||||||
# LAN example: ["http://192.168.3.11:3000","http://localhost:3000","http://127.0.0.1:3000"]
|
# LAN example: ["http://192.168.3.11:3000","http://localhost:3000","http://127.0.0.1:3000"]
|
||||||
# Public-domain example: ["https://seg.example.com"]
|
# Public-domain example: ["https://seg.example.com"]
|
||||||
CORS_ORIGINS=["http://192.168.3.11:3000","http://localhost:3000","http://127.0.0.1:3000"]
|
CORS_ORIGINS=["https://seg.huijutec.cn","http://seg.huijutec.cn","http://localhost:3000","http://127.0.0.1:3000"]
|
||||||
|
|
||||||
JWT_SECRET_KEY=change-this-to-a-long-random-production-secret
|
JWT_SECRET_KEY=change-this-to-a-long-random-production-secret
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ MINIO_ACCESS_KEY=minioadmin
|
|||||||
MINIO_SECRET_KEY=minioadmin
|
MINIO_SECRET_KEY=minioadmin
|
||||||
|
|
||||||
# XXXX Browser-facing MinIO endpoint used to generate image/frame presigned URLs.
|
# XXXX Browser-facing MinIO endpoint used to generate image/frame presigned URLs.
|
||||||
# LAN example: localhost:9000 and MINIO_SECURE=false
|
# LAN example: localhost:9000 with MINIO_SECURE=false
|
||||||
# Public-domain example: seg-minio.example.com and MINIO_SECURE=true
|
# Public-domain example: seg-minio.example.com with MINIO_PUBLIC_SECURE=true
|
||||||
MINIO_PUBLIC_ENDPOINT=localhost:9000
|
MINIO_PUBLIC_ENDPOINT=localhost:9000
|
||||||
MINIO_SECURE=false
|
MINIO_SECURE=false
|
||||||
|
# MINIO_PUBLIC_SECURE=true
|
||||||
|
|
||||||
# Local directory containing SAM 2.1 checkpoints.
|
# Local directory containing SAM 2.1 checkpoints.
|
||||||
# Keep ./models for a self-contained deploy, or point to another path only when deliberately sharing a model cache.
|
# Keep ./models for a self-contained deploy, or point to another path only when deliberately sharing a model cache.
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ MINIO_ACCESS_KEY=minioadmin
|
|||||||
MINIO_SECRET_KEY=minioadmin
|
MINIO_SECRET_KEY=minioadmin
|
||||||
MINIO_PUBLIC_ENDPOINT=localhost:9000
|
MINIO_PUBLIC_ENDPOINT=localhost:9000
|
||||||
MINIO_SECURE=false
|
MINIO_SECURE=false
|
||||||
|
# MINIO_PUBLIC_SECURE=true
|
||||||
|
|
||||||
SAM_MODELS_DIR=./models
|
SAM_MODELS_DIR=./models
|
||||||
|
|
||||||
@@ -344,7 +345,8 @@ VITE_WS_PROGRESS_URL=wss://seg-api.example.com/ws/progress
|
|||||||
|
|
||||||
# XXXX Browser-facing MinIO endpoint
|
# XXXX Browser-facing MinIO endpoint
|
||||||
MINIO_PUBLIC_ENDPOINT=seg-minio.example.com
|
MINIO_PUBLIC_ENDPOINT=seg-minio.example.com
|
||||||
MINIO_SECURE=true
|
MINIO_SECURE=false
|
||||||
|
MINIO_PUBLIC_SECURE=true
|
||||||
|
|
||||||
# XXXX Browser origins
|
# XXXX Browser origins
|
||||||
CORS_ORIGINS=["https://seg.example.com"]
|
CORS_ORIGINS=["https://seg.example.com"]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class Settings(BaseSettings):
|
|||||||
minio_access_key: str = "minioadmin"
|
minio_access_key: str = "minioadmin"
|
||||||
minio_secret_key: str = "minioadmin"
|
minio_secret_key: str = "minioadmin"
|
||||||
minio_secure: bool = False
|
minio_secure: bool = False
|
||||||
|
minio_public_secure: bool | None = None
|
||||||
|
|
||||||
# SAM
|
# SAM
|
||||||
sam_default_model: str = "sam2.1_hiera_tiny"
|
sam_default_model: str = "sam2.1_hiera_tiny"
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ def get_minio_public_client() -> Minio:
|
|||||||
global _minio_public_client
|
global _minio_public_client
|
||||||
if _minio_public_client is None:
|
if _minio_public_client is None:
|
||||||
endpoint = settings.minio_public_endpoint or settings.minio_endpoint
|
endpoint = settings.minio_public_endpoint or settings.minio_endpoint
|
||||||
|
secure = settings.minio_public_secure
|
||||||
|
if secure is None:
|
||||||
|
secure = settings.minio_secure
|
||||||
_minio_public_client = Minio(
|
_minio_public_client = Minio(
|
||||||
endpoint,
|
endpoint,
|
||||||
access_key=settings.minio_access_key,
|
access_key=settings.minio_access_key,
|
||||||
secret_key=settings.minio_secret_key,
|
secret_key=settings.minio_secret_key,
|
||||||
secure=settings.minio_secure,
|
secure=secure,
|
||||||
)
|
)
|
||||||
return _minio_public_client
|
return _minio_public_client
|
||||||
|
|
||||||
|
|||||||
@@ -139,11 +139,11 @@
|
|||||||
|
|
||||||
| 元素 | 状态 | 说明 |
|
| 元素 | 状态 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 模板选择 | 真实可用 | 读取全局 templates,可切换 activeTemplateId,并会驱动分类树、mask 分类和导出类别信息 |
|
| 模板选择 | 真实可用 | 读取全局 templates,可切换当前项目的 activeTemplateId,并会驱动分类树、mask 分类和导出类别信息;不同项目的激活模板在前端 store 中按项目 ID 独立保存,切换项目时不会沿用上一个项目的模板;若本会话没有项目模板记忆,会从已保存 mask 的 `templateId` 推断当前项目模板 |
|
||||||
| 面板滚动条 | 真实可用 | 右侧本体/语义分类面板内容过长时自身滚动;滚动条使用 `seg-scrollbar`,默认低对比融入深色侧栏,hover/focus 时才增强显示 |
|
| 面板滚动条 | 真实可用 | 右侧本体/语义分类面板内容过长时自身滚动;滚动条使用 `seg-scrollbar`,默认低对比融入深色侧栏,hover/focus 时才增强显示 |
|
||||||
| 面板标题 | 已简化 | 原“本体论与属性分类管理树”固定说明栏已移除,右侧面板直接展示模板、透明度和语义分类树 |
|
| 面板标题 | 已简化 | 原“本体论与属性分类管理树”固定说明栏已移除,右侧面板直接展示模板、透明度和语义分类树 |
|
||||||
| 分类树展示 / 换标签 | 真实可用 | 显示当前模板 classes;点击分类会设为后续新 mask 的 activeClass;如果 Canvas 无选中 mask,则不会改变已有 mask;如果 Canvas 已选 mask,则同步更新已选 mask 及同一传播链前后帧对应 mask 的标签、颜色和 class 元数据,并把已选 mask 移到前端渲染最上层;当用户在 Canvas 点击已有 mask 时,本面板会按 mask 的 class id / 名称自动切换模板、设置 active class,并滚动/聚焦到对应分类按钮 |
|
| 分类树展示 / 换标签 | 真实可用 | 显示当前模板 classes;点击分类会设为后续新 mask 的 activeClass;如果 Canvas 无选中 mask,则不会改变已有 mask;如果 Canvas 已选 mask,则同步更新已选 mask 及同一传播链前后帧对应 mask 的标签、颜色和 class 元数据,并把已选 mask 移到前端渲染最上层;当用户在 Canvas 点击已有 mask 时,本面板会按 mask 的 class id / 名称自动切换模板、设置 active class,并滚动/聚焦到对应分类按钮 |
|
||||||
| 添加自定义分类 | 真实可用 | 需要先选择模板;新增分类通过 `PATCH /api/templates/{id}` 写入后端模板 `mapping_rules.classes`,并同步全局模板 store |
|
| 新增分类 | 真实可用 | 需要先选择模板;新增分类通过 `PATCH /api/templates/{id}` 写入后端模板 `mapping_rules.classes`,并同步全局模板 store;面板显示可见颜色色块,打开新增表单时会随机选择一个不与当前模板已有类别重复的默认颜色 |
|
||||||
| 目标实例属性标题 | 真实可用 | “特定目标实例属性追踪”下方显示当前选中 mask 的 `className/label`,不再跟随全局 active class,避免点过其他分类后标题固定成旧分类 |
|
| 目标实例属性标题 | 真实可用 | “特定目标实例属性追踪”下方显示当前选中 mask 的 `className/label`,不再跟随全局 active class,避免点过其他分类后标题固定成旧分类 |
|
||||||
| 当前选中区域计数 | 已移除 | 当前交互以单选 mask 为主,计数长期为 1,属于低价值信息,已从实例属性面板删除 |
|
| 当前选中区域计数 | 已移除 | 当前交互以单选 mask 为主,计数长期为 1,属于低价值信息,已从实例属性面板删除 |
|
||||||
| 后端拓扑锚点数量 | 真实可用 | 选中 mask 后调用 `POST /api/ai/analyze-mask`,后端按 polygon 的真实顶点数量返回 `topology_anchor_count`;`topology_anchors` 列表只保留最多 64 个抽样点用于调试展示,避免把真实数量误压成十几个;前端会忽略被浏览器中止或已过期的分析请求,避免切换 mask、拖动平滑预览或卸载组件时出现误报 |
|
| 后端拓扑锚点数量 | 真实可用 | 选中 mask 后调用 `POST /api/ai/analyze-mask`,后端按 polygon 的真实顶点数量返回 `topology_anchor_count`;`topology_anchors` 列表只保留最多 64 个抽样点用于调试展示,避免把真实数量误压成十几个;前端会忽略被浏览器中止或已过期的分析请求,避免切换 mask、拖动平滑预览或卸载组件时出现误报 |
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
| 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 变体选择、模型不可用时 AI 页禁用不可用变体和执行按钮、工作区所有变体不可用时禁用 AI自动推理、点/框/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 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频以当前参考帧全部 mask 和起止帧范围自动传播、同类多实例按来源 id 分开传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空传播 seed 扣除 holes 后注入 SAM 2 且传播结果保留 holes、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、传播中轮询任务进度、传播任务取消/重试、传播来源 metadata 回显、空提示/空结果反馈、GPU/SAM2.1 状态、AI 参数 options、局部裁剪推理、背景过滤、状态徽标、坐标归一化、正负点 labels、polygons 转 path、后端 fake registry |
|
| 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 变体选择、模型不可用时 AI 页禁用不可用变体和执行按钮、工作区所有变体不可用时禁用 AI自动推理、点/框/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 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频以当前参考帧全部 mask 和起止帧范围自动传播、同类多实例按来源 id 分开传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播创建 Celery 任务、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空传播 seed 扣除 holes 后注入 SAM 2 且传播结果保留 holes、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、传播中轮询任务进度、传播任务取消/重试、传播来源 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` | 保存状态按钮“保存 X 个改动/已全部保存”、保存标注、保存后用后端 saved annotation 替换已提交 draft、加载回显、更新 dirty 标注、dirty 本地旧 annotationId 预检缺失时直接重新 POST 创建、预检后 PATCH 404 时重新 POST 创建并回显替换、中空 mask 保存为 `polygons` + `holes` 并可回显为 ring 分组、清空删除已保存标注、GT mask 多类别导入、高精度 GT contour、导入 mask 可直接拓扑统计和边缘平滑、后端 seed point 归一化兼容但前端不显示或拖动、缺失 seed point 的普通 polygon 保存时自动写入代表点、项目不存在、帧不存在 |
|
| R7 标注保存 | `src/components/VideoWorkspace.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_ai.py` | 保存状态按钮“保存 X 个改动/已全部保存”、保存标注、保存后用后端 saved annotation 替换已提交 draft、加载回显、更新 dirty 标注、dirty 本地旧 annotationId 预检缺失时直接重新 POST 创建、预检后 PATCH 404 时重新 POST 创建并回显替换、中空 mask 保存为 `polygons` + `holes` 并可回显为 ring 分组、清空删除已保存标注、GT mask 多类别导入、高精度 GT contour、导入 mask 可直接拓扑统计和边缘平滑、后端 seed point 归一化兼容但前端不显示或拖动、缺失 seed point 的普通 polygon 保存时自动写入代表点、项目不存在、帧不存在 |
|
||||||
| R8 模板库 | `src/components/TemplateRegistry.test.tsx`, `src/components/TransientNotice.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_templates.py` | 前端模板加载/新建/编辑/删除、删除模板站内确认、鼠标复制模板为私有副本、所有模板归一化包含黑色 `maskid:0`“待分类”保留类、保留类固定最后且不可删除/拖拽上移、详情页“语义分类树(拖拽调层级)”标题、详情页“编辑模板”按钮和编辑图标、详情页垃圾桶删除 label 且不显示来源标签、编辑弹窗分类编辑不显示旧 category 来源元信息、编辑后详情页刷新、详情页和编辑弹窗拖拽语义层级顺序、拖拽保存 `zIndex` 且不改变 maskid、JSON 分类导入预览、`[[colors],[names]]` 数组格式、`{colors,names}` 对象格式、带前缀/宽松 keys/中文标点粘贴格式、JSON 错误内联提示、保存错误非阻塞提示、mapping_rules 解包/打包、后端模板 CRUD |
|
| R8 模板库 | `src/components/TemplateRegistry.test.tsx`, `src/components/TransientNotice.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_templates.py` | 前端模板加载/新建/编辑/删除、删除模板站内确认、鼠标复制模板为私有副本、所有模板归一化包含黑色 `maskid:0`“待分类”保留类、保留类固定最后且不可删除/拖拽上移、详情页“语义分类树(拖拽调层级)”标题、详情页“编辑模板”按钮和编辑图标、详情页垃圾桶删除 label 且不显示来源标签、编辑弹窗分类编辑不显示旧 category 来源元信息、编辑后详情页刷新、详情页和编辑弹窗拖拽语义层级顺序、拖拽保存 `zIndex` 且不改变 maskid、JSON 分类导入预览、`[[colors],[names]]` 数组格式、`{colors,names}` 对象格式、带前缀/宽松 keys/中文标点粘贴格式、JSON 错误内联提示、保存错误非阻塞提示、mapping_rules 解包/打包、后端模板 CRUD |
|
||||||
| R9 本体检查面板 | `src/components/OntologyInspector.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_ai.py` | 模板选择、已有 mask 时切换激活模板需确认并清空所有 mask/标注、无 mask 时直接切换、面板标题简化、面板低对比滚动条、工作区遮罩透明度滑杆、分类展示、具体分类选择、无选中 mask 时点击分类只设置后续新建类别且不改已有 mask、模板类别删除后项目旧 mask 回显为 `maskid:0` 待分类、Canvas 选区同步、点击 Canvas mask 后自动聚焦对应语义分类、点击分类给已选 mask 换标签并移动到前端渲染最上层、分类变更同步同一传播链前后帧对应 mask、自定义分类 PATCH 后端模板、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、选中 mask 后端拓扑属性分析、拓扑锚点数量按真实 polygon 顶点数显示、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览不标 dirty、应用边缘平滑后将 mask 标记为 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑保存时保留传播 lineage 而不把传播帧变成人工/AI 标注帧、平滑撤销/重做、平滑应用后强度归零 |
|
| R9 本体检查面板 | `src/components/OntologyInspector.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/store/useStore.test.ts`, `src/lib/classColors.test.ts`, `backend/tests/test_ai.py` | 模板选择、已有 mask 时切换激活模板需确认并清空所有 mask/标注、无 mask 时直接切换、面板标题简化、面板低对比滚动条、工作区遮罩透明度滑杆、分类展示、具体分类选择、无选中 mask 时点击分类只设置后续新建类别且不改已有 mask、模板类别删除后项目旧 mask 回显为 `maskid:0` 待分类、Canvas 选区同步、点击 Canvas mask 后自动聚焦对应语义分类、点击分类给已选 mask 换标签并移动到前端渲染最上层、分类变更同步同一传播链前后帧对应 mask、新增分类 PATCH 后端模板、显示新增分类颜色色块、随机生成不与当前模板重复的新增分类默认颜色、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、选中 mask 后端拓扑属性分析、拓扑锚点数量按真实 polygon 顶点数显示、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览不标 dirty、应用边缘平滑后将 mask 标记为 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑保存时保留传播 lineage 而不把传播帧变成人工/AI 标注帧、平滑撤销/重做、平滑应用后强度归零 |
|
||||||
| 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、主动断开不重连 |
|
| 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` | 统一分割结果导出按钮使用导出图标和绿色强调背景、统一分割结果导出下拉、导出前自动保存、整体/范围/当前帧范围参数、特定范围帧可通过播放进度条/视频处理进度条拖拽选择、下载 ZIP 按项目名/`0h00m00s000ms` 起止时间戳/起止项目帧序号命名、导出内容 outputs 参数、Mix_label 透明度参数和预览、兼容 COCO/PNG 路径、JSON 结构、maskid/GT 像素值映射 JSON、原始图片文件夹、按帧/按类别合并的分开 Mask 文件夹、GT_label 黑白图文件夹、Pro_label 彩色图文件夹、Mix_label 原图叠加图文件夹、GT/Pro/Mix 按内部优先级覆盖且和语义分类树顺序一致、GT_label 固定 uint8、GT_label 背景 0、保留类别真实 maskid、`maskid:0` 待分类在 GT_label/Pro_label 中与背景同为黑色 0、正整数 maskid 超出 1-255 拒绝导出、导出 GT_label 再导入保持类别一致 |
|
| R11 导出 | `src/components/VideoWorkspace.test.tsx`, `src/lib/api.test.ts`, `backend/tests/test_export.py` | 统一分割结果导出按钮使用导出图标和绿色强调背景、统一分割结果导出下拉、导出前自动保存、整体/范围/当前帧范围参数、特定范围帧可通过播放进度条/视频处理进度条拖拽选择、下载 ZIP 按项目名/`0h00m00s000ms` 起止时间戳/起止项目帧序号命名、导出内容 outputs 参数、Mix_label 透明度参数和预览、兼容 COCO/PNG 路径、JSON 结构、maskid/GT 像素值映射 JSON、原始图片文件夹、按帧/按类别合并的分开 Mask 文件夹、GT_label 黑白图文件夹、Pro_label 彩色图文件夹、Mix_label 原图叠加图文件夹、GT/Pro/Mix 按内部优先级覆盖且和语义分类树顺序一致、GT_label 固定 uint8、GT_label 背景 0、保留类别真实 maskid、`maskid:0` 待分类在 GT_label/Pro_label 中与背景同为黑色 0、正整数 maskid 超出 1-255 拒绝导出、导出 GT_label 再导入保持类别一致 |
|
||||||
| R12 配置 | `src/lib/config.test.ts` | env 优先、hostname 推导、WS 推导 |
|
| R12 配置 | `src/lib/config.test.ts` | env 优先、hostname 推导、WS 推导 |
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
| R6 | SAM 2.1 变体选择、模型不可用时 AI 页禁用不可用变体和执行按钮、工作区所有变体不可用时禁用 AI自动推理、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页框选/框选后加点、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可删除提示点、AI 页可删除选中候选、AI 页清空只移除本页候选、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频按参考帧全部 mask 和范围自动传播、同类多实例按来源 id 分开传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播 Celery 任务入队、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空 seed holes 栅格化扣除和传播结果 holes 提取、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、前端任务轮询进度、传播任务 runner 保存标注和结果权重 id、传播任务重试、传播空结果提示、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.py`, `test_tasks.py`, `test_sam2_engine.py` | 已覆盖 |
|
| R6 | SAM 2.1 变体选择、模型不可用时 AI 页禁用不可用变体和执行按钮、工作区所有变体不可用时禁用 AI自动推理、点/框/interactive、semantic 禁用、SAM 3 入口隐藏和后端拒绝、SAM 2.1 最高分候选去重、AI 页框选/框选后加点、AI 页提示工具上下文提示、AI 页重复执行替换旧候选、AI 页不渲染工作区已有 mask、AI 页可在候选 mask 上继续添加正/反点、AI 页可删除提示点、AI 页可删除选中候选、AI 页清空只移除本页候选、AI 页/右侧共享遮罩透明度只改预览 opacity、AI 页生成 mask 自动选中并可换标签、AI 页无语义候选禁止推送到工作区并用 error toast 提示、离开 AI 页时清理未分类候选、AI 页推送到工作区编辑保留选择和当前帧、SAM 2.1 视频按参考帧全部 mask 和范围自动传播、同类多实例按来源 id 分开传播、当前参考帧无遮罩提示、传播前只保存参考帧 draft/dirty seed mask、传播前独立选择 SAM 2.1 tiny/small/base+/large 权重、自动传播 Celery 任务入队、传播入队权重 id 规范化/拒绝不支持 id、传播 seed 来源 id/签名和历史平滑 metadata 兼容、中空 seed holes 栅格化扣除和传播结果 holes 提取、历史平滑 seed 保存前对 forward/backward polygon 实际应用边缘平滑并减少密集轮廓点、边缘平滑强度缓入递进曲线、未编辑传播结果作为 seed 时继承原始签名并跳过重复传播、已编辑传播结果保留 lineage 但重算签名并清理旧结果、中间帧人工新增替代 seed 时清理下游同物体旧传播结果、中间帧 backward 传播清理旧 forward 结果、换权重传播先清理旧结果、旧临时 seed id 传播结果兼容清理、前端任务轮询进度、传播任务 runner 保存标注和结果权重 id、传播任务重试、传播空结果提示、GPU/模型状态、参数 options、polygons 转 mask | `api.test.ts`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx`, `VideoWorkspace.test.tsx`, `ModelStatusBadge.test.tsx`, `test_ai.py`, `test_tasks.py`, `test_sam2_engine.py` | 已覆盖 |
|
||||||
| R7 | 保存状态按钮“保存 X 个改动/已全部保存”、保存、保存后替换已提交 draft、查询、更新、dirty 本地旧 annotationId 的预检缺失直接重新创建和 PATCH 404 重新创建、删除标注、工作区回显、清空已保存标注、GT mask 导入和 seed point 数据兼容、导入 mask 不显示黄色 seed point、高精度 GT contour、导入 mask 拓扑统计和边缘平滑、8-bit 低数值 GT_label 图导入、16-bit/uint16 GT_label 图拒绝、全背景 0 GT_label 图拒绝并保留“没有非背景 maskid 区域”提示、RGB 等通道 maskid 图导入、导入预览、未知 maskid 导入策略、非法彩色 GT mask 拒绝、尺寸不一致自动最近邻拉伸 | `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `api.test.ts`, `test_ai.py` | 已覆盖 |
|
| R7 | 保存状态按钮“保存 X 个改动/已全部保存”、保存、保存后替换已提交 draft、查询、更新、dirty 本地旧 annotationId 的预检缺失直接重新创建和 PATCH 404 重新创建、删除标注、工作区回显、清空已保存标注、GT mask 导入和 seed point 数据兼容、导入 mask 不显示黄色 seed point、高精度 GT contour、导入 mask 拓扑统计和边缘平滑、8-bit 低数值 GT_label 图导入、16-bit/uint16 GT_label 图拒绝、全背景 0 GT_label 图拒绝并保留“没有非背景 maskid 区域”提示、RGB 等通道 maskid 图导入、导入预览、未知 maskid 导入策略、非法彩色 GT mask 拒绝、尺寸不一致自动最近邻拉伸 | `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `api.test.ts`, `test_ai.py` | 已覆盖 |
|
||||||
| R8 | 模板加载、新建、编辑、删除、删除模板站内确认、鼠标复制模板为私有副本并保留 maskid/颜色/层级/规则、所有模板归一化包含黑色 `maskid:0`“待分类”保留类、保留类固定最后且不可删除/拖拽上移、详情页标题/编辑模板按钮/垃圾桶删 label、编辑弹窗分类编辑不显示旧 category 来源元信息、默认模板“腹腔镜胆囊切除术”和“头颈部CT分割”幂等 seed、头颈部 CT 默认分类名纯中文且不带括号英文翻译、恢复出厂设置保留并权威恢复系统模板、默认模板缺失后重建、默认语义分类树被修改/删减后覆盖恢复、编辑后详情页刷新、详情页和编辑弹窗拖拽语义层级顺序、拖拽保存 `zIndex` 且不改变 maskid、JSON 分类导入预览、数组/对象/常见粘贴格式导入、JSON 错误内联提示、保存错误非阻塞提示、mapping_rules 映射、后端 CRUD | `TemplateRegistry.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_templates.py`, `test_admin.py` | 已覆盖 |
|
| R8 | 模板加载、新建、编辑、删除、删除模板站内确认、鼠标复制模板为私有副本并保留 maskid/颜色/层级/规则、所有模板归一化包含黑色 `maskid:0`“待分类”保留类、保留类固定最后且不可删除/拖拽上移、详情页标题/编辑模板按钮/垃圾桶删 label、编辑弹窗分类编辑不显示旧 category 来源元信息、默认模板“腹腔镜胆囊切除术”和“头颈部CT分割”幂等 seed、头颈部 CT 默认分类名纯中文且不带括号英文翻译、恢复出厂设置保留并权威恢复系统模板、默认模板缺失后重建、默认语义分类树被修改/删减后覆盖恢复、编辑后详情页刷新、详情页和编辑弹窗拖拽语义层级顺序、拖拽保存 `zIndex` 且不改变 maskid、JSON 分类导入预览、数组/对象/常见粘贴格式导入、JSON 错误内联提示、保存错误非阻塞提示、mapping_rules 映射、后端 CRUD | `TemplateRegistry.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_templates.py`, `test_admin.py` | 已覆盖 |
|
||||||
| R9 | 模板选择、面板标题简化、工作区遮罩透明度滑杆、分类展示、分类选择、模板类别删除后项目旧 mask 回显为 `maskid:0` 待分类、分类树拖拽调整内部覆盖顺序且不改变 maskid、拖拽后同步同类 mask 层级并标记待保存、点击 mask 自动聚焦对应分类、已选 mask 换标签并置顶显示、分类变更同步同一传播链前后帧对应 mask、自定义分类写入后端模板、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、后端拓扑属性分析、拓扑锚点真实顶点计数、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览、边缘平滑应用后确认 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑撤销/重做、平滑应用后强度归零、占位状态 | `OntologyInspector.test.tsx`, `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts`, `test_ai.py` | 已覆盖 |
|
| R9 | 模板选择、面板标题简化、工作区遮罩透明度滑杆、分类展示、分类选择、模板类别删除后项目旧 mask 回显为 `maskid:0` 待分类、分类树拖拽调整内部覆盖顺序且不改变 maskid、拖拽后同步同类 mask 层级并标记待保存、点击 mask 自动聚焦对应分类、已选 mask 换标签并置顶显示、分类变更同步同一传播链前后帧对应 mask、新增分类写入后端模板、新增分类颜色色块和默认颜色避重、目标实例标题显示当前 mask label、隐藏当前选中区域计数、隐藏后端模型置信度、后端拓扑属性分析、拓扑锚点真实顶点计数、分析请求 abort/cancel 静默忽略且旧请求不覆盖新状态、边缘平滑强度防抖预览、边缘平滑应用后确认 dirty、平滑作为实际几何编辑、平滑同步传播链对应 mask、平滑撤销/重做、平滑应用后强度归零、占位状态 | `OntologyInspector.test.tsx`, `VideoWorkspace.test.tsx`, `CanvasArea.test.tsx`, `useStore.test.ts`, `classColors.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` | 已覆盖 |
|
| 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 | 统一“分割结果导出”下拉、整体视频/特定范围帧/当前图片导出、特定范围帧时间轴拖拽选择、ZIP 文件名 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`、时间戳 `0h00m00s000ms` 格式、项目帧序号使用抽帧后 1-based 顺序、分开 Mask/GT_label/Pro_label/Mix_label outputs、Mix_label 透明度、导出前保存、兼容 COCO/PNG ZIP 路径、JSON/ZIP 结构、maskid/GT 像素值映射、原始图片导出、分开 Mask 按帧子目录与同类合并命名、GT_label/Pro_label/Mix_label 命名、GT/Pro/Mix 内部优先级融合且和语义分类树顺序一致、GT_label 固定 uint8、GT_label 背景 0、保留类别真实 maskid、`maskid:0` 待分类导出为黑色 0、正整数 maskid 超出 1-255 拒绝导出、导出的 GT_label 可按同一模板导回 | `VideoWorkspace.test.tsx`, `api.test.ts`, `test_export.py` | 已覆盖 |
|
| R11 | 统一“分割结果导出”下拉、整体视频/特定范围帧/当前图片导出、特定范围帧时间轴拖拽选择、ZIP 文件名 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`、时间戳 `0h00m00s000ms` 格式、项目帧序号使用抽帧后 1-based 顺序、分开 Mask/GT_label/Pro_label/Mix_label outputs、Mix_label 透明度、导出前保存、兼容 COCO/PNG ZIP 路径、JSON/ZIP 结构、maskid/GT 像素值映射、原始图片导出、分开 Mask 按帧子目录与同类合并命名、GT_label/Pro_label/Mix_label 命名、GT/Pro/Mix 内部优先级融合且和语义分类树顺序一致、GT_label 固定 uint8、GT_label 背景 0、保留类别真实 maskid、`maskid:0` 待分类导出为黑色 0、正整数 maskid 超出 1-255 拒绝导出、导出的 GT_label 可按同一模板导回 | `VideoWorkspace.test.tsx`, `api.test.ts`, `test_export.py` | 已覆盖 |
|
||||||
| R12 | API/WS 地址 env 优先和 hostname 推导 | `config.test.ts` | 已覆盖 |
|
| R12 | API/WS 地址 env 优先和 hostname 推导 | `config.test.ts` | 已覆盖 |
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
| `currentProject` / `frames` / `currentFrameIndex` | `src/store/useStore.ts` | 当前工作项目、帧序列和当前帧。 |
|
| `currentProject` / `frames` / `currentFrameIndex` | `src/store/useStore.ts` | 当前工作项目、帧序列和当前帧。 |
|
||||||
| `activeTool` | `src/store/useStore.ts` | 工作区当前工具。 |
|
| `activeTool` | `src/store/useStore.ts` | 工作区当前工具。 |
|
||||||
| `selectedMaskIds` | `src/store/useStore.ts` | 当前选中的 mask id 列表;Canvas、本体面板和 AI 页共享。 |
|
| `selectedMaskIds` | `src/store/useStore.ts` | 当前选中的 mask id 列表;Canvas、本体面板和 AI 页共享。 |
|
||||||
| `activeTemplateId` / `activeClass` | `src/store/useStore.ts` | 当前模板和后续新建 mask 使用的语义类别。 |
|
| `activeTemplateId` / `projectActiveTemplateIds` / `activeClass` | `src/store/useStore.ts` | 当前项目的激活模板、各项目独立保存的激活模板映射,以及后续新建 mask 使用的语义类别。 |
|
||||||
| `maskHistory` / `maskFuture` | `src/store/useStore.ts` | 撤销/重做栈。 |
|
| `maskHistory` / `maskFuture` | `src/store/useStore.ts` | 撤销/重做栈。 |
|
||||||
|
|
||||||
## 工作区工具自动机
|
## 工作区工具自动机
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
| 无选中 mask | 仅更新 `activeClass` | 后续新建 mask 使用该类别;已有 mask 不变 | `OntologyInspector.test.tsx` |
|
| 无选中 mask | 仅更新 `activeClass` | 后续新建 mask 使用该类别;已有 mask 不变 | `OntologyInspector.test.tsx` |
|
||||||
| 有选中 mask | 更新已选 mask 的 class/label/color;同传播链对应 mask 同步更新 | 已保存 mask 标记为 dirty;已选 mask 移到前端渲染数组末尾 | `OntologyInspector.test.tsx` |
|
| 有选中 mask | 更新已选 mask 的 class/label/color;同传播链对应 mask 同步更新 | 已保存 mask 标记为 dirty;已选 mask 移到前端渲染数组末尾 | `OntologyInspector.test.tsx` |
|
||||||
| 当前 mask 的类别被删除 | 工作区回显时降级为 `maskid:0` “待分类” | 保留几何并等待用户重新分类保存 | `VideoWorkspace.test.tsx` |
|
| 当前 mask 的类别被删除 | 工作区回显时降级为 `maskid:0` “待分类” | 保留几何并等待用户重新分类保存 | `VideoWorkspace.test.tsx` |
|
||||||
|
| 新增分类 | 打开新增表单时生成一个与当前模板已有颜色不同的默认色,并显示可见色块;保存后写入当前模板并设为当前 active class | 新建/后续 mask 使用新增类别 | `OntologyInspector.test.tsx`、`classColors.test.ts` |
|
||||||
|
|
||||||
## 键盘交互
|
## 键盘交互
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@
|
|||||||
| 交互 | 状态机 | 测试 |
|
| 交互 | 状态机 | 测试 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| 切换激活模板 | 无 mask 直接切换;有任意 mask 时弹确认;确认后删除项目所有本地/后端标注再切换;取消则保持原模板 | `OntologyInspector.test.tsx` |
|
| 切换激活模板 | 无 mask 直接切换;有任意 mask 时弹确认;确认后删除项目所有本地/后端标注再切换;取消则保持原模板 | `OntologyInspector.test.tsx` |
|
||||||
|
| 切换项目 | 项目 ID 变化时清空临时帧、mask、选区和撤销栈,并按项目 ID 恢复该项目上次使用的激活模板;若本会话没有该项目模板记忆,则从已保存 mask 的 `templateId` 推断项目模板;同一项目对象刷新名称/封面时不清空工作区 | `useStore.test.ts`、`templateSelection.test.ts` |
|
||||||
| 删除模板 | 站内确认后删除;系统默认模板可由演示恢复出厂设置恢复 | `TemplateRegistry.test.tsx`、后端模板/管理员测试 |
|
| 删除模板 | 站内确认后删除;系统默认模板可由演示恢复出厂设置恢复 | `TemplateRegistry.test.tsx`、后端模板/管理员测试 |
|
||||||
| 复制模板 | 鼠标点击复制入口,生成当前用户私有副本并保留分类颜色、maskid 和层级 | `TemplateRegistry.test.tsx` |
|
| 复制模板 | 鼠标点击复制入口,生成当前用户私有副本并保留分类颜色、maskid 和层级 | `TemplateRegistry.test.tsx` |
|
||||||
| 项目复制 | 项目删除按钮旁复制入口;可选“新项目重置”或“全内容复制” | `ProjectLibrary.test.tsx` |
|
| 项目复制 | 项目删除按钮旁复制入口;可选“新项目重置”或“全内容复制” | `ProjectLibrary.test.tsx` |
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles:
|
profiles:
|
||||||
- frpc
|
- frpc
|
||||||
command: ["-c", "/etc/frp/frpc.toml"]
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/frpc/frpc.toml:/etc/frp/frpc.toml:ro
|
- ./docker/frpc/frpc.toml:/etc/frp/frpc.toml:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -3,43 +3,41 @@
|
|||||||
# after replacing every "# XXXX" placeholder below.
|
# after replacing every "# XXXX" placeholder below.
|
||||||
|
|
||||||
# 基础连接配置
|
# 基础连接配置
|
||||||
# XXXX 公网 frps 服务器地址,例如 "1.2.3.4" 或 "frps.example.com"
|
serverAddr = "82.157.255.195"
|
||||||
serverAddr = "XX.XX.XX.XX"
|
|
||||||
# XXXX 公网 frps 服务端口,必须与 frps 的 bindPort 一致
|
# XXXX 公网 frps 服务端口,必须与 frps 的 bindPort 一致
|
||||||
serverPort = 7000
|
serverPort = 7000
|
||||||
|
|
||||||
# 权限验证 - 必须与服务端一致
|
# 权限验证 - 必须与服务端一致
|
||||||
auth.method = "token"
|
auth.method = "token"
|
||||||
# XXXX 必须替换为 frps 服务端配置中的 token
|
auth.token = "en.xjtu.edu.cn"
|
||||||
auth.token = "XXXXX"
|
|
||||||
|
|
||||||
# 传输配置优化
|
# 传输配置优化
|
||||||
transport.poolCount = 5
|
transport.poolCount = 5
|
||||||
transport.heartbeatTimeout = -1
|
transport.heartbeatTimeout = -1
|
||||||
|
|
||||||
# --- 映射前端访问端口 ---
|
# --- 映射前端访问端口 ---
|
||||||
# XXXX NPM 可反代公网服务器本机 remotePort 13000 到 seg.example.com
|
# NPM 可反代公网服务器本机 remotePort 10000 到 seg.huijutec.cn
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "seg-frontend"
|
name = "seg-frontend"
|
||||||
type = "tcp"
|
type = "tcp"
|
||||||
localIP = "frontend"
|
localIP = "frontend"
|
||||||
localPort = 80
|
localPort = 80
|
||||||
remotePort = 13000
|
remotePort = 10000
|
||||||
|
|
||||||
# --- 映射后端 API + WebSocket 端口 ---
|
# --- 映射后端 API + WebSocket 端口 ---
|
||||||
# XXXX NPM 可反代公网服务器本机 remotePort 18000 到 seg-api.example.com,并开启 WebSocket Support
|
# NPM 可反代公网服务器本机 remotePort 10001 到 seg-api.huijutec.cn,并开启 WebSocket Support
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "seg-backend"
|
name = "seg-backend"
|
||||||
type = "tcp"
|
type = "tcp"
|
||||||
localIP = "backend"
|
localIP = "backend"
|
||||||
localPort = 8000
|
localPort = 8000
|
||||||
remotePort = 18000
|
remotePort = 10001
|
||||||
|
|
||||||
# --- 映射 MinIO 图片/帧图/缩略图端口 ---
|
# --- 映射 MinIO 图片/帧图/缩略图端口 ---
|
||||||
# XXXX NPM 可反代公网服务器本机 remotePort 19000 到 seg-minio.example.com
|
# NPM 可反代公网服务器本机 remotePort 10002 到 seg-minio.huijutec.cn
|
||||||
[[proxies]]
|
[[proxies]]
|
||||||
name = "seg-minio"
|
name = "seg-minio"
|
||||||
type = "tcp"
|
type = "tcp"
|
||||||
localIP = "minio"
|
localIP = "minio"
|
||||||
localPort = 9000
|
localPort = 9000
|
||||||
remotePort = 19000
|
remotePort = 10002
|
||||||
|
|||||||
291
scripts/capture_core_feature_materials.mjs
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const BASE_URL = process.env.SEG_DEMO_URL || 'https://seg.huijutec.cn/';
|
||||||
|
const USERNAME = process.env.SEG_DEMO_USER || 'admin';
|
||||||
|
const PASSWORD = process.env.SEG_DEMO_PASSWORD || '123456';
|
||||||
|
const OUT_ROOT = process.env.SEG_DEMO_OUT_DIR || path.resolve('新撰写软著文档');
|
||||||
|
const IMAGE_DIR = path.join(OUT_ROOT, 'images');
|
||||||
|
const VIDEO_DIR = path.join(OUT_ROOT, '系统使用视频');
|
||||||
|
const viewport = { width: 1920, height: 1080 };
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const shouldSkipVideos = process.env.SEG_SKIP_VIDEO === '1';
|
||||||
|
|
||||||
|
async function ensureDirs() {
|
||||||
|
await fs.mkdir(IMAGE_DIR, { recursive: true });
|
||||||
|
await fs.mkdir(VIDEO_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 60000 });
|
||||||
|
await page.locator('input[type="text"]').first().fill(USERNAME);
|
||||||
|
await page.locator('input[type="password"]').first().fill(PASSWORD);
|
||||||
|
await page.getByRole('button', { name: /安全登录|登录/ }).click();
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {});
|
||||||
|
await sleep(1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shot(page, filename) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(IMAGE_DIR, filename),
|
||||||
|
fullPage: false,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
console.log(`screenshot ${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickTitle(page, title, settle = 1000) {
|
||||||
|
await page.getByTitle(title).first().click();
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {});
|
||||||
|
await sleep(settle);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkspace(page) {
|
||||||
|
await clickTitle(page, '项目库', 800);
|
||||||
|
await page.getByText('演视LC视频序列').first().click();
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {});
|
||||||
|
await sleep(2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProject(page, name) {
|
||||||
|
await clickTitle(page, '项目库', 800);
|
||||||
|
await page.getByText(name).first().click();
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {});
|
||||||
|
await sleep(1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canvasBox(page) {
|
||||||
|
const canvas = page.locator('.konvajs-content canvas').first();
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
if (!box) throw new Error('Canvas is not visible.');
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawPolygonDraft(page) {
|
||||||
|
await page.getByRole('button', { name: /肿瘤\/结节|胆囊/ }).first().click().catch(() => {});
|
||||||
|
await page.getByTitle('创建多边形 (P)').click();
|
||||||
|
await sleep(400);
|
||||||
|
const box = await canvasBox(page);
|
||||||
|
const points = [
|
||||||
|
[box.x + box.width * 0.38, box.y + box.height * 0.38],
|
||||||
|
[box.x + box.width * 0.57, box.y + box.height * 0.40],
|
||||||
|
[box.x + box.width * 0.52, box.y + box.height * 0.60],
|
||||||
|
[box.x + box.width * 0.35, box.y + box.height * 0.55],
|
||||||
|
];
|
||||||
|
for (const [x, y] of points) {
|
||||||
|
await page.mouse.click(x, y);
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completePolygon(page) {
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawRectangleMask(page) {
|
||||||
|
await page.getByTitle('创建矩形 (R)').click();
|
||||||
|
await sleep(300);
|
||||||
|
const box = await canvasBox(page);
|
||||||
|
await page.mouse.move(box.x + box.width * 0.32, box.y + box.height * 0.35);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x + box.width * 0.52, box.y + box.height * 0.55, { steps: 8 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await sleep(700);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawBrushStroke(page) {
|
||||||
|
await page.getByTitle('画笔').click();
|
||||||
|
await sleep(300);
|
||||||
|
const box = await canvasBox(page);
|
||||||
|
await page.mouse.move(box.x + box.width * 0.42, box.y + box.height * 0.45);
|
||||||
|
await page.mouse.down();
|
||||||
|
for (const [px, py] of [[0.43, 0.47], [0.45, 0.50], [0.48, 0.51], [0.50, 0.49]]) {
|
||||||
|
await page.mouse.move(box.x + box.width * px, box.y + box.height * py, { steps: 4 });
|
||||||
|
}
|
||||||
|
await page.mouse.up();
|
||||||
|
await sleep(800);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureCoreScreenshots() {
|
||||||
|
const browser = await chromium.launch({ headless: true, args: ['--window-size=1920,1080'] });
|
||||||
|
const context = await browser.newContext({ viewport, deviceScaleFactor: 1 });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await login(page);
|
||||||
|
await page.getByTitle(/当前用户.*点击退出/).first().hover().catch(async () => {
|
||||||
|
await page.mouse.move(32, 1020);
|
||||||
|
});
|
||||||
|
await sleep(800);
|
||||||
|
await shot(page, '38-logout-button-hover.png');
|
||||||
|
|
||||||
|
await clickTitle(page, '项目库', 800);
|
||||||
|
await shot(page, '39-project-library-refreshed.png');
|
||||||
|
|
||||||
|
await openProject(page, '演视DICOM序列');
|
||||||
|
await shot(page, '45-dicom-workspace-main.png');
|
||||||
|
await page.getByText('0005').first().click().catch(() => {});
|
||||||
|
await sleep(900);
|
||||||
|
await shot(page, '46-dicom-frame-switched.png');
|
||||||
|
|
||||||
|
await openWorkspace(page);
|
||||||
|
await shot(page, '24-workspace-current-frame-timeline.png');
|
||||||
|
|
||||||
|
await page.getByText('0003').first().click().catch(async () => {
|
||||||
|
const box = await page.locator('body').boundingBox();
|
||||||
|
if (box) await page.mouse.click(box.x + box.width * 0.55, box.y + box.height * 0.95);
|
||||||
|
});
|
||||||
|
await sleep(1200);
|
||||||
|
await shot(page, '32-workspace-current-frame-switched.png');
|
||||||
|
|
||||||
|
await drawPolygonDraft(page);
|
||||||
|
await shot(page, '25-create-polygon-vertices.png');
|
||||||
|
await completePolygon(page);
|
||||||
|
await shot(page, '26-create-polygon-completed.png');
|
||||||
|
await drawRectangleMask(page);
|
||||||
|
await shot(page, '40-workspace-rectangle-mask.png');
|
||||||
|
await shot(page, '47-semantic-tree-selected-mask-before.png');
|
||||||
|
await page.getByRole('button', { name: /下颌骨|甲状腺|气管/ }).first().click().catch(async () => {
|
||||||
|
await page.getByText('下颌骨').first().click().catch(() => {});
|
||||||
|
});
|
||||||
|
await sleep(700);
|
||||||
|
await shot(page, '48-semantic-tree-change-category.png');
|
||||||
|
await drawBrushStroke(page);
|
||||||
|
await shot(page, '41-workspace-brush-mask.png');
|
||||||
|
|
||||||
|
await page.getByTitle('AI自动推理').click();
|
||||||
|
await sleep(800);
|
||||||
|
await page.getByLabel('传播起始帧').fill('1').catch(() => {});
|
||||||
|
await page.getByLabel('传播结束帧').fill('12').catch(() => {});
|
||||||
|
await shot(page, '27-ai-auto-inference-range.png');
|
||||||
|
await shot(page, '33-ai-auto-inference-wide-range.png');
|
||||||
|
await page.getByRole('button', { name: '开始传播' }).click();
|
||||||
|
await sleep(1800);
|
||||||
|
await shot(page, '28-ai-auto-inference-running.png');
|
||||||
|
await page.waitForFunction(() => document.body.innerText.includes('已自动传播') || document.body.innerText.includes('没有生成新'), null, { timeout: 90000 }).catch(() => {});
|
||||||
|
await sleep(1000);
|
||||||
|
await shot(page, '29-ai-auto-inference-completed.png');
|
||||||
|
await page.getByText('0008').first().click().catch(() => {});
|
||||||
|
await sleep(1300);
|
||||||
|
const propagatedBox = await canvasBox(page);
|
||||||
|
await page.mouse.click(propagatedBox.x + propagatedBox.width * 0.46, propagatedBox.y + propagatedBox.height * 0.50);
|
||||||
|
await sleep(800);
|
||||||
|
await shot(page, '34-ai-auto-inference-result-selected.png');
|
||||||
|
|
||||||
|
await clickTitle(page, 'AI智能分割', 1800);
|
||||||
|
await page.getByRole('button', { name: '正向选点' }).click();
|
||||||
|
const box = await canvasBox(page);
|
||||||
|
await page.mouse.click(box.x + box.width * 0.48, box.y + box.height * 0.48);
|
||||||
|
await sleep(700);
|
||||||
|
await shot(page, '30-ai-segmentation-positive-point.png');
|
||||||
|
await page.getByRole('button', { name: '反向选点' }).click();
|
||||||
|
await page.mouse.click(box.x + box.width * 0.34, box.y + box.height * 0.40);
|
||||||
|
await sleep(700);
|
||||||
|
await shot(page, '35-ai-segmentation-negative-point.png');
|
||||||
|
await page.getByRole('button', { name: '执行高精度语义分割' }).click();
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const text = document.body.innerText;
|
||||||
|
return text.includes('AI Mask') || text.includes('候选') || text.includes('推送至工作区编辑');
|
||||||
|
}, null, { timeout: 90000 }).catch(() => {});
|
||||||
|
await sleep(1200);
|
||||||
|
await shot(page, '31-ai-segmentation-result.png');
|
||||||
|
await shot(page, '36-ai-segmentation-positive-negative-result.png');
|
||||||
|
await page.getByRole('button', { name: /清空全体锚点|清空所有锚点|清空/ }).click().catch(() => {});
|
||||||
|
await sleep(500);
|
||||||
|
await page.getByRole('button', { name: '边界框选' }).click();
|
||||||
|
await sleep(500);
|
||||||
|
const boxPrompt = await canvasBox(page);
|
||||||
|
await page.mouse.move(boxPrompt.x + boxPrompt.width * 0.38, boxPrompt.y + boxPrompt.height * 0.34);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(boxPrompt.x + boxPrompt.width * 0.68, boxPrompt.y + boxPrompt.height * 0.72, { steps: 8 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await sleep(800);
|
||||||
|
await shot(page, '43-ai-segmentation-box-prompt.png');
|
||||||
|
await page.getByRole('button', { name: '执行高精度语义分割' }).click();
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const text = document.body.innerText;
|
||||||
|
return text.includes('候选') || text.includes('推送至工作区编辑');
|
||||||
|
}, null, { timeout: 90000 }).catch(() => {});
|
||||||
|
await sleep(1200);
|
||||||
|
await shot(page, '44-ai-segmentation-box-result.png');
|
||||||
|
|
||||||
|
await page.getByTitle(/当前用户.*点击退出/).first().hover().catch(async () => {
|
||||||
|
await page.mouse.move(32, 1020);
|
||||||
|
});
|
||||||
|
await sleep(800);
|
||||||
|
await shot(page, '37-logout-hover.png');
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordVideo(name, action) {
|
||||||
|
const browser = await chromium.launch({ headless: true, args: ['--window-size=1920,1080'] });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport,
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
recordVideo: { dir: VIDEO_DIR, size: viewport },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await action(page);
|
||||||
|
await sleep(800);
|
||||||
|
const video = page.video();
|
||||||
|
await context.close();
|
||||||
|
await browser.close();
|
||||||
|
const videoPath = await video?.path();
|
||||||
|
if (!videoPath) return;
|
||||||
|
const finalPath = path.join(VIDEO_DIR, `${name}.webm`);
|
||||||
|
await fs.rm(finalPath, { force: true });
|
||||||
|
await fs.rename(videoPath, finalPath);
|
||||||
|
console.log(`video ${path.basename(finalPath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureVideos() {
|
||||||
|
await recordVideo('05-创建多边形标注演示', async (page) => {
|
||||||
|
await login(page);
|
||||||
|
await openWorkspace(page);
|
||||||
|
await drawPolygonDraft(page);
|
||||||
|
await sleep(900);
|
||||||
|
await completePolygon(page);
|
||||||
|
await sleep(1600);
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordVideo('06-AI自动推理传播演示', async (page) => {
|
||||||
|
await login(page);
|
||||||
|
await openWorkspace(page);
|
||||||
|
await drawPolygonDraft(page);
|
||||||
|
await completePolygon(page);
|
||||||
|
await page.getByTitle('AI自动推理').click();
|
||||||
|
await sleep(900);
|
||||||
|
await page.getByLabel('传播起始帧').fill('1').catch(() => {});
|
||||||
|
await page.getByLabel('传播结束帧').fill('12').catch(() => {});
|
||||||
|
await sleep(900);
|
||||||
|
await page.getByRole('button', { name: '开始传播' }).click();
|
||||||
|
await sleep(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordVideo('07-AI智能分割点选推理演示', async (page) => {
|
||||||
|
await login(page);
|
||||||
|
await openWorkspace(page);
|
||||||
|
await clickTitle(page, 'AI智能分割', 1800);
|
||||||
|
await page.getByRole('button', { name: '正向选点' }).click();
|
||||||
|
const box = await canvasBox(page);
|
||||||
|
await page.mouse.click(box.x + box.width * 0.48, box.y + box.height * 0.48);
|
||||||
|
await sleep(1000);
|
||||||
|
await page.getByRole('button', { name: '执行高精度语义分割' }).click();
|
||||||
|
await sleep(5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureDirs();
|
||||||
|
await captureCoreScreenshots();
|
||||||
|
if (!shouldSkipVideos) {
|
||||||
|
await captureVideos();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
367
scripts/capture_soft_copyright_materials.mjs
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import zlib from 'node:zlib';
|
||||||
|
|
||||||
|
const BASE_URL = process.env.SEG_DEMO_URL || 'https://seg.huijutec.cn/';
|
||||||
|
const USERNAME = process.env.SEG_DEMO_USER || 'admin';
|
||||||
|
const PASSWORD = process.env.SEG_DEMO_PASSWORD || '123456';
|
||||||
|
const OUT_ROOT = process.env.SEG_DEMO_OUT_DIR || path.resolve('新撰写软著文档');
|
||||||
|
const IMAGE_DIR = path.join(OUT_ROOT, 'images');
|
||||||
|
const VIDEO_DIR = path.join(OUT_ROOT, '系统使用视频');
|
||||||
|
const TMP_DIR = path.join(OUT_ROOT, '.capture-tmp');
|
||||||
|
|
||||||
|
const viewport = { width: 1920, height: 1080 };
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const shots = [
|
||||||
|
['01-login.png', '系统登录界面图'],
|
||||||
|
['02-dashboard.png', '系统登录后总体概况界面图'],
|
||||||
|
['03-main-layout.png', '系统主界面整体布局图'],
|
||||||
|
['04-dashboard-tasks.png', '后台任务列表及任务进度界面图'],
|
||||||
|
['05-project-library.png', '项目库列表界面图'],
|
||||||
|
['06-import-media-options.png', '导入视频与DICOM资源选择界面图'],
|
||||||
|
['07-project-copy-dialog.png', '项目复制操作界面图'],
|
||||||
|
['08-frame-parse-dialog.png', '视频生成帧配置界面图'],
|
||||||
|
['09-workspace-main.png', '分割工作区主界面图'],
|
||||||
|
['10-workspace-tools.png', '左侧标注工具栏与语义分类树界面图'],
|
||||||
|
['11-workspace-draw-mask.png', '多边形、矩形、圆形和画笔标注操作界面图'],
|
||||||
|
['12-workspace-auto-propagate-range.png', 'AI自动推理范围选择界面图'],
|
||||||
|
['13-workspace-export-dialog.png', '分割结果导出配置界面图'],
|
||||||
|
['14-gt-mask-import-preview.png', 'GT Mask导入预览界面图'],
|
||||||
|
['15-ai-page.png', 'AI智能分割模型选择界面图'],
|
||||||
|
['16-ai-prompt-tools.png', 'AI智能分割点选和框选工具界面图'],
|
||||||
|
['17-template-library.png', '模板库模板清单界面图'],
|
||||||
|
['18-template-edit-dialog.png', '模板分类树编辑界面图'],
|
||||||
|
['19-template-batch-import.png', '模板批量导入分类界面图'],
|
||||||
|
['20-user-admin.png', '管理员用户管理后台界面图'],
|
||||||
|
['21-user-create-dialog.png', '新增标注员账号界面图'],
|
||||||
|
['22-audit-reset-dialog.png', '审计日志和恢复演示出厂设置确认界面图'],
|
||||||
|
['23-logout.png', '退出登录按钮和返回登录界面图'],
|
||||||
|
];
|
||||||
|
|
||||||
|
async function ensureDirs() {
|
||||||
|
await fs.mkdir(IMAGE_DIR, { recursive: true });
|
||||||
|
await fs.mkdir(VIDEO_DIR, { recursive: true });
|
||||||
|
await fs.mkdir(TMP_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 60000 });
|
||||||
|
await page.locator('input[type="text"]').first().fill(USERNAME);
|
||||||
|
await page.locator('input[type="password"]').first().fill(PASSWORD);
|
||||||
|
await page.getByRole('button', { name: /安全登录|登录/ }).click();
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {});
|
||||||
|
await sleep(1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shot(page, filename) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(IMAGE_DIR, filename),
|
||||||
|
fullPage: false,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
console.log(`screenshot ${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickIfVisible(locator, timeout = 2500) {
|
||||||
|
try {
|
||||||
|
await locator.waitFor({ state: 'visible', timeout });
|
||||||
|
await locator.click();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeDialog(page) {
|
||||||
|
const cancelButtons = page.getByRole('button', { name: /^取消$/ });
|
||||||
|
const count = await cancelButtons.count().catch(() => 0);
|
||||||
|
if (count > 0) {
|
||||||
|
await cancelButtons.last().click().catch(() => {});
|
||||||
|
await sleep(500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.keyboard.press('Escape').catch(() => {});
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gotoModule(page, title, settle = 1200) {
|
||||||
|
await page.getByTitle(title).first().click();
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {});
|
||||||
|
await sleep(settle);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkspaceProject(page) {
|
||||||
|
await gotoModule(page, '项目库', 900);
|
||||||
|
await page.getByText('演视LC视频序列').first().click();
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {});
|
||||||
|
await sleep(2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawDemoMask(page) {
|
||||||
|
await page.getByRole('button', { name: /肿瘤\/结节/ }).first().click().catch(() => {});
|
||||||
|
await page.getByTitle('创建矩形 (R)').click();
|
||||||
|
await sleep(400);
|
||||||
|
const canvas = page.locator('.konvajs-content canvas').first();
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
if (!box) return;
|
||||||
|
const x1 = box.x + box.width * 0.36;
|
||||||
|
const y1 = box.y + box.height * 0.38;
|
||||||
|
const x2 = box.x + box.width * 0.55;
|
||||||
|
const y2 = box.y + box.height * 0.58;
|
||||||
|
await page.mouse.move(x1, y1);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(x2, y2, { steps: 12 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await sleep(900);
|
||||||
|
}
|
||||||
|
|
||||||
|
function crc32(buffer) {
|
||||||
|
let crc = -1;
|
||||||
|
for (const byte of buffer) {
|
||||||
|
crc ^= byte;
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (crc ^ -1) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pngChunk(type, data) {
|
||||||
|
const typeBuf = Buffer.from(type);
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(data.length, 0);
|
||||||
|
const crc = Buffer.alloc(4);
|
||||||
|
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
||||||
|
return Buffer.concat([length, typeBuf, data, crc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGtMaskPng(filePath) {
|
||||||
|
const width = 240;
|
||||||
|
const height = 160;
|
||||||
|
const raw = Buffer.alloc((width + 1) * height);
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
const row = y * (width + 1);
|
||||||
|
raw[row] = 0;
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
raw[row + 1 + x] = x > 50 && x < 185 && y > 35 && y < 125 ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(width, 0);
|
||||||
|
ihdr.writeUInt32BE(height, 4);
|
||||||
|
ihdr[8] = 8;
|
||||||
|
ihdr[9] = 0;
|
||||||
|
ihdr[10] = 0;
|
||||||
|
ihdr[11] = 0;
|
||||||
|
ihdr[12] = 0;
|
||||||
|
const png = Buffer.concat([
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
pngChunk('IHDR', ihdr),
|
||||||
|
pngChunk('IDAT', zlib.deflateSync(raw)),
|
||||||
|
pngChunk('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
await fs.writeFile(filePath, png);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureScreenshots() {
|
||||||
|
const browser = await chromium.launch({ headless: true, args: ['--window-size=1920,1080'] });
|
||||||
|
const context = await browser.newContext({ viewport, deviceScaleFactor: 1 });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 60000 });
|
||||||
|
await sleep(1000);
|
||||||
|
await shot(page, '01-login.png');
|
||||||
|
await login(page);
|
||||||
|
await shot(page, '02-dashboard.png');
|
||||||
|
await shot(page, '03-main-layout.png');
|
||||||
|
await shot(page, '04-dashboard-tasks.png');
|
||||||
|
|
||||||
|
await gotoModule(page, '项目库');
|
||||||
|
await shot(page, '05-project-library.png');
|
||||||
|
await page.getByRole('button', { name: '导入多媒体资源' }).click();
|
||||||
|
await sleep(700);
|
||||||
|
await shot(page, '06-import-media-options.png');
|
||||||
|
await page.getByRole('button', { name: '导入多媒体资源' }).click().catch(() => {});
|
||||||
|
await sleep(500);
|
||||||
|
await page.getByTitle('复制项目').first().click();
|
||||||
|
await sleep(700);
|
||||||
|
await shot(page, '07-project-copy-dialog.png');
|
||||||
|
await closeDialog(page);
|
||||||
|
await page.getByRole('button', { name: /重新生成帧|生成帧/ }).first().click();
|
||||||
|
await sleep(900);
|
||||||
|
await shot(page, '08-frame-parse-dialog.png');
|
||||||
|
await closeDialog(page);
|
||||||
|
|
||||||
|
await openWorkspaceProject(page);
|
||||||
|
await shot(page, '09-workspace-main.png');
|
||||||
|
await shot(page, '10-workspace-tools.png');
|
||||||
|
await drawDemoMask(page);
|
||||||
|
await shot(page, '11-workspace-draw-mask.png');
|
||||||
|
await page.getByTitle('AI自动推理').click();
|
||||||
|
await sleep(1000);
|
||||||
|
await shot(page, '12-workspace-auto-propagate-range.png');
|
||||||
|
await page.keyboard.press('Escape').catch(() => {});
|
||||||
|
await sleep(500);
|
||||||
|
await page.getByRole('button', { name: '分割结果导出' }).click();
|
||||||
|
await sleep(900);
|
||||||
|
await shot(page, '13-workspace-export-dialog.png');
|
||||||
|
await closeDialog(page);
|
||||||
|
|
||||||
|
const gtPath = path.join(TMP_DIR, 'gt_mask_preview.png');
|
||||||
|
await createGtMaskPng(gtPath);
|
||||||
|
const fileChooserPromise = page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => null);
|
||||||
|
await page.getByTitle('导入 GT Mask').click();
|
||||||
|
const fileChooser = await fileChooserPromise;
|
||||||
|
if (fileChooser) {
|
||||||
|
await fileChooser.setFiles(gtPath);
|
||||||
|
await sleep(2200);
|
||||||
|
await shot(page, '14-gt-mask-import-preview.png');
|
||||||
|
await closeDialog(page);
|
||||||
|
} else {
|
||||||
|
await shot(page, '14-gt-mask-import-preview.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
await gotoModule(page, 'AI智能分割', 1600);
|
||||||
|
await shot(page, '15-ai-page.png');
|
||||||
|
await page.getByRole('button', { name: '正向选点' }).click().catch(() => {});
|
||||||
|
await sleep(500);
|
||||||
|
await shot(page, '16-ai-prompt-tools.png');
|
||||||
|
|
||||||
|
await gotoModule(page, '模板库', 1500);
|
||||||
|
await shot(page, '17-template-library.png');
|
||||||
|
await page.getByRole('button', { name: '编辑模板' }).last().click();
|
||||||
|
await sleep(900);
|
||||||
|
await shot(page, '18-template-edit-dialog.png');
|
||||||
|
await page.getByRole('button', { name: /批量导入/ }).click().catch(() => {});
|
||||||
|
await sleep(700);
|
||||||
|
const textareas = page.locator('textarea');
|
||||||
|
if ((await textareas.count()) > 0) {
|
||||||
|
await textareas.last().fill('{"colors":[[255,0,0],[0,255,0]],"names":["示例类别一","示例类别二"]}').catch(() => {});
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
await shot(page, '19-template-batch-import.png');
|
||||||
|
await closeDialog(page);
|
||||||
|
|
||||||
|
await gotoModule(page, '用户管理', 1500);
|
||||||
|
await shot(page, '20-user-admin.png');
|
||||||
|
await page.getByRole('button', { name: '新增用户' }).click();
|
||||||
|
await sleep(900);
|
||||||
|
await shot(page, '21-user-create-dialog.png');
|
||||||
|
await closeDialog(page);
|
||||||
|
await page.getByRole('button', { name: '恢复演示出厂设置' }).click();
|
||||||
|
await sleep(900);
|
||||||
|
await shot(page, '22-audit-reset-dialog.png');
|
||||||
|
await closeDialog(page);
|
||||||
|
await page.getByTitle('当前用户:admin,点击退出').hover();
|
||||||
|
await sleep(700);
|
||||||
|
await page.getByTitle('当前用户:admin,点击退出').click();
|
||||||
|
await sleep(1200);
|
||||||
|
await shot(page, '23-logout.png');
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordVideo(name, action) {
|
||||||
|
const browser = await chromium.launch({ headless: true, args: ['--window-size=1920,1080'] });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport,
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
recordVideo: { dir: VIDEO_DIR, size: viewport },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await action(page);
|
||||||
|
await sleep(800);
|
||||||
|
const video = page.video();
|
||||||
|
await context.close();
|
||||||
|
await browser.close();
|
||||||
|
const videoPath = await video?.path();
|
||||||
|
if (!videoPath) return null;
|
||||||
|
const finalPath = path.join(VIDEO_DIR, `${name}.webm`);
|
||||||
|
await fs.rm(finalPath, { force: true });
|
||||||
|
await fs.rename(videoPath, finalPath);
|
||||||
|
console.log(`video ${path.basename(finalPath)}`);
|
||||||
|
return finalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureVideos() {
|
||||||
|
await recordVideo('01-登录与总体概况演示', async (page) => {
|
||||||
|
await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 60000 });
|
||||||
|
await sleep(1200);
|
||||||
|
await login(page);
|
||||||
|
await sleep(1800);
|
||||||
|
await gotoModule(page, '总体概况', 2400);
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordVideo('02-项目库与分割工作区演示', async (page) => {
|
||||||
|
await login(page);
|
||||||
|
await gotoModule(page, '项目库', 1800);
|
||||||
|
await page.getByRole('button', { name: '导入多媒体资源' }).click();
|
||||||
|
await sleep(1400);
|
||||||
|
await page.getByRole('button', { name: '导入多媒体资源' }).click().catch(() => {});
|
||||||
|
await sleep(600);
|
||||||
|
await page.getByText('演视LC视频序列').first().click();
|
||||||
|
await sleep(2600);
|
||||||
|
await drawDemoMask(page);
|
||||||
|
await sleep(1600);
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordVideo('03-AI推理与结果导出演示', async (page) => {
|
||||||
|
await login(page);
|
||||||
|
await openWorkspaceProject(page);
|
||||||
|
await page.getByTitle('AI自动推理').click();
|
||||||
|
await sleep(2000);
|
||||||
|
await page.keyboard.press('Escape').catch(() => {});
|
||||||
|
await page.getByRole('button', { name: '分割结果导出' }).click();
|
||||||
|
await sleep(2200);
|
||||||
|
await closeDialog(page);
|
||||||
|
await gotoModule(page, 'AI智能分割', 2600);
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordVideo('04-模板库与用户管理演示', async (page) => {
|
||||||
|
await login(page);
|
||||||
|
await gotoModule(page, '模板库', 2200);
|
||||||
|
await page.getByRole('button', { name: '编辑模板' }).last().click();
|
||||||
|
await sleep(1800);
|
||||||
|
await closeDialog(page);
|
||||||
|
await gotoModule(page, '用户管理', 2200);
|
||||||
|
await page.getByRole('button', { name: '新增用户' }).click();
|
||||||
|
await sleep(1800);
|
||||||
|
await closeDialog(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureDirs();
|
||||||
|
await captureScreenshots();
|
||||||
|
await captureVideos();
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(OUT_ROOT, '功能验证与素材清单.md'),
|
||||||
|
[
|
||||||
|
'# 功能验证与素材清单',
|
||||||
|
'',
|
||||||
|
`验证地址:${BASE_URL}`,
|
||||||
|
`验证时间:${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
|
||||||
|
'',
|
||||||
|
'## 截图文件',
|
||||||
|
...shots.map(([file, title]) => `- images/${file}:${title}`),
|
||||||
|
'',
|
||||||
|
'## 分段视频',
|
||||||
|
'- 系统使用视频/01-登录与总体概况演示.webm',
|
||||||
|
'- 系统使用视频/02-项目库与分割工作区演示.webm',
|
||||||
|
'- 系统使用视频/03-AI推理与结果导出演示.webm',
|
||||||
|
'- 系统使用视频/04-模板库与用户管理演示.webm',
|
||||||
|
'',
|
||||||
|
'## 验证说明',
|
||||||
|
'本次验证以管理员账号进入线上系统,逐项检查登录、总体概况、项目库、分割工作区、AI 智能分割、AI 自动推理入口、GT Mask 导入预览、分割结果导出、模板库、用户管理、审计日志和退出登录等说明书涉及功能。删除项目、恢复演示出厂设置、生成帧确认、导出下载确认等可能改变演示环境或产生下载文件的危险提交动作仅验证入口与确认界面,不执行最终提交。',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
93
scripts/record_usage_video.mjs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const BASE_URL = process.env.SEG_DEMO_URL || 'https://seg.huijutec.cn/';
|
||||||
|
const USERNAME = process.env.SEG_DEMO_USER || 'admin';
|
||||||
|
const PASSWORD = process.env.SEG_DEMO_PASSWORD || '123456';
|
||||||
|
const OUT_DIR = process.env.SEG_DEMO_OUT_DIR || path.resolve('新撰写软著文档/系统使用视频');
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
async function clickIfVisible(locator, timeout = 3000) {
|
||||||
|
try {
|
||||||
|
await locator.waitFor({ state: 'visible', timeout });
|
||||||
|
await locator.click();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showModule(page, title, dwell = 2200) {
|
||||||
|
await clickIfVisible(page.getByTitle(title).first(), 5000);
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {});
|
||||||
|
await sleep(dwell);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--window-size=1920,1080'],
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
recordVideo: {
|
||||||
|
dir: OUT_DIR,
|
||||||
|
size: { width: 1920, height: 1080 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 60000 });
|
||||||
|
await sleep(1200);
|
||||||
|
|
||||||
|
const usernameInput = page.locator('input[type="text"]').first();
|
||||||
|
const passwordInput = page.locator('input[type="password"]').first();
|
||||||
|
await usernameInput.fill(USERNAME);
|
||||||
|
await sleep(400);
|
||||||
|
await passwordInput.fill(PASSWORD);
|
||||||
|
await sleep(500);
|
||||||
|
await page.getByRole('button', { name: /安全登录|登录/ }).click();
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {});
|
||||||
|
await sleep(2500);
|
||||||
|
|
||||||
|
await showModule(page, '总体概况', 2500);
|
||||||
|
await showModule(page, '项目库', 2500);
|
||||||
|
|
||||||
|
const demoProject = page.getByText(/演视LC视频序列|演视DICOM序列|LC视频序列|DICOM序列/).first();
|
||||||
|
if (await clickIfVisible(demoProject, 3500)) {
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {});
|
||||||
|
await sleep(3000);
|
||||||
|
} else {
|
||||||
|
await showModule(page, '分割工作区', 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await showModule(page, 'AI智能分割', 3000);
|
||||||
|
await showModule(page, '模板库', 3000);
|
||||||
|
await showModule(page, '分割工作区', 3000);
|
||||||
|
await showModule(page, '项目库', 2200);
|
||||||
|
await showModule(page, '总体概况', 2200);
|
||||||
|
|
||||||
|
if (await page.getByTitle('用户管理').count()) {
|
||||||
|
await showModule(page, '用户管理', 2600);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(1200);
|
||||||
|
const video = page.video();
|
||||||
|
await context.close();
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
const videoPath = await video?.path();
|
||||||
|
if (!videoPath) throw new Error('Playwright did not produce a video file.');
|
||||||
|
const finalPath = path.resolve(OUT_DIR, '多模态影像及视频智能语义分割与标注系统-使用演示.webm');
|
||||||
|
await fs.rm(finalPath, { force: true });
|
||||||
|
await fs.rename(videoPath, finalPath);
|
||||||
|
console.log(finalPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
2326
src/components/CanvasArea.test.tsx
Normal file
57
src/components/OntologyInspector.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { resetStore } from '../test/storeTestUtils';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
import { OntologyInspector } from './OntologyInspector';
|
||||||
|
|
||||||
|
const apiMock = vi.hoisted(() => ({
|
||||||
|
analyzeMask: vi.fn(),
|
||||||
|
deleteAnnotation: vi.fn(),
|
||||||
|
smoothMaskGeometry: vi.fn(),
|
||||||
|
updateTemplate: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
analyzeMask: apiMock.analyzeMask,
|
||||||
|
deleteAnnotation: apiMock.deleteAnnotation,
|
||||||
|
smoothMaskGeometry: apiMock.smoothMaskGeometry,
|
||||||
|
updateTemplate: apiMock.updateTemplate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('OntologyInspector', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStore();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useStore.setState({
|
||||||
|
templates: [
|
||||||
|
{
|
||||||
|
id: 'template-1',
|
||||||
|
name: '测试模板',
|
||||||
|
classes: [
|
||||||
|
{ id: 'class-1', name: '胆囊', color: '#ef4444', zIndex: 20, maskId: 1 },
|
||||||
|
{ id: 'class-2', name: '肝脏', color: '#f97316', zIndex: 10, maskId: 2 },
|
||||||
|
{ id: 'reserved-unclassified', name: '待分类', color: '#000000', zIndex: 0, maskId: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeTemplateId: 'template-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the add-class form with a visible color swatch and updated label', () => {
|
||||||
|
render(<OntologyInspector />);
|
||||||
|
|
||||||
|
expect(screen.getByText('新增分类')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('自定义分类')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '展开新增分类' }));
|
||||||
|
|
||||||
|
const colorInput = screen.getByLabelText('新增分类颜色');
|
||||||
|
const colorSwatch = screen.getByTestId('new-class-color-swatch');
|
||||||
|
const selectedColor = (colorInput as HTMLInputElement).value;
|
||||||
|
expect(colorInput).toHaveAttribute('type', 'color');
|
||||||
|
expect(colorInput).not.toHaveValue('#ef4444');
|
||||||
|
expect(colorInput).not.toHaveValue('#f97316');
|
||||||
|
expect(colorSwatch).toHaveStyle(`background-color: ${selectedColor}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { cn } from '../lib/utils';
|
|||||||
import { getActiveTemplate } from '../lib/templateSelection';
|
import { getActiveTemplate } from '../lib/templateSelection';
|
||||||
import { analyzeMask, deleteAnnotation, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult, type SmoothMaskGeometryResult } from '../lib/api';
|
import { analyzeMask, deleteAnnotation, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult, type SmoothMaskGeometryResult } from '../lib/api';
|
||||||
import { isReservedUnclassifiedClass, nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
|
import { isReservedUnclassifiedClass, nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
|
||||||
|
import { pickDistinctClassColor } from '../lib/classColors';
|
||||||
|
|
||||||
const SMOOTHING_PREVIEW_DEBOUNCE_MS = 220;
|
const SMOOTHING_PREVIEW_DEBOUNCE_MS = 220;
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ export function OntologyInspector() {
|
|||||||
|
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [newClassName, setNewClassName] = useState('');
|
const [newClassName, setNewClassName] = useState('');
|
||||||
const [newClassColor, setNewClassColor] = useState('#06b6d4');
|
const [newClassColor, setNewClassColor] = useState(() => pickDistinctClassColor([]));
|
||||||
const [isSavingClass, setIsSavingClass] = useState(false);
|
const [isSavingClass, setIsSavingClass] = useState(false);
|
||||||
const [classSaveMessage, setClassSaveMessage] = useState('');
|
const [classSaveMessage, setClassSaveMessage] = useState('');
|
||||||
const [dragClassId, setDragClassId] = useState<string | null>(null);
|
const [dragClassId, setDragClassId] = useState<string | null>(null);
|
||||||
@@ -129,6 +130,20 @@ export function OntologyInspector() {
|
|||||||
smoothingPreviewTimerRef.current = null;
|
smoothingPreviewTimerRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const pickNextClassColor = React.useCallback(() => (
|
||||||
|
pickDistinctClassColor(templateClasses.map((templateClass) => templateClass.color))
|
||||||
|
), [templateClasses]);
|
||||||
|
|
||||||
|
const toggleAddForm = React.useCallback(() => {
|
||||||
|
setShowAddForm((current) => {
|
||||||
|
const next = !current;
|
||||||
|
if (next) {
|
||||||
|
setNewClassColor(pickNextClassColor());
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [pickNextClassColor]);
|
||||||
|
|
||||||
const selectedMaskClass = useMemo(() => {
|
const selectedMaskClass = useMemo(() => {
|
||||||
if (!selectedMask) return null;
|
if (!selectedMask) return null;
|
||||||
const allTemplateClasses = templates.flatMap((template) => (
|
const allTemplateClasses = templates.flatMap((template) => (
|
||||||
@@ -542,11 +557,12 @@ export function OntologyInspector() {
|
|||||||
setActiveTemplateId(updated.id);
|
setActiveTemplateId(updated.id);
|
||||||
handleSelectClass(newClass);
|
handleSelectClass(newClass);
|
||||||
setNewClassName('');
|
setNewClassName('');
|
||||||
|
setNewClassColor(pickDistinctClassColor([...templateClasses.map((templateClass) => templateClass.color), newClass.color]));
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
setClassSaveMessage('自定义分类已保存到后端模板');
|
setClassSaveMessage('新增分类已保存到后端模板');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Save custom class failed:', err);
|
console.error('Save custom class failed:', err);
|
||||||
setClassSaveMessage('自定义分类保存失败');
|
setClassSaveMessage('新增分类保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingClass(false);
|
setIsSavingClass(false);
|
||||||
}
|
}
|
||||||
@@ -729,9 +745,11 @@ export function OntologyInspector() {
|
|||||||
{/* Add Custom Class */}
|
{/* Add Custom Class */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">自定义分类</h3>
|
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">新增分类</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddForm(!showAddForm)}
|
type="button"
|
||||||
|
aria-label={showAddForm ? '收起新增分类' : '展开新增分类'}
|
||||||
|
onClick={toggleAddForm}
|
||||||
className="text-cyan-400 hover:text-cyan-300 transition-colors"
|
className="text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
@@ -740,12 +758,20 @@ export function OntologyInspector() {
|
|||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<div className="bg-[#1a1a1a] border border-white/10 rounded-lg p-3 space-y-2">
|
<div className="bg-[#1a1a1a] border border-white/10 rounded-lg p-3 space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<label className="relative flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-white/20 bg-white/5 shadow-inner shadow-black/40" title={`新增分类颜色 ${newClassColor}`}>
|
||||||
type="color"
|
<span
|
||||||
value={newClassColor}
|
data-testid="new-class-color-swatch"
|
||||||
onChange={(e) => setNewClassColor(e.target.value)}
|
className="h-5 w-5 rounded-sm border border-white/50 shadow-sm shadow-black"
|
||||||
className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer"
|
style={{ backgroundColor: newClassColor }}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
aria-label="新增分类颜色"
|
||||||
|
type="color"
|
||||||
|
value={newClassColor}
|
||||||
|
onChange={(e) => setNewClassColor(e.target.value)}
|
||||||
|
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newClassName}
|
value={newClassName}
|
||||||
@@ -757,7 +783,7 @@ export function OntologyInspector() {
|
|||||||
<button onClick={handleAddCustom} className="text-cyan-400 hover:text-cyan-300">
|
<button onClick={handleAddCustom} className="text-cyan-400 hover:text-cyan-300">
|
||||||
{isSavingClass ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
|
{isSavingClass ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setShowAddForm(false)} className="text-gray-500 hover:text-gray-300">
|
<button type="button" onClick={() => setShowAddForm(false)} className="text-gray-500 hover:text-gray-300">
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
179
src/components/ToolsPalette.test.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
import { resetStore } from '../test/storeTestUtils';
|
||||||
|
import { ToolsPalette } from './ToolsPalette';
|
||||||
|
|
||||||
|
describe('ToolsPalette', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches workspace editing tools without showing AI prompt or duplicate undo tools', () => {
|
||||||
|
const setActiveTool = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ToolsPalette
|
||||||
|
activeTool="move"
|
||||||
|
setActiveTool={setActiveTool}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTitle('创建多边形 (P)'));
|
||||||
|
fireEvent.click(screen.getByTitle('调整多边形 (E)'));
|
||||||
|
fireEvent.click(screen.getByTitle('画笔 (B)'));
|
||||||
|
fireEvent.click(screen.getByTitle('橡皮擦 (X)'));
|
||||||
|
|
||||||
|
expect(setActiveTool).toHaveBeenNthCalledWith(1, 'create_polygon');
|
||||||
|
expect(setActiveTool).toHaveBeenNthCalledWith(2, 'edit_polygon');
|
||||||
|
expect(setActiveTool).toHaveBeenNthCalledWith(3, 'brush');
|
||||||
|
expect(setActiveTool).toHaveBeenNthCalledWith(4, 'eraser');
|
||||||
|
expect(screen.queryByTitle('正向选点 (SAM)')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle('反向选点 (SAM)')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle('边界框选 (SAM)')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle('撤销操作 (Ctrl+Z)')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle('重做操作 (Ctrl+Shift+Z)')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle('创建点 (C)')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTitle('创建线段 (L)')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows size controls for brush and eraser tools', () => {
|
||||||
|
const { rerender } = render(<ToolsPalette activeTool="brush" setActiveTool={vi.fn()} />);
|
||||||
|
const brushSize = screen.getByLabelText('画笔大小');
|
||||||
|
fireEvent.change(brushSize, { target: { value: '36' } });
|
||||||
|
expect(useStore.getState().brushSize).toBe(36);
|
||||||
|
|
||||||
|
rerender(<ToolsPalette activeTool="eraser" setActiveTool={vi.fn()} />);
|
||||||
|
const eraserSize = screen.getByLabelText('橡皮擦大小');
|
||||||
|
fireEvent.change(eraserSize, { target: { value: '48' } });
|
||||||
|
expect(useStore.getState().eraserSize).toBe(48);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places GT mask import after overlap removal with a distinct violet style', () => {
|
||||||
|
const onImportGtMask = vi.fn();
|
||||||
|
render(
|
||||||
|
<ToolsPalette
|
||||||
|
activeTool="move"
|
||||||
|
setActiveTool={vi.fn()}
|
||||||
|
onImportGtMask={onImportGtMask}
|
||||||
|
canImportGtMask
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const overlapButton = screen.getByTitle('重叠区域去除 (-)');
|
||||||
|
const importButton = screen.getByTitle('导入 GT Mask');
|
||||||
|
fireEvent.click(importButton);
|
||||||
|
|
||||||
|
expect(onImportGtMask).toHaveBeenCalled();
|
||||||
|
expect(importButton).toHaveClass('bg-violet-500/10');
|
||||||
|
expect(overlapButton.compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes clear mask action in the left toolbar', () => {
|
||||||
|
const onClearMasks = vi.fn();
|
||||||
|
const onDeleteMasks = vi.fn();
|
||||||
|
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} onClearMasks={onClearMasks} onDeleteMasks={onDeleteMasks} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTitle('删除选中遮罩 (Del)'));
|
||||||
|
fireEvent.click(screen.getByTitle('清空遮罩'));
|
||||||
|
|
||||||
|
expect(onDeleteMasks).toHaveBeenCalled();
|
||||||
|
expect(onClearMasks).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a physical clear selection button next to the selection tool', () => {
|
||||||
|
const onClearSelection = vi.fn();
|
||||||
|
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} onClearSelection={onClearSelection} />);
|
||||||
|
|
||||||
|
const moveButton = screen.getByTitle('拖拽 / 选择 (V)');
|
||||||
|
const clearSelectionButton = screen.getByTitle('取消选中 (Esc)');
|
||||||
|
const editButton = screen.getByTitle('调整多边形 (E)');
|
||||||
|
fireEvent.click(clearSelectionButton);
|
||||||
|
|
||||||
|
expect(onClearSelection).toHaveBeenCalled();
|
||||||
|
expect(clearSelectionButton).toHaveAttribute('aria-label', '取消选中');
|
||||||
|
expect(moveButton.compareDocumentPosition(clearSelectionButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(clearSelectionButton.compareDocumentPosition(editButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places colored auto propagation below the eraser tool', () => {
|
||||||
|
const setActiveTool = vi.fn();
|
||||||
|
const onAutoPropagate = vi.fn();
|
||||||
|
render(
|
||||||
|
<ToolsPalette
|
||||||
|
activeTool="move"
|
||||||
|
setActiveTool={setActiveTool}
|
||||||
|
onAutoPropagate={onAutoPropagate}
|
||||||
|
canAutoPropagate
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const eraserButton = screen.getByTitle('橡皮擦 (X)');
|
||||||
|
const autoButton = screen.getByRole('button', { name: 'AI自动推理' });
|
||||||
|
fireEvent.click(autoButton);
|
||||||
|
|
||||||
|
expect(autoButton).toHaveClass('bg-cyan-500/10');
|
||||||
|
expect(autoButton.querySelector('[data-testid="ai-auto-inference-icon"]')).toBeInTheDocument();
|
||||||
|
expect(eraserButton.compareDocumentPosition(autoButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(setActiveTool).toHaveBeenCalledWith('auto_propagate');
|
||||||
|
expect(onAutoPropagate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates drawing, editing, and external action tool groups', () => {
|
||||||
|
const { container } = render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} canImportGtMask />);
|
||||||
|
|
||||||
|
const separators = Array.from(container.querySelectorAll('.h-px'));
|
||||||
|
const externalActionSeparator = screen.getByTestId('tool-group-separator');
|
||||||
|
const clearSelectionButton = screen.getByTitle('取消选中 (Esc)');
|
||||||
|
const circleButton = screen.getByTitle('创建圆 (O)');
|
||||||
|
const brushButton = screen.getByTitle('画笔 (B)');
|
||||||
|
const eraserButton = screen.getByTitle('橡皮擦 (X)');
|
||||||
|
const autoButton = screen.getByRole('button', { name: 'AI自动推理' });
|
||||||
|
const mergeButton = screen.getByTitle('区域合并 (+)');
|
||||||
|
const removeButton = screen.getByTitle('重叠区域去除 (-)');
|
||||||
|
const deleteButton = screen.getByTitle('删除选中遮罩 (Del)');
|
||||||
|
const clearButton = screen.getByTitle('清空遮罩');
|
||||||
|
const importButton = screen.getByTitle('导入 GT Mask');
|
||||||
|
|
||||||
|
expect(separators).toHaveLength(3);
|
||||||
|
expect(externalActionSeparator).toBe(separators[2]);
|
||||||
|
expect(clearSelectionButton.compareDocumentPosition(circleButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(circleButton.compareDocumentPosition(separators[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(separators[0].compareDocumentPosition(brushButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(eraserButton.compareDocumentPosition(autoButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(autoButton.compareDocumentPosition(separators[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(separators[1].compareDocumentPosition(mergeButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(removeButton.compareDocumentPosition(deleteButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(deleteButton.compareDocumentPosition(clearButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(clearButton.compareDocumentPosition(separators[2]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(separators[2].compareDocumentPosition(importButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
separators.forEach((separator) => {
|
||||||
|
expect(separator).toHaveClass('bg-white/15');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to SAM trigger and calls the AI navigation hook', () => {
|
||||||
|
const setActiveTool = vi.fn();
|
||||||
|
const onTriggerAI = vi.fn();
|
||||||
|
|
||||||
|
render(<ToolsPalette activeTool="move" setActiveTool={setActiveTool} onTriggerAI={onTriggerAI} />);
|
||||||
|
const aiButton = screen.getByTitle('打开 AI 智能分割');
|
||||||
|
expect(aiButton.querySelector('[data-testid="ai-segmentation-icon"]')).toBeInTheDocument();
|
||||||
|
fireEvent.click(aiButton);
|
||||||
|
|
||||||
|
expect(setActiveTool).toHaveBeenCalledWith('sam_trigger');
|
||||||
|
expect(onTriggerAI).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses compact vertically scrollable layout for smaller workspaces', () => {
|
||||||
|
const { container } = render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} />);
|
||||||
|
const palette = container.firstElementChild;
|
||||||
|
|
||||||
|
expect(palette).toHaveClass('w-14');
|
||||||
|
expect(palette).toHaveClass('overflow-y-auto');
|
||||||
|
expect(palette).toHaveClass('seg-scrollbar');
|
||||||
|
expect(palette?.firstElementChild).toHaveClass('w-12');
|
||||||
|
expect(screen.getByTitle('创建多边形 (P)')).toHaveClass('h-9');
|
||||||
|
expect(screen.getByTitle('打开 AI 智能分割')).toHaveClass('h-9');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,7 @@ import { DEFAULT_AI_MODEL_ID, SAM2_MODEL_OPTIONS, type AiModelId, type Frame, ty
|
|||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { normalizeClassMaskIds } from '../lib/maskIds';
|
import { normalizeClassMaskIds } from '../lib/maskIds';
|
||||||
import { getUndoRedoShortcut } from '../lib/keyboardShortcuts';
|
import { getUndoRedoShortcut } from '../lib/keyboardShortcuts';
|
||||||
|
import { inferActiveTemplateIdFromMasks } from '../lib/templateSelection';
|
||||||
|
|
||||||
type PropagationDirection = 'forward' | 'backward';
|
type PropagationDirection = 'forward' | 'backward';
|
||||||
type PropagationProgress = {
|
type PropagationProgress = {
|
||||||
@@ -633,6 +634,17 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
|||||||
));
|
));
|
||||||
const mergedMasks = [...unsavedMasks, ...savedMasks];
|
const mergedMasks = [...unsavedMasks, ...savedMasks];
|
||||||
setMasks(mergedMasks);
|
setMasks(mergedMasks);
|
||||||
|
const latestStoreState = useStore.getState();
|
||||||
|
const hasProjectTemplateMemory = Object.prototype.hasOwnProperty.call(
|
||||||
|
latestStoreState.projectActiveTemplateIds,
|
||||||
|
String(projectId),
|
||||||
|
);
|
||||||
|
if (!hasProjectTemplateMemory && !latestStoreState.activeTemplateId) {
|
||||||
|
const inferredTemplateId = inferActiveTemplateIdFromMasks(latestTemplates, savedMasks);
|
||||||
|
if (inferredTemplateId) {
|
||||||
|
latestStoreState.setActiveTemplateId(inferredTemplateId);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (preserveSelectedIds.length > 0) {
|
if (preserveSelectedIds.length > 0) {
|
||||||
const mergedMaskIds = new Set(mergedMasks.map((mask) => mask.id));
|
const mergedMaskIds = new Set(mergedMasks.map((mask) => mask.id));
|
||||||
const nextSelectedIds = preserveSelectedIds.filter((id) => mergedMaskIds.has(id));
|
const nextSelectedIds = preserveSelectedIds.filter((id) => mergedMaskIds.has(id));
|
||||||
|
|||||||
34
src/lib/classColors.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { pickDistinctClassColor } from './classColors';
|
||||||
|
|
||||||
|
describe('classColors', () => {
|
||||||
|
it('picks a palette color that is not already used', () => {
|
||||||
|
const color = pickDistinctClassColor(['#ef4444', '#f97316'], () => 0);
|
||||||
|
expect(color).toBe('#f59e0b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to generated colors when the palette is exhausted', () => {
|
||||||
|
const usedColors = [
|
||||||
|
'#ef4444',
|
||||||
|
'#f97316',
|
||||||
|
'#f59e0b',
|
||||||
|
'#eab308',
|
||||||
|
'#84cc16',
|
||||||
|
'#22c55e',
|
||||||
|
'#10b981',
|
||||||
|
'#14b8a6',
|
||||||
|
'#06b6d4',
|
||||||
|
'#0ea5e9',
|
||||||
|
'#3b82f6',
|
||||||
|
'#6366f1',
|
||||||
|
'#8b5cf6',
|
||||||
|
'#a855f7',
|
||||||
|
'#d946ef',
|
||||||
|
'#ec4899',
|
||||||
|
'#f43f5e',
|
||||||
|
];
|
||||||
|
const color = pickDistinctClassColor(usedColors, () => 0.25);
|
||||||
|
expect(color).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
expect(usedColors).not.toContain(color);
|
||||||
|
});
|
||||||
|
});
|
||||||
78
src/lib/classColors.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
const CLASS_COLOR_PALETTE = [
|
||||||
|
'#ef4444',
|
||||||
|
'#f97316',
|
||||||
|
'#f59e0b',
|
||||||
|
'#eab308',
|
||||||
|
'#84cc16',
|
||||||
|
'#22c55e',
|
||||||
|
'#10b981',
|
||||||
|
'#14b8a6',
|
||||||
|
'#06b6d4',
|
||||||
|
'#0ea5e9',
|
||||||
|
'#3b82f6',
|
||||||
|
'#6366f1',
|
||||||
|
'#8b5cf6',
|
||||||
|
'#a855f7',
|
||||||
|
'#d946ef',
|
||||||
|
'#ec4899',
|
||||||
|
'#f43f5e',
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeHexColor(color: string): string | null {
|
||||||
|
const raw = color.trim().toLowerCase();
|
||||||
|
if (/^#[0-9a-f]{6}$/.test(raw)) return raw;
|
||||||
|
if (/^#[0-9a-f]{3}$/.test(raw)) {
|
||||||
|
return `#${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToHex(hue: number, saturation: number, lightness: number): string {
|
||||||
|
const normalizedSaturation = saturation / 100;
|
||||||
|
const normalizedLightness = lightness / 100;
|
||||||
|
const chroma = (1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation;
|
||||||
|
const intermediate = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||||
|
const match = normalizedLightness - chroma / 2;
|
||||||
|
const [red, green, blue] = hue < 60
|
||||||
|
? [chroma, intermediate, 0]
|
||||||
|
: hue < 120
|
||||||
|
? [intermediate, chroma, 0]
|
||||||
|
: hue < 180
|
||||||
|
? [0, chroma, intermediate]
|
||||||
|
: hue < 240
|
||||||
|
? [0, intermediate, chroma]
|
||||||
|
: hue < 300
|
||||||
|
? [intermediate, 0, chroma]
|
||||||
|
: [chroma, 0, intermediate];
|
||||||
|
return [red, green, blue]
|
||||||
|
.map((part) => Math.round((part + match) * 255).toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
.replace(/^/, '#');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickDistinctClassColor(
|
||||||
|
existingColors: Array<string | undefined | null>,
|
||||||
|
random: () => number = Math.random,
|
||||||
|
): string {
|
||||||
|
const usedColors = new Set(
|
||||||
|
existingColors
|
||||||
|
.map((color) => normalizeHexColor(color || ''))
|
||||||
|
.filter((color): color is string => Boolean(color)),
|
||||||
|
);
|
||||||
|
const paletteCandidates = CLASS_COLOR_PALETTE.filter((color) => !usedColors.has(color));
|
||||||
|
if (paletteCandidates.length > 0) {
|
||||||
|
return paletteCandidates[Math.floor(random() * paletteCandidates.length) % paletteCandidates.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 48; attempt += 1) {
|
||||||
|
const color = hslToHex(Math.floor(random() * 360), 76, 56);
|
||||||
|
if (!usedColors.has(color)) return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallbackIndex = 0;
|
||||||
|
while (true) {
|
||||||
|
const color = hslToHex((fallbackIndex * 137) % 360, 76, 56);
|
||||||
|
if (!usedColors.has(color)) return color;
|
||||||
|
fallbackIndex += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/lib/templateSelection.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Mask, Template } from '../store/useStore';
|
||||||
|
import { inferActiveTemplateIdFromMasks } from './templateSelection';
|
||||||
|
|
||||||
|
const templates: Template[] = [
|
||||||
|
{ id: 'template-a', name: '模板 A', classes: [] },
|
||||||
|
{ id: 'template-b', name: '模板 B', classes: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeMask(id: string, templateId?: string): Mask {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
frameId: 'frame-1',
|
||||||
|
templateId,
|
||||||
|
pathData: 'M 0 0 L 10 0 L 10 10 Z',
|
||||||
|
label: 'mask',
|
||||||
|
color: '#ff0000',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('templateSelection', () => {
|
||||||
|
it('infers the project active template from saved masks', () => {
|
||||||
|
expect(inferActiveTemplateIdFromMasks(templates, [
|
||||||
|
makeMask('m1', 'template-a'),
|
||||||
|
makeMask('m2', 'template-b'),
|
||||||
|
makeMask('m3', 'template-b'),
|
||||||
|
])).toBe('template-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores masks without a valid template reference', () => {
|
||||||
|
expect(inferActiveTemplateIdFromMasks(templates, [
|
||||||
|
makeMask('m1'),
|
||||||
|
makeMask('m2', 'missing-template'),
|
||||||
|
])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Template, TemplateClass } from '../store/useStore';
|
import type { Mask, Template, TemplateClass } from '../store/useStore';
|
||||||
|
|
||||||
export function getActiveTemplate(templates: Template[], activeTemplateId: string | null): Template | null {
|
export function getActiveTemplate(templates: Template[], activeTemplateId: string | null): Template | null {
|
||||||
return templates.find((template) => template.id === activeTemplateId) || templates[0] || null;
|
return templates.find((template) => template.id === activeTemplateId) || templates[0] || null;
|
||||||
@@ -13,3 +13,17 @@ export function getActiveClass(
|
|||||||
if (!template) return null;
|
if (!template) return null;
|
||||||
return template.classes.find((templateClass) => templateClass.id === activeClassId) || null;
|
return template.classes.find((templateClass) => templateClass.id === activeClassId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function inferActiveTemplateIdFromMasks(templates: Template[], masks: Mask[]): string | null {
|
||||||
|
const validTemplateIds = new Set(templates.map((template) => String(template.id)));
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
masks.forEach((mask) => {
|
||||||
|
if (!mask.templateId) return;
|
||||||
|
const templateId = String(mask.templateId);
|
||||||
|
if (!validTemplateIds.has(templateId)) return;
|
||||||
|
counts.set(templateId, (counts.get(templateId) || 0) + 1);
|
||||||
|
});
|
||||||
|
return Array.from(counts.entries())
|
||||||
|
.sort((left, right) => right[1] - left[1])
|
||||||
|
.at(0)?.[0] || null;
|
||||||
|
}
|
||||||
|
|||||||
67
src/store/useStore.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { resetStore } from '../test/storeTestUtils';
|
||||||
|
import { Project, useStore } from './useStore';
|
||||||
|
|
||||||
|
function makeProject(id: string, name = id): Project {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
status: 'ready',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useStore project workspace state', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the active template isolated per project', () => {
|
||||||
|
const projectA = makeProject('project-a');
|
||||||
|
const projectB = makeProject('project-b');
|
||||||
|
|
||||||
|
useStore.getState().setCurrentProject(projectA);
|
||||||
|
useStore.getState().setActiveTemplateId('template-a');
|
||||||
|
|
||||||
|
useStore.getState().setCurrentProject(projectB);
|
||||||
|
expect(useStore.getState().activeTemplateId).toBeNull();
|
||||||
|
|
||||||
|
useStore.getState().setActiveTemplateId('template-b');
|
||||||
|
|
||||||
|
useStore.getState().setCurrentProject(projectA);
|
||||||
|
expect(useStore.getState().activeTemplateId).toBe('template-a');
|
||||||
|
|
||||||
|
useStore.getState().setCurrentProject(projectB);
|
||||||
|
expect(useStore.getState().activeTemplateId).toBe('template-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears transient workspace data only when the project id changes', () => {
|
||||||
|
const project = makeProject('project-a', '旧名称');
|
||||||
|
|
||||||
|
useStore.setState({
|
||||||
|
currentProject: project,
|
||||||
|
activeTemplateId: 'template-a',
|
||||||
|
projectActiveTemplateIds: { [project.id]: 'template-a' },
|
||||||
|
activeClassId: 'class-a',
|
||||||
|
activeClass: { id: 'class-a', name: '胆囊', color: '#ff0000', zIndex: 1 },
|
||||||
|
frames: [{ id: 'frame-a', projectId: project.id, index: 0, url: '/a.jpg', width: 100, height: 100 }],
|
||||||
|
currentFrameIndex: 1,
|
||||||
|
annotations: [{ id: 'ann-a', frameId: 'frame-a', type: 'polygon', points: [], label: '胆囊', color: '#ff0000' }],
|
||||||
|
masks: [{ id: 'mask-a', frameId: 'frame-a', pathData: 'M 0 0 L 10 0 L 10 10 Z', label: '胆囊', color: '#ff0000' }],
|
||||||
|
selectedMaskIds: ['mask-a'],
|
||||||
|
maskHistory: [[]],
|
||||||
|
});
|
||||||
|
|
||||||
|
useStore.getState().setCurrentProject({ ...project, name: '新名称' });
|
||||||
|
expect(useStore.getState().activeTemplateId).toBe('template-a');
|
||||||
|
expect(useStore.getState().masks).toHaveLength(1);
|
||||||
|
expect(useStore.getState().currentProject?.name).toBe('新名称');
|
||||||
|
|
||||||
|
useStore.getState().setCurrentProject(makeProject('project-b'));
|
||||||
|
expect(useStore.getState().activeTemplateId).toBeNull();
|
||||||
|
expect(useStore.getState().activeClass).toBeNull();
|
||||||
|
expect(useStore.getState().frames).toEqual([]);
|
||||||
|
expect(useStore.getState().masks).toEqual([]);
|
||||||
|
expect(useStore.getState().selectedMaskIds).toEqual([]);
|
||||||
|
expect(useStore.getState().maskHistory).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -169,6 +169,7 @@ export interface AppState {
|
|||||||
// Templates
|
// Templates
|
||||||
templates: Template[];
|
templates: Template[];
|
||||||
activeTemplateId: string | null;
|
activeTemplateId: string | null;
|
||||||
|
projectActiveTemplateIds: Record<string, string | null>;
|
||||||
activeClassId: string | null;
|
activeClassId: string | null;
|
||||||
activeClass: TemplateClass | null;
|
activeClass: TemplateClass | null;
|
||||||
setTemplates: (templates: Template[]) => void;
|
setTemplates: (templates: Template[]) => void;
|
||||||
@@ -215,6 +216,7 @@ export const useStore = create<AppState>((set) => ({
|
|||||||
maskHistory: [],
|
maskHistory: [],
|
||||||
maskFuture: [],
|
maskFuture: [],
|
||||||
activeTemplateId: null,
|
activeTemplateId: null,
|
||||||
|
projectActiveTemplateIds: {},
|
||||||
activeClassId: null,
|
activeClassId: null,
|
||||||
activeClass: null,
|
activeClass: null,
|
||||||
});
|
});
|
||||||
@@ -224,7 +226,29 @@ export const useStore = create<AppState>((set) => ({
|
|||||||
projects: [],
|
projects: [],
|
||||||
currentProject: null,
|
currentProject: null,
|
||||||
setProjects: (projects: Project[]) => set({ projects }),
|
setProjects: (projects: Project[]) => set({ projects }),
|
||||||
setCurrentProject: (currentProject: Project | null) => set({ currentProject }),
|
setCurrentProject: (currentProject: Project | null) =>
|
||||||
|
set((state) => {
|
||||||
|
const previousProjectId = state.currentProject?.id || null;
|
||||||
|
const nextProjectId = currentProject?.id || null;
|
||||||
|
if (previousProjectId === nextProjectId) {
|
||||||
|
return { currentProject };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
currentProject,
|
||||||
|
frames: [],
|
||||||
|
currentFrameIndex: 0,
|
||||||
|
annotations: [],
|
||||||
|
masks: [],
|
||||||
|
selectedMaskIds: [],
|
||||||
|
maskHistory: [],
|
||||||
|
maskFuture: [],
|
||||||
|
activeTemplateId: nextProjectId
|
||||||
|
? state.projectActiveTemplateIds[nextProjectId] || null
|
||||||
|
: null,
|
||||||
|
activeClassId: null,
|
||||||
|
activeClass: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
addProject: (project: Project) =>
|
addProject: (project: Project) =>
|
||||||
set((state) => ({ projects: [project, ...state.projects] })),
|
set((state) => ({ projects: [project, ...state.projects] })),
|
||||||
updateProject: (project: Project) =>
|
updateProject: (project: Project) =>
|
||||||
@@ -321,10 +345,22 @@ export const useStore = create<AppState>((set) => ({
|
|||||||
// Templates
|
// Templates
|
||||||
templates: [],
|
templates: [],
|
||||||
activeTemplateId: null,
|
activeTemplateId: null,
|
||||||
|
projectActiveTemplateIds: {},
|
||||||
activeClassId: null,
|
activeClassId: null,
|
||||||
activeClass: null,
|
activeClass: null,
|
||||||
setTemplates: (templates: Template[]) => set({ templates }),
|
setTemplates: (templates: Template[]) => set({ templates }),
|
||||||
setActiveTemplateId: (activeTemplateId: string | null) => set({ activeTemplateId }),
|
setActiveTemplateId: (activeTemplateId: string | null) =>
|
||||||
|
set((state) => {
|
||||||
|
const projectId = state.currentProject?.id;
|
||||||
|
if (!projectId) return { activeTemplateId };
|
||||||
|
return {
|
||||||
|
activeTemplateId,
|
||||||
|
projectActiveTemplateIds: {
|
||||||
|
...state.projectActiveTemplateIds,
|
||||||
|
[projectId]: activeTemplateId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
setActiveClassId: (activeClassId: string | null) => set({ activeClassId }),
|
setActiveClassId: (activeClassId: string | null) => set({ activeClassId }),
|
||||||
setActiveClass: (activeClass: TemplateClass | null) => set({
|
setActiveClass: (activeClass: TemplateClass | null) => set({
|
||||||
activeClass,
|
activeClass,
|
||||||
|
|||||||
178
src/test/setup.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { afterEach, vi } from 'vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('alert', vi.fn());
|
||||||
|
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||||
|
URL.createObjectURL = vi.fn(() => 'blob:mock-url');
|
||||||
|
URL.revokeObjectURL = vi.fn();
|
||||||
|
HTMLAnchorElement.prototype.click = vi.fn();
|
||||||
|
|
||||||
|
function makeStageEvent(x = 120, y = 80) {
|
||||||
|
const stage = {
|
||||||
|
getPointerPosition: () => ({ x, y }),
|
||||||
|
getRelativePointerPosition: () => ({ x, y }),
|
||||||
|
scaleX: () => 1,
|
||||||
|
x: () => 0,
|
||||||
|
y: () => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
evt: { preventDefault: vi.fn(), deltaY: -1 },
|
||||||
|
target: {
|
||||||
|
getStage: () => stage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('react-konva', () => ({
|
||||||
|
Stage: ({ children, onClick, onMouseDown, onMouseUp, onMouseMove, onWheel, onDragEnd, scaleX, scaleY, x, y, width, height }: any) => {
|
||||||
|
const coords = (event: React.MouseEvent<HTMLDivElement>, fallbackX: number, fallbackY: number) => ({
|
||||||
|
x: event.clientX || fallbackX,
|
||||||
|
y: event.clientY || fallbackY,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="konva-stage"
|
||||||
|
data-has-drag-end={Boolean(onDragEnd)}
|
||||||
|
data-scale-x={scaleX}
|
||||||
|
data-scale-y={scaleY}
|
||||||
|
data-x={x}
|
||||||
|
data-y={y}
|
||||||
|
data-width={width}
|
||||||
|
data-height={height}
|
||||||
|
onClick={(event) => {
|
||||||
|
const point = coords(event, 120, 80);
|
||||||
|
onClick?.(makeStageEvent(point.x, point.y));
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
const point = coords(event, 120, 80);
|
||||||
|
onMouseDown?.(makeStageEvent(point.x, point.y));
|
||||||
|
}}
|
||||||
|
onMouseUp={(event) => {
|
||||||
|
const point = coords(event, 260, 200);
|
||||||
|
onMouseUp?.(makeStageEvent(point.x, point.y));
|
||||||
|
}}
|
||||||
|
onMouseMove={(event) => {
|
||||||
|
const point = coords(event, 180, 120);
|
||||||
|
onMouseMove?.(makeStageEvent(point.x, point.y));
|
||||||
|
}}
|
||||||
|
onWheel={() => onWheel?.(makeStageEvent())}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
const stageTarget: any = {
|
||||||
|
x: () => event.clientX || 0,
|
||||||
|
y: () => event.clientY || 0,
|
||||||
|
};
|
||||||
|
stageTarget.getStage = () => stageTarget;
|
||||||
|
const childTarget = {
|
||||||
|
x: () => event.clientX || 0,
|
||||||
|
y: () => event.clientY || 0,
|
||||||
|
getStage: () => stageTarget,
|
||||||
|
};
|
||||||
|
onDragEnd?.({
|
||||||
|
target: event.target === event.currentTarget ? stageTarget : childTarget,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Layer: ({ children }: any) => <div data-testid="konva-layer">{children}</div>,
|
||||||
|
Group: ({ children, opacity }: any) => <div data-testid="konva-group" data-opacity={opacity}>{children}</div>,
|
||||||
|
Image: ({ image }: any) => <img data-testid="konva-image" alt="" src={image?.src || ''} />,
|
||||||
|
Circle: (props: any) => (
|
||||||
|
<span
|
||||||
|
data-testid="konva-circle"
|
||||||
|
data-fill={props.fill}
|
||||||
|
data-x={props.x}
|
||||||
|
data-y={props.y}
|
||||||
|
onClick={(event) => {
|
||||||
|
const point = {
|
||||||
|
x: event.clientX || 120,
|
||||||
|
y: event.clientY || 80,
|
||||||
|
};
|
||||||
|
const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
|
||||||
|
props.onClick?.(konvaEvent);
|
||||||
|
if (konvaEvent.cancelBubble) event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
const point = {
|
||||||
|
x: event.clientX || props.x || 120,
|
||||||
|
y: event.clientY || props.y || 80,
|
||||||
|
};
|
||||||
|
const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
|
||||||
|
props.onMouseDown?.(konvaEvent);
|
||||||
|
props.onDragStart?.(konvaEvent);
|
||||||
|
if (konvaEvent.cancelBubble) event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseMove={(event) => props.onDragMove?.({
|
||||||
|
target: {
|
||||||
|
x: () => event.clientX || props.x || 0,
|
||||||
|
y: () => event.clientY || props.y || 0,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
onMouseUp={(event: React.MouseEvent<HTMLSpanElement>) => props.onDragEnd?.({
|
||||||
|
target: {
|
||||||
|
x: () => event.clientX || props.x || 0,
|
||||||
|
y: () => event.clientY || props.y || 0,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
onDragEnd={(event: React.DragEvent<HTMLSpanElement>) => props.onDragEnd?.({
|
||||||
|
target: {
|
||||||
|
x: () => event.clientX || props.x || 0,
|
||||||
|
y: () => event.clientY || props.y || 0,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
Rect: (props: any) => <span data-testid="konva-rect" data-width={props.width} />,
|
||||||
|
Path: (props: any) => (
|
||||||
|
<span
|
||||||
|
data-testid="konva-path"
|
||||||
|
data-path={props.data}
|
||||||
|
data-fill={props.fill}
|
||||||
|
data-stroke={props.stroke}
|
||||||
|
data-stroke-width={props.strokeWidth}
|
||||||
|
data-dash={props.dash?.join(',') || ''}
|
||||||
|
data-fill-rule={props.fillRule}
|
||||||
|
onClick={(event) => {
|
||||||
|
const point = {
|
||||||
|
x: event.clientX || 120,
|
||||||
|
y: event.clientY || 80,
|
||||||
|
};
|
||||||
|
const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
|
||||||
|
props.onClick?.(konvaEvent);
|
||||||
|
if (konvaEvent.cancelBubble) event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
const point = {
|
||||||
|
x: event.clientX || 120,
|
||||||
|
y: event.clientY || 80,
|
||||||
|
};
|
||||||
|
const konvaEvent = { ...makeStageEvent(point.x, point.y), cancelBubble: false };
|
||||||
|
props.onDblClick?.(konvaEvent);
|
||||||
|
if (konvaEvent.cancelBubble) event.stopPropagation();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('use-image', () => ({
|
||||||
|
default: (src: string) => [
|
||||||
|
{
|
||||||
|
src,
|
||||||
|
width: 640,
|
||||||
|
height: 360,
|
||||||
|
naturalWidth: 640,
|
||||||
|
naturalHeight: 360,
|
||||||
|
},
|
||||||
|
'loaded',
|
||||||
|
],
|
||||||
|
}));
|
||||||
31
src/test/storeTestUtils.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { DEFAULT_AI_MODEL_ID, DEFAULT_BRUSH_SIZE, DEFAULT_ERASER_SIZE, useStore } from '../store/useStore';
|
||||||
|
|
||||||
|
export function resetStore() {
|
||||||
|
useStore.setState({
|
||||||
|
isAuthenticated: false,
|
||||||
|
token: null,
|
||||||
|
currentUser: null,
|
||||||
|
projects: [],
|
||||||
|
currentProject: null,
|
||||||
|
activeModule: 'dashboard',
|
||||||
|
activeTool: 'move',
|
||||||
|
aiModel: DEFAULT_AI_MODEL_ID,
|
||||||
|
frames: [],
|
||||||
|
currentFrameIndex: 0,
|
||||||
|
annotations: [],
|
||||||
|
masks: [],
|
||||||
|
selectedMaskIds: [],
|
||||||
|
maskPreviewOpacity: 50,
|
||||||
|
brushSize: DEFAULT_BRUSH_SIZE,
|
||||||
|
eraserSize: DEFAULT_ERASER_SIZE,
|
||||||
|
maskHistory: [],
|
||||||
|
maskFuture: [],
|
||||||
|
templates: [],
|
||||||
|
activeTemplateId: null,
|
||||||
|
projectActiveTemplateIds: {},
|
||||||
|
activeClassId: null,
|
||||||
|
activeClass: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
24
vitest.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
environmentOptions: {
|
||||||
|
jsdom: {
|
||||||
|
url: 'http://seg.local:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
globals: true,
|
||||||
|
setupFiles: './src/test/setup.tsx',
|
||||||
|
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||||
|
css: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
新撰写软著文档/1. 软著说明书.docx
Normal file
208
新撰写软著文档/1. 软著说明书.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# 多模态影像及视频智能语义分割与标注系统说明书
|
||||||
|
|
||||||
|
# 1.系统注册
|
||||||
|
|
||||||
|
本系统采用受控账号准入方式。普通标注人员账号由管理员在“用户管理”界面统一创建,用户首次使用系统前,应由管理员完成账号注册与初始密码分配。管理员登录系统后,单击左侧导航栏中的“用户管理”按钮,进入用户与权限管理界面。在新增用户区域中,依次填写用户名、初始密码等信息,系统默认将新增账号设置为标注员角色。填写完成后,单击“新增用户”按钮,系统完成账号创建,并在用户列表中显示该账号信息。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
若需调整账号状态,管理员可在用户列表中查看当前用户的启用状态,并根据实际管理需要对用户执行启用、停用、修改密码或删除操作。系统仅保留唯一管理员账号,其余业务使用人员均作为标注员参与项目管理和分割标注工作。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# 2.系统登录
|
||||||
|
|
||||||
|
打开系统后,系统自动显示登录界面。用户在登录界面中依次输入账号和密码,确认信息无误后,单击“安全登录”按钮。系统验证通过后,将进入系统主界面,并默认显示“总体概况”页面。若账号或密码填写错误,系统将在登录表单区域显示错误提示,用户可重新填写后再次登录。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
登录成功后,左侧显示系统功能导航栏,包括“总体概况”“项目库”“分割工作区”“AI智能分割”“模板库”等功能入口。管理员账号还可看到“用户管理”入口。用户可根据业务需求单击相应按钮进入对应功能页面。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
# 3.系统核心管理界面
|
||||||
|
|
||||||
|
系统主界面采用左侧功能导航与右侧业务工作区布局。左侧导航用于切换系统模块,右侧区域根据当前选择显示项目数据、分割工作区、模型状态、模板分类或用户管理内容。系统左下角显示当前模型运行状态,底部用户按钮可用于退出登录。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 3.1 总体概况
|
||||||
|
|
||||||
|
用户登录后,系统默认进入“总体概况”页面。该页面用于查看系统全局数据监控情况,包括项目总数、处理任务数量、已保存标注数量、系统负载等摘要信息。页面中还展示后台任务列表与活动记录,用户可了解视频解析、DICOM 解析、自动推理等任务的处理进度。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
在任务列表中,用户可查看任务名称、处理进度、当前状态和更新时间。对于正在执行的任务,可根据系统提供的操作按钮进行取消处理;对于失败或已取消的任务,可根据需要重新执行。任务执行过程中,系统会在界面中持续更新进度提示,便于用户掌握当前处理状态。
|
||||||
|
|
||||||
|
## 3.2 项目库管理
|
||||||
|
|
||||||
|
单击左侧“项目库”按钮后,系统进入视频与连续帧项目库页面。该页面用于管理多媒体项目,支持导入视频文件、DICOM 序列文件,并对已有项目进行查看、重命名、复制、删除和进入工作区等操作。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
用户单击“导入多媒体资源”按钮后,系统显示导入选项。选择“导入视频”时,用户在本地选择目标视频文件,系统显示视频导入配置界面。用户确认后,系统开始上传视频并显示导入进度。上传完成后,项目卡片会出现在项目库中。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
视频项目导入完成后,若项目尚未生成帧序列,用户可单击项目卡片中的“生成帧”按钮。系统弹出帧生成配置界面,用户可选择抽帧帧率。确认后,系统开始生成帧,并在项目库与任务列表中显示处理进度。帧生成完成后,项目封面与帧数量会自动更新。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
选择“导入DICOM序列”时,用户可批量选择 DICOM 文件。系统会按照文件名自然顺序读取序列,避免切片顺序错位。上传及解析过程中,项目库会显示导入进度、文件数量和处理状态。解析完成后,DICOM 项目可与普通帧序列项目一样进入分割工作区进行标注。系统会将 DICOM 序列转换为连续帧节点,后续可按帧浏览、标注、AI 辅助分割、导入 GT Mask 和导出分割结果。项目库中会同时显示视频项目与 DICOM 项目的封面、帧数量和就绪状态。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
项目卡片中提供项目名称编辑入口。用户单击编辑按钮后,可修改项目名称,确认后系统更新项目列表中的显示名称。项目卡片旁还提供复制功能,用户可选择“新项目重置”或“全内容复制”;前者复制项目媒体和帧序列但不复制标注,后者复制项目内容及已有标注信息。删除项目时,系统会弹出确认提示,用户确认后系统移除该项目。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## 3.3 分割工作区
|
||||||
|
|
||||||
|
在项目库中选择项目后,系统进入“分割工作区”。分割工作区中部为影像或视频帧显示区域,左侧为标注工具栏,右侧为语义分类树,底部为帧序列时间轴。用户可在此完成帧浏览、手工标注、AI 自动推理、GT Mask 导入、标注保存与结果导出等工作。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
用户可通过底部时间轴切换当前帧,也可使用播放控制按钮浏览连续帧。右下角显示当前帧序号及总帧数。若某一帧已经存在人工标注、AI 标注或自动传播结果,时间轴会用不同颜色进行提示,便于用户快速定位已处理帧。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
对于 DICOM 影像序列项目,系统同样在分割工作区中显示当前切片、底部切片时间轴和右侧语义分类树。用户可像处理视频帧一样浏览 DICOM 切片并执行标注。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
用户单击底部 DICOM 切片缩略图后,系统切换到对应切片并更新当前帧序号。不同切片可分别保存人工标注、AI 结果和导出数据。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
左侧工具栏提供多边形、矩形、圆形、画笔、橡皮擦、区域合并、重叠区域去除、删除遮罩、清空遮罩、导入 GT Mask 和 AI 智能分割等工具。用户在右侧语义分类树中选择分类后,可使用绘制工具在当前图像上创建新的标注区域;若当前已有选中遮罩,绘制内容可并入当前选中区域。各工具按钮按绘制、修正、范围处理和导入推理类别分组显示,便于用户快速选择。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
使用“创建多边形”工具时,用户可依次单击图像边界点形成标注轮廓。系统会在图像上显示已放置顶点和临时边界线,帮助用户确认轮廓位置。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
多边形闭合后,系统生成遮罩并自动选中该遮罩,界面显示可编辑边界点。用户可继续拖动顶点微调区域,也可切换语义分类树修改该遮罩所属类别。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
用户选择矩形工具后,可在图像上按住鼠标并拖拽形成矩形遮罩。矩形遮罩创建后同样进入可编辑状态,便于用户调整边界或修改分类。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
用户选择画笔工具后,可调节画笔大小并在图像中连续涂画。若已有选中遮罩,涂画内容会并入当前遮罩;若未选中遮罩,系统会按照当前语义分类创建新的遮罩。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
当需要处理多个相邻或相关区域时,用户可选择“区域合并”功能,将多个遮罩合并为同一区域;选择“重叠区域去除”功能,可对重叠遮罩进行裁决处理。对于传播产生的连续帧遮罩,系统会提示用户选择操作范围,包括当前帧、指定范围帧或所有传播帧。
|
||||||
|
|
||||||
|
用户可通过“DEL”按钮或键盘删除选中遮罩,也可通过“清空遮罩”按钮清除当前帧或传播范围内的遮罩。若选择的范围包含人工标注帧,系统会提示用户确认是否同步处理人工标注内容,避免误删重要数据。
|
||||||
|
|
||||||
|
右侧“语义分类树”用于管理当前项目的标注类别。用户可查看每个分类的颜色、名称和 maskid,也可通过分类右侧的显示按钮控制该类遮罩的显示状态。没有选中任何遮罩时,单击分类仅改变后续新建遮罩的默认分类。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
当图像中已有选中遮罩时,用户单击右侧语义分类树中的其他分类,系统会将该遮罩调整为新的语义类别,并同步更新遮罩颜色和右侧当前选中分类状态。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 3.4 AI 自动推理
|
||||||
|
|
||||||
|
在分割工作区中,用户可单击左侧工具栏的“AI自动推理”按钮。系统进入传播范围选择状态,并在顶部显示传播权重、起始帧、结束帧、向前传播帧数和向后传播帧数。用户可在时间轴上拖拽选择范围,也可直接填写帧号。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
确认范围后,用户单击“开始传播”按钮。系统以当前帧已有遮罩为参考,在指定范围内自动生成连续帧分割结果。传播期间,界面顶部显示当前处理状态,用户可继续观察时间轴与当前帧区域。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
传播完成后,系统会保存生成结果,并在底部时间轴中用自动传播颜色标识相关帧段。用户可切换到传播后的帧检查遮罩位置,必要时继续使用多边形调整、画笔、橡皮擦或语义分类树进行修正。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
用户单击自动推理生成的目标帧后,系统会显示该帧的传播结果。用户可继续单击遮罩查看当前区域效果,并根据实际需要进行后续修正或保存。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 3.5 AI 智能分割
|
||||||
|
|
||||||
|
单击左侧导航栏中的“AI智能分割”按钮,系统进入模型端推理可视化界面。用户可选择 SAM 2.1 Tiny、Small、Base+、Large 等模型权重。系统会显示当前模型是否可用以及设备状态,若模型不可用,对应选项将不可执行。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
AI 智能分割界面提供正向选点、反向选点、边界框选和视口控制等交互式提示工具。用户可先选择提示方式,再在图像区域中添加对应提示。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
用户可在图像中添加正向点、反向点或边界框提示,系统根据提示生成候选分割结果。添加正向点时,用户应单击目标区域内部;添加反向点时,用户应单击希望排除的区域。提示点会显示在图像上,用户可根据需要删除提示点后重新选择。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
切换到“反向选点”后,用户可在不希望纳入结果的区域添加反向提示点。正向点与反向点会同时显示在图像上,系统据此细化目标区域与排除区域之间的边界。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
单击“执行高精度语义分割”后,系统生成候选遮罩并叠加显示在图像上。仅使用正向点时,系统会围绕目标点生成候选区域,用户可判断该结果是否符合预期。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
正向点与反向点共同使用时,系统会结合包含区域和排除区域重新细化候选遮罩。用户可查看候选遮罩边界,并可继续添加提示点进行细化。确认结果后,用户可将分割结果推送至分割工作区,并在工作区中继续进行语义分类、顶点调整和保存。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
用户也可选择“边界框选”方式,在图像中拖拽形成矩形提示框。系统会以框选范围作为主要参考区域进行分割。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
执行分割后,系统在框选范围内生成候选遮罩,用户可根据遮罩覆盖情况决定是否继续细化或推送至分割工作区。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## 3.6 GT Mask 导入与结果预览
|
||||||
|
|
||||||
|
在分割工作区中,用户可单击“导入 GT Mask”按钮,选择本地 GT Mask 图片。系统会检查图片是否符合要求:GT 图片应为灰度图,或 RGB 三通道像素值一致的图像,像素值代表对应 maskid,0 代表背景。若图片尺寸与当前帧不同,系统会自动按当前帧尺寸适配。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
导入后,系统显示导入结果预览。若图片中存在当前模板未定义的类别,系统会提示用户选择处理方式:可舍弃未知类别,也可将其作为“待分类”遮罩导入,后续再重新命名。若图片不符合要求或没有非背景区域,系统会显示错误提示并拒绝导入。
|
||||||
|
|
||||||
|
## 3.7 分割结果导出
|
||||||
|
|
||||||
|
完成标注后,用户可在分割工作区单击“分割结果导出”按钮。系统弹出导出配置界面,用户可选择导出范围,包括当前图片、特定范围帧或整体视频。系统默认导出当前帧结果,用户也可在时间轴上拖拽选择导出范围。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
导出内容可包括分开二值 mask、GT_label 黑白图、Pro_label 彩色图、Mix_label 原图叠加图及标注数据文件。用户确认后,系统生成压缩包并下载到本地。导出的 GT_label 文件中,像素值与语义分类的 maskid 对应;“待分类”与背景均以 0 值表示。
|
||||||
|
|
||||||
|
## 3.8 模板库管理
|
||||||
|
|
||||||
|
单击左侧“模板库”按钮,系统进入生效中模板架构清单页面。模板库用于管理语义分类模板,系统内置腹腔镜胆囊切除术、头颈部 CT 分割等默认模板。用户可查看模板名称、描述和分类树,并可复制模板形成新的可编辑模板。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
进入模板详情后,用户可查看“语义分类树(拖拽调层级)”。用户可单击“编辑模板”按钮新增或修改分类,也可通过拖拽调整分类层级。每个分类包含名称、颜色和 maskid 信息,其中“待分类”分类固定为 maskid 0,不可删除,且始终位于分类树最后。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
系统支持批量导入分类。用户可在批量导入区域粘贴颜色和名称数据,系统预览分类数量、颜色和 maskid 分配结果。确认后,分类将加入模板并可在分割工作区中使用。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## 3.9 用户管理与审计
|
||||||
|
|
||||||
|
管理员可单击左侧“用户管理”进入用户管理后台。该页面显示当前用户列表、用户角色、启用状态和操作按钮。管理员可创建标注员账号、修改用户密码、停用或启用用户,也可删除不再使用的账号。
|
||||||
|
|
||||||
|
用户管理页面还显示安全审计日志。管理员可查看登录、用户管理、恢复演示环境等操作记录,用于追踪系统使用情况。若需要恢复演示环境,管理员可单击“恢复演示出厂设置”,并按界面提示输入确认文本。系统完成恢复后,仅保留默认管理员、演示视频项目、演示 DICOM 项目和默认模板数据。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 3.10 退出系统
|
||||||
|
|
||||||
|
用户完成工作后,可单击左下角当前用户图标旁的退出按钮。系统清除当前登录状态并返回登录界面。再次使用时,用户需重新输入账号和密码登录系统。
|
||||||
|
|
||||||
|

|
||||||
BIN
新撰写软著文档/2. 软著登记表.docx
Normal file
62
新撰写软著文档/2. 软著登记表.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
受理号
|
||||||
|
|
||||||
|
分类号
|
||||||
|
|
||||||
|
登记号
|
||||||
|
|
||||||
|
# 计算机软件著作权登记申请表
|
||||||
|
|
||||||
|
中国版权保护中心
|
||||||
|
|
||||||
|
注意:表中红色字体栏的部分为必填项。
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><td rowspan="3">软件基本信息</td><td>软件名称</td><td colspan="2">多模态影像及视频智能语义分割与标注系统</td><td colspan="2">版本号</td><td colspan="2">V1.0</td></tr>
|
||||||
|
<tr><td>软件简称</td><td colspan="2">智能语义分割系统</td><td colspan="2">分类号</td><td colspan="2">应用软件</td></tr>
|
||||||
|
<tr><td>软件作品说明</td><td colspan="6">☑原创 □修改(含翻译软件、合成软件) 修改软件须经原权利人授权 原有软件已经登记 ·原登记号:____ ·修改(翻译或合成)软件作品说明:____</td></tr>
|
||||||
|
<tr><td colspan="2">开发完成日期</td><td colspan="6">____年____月____日</td></tr>
|
||||||
|
<tr><td colspan="2">发表状态</td><td colspan="6">□已发表 首次发表日期:____年____月____日 首次发表地点:____ ☑未发表(允许公众查询)</td></tr>
|
||||||
|
<tr><td colspan="2">开发方式</td><td colspan="6">☑独立开发 □合作开发 □委托开发 □下达任务开发</td></tr>
|
||||||
|
<tr><td rowspan="6">著作权人</td><td>姓名或名称</td><td>证件类型</td><td colspan="2">证件号</td><td>国籍</td><td>省份/城市</td><td>园区(非必填)</td></tr>
|
||||||
|
<tr><td>待填写</td><td>待填写</td><td colspan="2">待填写</td><td>中国</td><td>待填写</td><td></td></tr>
|
||||||
|
<tr><td>待填写</td><td>待填写</td><td colspan="2">待填写</td><td>中国</td><td>待填写</td><td></td></tr>
|
||||||
|
<tr><td>待填写</td><td>待填写</td><td colspan="2">待填写</td><td>中国</td><td>待填写</td><td></td></tr>
|
||||||
|
<tr><td>待填写</td><td>待填写</td><td colspan="2">待填写</td><td>中国</td><td>待填写</td><td></td></tr>
|
||||||
|
<tr><td>待填写</td><td>待填写</td><td colspan="2">待填写</td><td>中国</td><td>待填写</td><td></td></tr>
|
||||||
|
<tr><td rowspan="2">权利说明</td><td>权利取得方式</td><td colspan="6">☑原始取得 □继受取得(□受让 □承受 □继承) □原软件已登记(原登记号:____) □原登记做过变更或补充(变更或补充证明书编号:____)</td></tr>
|
||||||
|
<tr><td>权利范围</td><td colspan="6">☑全部 □部分(□发表权 □署名权 □修改权 □复制权 □发行权 □出租权 □信息网络传播权 □翻译权 □应当由著作权人享有的其他权利)</td></tr>
|
||||||
|
<tr><td>鉴别材料</td><td>一般交存☑</td><td colspan="6">提交源程序前连续的30页和后连续的30页;提交任何一种文档的前连续的30页和后连续的30页;☑一种文档</td></tr>
|
||||||
|
<tr><td>鉴别材料</td><td>例外交存□</td><td colspan="6">不填</td></tr>
|
||||||
|
<tr><td rowspan="9">软件功能和技术特点</td><td>开发的硬件环境(50字以内)</td><td colspan="6">采用具备多核处理器、32GB以上内存、SSD存储及NVIDIA GPU的工作站或服务器进行开发和调试。</td></tr>
|
||||||
|
<tr><td>运行的硬件环境(50字以内)</td><td colspan="6">支持部署于标准服务器或工作站;建议配置多核CPU、16GB以上内存、SSD存储,AI推理建议配置NVIDIA GPU。</td></tr>
|
||||||
|
<tr><td>开发该软件的操作系统(50字以内)</td><td colspan="6">基于Linux操作系统进行开发,主要使用Ubuntu环境完成前后端开发、Docker部署和模型联调。</td></tr>
|
||||||
|
<tr><td>软件开发环境/开发工具(50字以内)</td><td colspan="6">前端采用React、TypeScript、Vite开发;后端采用FastAPI;数据库为PostgreSQL,配合Redis、MinIO和Docker。</td></tr>
|
||||||
|
<tr><td>该软件的运行平台/操作系统(50字以内)</td><td colspan="6">采用B/S架构,服务端支持Linux和Docker环境,客户端可通过Chrome、Edge等现代浏览器访问。</td></tr>
|
||||||
|
<tr><td>软件运行支撑环境/支持软件(50字以内)</td><td colspan="6">运行依赖Docker、PostgreSQL、Redis、MinIO、Python运行环境、Node构建环境及可选NVIDIA Container Toolkit。</td></tr>
|
||||||
|
<tr><td>编程语言及版本号</td><td>前端采用TypeScript 5与React 19;后端采用Python 3.11与FastAPI。</td><td>源程序量</td><td colspan="4">约23337行代码</td></tr>
|
||||||
|
<tr><td>开发目的(50字以内)</td><td>为医学影像与视频数据提供智能语义分割、标注管理和结果导出工具,提升标注效率与数据质量。</td><td>面向领域/行业(50字以内)</td><td colspan="4">面向医疗影像、医学视频分析、科研标注、智能诊疗辅助和多模态数据管理等领域。</td></tr>
|
||||||
|
<tr><td>主要功能(200字以内)</td><td colspan="6">本系统支持用户登录、项目库管理、视频与DICOM序列导入、帧序列生成、交互式图像标注、语义分类模板管理、SAM 2.1智能分割、连续帧AI自动推理、GT Mask导入校验、遮罩编辑、区域合并、重叠去除、任务进度监控、用户与审计管理及分割结果导出。系统可对多模态影像和视频数据进行规范化标注,支持生成GT_label、Pro_label、Mix_label等结果文件。</td></tr>
|
||||||
|
<tr><td></td><td>技术特点(100字以内)</td><td colspan="6">系统采用前后端分离B/S架构,集成React交互式Canvas标注、FastAPI服务、Celery任务队列、MinIO对象存储和SAM 2.1模型推理,支持CPU/GPU部署及Docker自包含发布。</td></tr>
|
||||||
|
<tr><td rowspan="4">申请人信息</td><td>姓名或名称(必须包括著作权人)</td><td>待填写</td><td>电话</td><td colspan="4">待填写</td></tr>
|
||||||
|
<tr><td>详细地址</td><td>待填写</td><td>邮编</td><td colspan="4">待填写</td></tr>
|
||||||
|
<tr><td>联系人</td><td>待填写</td><td>手机</td><td colspan="4">待填写</td></tr>
|
||||||
|
<tr><td>E-mail</td><td>待填写</td><td>传真</td><td colspan="4">待填写</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
注:以上表格中未标注非必填的内容均为必填项,请申请人根据实际主体信息补充完整。
|
||||||
|
|
||||||
|
现申请之前需要先提供材料,帮著作权人进行实名认证,周期通常为1-3个工作日。材料如下:
|
||||||
|
|
||||||
|
著作权人为企业的:
|
||||||
|
|
||||||
|
1. 申请人营业执照扫描件;
|
||||||
|
2. 被授权人或企业联系人身份证正反面扫描件,需保持清晰;
|
||||||
|
3. 提供真实有效的手机号码和邮箱号码;
|
||||||
|
4. 提供授权联系人相关盖章扫描件。
|
||||||
|
|
||||||
|
著作权人为个人的:
|
||||||
|
|
||||||
|
1. 提供联系人身份证正反面扫描件及手持身份证正面照片;
|
||||||
|
2. 提供真实有效的手机号码和邮箱号码。
|
||||||
|
|
||||||
|
注册时,版权登记平台可能向预留手机号发送验证码,请及时提供。注册成功后,在系统中填写申请表时,也可能再次向预留手机号发送验证码,请按要求配合完成验证。
|
||||||
BIN
新撰写软著文档/3. 代码汇总.docx
Normal file
1023
新撰写软著文档/3. 代码汇总.md
Normal file
BIN
新撰写软著文档/images/01-login.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
新撰写软著文档/images/02-dashboard.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
新撰写软著文档/images/03-main-layout.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
新撰写软著文档/images/04-dashboard-tasks.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
新撰写软著文档/images/05-project-library.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
新撰写软著文档/images/06-import-media-options.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
新撰写软著文档/images/07-project-copy-dialog.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
新撰写软著文档/images/08-frame-parse-dialog.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
新撰写软著文档/images/09-workspace-main.png
Normal file
|
After Width: | Height: | Size: 784 KiB |
BIN
新撰写软著文档/images/10-workspace-tools.png
Normal file
|
After Width: | Height: | Size: 784 KiB |
BIN
新撰写软著文档/images/11-workspace-draw-mask.png
Normal file
|
After Width: | Height: | Size: 783 KiB |
BIN
新撰写软著文档/images/12-workspace-auto-propagate-range.png
Normal file
|
After Width: | Height: | Size: 792 KiB |
BIN
新撰写软著文档/images/13-workspace-export-dialog.png
Normal file
|
After Width: | Height: | Size: 838 KiB |
BIN
新撰写软著文档/images/14-gt-mask-import-preview.png
Normal file
|
After Width: | Height: | Size: 638 KiB |
BIN
新撰写软著文档/images/15-ai-page.png
Normal file
|
After Width: | Height: | Size: 641 KiB |
BIN
新撰写软著文档/images/16-ai-prompt-tools.png
Normal file
|
After Width: | Height: | Size: 652 KiB |
BIN
新撰写软著文档/images/17-template-library.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
新撰写软著文档/images/18-template-edit-dialog.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
新撰写软著文档/images/19-template-batch-import.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
新撰写软著文档/images/20-user-admin.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
新撰写软著文档/images/21-user-create-dialog.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
新撰写软著文档/images/22-audit-reset-dialog.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
新撰写软著文档/images/23-logout.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
新撰写软著文档/images/24-workspace-current-frame-timeline.png
Normal file
|
After Width: | Height: | Size: 779 KiB |
BIN
新撰写软著文档/images/25-create-polygon-vertices.png
Normal file
|
After Width: | Height: | Size: 802 KiB |
BIN
新撰写软著文档/images/26-create-polygon-completed.png
Normal file
|
After Width: | Height: | Size: 792 KiB |
BIN
新撰写软著文档/images/27-ai-auto-inference-range.png
Normal file
|
After Width: | Height: | Size: 799 KiB |
BIN
新撰写软著文档/images/28-ai-auto-inference-running.png
Normal file
|
After Width: | Height: | Size: 807 KiB |
BIN
新撰写软著文档/images/29-ai-auto-inference-completed.png
Normal file
|
After Width: | Height: | Size: 800 KiB |
BIN
新撰写软著文档/images/30-ai-segmentation-positive-point.png
Normal file
|
After Width: | Height: | Size: 630 KiB |
BIN
新撰写软著文档/images/31-ai-segmentation-result.png
Normal file
|
After Width: | Height: | Size: 588 KiB |
BIN
新撰写软著文档/images/32-workspace-current-frame-switched.png
Normal file
|
After Width: | Height: | Size: 790 KiB |
BIN
新撰写软著文档/images/33-ai-auto-inference-wide-range.png
Normal file
|
After Width: | Height: | Size: 799 KiB |
BIN
新撰写软著文档/images/34-ai-auto-inference-result-selected.png
Normal file
|
After Width: | Height: | Size: 795 KiB |
BIN
新撰写软著文档/images/35-ai-segmentation-negative-point.png
Normal file
|
After Width: | Height: | Size: 630 KiB |
BIN
新撰写软著文档/images/36-ai-segmentation-positive-negative-result.png
Normal file
|
After Width: | Height: | Size: 588 KiB |
BIN
新撰写软著文档/images/37-logout-hover.png
Normal file
|
After Width: | Height: | Size: 590 KiB |
BIN
新撰写软著文档/images/38-logout-button-hover.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
新撰写软著文档/images/39-project-library-refreshed.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
新撰写软著文档/images/40-workspace-rectangle-mask.png
Normal file
|
After Width: | Height: | Size: 780 KiB |
BIN
新撰写软著文档/images/41-workspace-brush-mask.png
Normal file
|
After Width: | Height: | Size: 782 KiB |
BIN
新撰写软著文档/images/43-ai-segmentation-box-prompt.png
Normal file
|
After Width: | Height: | Size: 652 KiB |
BIN
新撰写软著文档/images/44-ai-segmentation-box-result.png
Normal file
|
After Width: | Height: | Size: 652 KiB |
BIN
新撰写软著文档/images/45-dicom-workspace-main.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
新撰写软著文档/images/46-dicom-frame-switched.png
Normal file
|
After Width: | Height: | Size: 214 KiB |
BIN
新撰写软著文档/images/47-semantic-tree-selected-mask-before.png
Normal file
|
After Width: | Height: | Size: 782 KiB |
BIN
新撰写软著文档/images/48-semantic-tree-change-category.png
Normal file
|
After Width: | Height: | Size: 782 KiB |
83
新撰写软著文档/功能验证与素材清单.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# 功能验证与素材清单
|
||||||
|
|
||||||
|
验证地址:https://seg.huijutec.cn/
|
||||||
|
验证时间:2026/5/8 02:07:00
|
||||||
|
|
||||||
|
## 截图文件
|
||||||
|
- images/01-login.png:系统登录界面图
|
||||||
|
- images/02-dashboard.png:系统登录后总体概况界面图
|
||||||
|
- images/03-main-layout.png:系统主界面整体布局图
|
||||||
|
- images/04-dashboard-tasks.png:后台任务列表及任务进度界面图
|
||||||
|
- images/05-project-library.png:项目库列表界面图
|
||||||
|
- images/06-import-media-options.png:导入视频与DICOM资源选择界面图
|
||||||
|
- images/07-project-copy-dialog.png:项目复制操作界面图
|
||||||
|
- images/08-frame-parse-dialog.png:视频生成帧配置界面图
|
||||||
|
- images/09-workspace-main.png:分割工作区主界面图
|
||||||
|
- images/10-workspace-tools.png:左侧标注工具栏与语义分类树界面图
|
||||||
|
- images/11-workspace-draw-mask.png:多边形、矩形、圆形和画笔标注操作界面图
|
||||||
|
- images/12-workspace-auto-propagate-range.png:AI自动推理范围选择界面图
|
||||||
|
- images/13-workspace-export-dialog.png:分割结果导出配置界面图
|
||||||
|
- images/14-gt-mask-import-preview.png:GT Mask导入预览界面图
|
||||||
|
- images/15-ai-page.png:AI智能分割模型选择界面图
|
||||||
|
- images/16-ai-prompt-tools.png:AI智能分割点选和框选工具界面图
|
||||||
|
- images/17-template-library.png:模板库模板清单界面图
|
||||||
|
- images/18-template-edit-dialog.png:模板分类树编辑界面图
|
||||||
|
- images/19-template-batch-import.png:模板批量导入分类界面图
|
||||||
|
- images/20-user-admin.png:管理员用户管理后台界面图
|
||||||
|
- images/21-user-create-dialog.png:新增标注员账号界面图
|
||||||
|
- images/22-audit-reset-dialog.png:审计日志和恢复演示出厂设置确认界面图
|
||||||
|
- images/23-logout.png:退出登录按钮和返回登录界面图
|
||||||
|
- images/24-workspace-current-frame-timeline.png:分割工作区当前帧和帧序列时间轴界面图
|
||||||
|
- images/25-create-polygon-vertices.png:创建多边形过程中顶点和临时轮廓界面图
|
||||||
|
- images/26-create-polygon-completed.png:多边形闭合后遮罩和可编辑边界点界面图
|
||||||
|
- images/27-ai-auto-inference-range.png:AI自动推理传播范围和权重选择界面图
|
||||||
|
- images/28-ai-auto-inference-running.png:AI自动推理任务运行状态界面图
|
||||||
|
- images/29-ai-auto-inference-completed.png:AI自动推理完成后传播帧段标识界面图
|
||||||
|
- images/30-ai-segmentation-positive-point.png:AI智能分割正向点提示界面图
|
||||||
|
- images/31-ai-segmentation-result.png:AI智能分割候选结果叠加界面图
|
||||||
|
- images/32-workspace-current-frame-switched.png:分割工作区切换当前帧后的时间轴界面图
|
||||||
|
- images/33-ai-auto-inference-wide-range.png:AI自动推理较大传播范围选择界面图
|
||||||
|
- images/34-ai-auto-inference-result-selected.png:AI自动推理生成帧结果查看界面图
|
||||||
|
- images/35-ai-segmentation-negative-point.png:AI智能分割反向点提示界面图
|
||||||
|
- images/36-ai-segmentation-positive-negative-result.png:AI智能分割正向点与反向点共同生成结果界面图
|
||||||
|
- images/37-logout-hover.png:AI智能分割页面退出按钮悬停提示界面图
|
||||||
|
- images/38-logout-button-hover.png:总体概况页面退出按钮悬停提示界面图
|
||||||
|
- images/39-project-library-refreshed.png:项目库视频项目与DICOM项目就绪状态界面图
|
||||||
|
- images/40-workspace-rectangle-mask.png:分割工作区矩形遮罩创建界面图
|
||||||
|
- images/41-workspace-brush-mask.png:分割工作区画笔涂画与遮罩合并界面图
|
||||||
|
- images/43-ai-segmentation-box-prompt.png:AI智能分割边界框提示界面图
|
||||||
|
- images/44-ai-segmentation-box-result.png:AI智能分割边界框生成结果界面图
|
||||||
|
- images/45-dicom-workspace-main.png:DICOM影像序列进入分割工作区界面图
|
||||||
|
- images/46-dicom-frame-switched.png:DICOM影像序列切换切片界面图
|
||||||
|
- images/47-semantic-tree-selected-mask-before.png:右侧语义分类树选中遮罩初始分类界面图
|
||||||
|
- images/48-semantic-tree-change-category.png:右侧语义分类树调整遮罩类别界面图
|
||||||
|
|
||||||
|
## 分段视频
|
||||||
|
- 系统使用视频/01-登录与总体概况演示.mp4
|
||||||
|
- 系统使用视频/02-项目库与分割工作区演示.mp4
|
||||||
|
- 系统使用视频/03-AI推理与结果导出演示.mp4
|
||||||
|
- 系统使用视频/04-模板库与用户管理演示.mp4
|
||||||
|
- 系统使用视频/05-创建多边形标注演示.mp4
|
||||||
|
- 系统使用视频/06-AI自动推理传播演示.mp4
|
||||||
|
- 系统使用视频/07-AI智能分割点选推理演示.mp4
|
||||||
|
- 系统使用视频/01-登录与总体概况演示.webm
|
||||||
|
- 系统使用视频/02-项目库与分割工作区演示.webm
|
||||||
|
- 系统使用视频/03-AI推理与结果导出演示.webm
|
||||||
|
- 系统使用视频/04-模板库与用户管理演示.webm
|
||||||
|
- 系统使用视频/05-创建多边形标注演示.webm
|
||||||
|
- 系统使用视频/06-AI自动推理传播演示.webm
|
||||||
|
- 系统使用视频/07-AI智能分割点选推理演示.webm
|
||||||
|
|
||||||
|
## 验证说明
|
||||||
|
本次验证以管理员账号进入线上系统,逐项检查登录、总体概况、项目库、DICOM影像序列工作区、分割工作区、切换当前帧、创建多边形标注、矩形标注、画笔标注、右侧语义分类树调整类别、AI 智能分割正向点/反向点/边界框推理、AI 自动推理较大范围传播、传播结果帧查看、GT Mask 导入预览、分割结果导出、模板库、用户管理、审计日志和退出登录等说明书涉及功能。删除项目、恢复演示出厂设置、生成帧确认、导出下载确认等可能改变演示环境或产生下载文件的危险提交动作仅验证入口与确认界面,不执行最终提交。说明书已调整为同一功能区域可配置多张不同功能截图,但每个截图文件只引用一次,避免同一界面图重复出现在多个功能段落;说明书正文已移除“查看分段演示视频”类链接文本。
|
||||||
|
|
||||||
|
## 自动化测试补充
|
||||||
|
|
||||||
|
| 功能点 | 测试文件 | 覆盖内容 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| AI 智能分割 | `src/components/AISegmentation.test.tsx`、`src/components/ToolsPalette.test.tsx` | 验证 SAM 2.1 模型选择、模型不可用禁用、正向点/反向点/框选提示、执行高精度语义分割请求参数、AI 页面不显示 SAM3 入口、工作区左侧“打开 AI 智能分割”按钮使用 AI 图标并触发导航。 |
|
||||||
|
| AI 自动推理 | `src/components/VideoWorkspace.test.tsx`、`src/components/ToolsPalette.test.tsx` | 验证左侧彩色“AI自动推理”入口位于橡皮擦下方、点击后进入传播范围选择、参考帧无遮罩时不入队、传播权重与起止帧进入后台任务、同参考帧多个 mask 会生成多 step 传播任务。 |
|
||||||
|
| 创建多边形及手工绘制 | `src/components/CanvasArea.test.tsx`、`src/components/ToolsPalette.test.tsx` | 验证工具栏能切换到“创建多边形”,多边形点击取点后可按 Enter 完成、三点后可点击首节点闭合、Esc 只取消临时点和选区且不删除已有 mask,创建完成后自动选中新 mask 并显示边界点。 |
|
||||||
|
| 画笔、橡皮擦和绘制尺寸 | `src/components/CanvasArea.test.tsx`、`src/components/ToolsPalette.test.tsx` | 验证画笔/橡皮擦尺寸滑杆、画笔无选中时创建当前语义 mask、有选中时并入选中 mask、画笔不能从图外创建、靠边笔触会裁剪到图像范围内、橡皮擦从选中 mask 扣除。 |
|
||||||
|
|
||||||
|
以上测试均使用 Vitest、Testing Library、mock Konva 和 mock 后端接口完成,不依赖真实 GPU、真实模型权重或线上服务,可作为说明书截图之外的交互回归验证。
|
||||||
350
新撰写软著文档/撰写Agent.md
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
# 软著文档撰写 Agent 工作流
|
||||||
|
|
||||||
|
本文件是通用软著材料生成流程。该 Agent 的正式撰写工作必须在用户给出 `参考软著构建模板/` 后开始:先学习模板,再分析项目源码和基本信息,最后生成软著说明书、登记表、代码汇总和可编辑 Word 文档。
|
||||||
|
|
||||||
|
## 1. 角色定位
|
||||||
|
|
||||||
|
你是一名软件技术文档工程师和中国计算机软件著作权申报材料撰写专家。
|
||||||
|
|
||||||
|
目标是根据参考模板、项目基本信息、源码和实际界面,生成符合软著申报习惯的材料。
|
||||||
|
|
||||||
|
说明书面向用户和审查人员,不面向开发者。不要在说明书中出现“调用 API”“路由跳转”“触发接口”“state”“payload”等开发黑话。
|
||||||
|
|
||||||
|
## 2. 标准目录
|
||||||
|
|
||||||
|
```text
|
||||||
|
参考软著构建模板/
|
||||||
|
待分析代码目录/
|
||||||
|
新撰写软著文档/
|
||||||
|
```
|
||||||
|
|
||||||
|
`参考软著构建模板/` 是启动撰写的前置材料,存放样例文档,如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
参考-1. 软著说明书.md
|
||||||
|
参考-2. 软著登记表.md
|
||||||
|
参考-3. 代码汇总.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`待分析代码目录/` 存放源码、配置、README、部署文档、基本信息。
|
||||||
|
|
||||||
|
`新撰写软著文档/` 是最终输出目录。
|
||||||
|
|
||||||
|
标准交付文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
新撰写软著文档/1. 软著说明书.md
|
||||||
|
新撰写软著文档/2. 软著登记表.md
|
||||||
|
新撰写软著文档/3. 代码汇总.md
|
||||||
|
新撰写软著文档/1. 软著说明书.docx
|
||||||
|
新撰写软著文档/2. 软著登记表.docx
|
||||||
|
新撰写软著文档/3. 代码汇总.docx
|
||||||
|
```
|
||||||
|
|
||||||
|
可选辅助目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
新撰写软著文档/images/
|
||||||
|
新撰写软著文档/系统使用视频/
|
||||||
|
新撰写软著文档/功能验证与素材清单.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 启动条件
|
||||||
|
|
||||||
|
正式撰写必须满足:
|
||||||
|
|
||||||
|
- 用户已经提供或复制 `参考软著构建模板/`。
|
||||||
|
- 模板中至少包含说明书、登记表、代码汇总三个参考样例。
|
||||||
|
- 用户已经提供待分析源码目录或核心代码。
|
||||||
|
|
||||||
|
如果用户尚未提供 `参考软著构建模板/`,不要开始撰写三份正式文档,应提示用户先提供该目录。
|
||||||
|
|
||||||
|
收到模板后,再确认用户是否提供:
|
||||||
|
|
||||||
|
- 软件名称、简称、版本号。
|
||||||
|
- 著作权人、申请人、联系人。
|
||||||
|
- 开发完成日期、首次发表日期。
|
||||||
|
- 源码目录。
|
||||||
|
- 运行地址或启动方式。
|
||||||
|
- 登录账号、演示数据或测试账号。
|
||||||
|
- 是否需要截图、录屏和 Word 版本。
|
||||||
|
|
||||||
|
信息缺失时,不要虚构关键申报字段,可写“待确认”,并在最终回复中提示用户补充。
|
||||||
|
|
||||||
|
如果用户只是在说明工作流、尚未给出模板和源码,回复:
|
||||||
|
|
||||||
|
```text
|
||||||
|
工作流已就绪
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 学习模板
|
||||||
|
|
||||||
|
这是正式撰写的第一步。收到 `参考软著构建模板/` 后,必须先读模板,再写文档。
|
||||||
|
|
||||||
|
重点学习:
|
||||||
|
|
||||||
|
- 说明书章节顺序和图文排版。
|
||||||
|
- 登记表字段、表格和填写口径。
|
||||||
|
- 代码汇总的文件标注和摘抄方式。
|
||||||
|
- 模板中的常用正式表述。
|
||||||
|
|
||||||
|
新文档内容必须来自当前项目,不得照搬模板业务内容。
|
||||||
|
|
||||||
|
模板学习完成后,再进入项目源码分析。不要跳过模板直接按通用格式生成。
|
||||||
|
|
||||||
|
## 5. 分析项目
|
||||||
|
|
||||||
|
优先阅读:
|
||||||
|
|
||||||
|
- `README`、安装部署文档、产品说明。
|
||||||
|
- 前端入口、导航、页面、路由或模块切换。
|
||||||
|
- 后端入口、路由、控制器、服务层、数据模型。
|
||||||
|
- 配置文件、依赖文件、数据库和中间件配置。
|
||||||
|
- 测试文件和演示数据。
|
||||||
|
|
||||||
|
需要整理:
|
||||||
|
|
||||||
|
- 用户角色和权限。
|
||||||
|
- 注册、登录、退出流程。
|
||||||
|
- 主界面结构。
|
||||||
|
- 核心业务模块。
|
||||||
|
- 数据列表、详情、新增、编辑、删除、导入、导出。
|
||||||
|
- 支持的数据类型,如图片、视频、DICOM、Excel、PDF、音频等。
|
||||||
|
- AI、算法、自动处理、任务进度、模板库、审计日志等特色功能。
|
||||||
|
|
||||||
|
## 6. 撰写说明书
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
新撰写软著文档/1. 软著说明书.md
|
||||||
|
```
|
||||||
|
|
||||||
|
必须包含:
|
||||||
|
|
||||||
|
- 系统注册或账号创建。
|
||||||
|
- 系统登录。
|
||||||
|
- 系统核心管理界面。
|
||||||
|
- 核心业务流程。
|
||||||
|
- 数据列表与信息查看。
|
||||||
|
- 新增、编辑、删除、导入、导出。
|
||||||
|
- 用户管理、设置、审计日志等辅助功能。
|
||||||
|
- 退出系统。
|
||||||
|
|
||||||
|
如系统没有自助注册,应写管理员创建账号,不要虚构注册页。
|
||||||
|
|
||||||
|
语言要求:
|
||||||
|
|
||||||
|
- 使用“用户单击”“系统显示”“填写完成后”“确认后”等正式操作说明。
|
||||||
|
- 不写接口、路由、组件、函数、数据库字段。
|
||||||
|
- 把代码逻辑转化为用户可见的页面、按钮、表单、列表和结果。
|
||||||
|
|
||||||
|
## 7. 图片与视频
|
||||||
|
|
||||||
|
初稿可用占位符:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[插入图片:系统登录界面图]
|
||||||
|
```
|
||||||
|
|
||||||
|
真实截图使用:
|
||||||
|
|
||||||
|
```text
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
截图规则:
|
||||||
|
|
||||||
|
- 一个功能区域可以放多张图。
|
||||||
|
- 每张图代表不同动作或状态。
|
||||||
|
- 同一张图不要重复引用。
|
||||||
|
- 核心功能必须图文并茂。
|
||||||
|
- 复杂功能至少展示入口、配置、执行中、结果。
|
||||||
|
- 多数据类型系统要分别展示,例如视频和 DICOM 都要有图。
|
||||||
|
- 不使用错误状态、半加载状态或遮挡严重的截图。
|
||||||
|
|
||||||
|
建议命名:
|
||||||
|
|
||||||
|
```text
|
||||||
|
images/01-login.png
|
||||||
|
images/02-dashboard.png
|
||||||
|
images/03-project-list.png
|
||||||
|
images/04-import-dialog.png
|
||||||
|
images/05-feature-config.png
|
||||||
|
images/06-feature-result.png
|
||||||
|
```
|
||||||
|
|
||||||
|
如需录屏,可分模块录制,不要求一次录完。
|
||||||
|
|
||||||
|
视频放入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
新撰写软著文档/系统使用视频/
|
||||||
|
```
|
||||||
|
|
||||||
|
若用户要求不在说明书中展示视频链接,不要写“查看分段演示视频”。
|
||||||
|
|
||||||
|
## 8. 撰写登记表
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
新撰写软著文档/2. 软著登记表.md
|
||||||
|
```
|
||||||
|
|
||||||
|
严格参照 `参考-2. 软著登记表.md`。
|
||||||
|
|
||||||
|
常见字段:
|
||||||
|
|
||||||
|
- 软件全称、简称、版本号。
|
||||||
|
- 著作权人、申请人、联系人。
|
||||||
|
- 开发完成日期、发表状态。
|
||||||
|
- 开发方式、权利取得方式、权利范围。
|
||||||
|
- 硬件环境、软件环境、运行平台。
|
||||||
|
- 编程语言和版本号。
|
||||||
|
- 源程序量。
|
||||||
|
- 开发目的、面向领域、主要功能、技术特点。
|
||||||
|
|
||||||
|
填写原则:
|
||||||
|
|
||||||
|
- 根据代码、配置和文档合理推断技术环境。
|
||||||
|
- 不确定字段写“待确认”。
|
||||||
|
- 主要功能控制在 200 字左右。
|
||||||
|
- 技术特点控制在 100 字左右。
|
||||||
|
- 登记表语言要精炼。
|
||||||
|
|
||||||
|
## 9. 提取代码汇总
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
新撰写软著文档/3. 代码汇总.md
|
||||||
|
```
|
||||||
|
|
||||||
|
代码汇总是核心源码摘抄,不是全量源码。
|
||||||
|
|
||||||
|
优先保留:
|
||||||
|
|
||||||
|
- 应用入口。
|
||||||
|
- 路由或模块切换。
|
||||||
|
- 核心页面状态和事件函数。
|
||||||
|
- 数据模型或实体核心字段。
|
||||||
|
- Controller、Router、Service 核心业务方法。
|
||||||
|
- 权限、导入、导出、任务处理、算法调用等关键逻辑。
|
||||||
|
|
||||||
|
删除或压缩:
|
||||||
|
|
||||||
|
- 第三方库源码。
|
||||||
|
- 构建产物。
|
||||||
|
- 样式文件和大量 CSS。
|
||||||
|
- 普通 import。
|
||||||
|
- Getter、Setter、构造方法。
|
||||||
|
- 重复 CRUD 样板。
|
||||||
|
- 无意义注释。
|
||||||
|
|
||||||
|
格式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
// src/App.tsx
|
||||||
|
...
|
||||||
|
|
||||||
|
// backend/services/ProjectService.py
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
不要在代码段之间穿插解释性文字。
|
||||||
|
|
||||||
|
## 10. 转换 Word
|
||||||
|
|
||||||
|
三份 Markdown 完成后,生成可编辑 Word:
|
||||||
|
|
||||||
|
```text
|
||||||
|
新撰写软著文档/1. 软著说明书.docx
|
||||||
|
新撰写软著文档/2. 软著登记表.docx
|
||||||
|
新撰写软著文档/3. 代码汇总.docx
|
||||||
|
```
|
||||||
|
|
||||||
|
优先使用 Pandoc:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pandoc "1. 软著说明书.md" -o "1. 软著说明书.docx"
|
||||||
|
pandoc "2. 软著登记表.md" -o "2. 软著登记表.docx"
|
||||||
|
pandoc "3. 代码汇总.md" -o "3. 代码汇总.docx"
|
||||||
|
```
|
||||||
|
|
||||||
|
如果没有 Pandoc,可用 `python-docx` 生成。
|
||||||
|
|
||||||
|
Word 要求:
|
||||||
|
|
||||||
|
- `.docx` 可编辑。
|
||||||
|
- 说明书图片嵌入 Word。
|
||||||
|
- 登记表保留可编辑表格。
|
||||||
|
- 代码汇总保留可编辑代码文本。
|
||||||
|
- 可被 Word、WPS 或 LibreOffice 打开。
|
||||||
|
|
||||||
|
建议校验:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unzip -t "1. 软著说明书.docx"
|
||||||
|
unzip -t "2. 软著登记表.docx"
|
||||||
|
unzip -t "3. 代码汇总.docx"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. 素材清单
|
||||||
|
|
||||||
|
如生成截图或视频,应同步维护:
|
||||||
|
|
||||||
|
```text
|
||||||
|
新撰写软著文档/功能验证与素材清单.md
|
||||||
|
```
|
||||||
|
|
||||||
|
建议记录:
|
||||||
|
|
||||||
|
- 验证地址或运行环境。
|
||||||
|
- 验证时间。
|
||||||
|
- 截图文件及对应功能。
|
||||||
|
- 视频文件及对应模块。
|
||||||
|
- 未执行危险操作的说明。
|
||||||
|
- 自动化测试或人工验证补充说明。
|
||||||
|
|
||||||
|
## 12. 常见注意事项
|
||||||
|
|
||||||
|
- 没有注册页:写管理员创建账号。
|
||||||
|
- 功能与代码不一致:以实际界面为准,并回查代码。
|
||||||
|
- 支持多数据类型:每类数据分别说明。
|
||||||
|
- 复杂功能:拆多步,每步配不同图。
|
||||||
|
- 截图重复:同一张图不要反复用。
|
||||||
|
- 代码太多:摘核心,不搬全量源码。
|
||||||
|
- 信息缺失:写“待确认”。
|
||||||
|
- 涉及敏感信息:使用演示数据或打码。
|
||||||
|
- 不确定的版本号、日期、主体信息不要编造。
|
||||||
|
|
||||||
|
## 13. 最终检查
|
||||||
|
|
||||||
|
交付前检查:
|
||||||
|
|
||||||
|
- 三份 Markdown 已生成。
|
||||||
|
- 三份 Word 已生成。
|
||||||
|
- Word 可打开、可编辑。
|
||||||
|
- 说明书包含注册、登录、核心界面、核心业务、退出。
|
||||||
|
- 登记表字段完整。
|
||||||
|
- 代码汇总已剔除冗余内容。
|
||||||
|
- 图片引用全部存在。
|
||||||
|
- 图片没有重复引用。
|
||||||
|
- 复杂功能有多张不同功能图。
|
||||||
|
- 多数据类型均有说明。
|
||||||
|
- 无开发黑话。
|
||||||
|
- 无模板残留内容。
|
||||||
|
- 素材清单已更新。
|
||||||
|
|
||||||
|
## 14. 最终回复
|
||||||
|
|
||||||
|
最终回复简明说明:
|
||||||
|
|
||||||
|
- 已生成或更新哪些文件。
|
||||||
|
- Word 是否已生成。
|
||||||
|
- 截图数量和是否有缺图、重复图。
|
||||||
|
- 哪些信息仍待确认。
|
||||||
|
- 是否已提交或推送。
|
||||||
|
|
||||||
|
不要在最终回复中粘贴整篇文档,除非用户明确要求。
|
||||||