commit b5413066a0758243ded564d373cc028d1c6d65b0 Author: admin <572701190@qq.com> Date: Thu May 7 19:06:07 2026 +0800 添加Docker自包含部署分支 - 新增 Seg_Server_Docker 自包含部署内容,包含前后端、FastAPI、Celery、PostgreSQL、Redis、MinIO、演示视频和 DICOM 数据。 - 保留 demo 数据以支持恢复演示出厂设置,排除 SAM 2.1 .pt 权重并在 README 中补充下载命令。 - 补充 GPU 部署、backend/worker 镜像复用、frpc/frps + NPM 公网域名反代部署说明。 - 在 .env/.env.example 中用 # XXXX 标注局域网和公网域名部署需要修改的配置项。 - 添加部署分支 .gitignore,忽略本地模型权重、构建产物、缓存和日志。 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5acd727 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +node_modules +dist +backend/__pycache__ +backend/.pytest_cache +backend/tests +backend/.env +uploads +frames +models/*.pt +demo/** +*.mp4 +*.dcm +*.7z +README.local.md diff --git a/.env b/.env new file mode 100644 index 0000000..30787fd --- /dev/null +++ b/.env @@ -0,0 +1,44 @@ +# 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 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 + +# XXXX Frontend build-time API/WebSocket endpoints. +# LAN default can stay empty because the frontend infers http://:8000. +# Public-domain example: +# VITE_API_BASE_URL=https://seg-api.example.com +# VITE_WS_PROGRESS_URL=wss://seg-api.example.com/ws/progress +VITE_API_BASE_URL= +VITE_WS_PROGRESS_URL= + +FRONTEND_PORT=3000 +BACKEND_PORT=8000 +MINIO_PORT=9000 +MINIO_CONSOLE_PORT=9001 + +POSTGRES_USER=seguser +POSTGRES_PASSWORD=segpass123 +POSTGRES_DB=segserver + +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin + +# XXXX Browser-facing MinIO endpoint used to generate image/frame presigned URLs. +# LAN example: 192.168.3.11:9000 and MINIO_SECURE=false +# Public-domain example: seg-minio.example.com and MINIO_SECURE=true +MINIO_PUBLIC_ENDPOINT=192.168.3.11:9000 +MINIO_SECURE=false + +# Local directory containing SAM 2.1 checkpoints. +# Keep this relative path so the whole Seg_Server_Docker folder can be moved. +SAM_MODELS_DIR=./models + +# 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"] +# Public-domain example: ["https://seg.example.com"] +CORS_ORIGINS=["http://192.168.3.11:3000","http://localhost:3000","http://127.0.0.1:3000"] + +JWT_SECRET_KEY=change-this-to-a-long-random-production-secret +ACCESS_TOKEN_EXPIRE_MINUTES=1440 +DEFAULT_ADMIN_USERNAME=admin +DEFAULT_ADMIN_PASSWORD=123456 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8a4d537 --- /dev/null +++ b/.env.example @@ -0,0 +1,44 @@ +# 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 Public-domain access through frpc/frps + NPM: set PUBLIC_HOST to the external frontend host, for example seg.example.com. +PUBLIC_HOST=localhost + +# XXXX Frontend build-time API/WebSocket endpoints. +# LAN default can stay empty because the frontend infers http://:8000. +# Public-domain example: +# VITE_API_BASE_URL=https://seg-api.example.com +# VITE_WS_PROGRESS_URL=wss://seg-api.example.com/ws/progress +VITE_API_BASE_URL= +VITE_WS_PROGRESS_URL= + +FRONTEND_PORT=3000 +BACKEND_PORT=8000 +MINIO_PORT=9000 +MINIO_CONSOLE_PORT=9001 + +POSTGRES_USER=seguser +POSTGRES_PASSWORD=segpass123 +POSTGRES_DB=segserver + +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin + +# XXXX Browser-facing MinIO endpoint used to generate image/frame presigned URLs. +# LAN example: localhost:9000 and MINIO_SECURE=false +# Public-domain example: seg-minio.example.com and MINIO_SECURE=true +MINIO_PUBLIC_ENDPOINT=localhost:9000 +MINIO_SECURE=false + +# 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. +SAM_MODELS_DIR=./models + +# 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"] +# Public-domain example: ["https://seg.example.com"] +CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000"] + +JWT_SECRET_KEY=change-this-to-a-long-random-production-secret +ACCESS_TOKEN_EXPIRE_MINUTES=1440 +DEFAULT_ADMIN_USERNAME=admin +DEFAULT_ADMIN_PASSWORD=123456 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8bb9df --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Local runtime data and downloaded model weights +models/*.pt +.env.local + +# Node / build outputs +node_modules/ +dist/ + +# Python cache +__pycache__/ +*.py[cod] +.pytest_cache/ + +# Logs and temporary files +*.log +*.tmp diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..4cc3d92 --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg libglib2.0-0 curl build-essential git \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/requirements-docker.txt /tmp/requirements.txt +RUN pip install --upgrade pip \ + && pip install -r /tmp/requirements.txt + +COPY backend /app/backend +WORKDIR /app/backend + +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..c486581 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,17 @@ +FROM node:20-bookworm-slim AS builder +WORKDIR /app +ARG VITE_API_BASE_URL= +ARG VITE_WS_PROGRESS_URL= +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +ENV VITE_WS_PROGRESS_URL=$VITE_WS_PROGRESS_URL +COPY package.json package-lock.json ./ +RUN npm ci +COPY index.html tsconfig.json vite.config.ts ./ +COPY src ./src +COPY public ./public +RUN npm run build + +FROM nginx:1.27-alpine +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e38ffb8 --- /dev/null +++ b/README.md @@ -0,0 +1,504 @@ +# Seg_Server_Docker 部署说明 + +`Seg_Server_Docker` 是语义分割系统的自包含 Docker 部署包。目标不是“最小可运行”,而是尽量完整、可搬迁、能适应 CPU/GPU 与局域网访问等常见环境的可运行版本。 + +部署时只需要保留并移动这一整个文件夹。默认配置只引用本目录内的 `models/`、`demo/`、前端源码、后端源码和 Docker 配置,不依赖旁边的源码仓库,也不依赖固定宿主机路径。 + +## 目录内容 + +```text +Seg_Server_Docker/ +├── docker-compose.yml # 基础服务:前端、后端、worker、PostgreSQL、Redis、MinIO +├── docker-compose.gpu.yml # GPU 覆盖配置:给 backend/worker 透传 NVIDIA GPU +├── Dockerfile.backend # FastAPI + Celery + PyTorch + SAM2 后端镜像 +├── Dockerfile.frontend # React 前端构建 + Nginx 静态服务 +├── .env # 当前部署环境变量 +├── .env.example # 环境变量模板 +├── backend/ # FastAPI 后端代码 +├── src/ # React 前端代码 +├── public/ # 前端静态资源 +├── docker/ # Nginx 配置等 Docker 辅助文件 +├── demo/ # 演示数据 +│ ├── 演视LC视频序列.mp4 +│ └── 演视DICOM序列/ +└── models/ # SAM 2.1 权重,容器内挂载为 /app/models +``` + +## 服务说明 + +| 服务 | 容器名 | 作用 | 默认端口 | +|---|---|---|---| +| frontend | `seg-frontend` | Web 前端页面 | `3000 -> 80` | +| backend | `seg-backend` | FastAPI API、登录、项目、模板、标注、单帧 AI 分割 | `8000 -> 8000` | +| worker | `seg-worker` | Celery 后台任务、视频拆帧、DICOM 解析、AI 自动推理/传播 | 内部访问 | +| postgres | `seg-postgres` | 元数据数据库 | 内部访问 | +| redis | `seg-redis` | Celery broker/result backend | 内部访问 | +| minio | `seg-minio` | 视频、DICOM、帧图、缩略图等对象存储 | `9000`、`9001` | + +`backend` 构建并发布 `seg-server-backend:latest` 镜像,`worker` 直接复用这个镜像。更新后端依赖、PyTorch、SAM2 或系统包时,只需要重建 `backend` 镜像,再同时重启 `backend` 与 `worker`,避免 API 端显示模型可用但 worker 执行传播时缺依赖。 + +`worker` 配置了 `pull_policy: never`,只使用本地 `seg-server-backend:latest`,不会尝试从远端镜像仓库拉取这个内部镜像。首次部署请使用带 `--build` 的启动命令,让 `backend` 先构建出本地镜像。 + +## 部署前准备 + +1. 安装 Docker Engine 和 Docker Compose plugin。 +2. 确认当前目录包含演示数据: + +```bash +ls demo/演视LC视频序列.mp4 +ls demo/演视DICOM序列 +``` + +3. 确认当前目录包含 SAM 2.1 权重: + +```bash +ls -lh models/ +``` + +推荐至少包含: + +```text +models/sam2_hiera_tiny.pt +models/sam2.1_hiera_small.pt +models/sam2.1_hiera_base_plus.pt +models/sam2.1_hiera_large.pt +``` + +如果只部署部分权重,系统仍可启动;缺失权重对应的模型变体会显示不可用。 + +### 下载 SAM 2.1 权重 + +部署分支不提交 `.pt` 权重文件。首次部署前在本目录执行: + +```bash +mkdir -p models +wget -O models/sam2_hiera_tiny.pt https://dl.fbaipublicfiles.com/segment_anything_2/072824/sam2_hiera_tiny.pt +wget -O models/sam2.1_hiera_small.pt https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_small.pt +wget -O models/sam2.1_hiera_base_plus.pt https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_base_plus.pt +wget -O models/sam2.1_hiera_large.pt https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_large.pt +``` + +也可以只下载 tiny/small 等需要的权重。`sam2_hiera_tiny.pt` 是兼容旧名,系统会把它作为 `sam2.1_hiera_tiny` 的 fallback 使用。 + +## 环境变量 + +部署前编辑 `.env`: + +```ini +PUBLIC_HOST=localhost + +VITE_API_BASE_URL= +VITE_WS_PROGRESS_URL= + +FRONTEND_PORT=3000 +BACKEND_PORT=8000 +MINIO_PORT=9000 +MINIO_CONSOLE_PORT=9001 + +POSTGRES_USER=seguser +POSTGRES_PASSWORD=segpass123 +POSTGRES_DB=segserver + +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_PUBLIC_ENDPOINT=localhost:9000 +MINIO_SECURE=false + +SAM_MODELS_DIR=./models + +CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000"] + +JWT_SECRET_KEY=change-this-to-a-long-random-production-secret +ACCESS_TOKEN_EXPIRE_MINUTES=1440 +DEFAULT_ADMIN_USERNAME=admin +DEFAULT_ADMIN_PASSWORD=123456 +``` + +局域网访问时,把 `PUBLIC_HOST` 改成部署机器 IP,并把对应前端地址加入 `CORS_ORIGINS`。例如前端通过 `http://192.168.3.11:3000` 打开时: + +```ini +PUBLIC_HOST=192.168.3.11 +VITE_API_BASE_URL= +VITE_WS_PROGRESS_URL= +MINIO_PUBLIC_ENDPOINT=192.168.3.11:9000 +MINIO_SECURE=false +CORS_ORIGINS=["http://192.168.3.11:3000","http://localhost:3000","http://127.0.0.1:3000"] +``` + +保持 `SAM_MODELS_DIR=./models` 可以让部署包自包含、可搬迁。只有在刻意共享外部模型缓存时,才把它改成其他路径。 + +`.env` 和 `.env.example` 中用 `# XXXX` 标出的配置项,是局域网部署或公网域名反代部署时最常需要修改的地方。 + +生产或长期演示环境请修改: + +- `JWT_SECRET_KEY` +- `DEFAULT_ADMIN_PASSWORD` +- `POSTGRES_PASSWORD` +- `MINIO_ACCESS_KEY` +- `MINIO_SECRET_KEY` + +## CPU 启动 + +适用于没有 NVIDIA GPU 或暂时不配置 GPU 透传的环境: + +```bash +docker compose up -d --build +``` + +查看状态: + +```bash +docker compose ps +curl http://localhost:8000/health +``` + +CPU 模式下,如果 PyTorch、SAM2 和 `.pt` 权重存在,模型可以加载,但推理速度明显慢于 GPU。 + +## GPU 启动 + +GPU 模式要求宿主机满足: + +1. NVIDIA 驱动可用。 +2. `nvidia-smi` 能看到 GPU。 +3. Docker 已安装并配置 NVIDIA Container Toolkit。 +4. `docker run --rm --gpus all ... nvidia-smi` 能在容器内看到 GPU。 + +先验证宿主机 GPU: + +```bash +nvidia-smi +``` + +验证 Docker GPU 透传: + +```bash +docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi +``` + +如果 Docker GPU 验证失败,需要先安装并配置 NVIDIA Container Toolkit: + +```bash +curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \ + | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg + +curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \ + | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \ + | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list + +sudo apt-get update +sudo apt-get install -y nvidia-container-toolkit +sudo nvidia-ctk runtime configure --runtime=docker +sudo systemctl restart docker +``` + +启动 GPU 版: + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build +``` + +验证 backend 和 worker 都能看到 GPU: + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml exec -T backend python - <<'PY' +import torch +print(torch.cuda.is_available()) +print(torch.cuda.device_count()) +print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else "NO CUDA") +PY + +docker compose -f docker-compose.yml -f docker-compose.gpu.yml exec -T worker python - <<'PY' +import torch +print(torch.cuda.is_available()) +print(torch.cuda.device_count()) +print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else "NO CUDA") +PY +``` + +如果 backend 是 GPU 但 worker 不是 GPU,AI 自动推理/传播会失败。此时重建并重启二者: + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml build backend +docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d backend worker +``` + +## 公网域名 + frpc/frps + NPM + +如果公网服务器绑定了域名,并通过 frpc/frps 把内网部署机器端口映射到公网服务器本机端口,再由 Nginx Proxy Manager 反向代理,推荐使用三个子域名: + +```text +seg.example.com -> 前端 +seg-api.example.com -> 后端 API + WebSocket +seg-minio.example.com -> MinIO 图片/帧图/缩略图 +``` + +推荐链路: + +```text +浏览器 + -> 公网服务器 NPM + -> 公网服务器本机 frps 映射端口 + -> frpc + -> 内网部署机器的 3000 / 8000 / 9000 +``` + +端口映射建议: + +```text +内网部署机器 3000 -> 公网服务器本机 13000 -> NPM: seg.example.com +内网部署机器 8000 -> 公网服务器本机 18000 -> NPM: seg-api.example.com +内网部署机器 9000 -> 公网服务器本机 19000 -> NPM: seg-minio.example.com +``` + +NPM 中配置三个 Proxy Host: + +```text +seg.example.com + Scheme: http + Forward Hostname / IP: 127.0.0.1 + Forward Port: 13000 + +seg-api.example.com + Scheme: http + Forward Hostname / IP: 127.0.0.1 + Forward Port: 18000 + Websocket Support: 开启 + +seg-minio.example.com + Scheme: http + Forward Hostname / IP: 127.0.0.1 + Forward Port: 19000 +``` + +建议三个域名都在 NPM 里申请 HTTPS 证书,并开启 HTTPS。公网 HTTPS 场景下,内网 `Seg_Server_Docker/.env` 需要修改 `# XXXX` 标出的几项: + +```ini +# XXXX Public-domain access through frpc/frps + NPM +PUBLIC_HOST=seg.example.com + +# XXXX Frontend build-time API/WebSocket endpoints +VITE_API_BASE_URL=https://seg-api.example.com +VITE_WS_PROGRESS_URL=wss://seg-api.example.com/ws/progress + +# XXXX Browser-facing MinIO endpoint +MINIO_PUBLIC_ENDPOINT=seg-minio.example.com +MINIO_SECURE=true + +# XXXX Browser origins +CORS_ORIGINS=["https://seg.example.com"] +``` + +修改 `VITE_API_BASE_URL` 或 `VITE_WS_PROGRESS_URL` 后必须重建前端,因为它们是前端构建期变量: + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml build frontend +docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d frontend backend worker +``` + +如果同时从零启动 GPU 版: + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build +``` + +公网域名部署后,浏览器只需要访问: + +```text +https://seg.example.com +``` + +不要只反代前端 `3000`。后端 `8000` 不通会导致登录、项目、任务和 AI 接口失败;MinIO `9000` 不通会导致帧图、缩略图、预览图等资源加载失败。 + +## 访问入口 + +默认地址: + +```text +前端:http://localhost:3000 +后端:http://localhost:8000 +MinIO API:http://localhost:9000 +MinIO 控制台:http://localhost:9001 +``` + +局域网访问时,把 `localhost` 换成 `.env` 中的 `PUBLIC_HOST`。 + +默认账号: + +```text +用户名:admin +密码:123456 +``` + +## 演示数据和恢复出厂设置 + +演示数据固定从部署包本地读取: + +```text +demo/演视LC视频序列.mp4 +demo/演视DICOM序列/ +``` + +系统启动和“恢复演示出厂设置”都会使用这两个路径。恢复后应出现: + +- `演视LC视频序列` +- `演视DICOM序列` + +DICOM 序列会按文件名自然顺序读取,避免切片顺序错位。 + +## 常用运维命令 + +启动: + +```bash +docker compose up -d +``` + +GPU 启动: + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d +``` + +停止: + +```bash +docker compose down +``` + +查看容器: + +```bash +docker compose ps +``` + +查看日志: + +```bash +docker compose logs -f backend +docker compose logs -f worker +docker compose logs -f frontend +``` + +重建前后端: + +```bash +docker compose build backend frontend +docker compose up -d backend worker frontend +``` + +重建 GPU 版后端和 worker: + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml build backend +docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d backend worker +``` + +清空当前部署数据: + +```bash +docker compose down -v +docker compose up -d --build +``` + +注意:`down -v` 会删除数据库和 MinIO 对象存储卷,项目、帧、标注和上传文件都会清空。 + +## 模型状态验证 + +登录后前端左下角会显示 CPU/GPU 状态。也可以通过后端接口检查: + +```bash +curl http://localhost:8000/health +``` + +模型状态接口需要登录 token。更简单的容器内检查方式: + +```bash +docker compose exec -T backend python - <<'PY' +from services.sam_registry import sam_registry +print(sam_registry.runtime_status()) +PY +``` + +GPU 版: + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml exec -T backend python - <<'PY' +from services.sam_registry import sam_registry +print(sam_registry.runtime_status()) +PY +``` + +## 常见问题 + +### 前端能打开,但图片或缩略图打不开 + +检查 `.env`: + +- `PUBLIC_HOST` 是否是浏览器可以访问到的主机名或 IP。 +- `MINIO_PORT` 是否开放。 +- `CORS_ORIGINS` 是否包含当前前端地址。 + +修改 `.env` 后重启: + +```bash +docker compose up -d backend worker frontend +``` + +### 左下角显示 CPU + +先确认是 CPU 部署还是 GPU 部署。GPU 部署必须使用: + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d +``` + +再检查 Docker GPU 透传: + +```bash +docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi +``` + +如果这条命令失败,问题在宿主机 Docker GPU runtime,不在应用代码。 + +### 单帧 AI 分割可用,但 AI 自动推理无结果 + +检查 `seg-worker` 是否和 `seg-backend` 使用同一个镜像,并且 worker 是否能看到 PyTorch、SAM2 和 GPU: + +```bash +docker inspect seg-backend seg-worker --format '{{.Name}} {{.Image}} {{.Config.Image}}' + +docker compose -f docker-compose.yml -f docker-compose.gpu.yml exec -T worker python - <<'PY' +import importlib.util, torch +print("torch", bool(importlib.util.find_spec("torch"))) +print("sam2", bool(importlib.util.find_spec("sam2"))) +print("cuda", torch.cuda.is_available()) +PY +``` + +如果 worker 缺依赖,重建并重启: + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml build backend +docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d backend worker +``` + +### 恢复演示出厂设置后项目为空 + +检查 demo 文件是否仍在部署包本地: + +```bash +ls demo/演视LC视频序列.mp4 +ls demo/演视DICOM序列 +``` + +如果目录被移动或删掉,恢复出厂设置无法重新生成演示项目。 + +### 模型不可用 + +检查 `models/`: + +```bash +ls -lh models/ +``` + +缺失的 `.pt` 权重对应模型会显示不可用。部署包默认从 `./models` 挂载到容器内 `/app/models`。 diff --git a/backend/celery_app.py b/backend/celery_app.py new file mode 100644 index 0000000..d7ced3d --- /dev/null +++ b/backend/celery_app.py @@ -0,0 +1,21 @@ +"""Celery application for background processing.""" + +from celery import Celery + +from config import settings + +celery_app = Celery( + "seg_server", + broker=settings.redis_url, + backend=settings.redis_url, + include=["worker_tasks"], +) + +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + timezone="Asia/Shanghai", + enable_utc=True, + task_track_started=True, +) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..0948024 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,51 @@ +"""Application configuration using Pydantic Settings.""" + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # Database + db_url: str = "postgresql://seguser:segpass123@localhost:5432/segserver" + + # Redis + redis_url: str = "redis://localhost:6379/0" + + # MinIO + minio_endpoint: str = "192.168.3.11:9000" + minio_public_endpoint: str | None = None + minio_access_key: str = "minioadmin" + minio_secret_key: str = "minioadmin" + minio_secure: bool = False + + # SAM + sam_default_model: str = "sam2.1_hiera_tiny" + sam_model_path: str = "/home/wkmgc/Desktop/Seg_Server/models/sam2.1_hiera_tiny.pt" + sam_model_config: str = "configs/sam2.1/sam2.1_hiera_t.yaml" + sam3_model_version: str = "sam3" + sam3_checkpoint_path: str = "/home/wkmgc/Desktop/Seg_Server/sam3权重/sam3.pt" + sam3_external_enabled: bool = False + sam3_external_python: str = "/home/wkmgc/miniconda3/envs/sam3/bin/python" + sam3_timeout_seconds: int = 300 + sam3_status_cache_seconds: int = 30 + sam3_confidence_threshold: float = 0.5 + + # App + app_env: str = "development" + cors_origins: list[str] = ["http://localhost:3000", "http://192.168.3.11:3000"] + jwt_secret_key: str = "seg-server-dev-secret-change-me" + jwt_algorithm: str = "HS256" + access_token_expire_minutes: int = 60 * 24 + default_admin_username: str = "admin" + default_admin_password: str = "123456" + demo_video_path: str = "/home/wkmgc/Desktop/Seg_Server/demo/演视LC视频序列.mp4" + demo_dicom_dir: str = "/home/wkmgc/Desktop/Seg_Server/demo/演视DICOM序列" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + extra = "ignore" + + +settings = Settings() diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..a30d9c6 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,29 @@ +"""Database configuration using synchronous SQLAlchemy.""" + +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker, Session +from fastapi import Depends +from typing import Generator + +from config import settings + +engine = create_engine( + settings.db_url, + pool_pre_ping=True, + pool_size=10, + max_overflow=20, + echo=False, +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def get_db() -> Generator[Session, None, None]: + """FastAPI dependency that yields a database session.""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..a3e8802 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,317 @@ +"""FastAPI application entrypoint.""" + +import asyncio +import json +import logging +import os +from contextlib import asynccontextmanager, suppress +from datetime import datetime, timezone + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import inspect, text + +from config import settings +from database import Base, engine, SessionLocal +from minio_client import ensure_bucket_exists +from progress_events import PROGRESS_CHANNEL +from redis_client import get_redis_client, ping as redis_ping + +from routers import projects, templates, media, ai, export, auth, dashboard, tasks, admin + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", +) +logger = logging.getLogger(__name__) + + +def _ensure_runtime_schema_columns() -> None: + """Add nullable columns introduced after initial create_all deployments.""" + try: + inspector = inspect(engine) + frame_columns = {column["name"] for column in inspector.get_columns("frames")} + project_columns = {column["name"] for column in inspector.get_columns("projects")} + template_columns = {column["name"] for column in inspector.get_columns("templates")} + with engine.begin() as connection: + if "timestamp_ms" not in frame_columns: + connection.execute(text("ALTER TABLE frames ADD COLUMN timestamp_ms FLOAT")) + if "source_frame_number" not in frame_columns: + connection.execute(text("ALTER TABLE frames ADD COLUMN source_frame_number INTEGER")) + if "owner_user_id" not in project_columns: + connection.execute(text("ALTER TABLE projects ADD COLUMN owner_user_id INTEGER")) + if "owner_user_id" not in template_columns: + connection.execute(text("ALTER TABLE templates ADD COLUMN owner_user_id INTEGER")) + except Exception as exc: # noqa: BLE001 + logger.warning("Runtime schema column check failed: %s", exc) + + +def _seed_default_admin_sync() -> None: + """Ensure the single default admin exists without rewriting project ownership metadata.""" + from routers.auth import ensure_default_admin + + db = SessionLocal() + try: + admin = ensure_default_admin(db) + db.commit() + logger.info("Default admin ready id=%s", admin.id) + except Exception as exc: # noqa: BLE001 + logger.error("Failed to seed default admin: %s", exc) + finally: + db.close() + + +def _seed_default_project_sync() -> None: + """Synchronously seed the bundled demo video and DICOM projects on first startup.""" + from models import Project + from routers.auth import ensure_default_admin + from services.demo_media import ( + DEMO_DICOM_PROJECT_NAME, + DEMO_VIDEO_PROJECT_NAME, + LEGACY_DEMO_DICOM_PROJECT_NAMES, + LEGACY_DEMO_VIDEO_PROJECT_NAMES, + create_parsed_dicom_demo_project, + create_parsed_video_demo_project, + demo_dicom_files, + ) + + db = SessionLocal() + try: + admin = ensure_default_admin(db) + legacy_video = ( + db.query(Project) + .filter(Project.name.in_(LEGACY_DEMO_VIDEO_PROJECT_NAMES)) + .first() + ) + if legacy_video is not None: + legacy_video.name = DEMO_VIDEO_PROJECT_NAME + db.commit() + legacy_dicom = ( + db.query(Project) + .filter(Project.name.in_(LEGACY_DEMO_DICOM_PROJECT_NAMES)) + .first() + ) + if legacy_dicom is not None: + legacy_dicom.name = DEMO_DICOM_PROJECT_NAME + db.commit() + existing_video = db.query(Project).filter(Project.name == DEMO_VIDEO_PROJECT_NAME).first() + if existing_video is None and os.path.exists(settings.demo_video_path): + video_project = create_parsed_video_demo_project( + db, + owner=admin, + video_path=settings.demo_video_path, + project_name=DEMO_VIDEO_PROJECT_NAME, + ) + logger.info("Seeded default video project id=%s", video_project.id) + + existing_dicom = db.query(Project).filter(Project.name == DEMO_DICOM_PROJECT_NAME).first() + if existing_dicom is not None: + return + + if not demo_dicom_files(settings.demo_dicom_dir): + logger.warning("Default DICOM series not found at %s", settings.demo_dicom_dir) + return + + project = create_parsed_dicom_demo_project( + db, + owner=admin, + dicom_dir=settings.demo_dicom_dir, + project_name=DEMO_DICOM_PROJECT_NAME, + ) + logger.info("Seeded default DICOM project id=%s with %d frames", project.id, len(project.frames)) + except Exception as exc: + logger.error("Failed to seed default project: %s", exc) + finally: + db.close() + + +def _seed_default_templates_sync() -> None: + """Seed default ontology templates on first startup.""" + db = SessionLocal() + try: + ensure_default_templates(db) + except Exception as exc: + logger.error("Failed to seed default templates: %s", exc) + finally: + db.close() + + +def ensure_default_templates(db) -> None: + """Ensure all bundled system templates exist.""" + from services.default_templates import ensure_default_templates as _ensure_default_templates + + _ensure_default_templates(db) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan: startup and shutdown hooks.""" + progress_listener: asyncio.Task | None = None + # Startup + logger.info("Starting up SegServer backend...") + + # Initialize database tables + try: + Base.metadata.create_all(bind=engine) + _ensure_runtime_schema_columns() + _seed_default_admin_sync() + logger.info("Database tables initialized.") + except Exception as exc: # noqa: BLE001 + logger.error("Database initialization failed: %s", exc) + + # Check MinIO bucket + try: + ensure_bucket_exists() + except Exception as exc: # noqa: BLE001 + logger.error("MinIO bucket check failed: %s", exc) + + # Check Redis + if redis_ping(): + logger.info("Redis connection OK.") + else: + logger.warning("Redis connection failed.") + + try: + progress_listener = asyncio.create_task(_progress_pubsub_loop()) + except Exception as exc: # noqa: BLE001 + logger.error("Failed to start Redis progress subscription: %s", exc) + + # Seed default templates + try: + asyncio.create_task(asyncio.to_thread(_seed_default_templates_sync)) + except Exception as exc: # noqa: BLE001 + logger.error("Failed to start default template seeding: %s", exc) + + # Seed default project in background thread so it doesn't block startup + try: + asyncio.create_task(asyncio.to_thread(_seed_default_project_sync)) + except Exception as exc: # noqa: BLE001 + logger.error("Failed to start default project seeding: %s", exc) + + yield + + # Shutdown + logger.info("Shutting down SegServer backend...") + if progress_listener is not None: + progress_listener.cancel() + with suppress(asyncio.CancelledError): + await progress_listener + engine.dispose() + + +app = FastAPI( + title="SegServer API", + description="Semantic Segmentation System Backend", + version="1.0.0", + lifespan=lifespan, +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Routers +app.include_router(auth.router) +app.include_router(projects.router) +app.include_router(templates.router) +app.include_router(media.router) +app.include_router(ai.router) +app.include_router(export.router) +app.include_router(dashboard.router) +app.include_router(tasks.router) +app.include_router(admin.router) + + +@app.get("/health", tags=["Health"]) +def health_check() -> dict: + """Health check endpoint.""" + return {"status": "ok", "service": "SegServer"} + + +# --------------------------------------------------------------------------- +# WebSocket: 实时进度推送 +# --------------------------------------------------------------------------- +class ConnectionManager: + """Manage WebSocket connections for progress broadcasting.""" + + def __init__(self): + self.active_connections: list[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + logger.info("WebSocket client connected. Total: %d", len(self.active_connections)) + + def disconnect(self, websocket: WebSocket): + if websocket in self.active_connections: + self.active_connections.remove(websocket) + logger.info("WebSocket client disconnected. Total: %d", len(self.active_connections)) + + async def broadcast(self, message: dict): + """Broadcast a message to all connected clients.""" + for connection in self.active_connections.copy(): + try: + await connection.send_json(message) + except Exception as exc: + logger.warning("WebSocket send failed: %s", exc) + self.disconnect(connection) + + +manager = ConnectionManager() + + +async def _progress_pubsub_loop() -> None: + """Forward Redis task-progress events to connected WebSocket clients.""" + while True: + pubsub = None + try: + pubsub = get_redis_client().pubsub() + await asyncio.to_thread(pubsub.subscribe, PROGRESS_CHANNEL) + logger.info("Subscribed to Redis progress channel: %s", PROGRESS_CHANNEL) + while True: + message = await asyncio.to_thread(pubsub.get_message, True, 1.0) + if message is None: + await asyncio.sleep(0) + continue + raw_data = message.get("data") + payload = json.loads(raw_data) if isinstance(raw_data, str) else raw_data + if isinstance(payload, dict): + await manager.broadcast(payload) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.error("Redis progress subscription failed: %s", exc) + await asyncio.sleep(5) + finally: + if pubsub is not None: + with suppress(Exception): + await asyncio.to_thread(pubsub.close) + + +@app.websocket("/ws/progress") +async def websocket_progress(websocket: WebSocket): + """WebSocket endpoint for real-time parsing/AI progress updates.""" + await manager.connect(websocket) + try: + while True: + # Receive client messages (heartbeat / subscription requests) + data = await websocket.receive_text() + logger.debug("WebSocket received: %s", data) + + # Echo heartbeat to keep connection alive + await websocket.send_json({ + "type": "status", + "status": "connected", + "message": "Progress stream active", + "timestamp": datetime.now(timezone.utc).isoformat(), + }) + except WebSocketDisconnect: + manager.disconnect(websocket) + except Exception as exc: + logger.error("WebSocket error: %s", exc) + manager.disconnect(websocket) diff --git a/backend/minio_client.py b/backend/minio_client.py new file mode 100644 index 0000000..8d9df21 --- /dev/null +++ b/backend/minio_client.py @@ -0,0 +1,142 @@ +"""MinIO client wrapper for object storage operations.""" + +import io +import logging +from typing import Optional + +from minio import Minio +from minio.error import S3Error + +from config import settings + +logger = logging.getLogger(__name__) + +BUCKET_NAME = "seg-media" + +_minio_client: Optional[Minio] = None +_minio_public_client: Optional[Minio] = None + + +def get_minio_client() -> Minio: + """Return a singleton MinIO client instance.""" + global _minio_client + if _minio_client is None: + _minio_client = Minio( + settings.minio_endpoint, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=settings.minio_secure, + ) + return _minio_client + + +def get_minio_public_client() -> Minio: + """Return a MinIO client configured for browser-facing presigned URLs.""" + global _minio_public_client + if _minio_public_client is None: + endpoint = settings.minio_public_endpoint or settings.minio_endpoint + _minio_public_client = Minio( + endpoint, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=settings.minio_secure, + ) + return _minio_public_client + + +def ensure_bucket_exists() -> None: + """Create the bucket if it does not already exist.""" + client = get_minio_client() + try: + if not client.bucket_exists(BUCKET_NAME): + client.make_bucket(BUCKET_NAME) + logger.info("Created MinIO bucket: %s", BUCKET_NAME) + else: + logger.info("MinIO bucket %s already exists", BUCKET_NAME) + except S3Error as exc: + logger.error("MinIO bucket check/creation failed: %s", exc) + raise + + +def upload_file( + object_name: str, + data: bytes, + content_type: str = "application/octet-stream", + length: int = -1, +) -> str: + """Upload bytes to MinIO and return the object name. + + Args: + object_name: Destination path inside the bucket. + data: Raw bytes or a file-like object. + content_type: MIME type of the object. + length: Object size; -1 for unknown (uses chunked upload). + + Returns: + The object name (same as input). + """ + client = get_minio_client() + if isinstance(data, bytes): + data = io.BytesIO(data) + length = len(data.getvalue()) + + try: + client.put_object( + BUCKET_NAME, + object_name, + data, + length=length, + content_type=content_type, + ) + logger.info("Uploaded to MinIO: %s", object_name) + return object_name + except S3Error as exc: + logger.error("MinIO upload failed: %s", exc) + raise + + +from datetime import timedelta + +def get_presigned_url( + object_name: str, + expires: int = 3600, + method: str = "GET", +) -> str: + """Generate a presigned URL for an object. + + Args: + object_name: Path inside the bucket. + expires: Expiration time in seconds (default 1 hour). + method: HTTP method (GET or PUT). + + Returns: + Presigned URL string. + """ + client = get_minio_public_client() + try: + url = client.get_presigned_url(method, BUCKET_NAME, object_name, expires=timedelta(seconds=expires)) + return url + except S3Error as exc: + logger.error("MinIO presigned URL failed: %s", exc) + raise + + +def download_file(object_name: str) -> bytes: + """Download an object from MinIO and return its bytes. + + Args: + object_name: Path inside the bucket. + + Returns: + Raw bytes of the object. + """ + client = get_minio_client() + try: + response = client.get_object(BUCKET_NAME, object_name) + data = response.read() + response.close() + response.release_conn() + return data + except S3Error as exc: + logger.error("MinIO download failed: %s", exc) + raise diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..268bbcb --- /dev/null +++ b/backend/models.py @@ -0,0 +1,195 @@ +"""SQLAlchemy ORM models.""" + +from sqlalchemy import ( + Column, + Integer, + String, + Text, + DateTime, + ForeignKey, + JSON, + Float, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from database import Base +from statuses import PROJECT_STATUS_PENDING + + +class User(Base): + """Application user used for authentication and data ownership.""" + + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(150), unique=True, index=True, nullable=False) + password_hash = Column(String(255), nullable=False) + role = Column(String(50), default="annotator", nullable=False) + is_active = Column(Integer, default=1, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + projects = relationship("Project", back_populates="owner") + templates = relationship("Template", back_populates="owner") + + +class Project(Base): + """Project model representing a segmentation project.""" + + __tablename__ = "projects" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + video_path = Column(String(512), nullable=True) + thumbnail_url = Column(String(512), nullable=True) + status = Column(String(50), default=PROJECT_STATUS_PENDING, nullable=False) + source_type = Column(String(20), default="video", nullable=False) # video | dicom + original_fps = Column(Float, nullable=True) + parse_fps = Column(Float, default=30.0, nullable=False) + owner_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + owner = relationship("User", back_populates="projects") + frames = relationship("Frame", back_populates="project", cascade="all, delete-orphan") + annotations = relationship( + "Annotation", back_populates="project", cascade="all, delete-orphan" + ) + tasks = relationship( + "ProcessingTask", back_populates="project", cascade="all, delete-orphan" + ) + + +class Frame(Base): + """Frame model representing an extracted video frame.""" + + __tablename__ = "frames" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column(Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) + frame_index = Column(Integer, nullable=False) + image_url = Column(String(512), nullable=False) + width = Column(Integer, nullable=True) + height = Column(Integer, nullable=True) + timestamp_ms = Column(Float, nullable=True) + source_frame_number = Column(Integer, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + project = relationship("Project", back_populates="frames") + annotations = relationship( + "Annotation", back_populates="frame", cascade="all, delete-orphan" + ) + + +class Template(Base): + """Template (Ontology) model for segmentation classes.""" + + __tablename__ = "templates" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + color = Column(String(50), nullable=False) + z_index = Column(Integer, default=0, nullable=False) + mapping_rules = Column(JSON, nullable=True) + owner_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + owner = relationship("User", back_populates="templates") + annotations = relationship( + "Annotation", back_populates="template", cascade="all, delete-orphan" + ) + + +class Annotation(Base): + """Annotation model for segmentation masks and prompts.""" + + __tablename__ = "annotations" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column( + Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False + ) + frame_id = Column( + Integer, ForeignKey("frames.id", ondelete="CASCADE"), nullable=True + ) + template_id = Column( + Integer, ForeignKey("templates.id", ondelete="SET NULL"), nullable=True + ) + mask_data = Column(JSON, nullable=True) + points = Column(JSON, nullable=True) + bbox = Column(JSON, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + project = relationship("Project", back_populates="annotations") + frame = relationship("Frame", back_populates="annotations") + template = relationship("Template", back_populates="annotations") + masks = relationship("Mask", back_populates="annotation", cascade="all, delete-orphan") + + +class Mask(Base): + """Mask model for exported/derived mask files.""" + + __tablename__ = "masks" + + id = Column(Integer, primary_key=True, index=True) + annotation_id = Column( + Integer, ForeignKey("annotations.id", ondelete="CASCADE"), nullable=False + ) + mask_url = Column(String(512), nullable=False) + format = Column(String(50), default="png", nullable=False) # png / rle / json + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + annotation = relationship("Annotation", back_populates="masks") + + +class AuditLog(Base): + """Audit trail for security and administrative actions.""" + + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, index=True) + actor_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + action = Column(String(120), nullable=False) + target_type = Column(String(80), nullable=True) + target_id = Column(String(120), nullable=True) + detail = Column(JSON, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + actor = relationship("User") + + +class ProcessingTask(Base): + """Background task state persisted for dashboard and polling.""" + + __tablename__ = "processing_tasks" + + id = Column(Integer, primary_key=True, index=True) + task_type = Column(String(80), nullable=False) + status = Column(String(40), default="queued", nullable=False) + progress = Column(Integer, default=0, nullable=False) + message = Column(Text, nullable=True) + project_id = Column( + Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=True + ) + celery_task_id = Column(String(255), nullable=True) + payload = Column(JSON, nullable=True) + result = Column(JSON, nullable=True) + error = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + started_at = Column(DateTime(timezone=True), nullable=True) + finished_at = Column(DateTime(timezone=True), nullable=True) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + project = relationship("Project", back_populates="tasks") diff --git a/backend/progress_events.py b/backend/progress_events.py new file mode 100644 index 0000000..083bc0a --- /dev/null +++ b/backend/progress_events.py @@ -0,0 +1,66 @@ +"""Progress event payloads and Redis publication helpers.""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from typing import Any + +from redis_client import get_redis_client +from statuses import TASK_STATUS_CANCELLED, TASK_STATUS_FAILED, TASK_STATUS_SUCCESS + +logger = logging.getLogger(__name__) + +PROGRESS_CHANNEL = "seg:progress" + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _event_type(task_status: str) -> str: + if task_status == TASK_STATUS_SUCCESS: + return "complete" + if task_status == TASK_STATUS_CANCELLED: + return "cancelled" + if task_status == TASK_STATUS_FAILED: + return "error" + return "progress" + + +def task_progress_payload(task: Any) -> dict[str, Any]: + """Build the WebSocket payload from a persisted processing task.""" + project = getattr(task, "project", None) + project_name = getattr(project, "name", None) + status = getattr(task, "status", "") + updated_at = getattr(task, "updated_at", None) + timestamp = updated_at.isoformat() if updated_at is not None else _iso_now() + message = getattr(task, "message", None) + + return { + "type": _event_type(status), + "taskId": f"task-{task.id}", + "task_id": task.id, + "project_id": getattr(task, "project_id", None), + "projectName": project_name, + "filename": project_name, + "progress": getattr(task, "progress", 0), + "status": message or status, + "message": message, + "error": getattr(task, "error", None), + "timestamp": timestamp, + } + + +def publish_progress_event(payload: dict[str, Any]) -> None: + """Publish a JSON progress event without failing the worker on Redis errors.""" + try: + get_redis_client().publish(PROGRESS_CHANNEL, json.dumps(payload, ensure_ascii=False)) + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to publish progress event: %s", exc) + + +def publish_task_progress_event(task: Any) -> None: + """Publish a progress event for a ProcessingTask ORM object.""" + publish_progress_event(task_progress_payload(task)) diff --git a/backend/redis_client.py b/backend/redis_client.py new file mode 100644 index 0000000..d7d4799 --- /dev/null +++ b/backend/redis_client.py @@ -0,0 +1,61 @@ +"""Redis client wrapper for caching and task queuing.""" + +import json +import logging +from typing import Optional, Any + +import redis + +from config import settings + +logger = logging.getLogger(__name__) + +_redis_client: Optional[redis.Redis] = None + + +def get_redis_client() -> redis.Redis: + """Return a singleton Redis client instance.""" + global _redis_client + if _redis_client is None: + _redis_client = redis.from_url(settings.redis_url, decode_responses=True) + return _redis_client + + +def ping() -> bool: + """Check Redis connectivity.""" + try: + return get_redis_client().ping() + except redis.ConnectionError as exc: + logger.error("Redis ping failed: %s", exc) + return False + + +def set_json(key: str, value: Any, expire: Optional[int] = None) -> None: + """Store a JSON-serializable value in Redis.""" + client = get_redis_client() + try: + client.set(key, json.dumps(value), ex=expire) + except redis.RedisError as exc: + logger.error("Redis set_json failed: %s", exc) + raise + + +def get_json(key: str) -> Optional[Any]: + """Retrieve and deserialize a JSON value from Redis.""" + client = get_redis_client() + try: + data = client.get(key) + return json.loads(data) if data is not None else None + except redis.RedisError as exc: + logger.error("Redis get_json failed: %s", exc) + raise + + +def delete_key(key: str) -> int: + """Delete a key from Redis. Returns number of deleted keys.""" + client = get_redis_client() + try: + return client.delete(key) + except redis.RedisError as exc: + logger.error("Redis delete_key failed: %s", exc) + raise diff --git a/backend/requirements-docker.txt b/backend/requirements-docker.txt new file mode 100644 index 0000000..ac9f223 --- /dev/null +++ b/backend/requirements-docker.txt @@ -0,0 +1,20 @@ +fastapi +uvicorn[standard] +python-multipart +sqlalchemy +psycopg2-binary +redis +celery +minio +opencv-python-headless +pillow +scikit-image +pydicom +numpy +torch +torchvision +torchaudio +sam2 +pydantic-settings +python-jose[cryptography] +passlib[bcrypt] diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routers/admin.py b/backend/routers/admin.py new file mode 100644 index 0000000..511cd6b --- /dev/null +++ b/backend/routers/admin.py @@ -0,0 +1,299 @@ +"""Administrator-only user and audit management endpoints.""" + +import os +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from config import settings +from database import get_db +from models import Annotation, AuditLog, Frame, Mask, ProcessingTask, Project, Template, User +from routers.auth import SUPPORTED_ROLES, ensure_default_admin, hash_password, normalize_user_role, require_admin, write_audit_log +from schemas import ( + AdminUserCreate, + AdminUserUpdate, + AuditLogOut, + DemoFactoryResetOut, + DemoFactoryResetRequest, + UserOut, +) +from services.demo_media import ( + DEMO_DICOM_PROJECT_NAME, + DEMO_VIDEO_PROJECT_NAME, + create_parsed_dicom_demo_project, + create_parsed_video_demo_project, + demo_dicom_files, +) +from services.default_templates import restore_default_templates + +router = APIRouter(prefix="/api/admin", tags=["Admin"]) + +DEMO_RESET_CONFIRMATION = "RESET_DEMO_FACTORY" +DEMO_PROJECT_NAME = DEMO_DICOM_PROJECT_NAME + + +def _normalize_role(role: str | None) -> str: + normalized = (role or "annotator").strip().lower() + if normalized not in SUPPORTED_ROLES: + raise HTTPException(status_code=400, detail=f"Unsupported role: {role}") + return normalized + + +def _assert_non_admin_role(role: str) -> None: + if role == "admin": + raise HTTPException(status_code=400, detail="Only the default admin account can have admin role") + + +@router.get("/users", response_model=List[UserOut], summary="List users") +def list_users( + db: Session = Depends(get_db), + admin_user: User = Depends(require_admin), +) -> List[User]: + """Return all users for the administrator console.""" + _ = admin_user + users = db.query(User).order_by(User.id).all() + return [normalize_user_role(db, user) for user in users] + + +@router.post( + "/users", + response_model=UserOut, + status_code=status.HTTP_201_CREATED, + summary="Create user", +) +def create_user( + payload: AdminUserCreate, + db: Session = Depends(get_db), + admin_user: User = Depends(require_admin), +) -> User: + """Create a user with an initial password and role.""" + username = payload.username.strip() + if not username: + raise HTTPException(status_code=400, detail="Username is required") + if len(payload.password) < 6: + raise HTTPException(status_code=400, detail="Password must be at least 6 characters") + role = _normalize_role(payload.role) + _assert_non_admin_role(role) + user = User( + username=username, + password_hash=hash_password(payload.password), + role=role, + is_active=1 if payload.is_active else 0, + ) + db.add(user) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="Username already exists") from exc + db.refresh(user) + write_audit_log( + db, + actor=admin_user, + action="admin.user_created", + target_type="user", + target_id=user.id, + detail={"username": user.username, "role": user.role, "is_active": bool(user.is_active)}, + ) + return user + + +@router.patch("/users/{user_id}", response_model=UserOut, summary="Update user") +def update_user( + user_id: int, + payload: AdminUserUpdate, + db: Session = Depends(get_db), + admin_user: User = Depends(require_admin), +) -> User: + """Update username, password, role or active state.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + user = normalize_user_role(db, user) + + updates = payload.model_dump(exclude_unset=True) + audit_detail: dict = {"before": {"username": user.username, "role": user.role, "is_active": bool(user.is_active)}} + if "username" in updates: + username = (updates["username"] or "").strip() + if not username: + raise HTTPException(status_code=400, detail="Username is required") + if user.role == "admin" and username != settings.default_admin_username: + raise HTTPException(status_code=400, detail="Default admin username cannot be changed") + user.username = username + if "password" in updates: + password = updates["password"] or "" + if len(password) < 6: + raise HTTPException(status_code=400, detail="Password must be at least 6 characters") + user.password_hash = hash_password(password) + if "role" in updates: + next_role = _normalize_role(updates["role"]) + if user.username == settings.default_admin_username: + if next_role != "admin": + raise HTTPException(status_code=400, detail="Cannot remove the default admin role") + else: + _assert_non_admin_role(next_role) + user.role = next_role + if "is_active" in updates: + if user.id == admin_user.id and not updates["is_active"]: + raise HTTPException(status_code=400, detail="Cannot deactivate yourself") + user.is_active = 1 if updates["is_active"] else 0 + + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="Username already exists") from exc + db.refresh(user) + audit_detail["after"] = {"username": user.username, "role": user.role, "is_active": bool(user.is_active)} + audit_detail["password_changed"] = "password" in updates + write_audit_log( + db, + actor=admin_user, + action="admin.user_updated", + target_type="user", + target_id=user.id, + detail=audit_detail, + ) + return user + + +@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete user") +def delete_user( + user_id: int, + db: Session = Depends(get_db), + admin_user: User = Depends(require_admin), +) -> None: + """Delete a user when it is safe to remove the account.""" + if user_id == admin_user.id: + raise HTTPException(status_code=400, detail="Cannot delete yourself") + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + user = normalize_user_role(db, user) + if user.role == "admin": + raise HTTPException(status_code=400, detail="Cannot delete the default admin account") + username = user.username + db.delete(user) + db.commit() + write_audit_log( + db, + actor=admin_user, + action="admin.user_deleted", + target_type="user", + target_id=user_id, + detail={"username": username}, + ) + return None + + +@router.get("/audit-logs", response_model=List[AuditLogOut], summary="List audit logs") +def list_audit_logs( + limit: int = 100, + db: Session = Depends(get_db), + admin_user: User = Depends(require_admin), +) -> List[AuditLog]: + """Return recent audit events for administrators.""" + _ = admin_user + safe_limit = min(max(int(limit or 100), 1), 500) + return db.query(AuditLog).order_by(AuditLog.created_at.desc(), AuditLog.id.desc()).limit(safe_limit).all() + + +@router.post( + "/demo-factory-reset", + response_model=DemoFactoryResetOut, + summary="Reset demo data to factory defaults", +) +def reset_demo_factory( + payload: DemoFactoryResetRequest, + db: Session = Depends(get_db), + admin_user: User = Depends(require_admin), +) -> dict: + """Reset a demo deployment to one admin account, the demo video, and the demo DICOM project.""" + if payload.confirmation != DEMO_RESET_CONFIRMATION: + raise HTTPException(status_code=400, detail="Invalid reset confirmation") + + if not os.path.exists(settings.demo_video_path): + raise HTTPException( + status_code=409, + detail=f"Demo video not found: {settings.demo_video_path}", + ) + if not demo_dicom_files(settings.demo_dicom_dir): + raise HTTPException( + status_code=409, + detail=f"Demo DICOM series not found: {settings.demo_dicom_dir}", + ) + + requested_by = admin_user.username + preserved_admin = ensure_default_admin(db) + preserved_admin.username = settings.default_admin_username + preserved_admin.password_hash = hash_password(settings.default_admin_password) + preserved_admin.role = "admin" + preserved_admin.is_active = 1 + db.flush() + + deleted_counts = { + "masks": db.query(Mask).delete(synchronize_session=False), + "annotations": db.query(Annotation).delete(synchronize_session=False), + "frames": db.query(Frame).delete(synchronize_session=False), + "tasks": db.query(ProcessingTask).delete(synchronize_session=False), + "projects": db.query(Project).delete(synchronize_session=False), + "user_templates": db.query(Template).filter(Template.owner_user_id.is_not(None)).delete(synchronize_session=False), + "audit_logs": db.query(AuditLog).delete(synchronize_session=False), + "users": db.query(User).filter(User.id != preserved_admin.id).delete(synchronize_session=False), + } + db.flush() + db.expunge_all() + + preserved_admin = db.query(User).filter(User.username == settings.default_admin_username).first() + if not preserved_admin: + raise HTTPException(status_code=500, detail="Default admin was not preserved") + + restored_templates = restore_default_templates(db) + + video_project = create_parsed_video_demo_project( + db, + owner=preserved_admin, + video_path=settings.demo_video_path, + project_name=DEMO_VIDEO_PROJECT_NAME, + ) + + dicom_project = create_parsed_dicom_demo_project( + db, + owner=preserved_admin, + dicom_dir=settings.demo_dicom_dir, + project_name=DEMO_PROJECT_NAME, + ) + db.refresh(preserved_admin) + db.refresh(video_project) + db.refresh(dicom_project) + video_project.frame_count = len(video_project.frames) + dicom_project.frame_count = len(dicom_project.frames) + projects = [video_project, dicom_project] + + write_audit_log( + db, + actor=preserved_admin, + action="admin.demo_factory_reset", + target_type="project", + target_id=dicom_project.id, + detail={ + "project_names": [project.name for project in projects], + "video_path": video_project.video_path, + "dicom_path": dicom_project.video_path, + "source_types": [project.source_type for project in projects], + "frame_counts": {project.name: len(project.frames) for project in projects}, + "deleted_counts": deleted_counts, + "restored_templates": [template.name for template in restored_templates], + "requested_by": requested_by, + }, + ) + + return { + "admin_user": preserved_admin, + "project": dicom_project, + "projects": projects, + "deleted_counts": deleted_counts, + "message": "演示环境已恢复出厂设置", + } diff --git a/backend/routers/ai.py b/backend/routers/ai.py new file mode 100644 index 0000000..21960b9 --- /dev/null +++ b/backend/routers/ai.py @@ -0,0 +1,1228 @@ +"""AI inference endpoints using selectable SAM runtimes.""" + +import logging +import math +import tempfile +from pathlib import Path +from typing import Any, List + +import cv2 +import numpy as np +from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from database import get_db +from minio_client import download_file +from models import Project, Frame, Template, Annotation, ProcessingTask, User +from routers.auth import get_current_user, require_editor +from schemas import ( + AiRuntimeStatus, + MaskAnalysisRequest, + MaskAnalysisResponse, + SmoothMaskRequest, + SmoothMaskResponse, + PredictRequest, + PredictResponse, + PropagateRequest, + PropagateResponse, + PropagateTaskRequest, + ProcessingTaskOut, + AnnotationOut, + AnnotationCreate, + AnnotationUpdate, +) +from progress_events import publish_task_progress_event +from statuses import TASK_STATUS_QUEUED +from worker_tasks import propagate_project_masks +from services.sam_registry import ModelUnavailableError, sam_registry + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/ai", tags=["AI"]) +GT_MASK_EMPTY_DETAIL = "GT Mask 图片中没有非背景 maskid 区域。" +GT_IMPORT_MAX_CONTOUR_POINTS = 2048 +GT_IMPORT_CONTOUR_EPSILON_RATIO = 0.00075 +GT_IMPORT_MIN_CONTOUR_EPSILON = 0.35 +RESERVED_UNCLASSIFIED_CLASS = { + "id": "reserved-unclassified", + "name": "待分类", + "color": "#000000", + "zIndex": 0, + "maskId": 0, + "category": "系统保留", +} + + +def _shared_project_or_404(project_id: int, db: Session, current_user: User) -> Project: + _ = current_user + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +def _shared_frame_or_404(frame_id: int, db: Session, current_user: User, project_id: int | None = None) -> Frame: + _ = current_user + query = ( + db.query(Frame) + .join(Project, Project.id == Frame.project_id) + .filter(Frame.id == frame_id) + ) + if project_id is not None: + query = query.filter(Frame.project_id == project_id) + frame = query.first() + if not frame: + raise HTTPException(status_code=404, detail="Frame not found") + return frame + + +def _visible_template_or_404(template_id: int, db: Session, current_user: User) -> Template: + template = db.query(Template).filter( + Template.id == template_id, + or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None)), + ).first() + if not template: + raise HTTPException(status_code=404, detail="Template not found") + return template + + +def _normalize_hex_color(value: Any) -> str | None: + if not isinstance(value, str): + return None + text = value.strip().lower() + if not text: + return None + if not text.startswith("#"): + text = f"#{text}" + if len(text) == 4: + text = "#" + "".join(char * 2 for char in text[1:]) + if len(text) != 7: + return None + try: + int(text[1:], 16) + except ValueError: + return None + return text + + +def _rgb_tuple_to_hex(rgb: tuple[int, int, int]) -> str: + values = [] + for channel in rgb: + value = int(channel) + if value > 255: + value = int(round(value / 257)) + values.append(min(max(value, 0), 255)) + return f"#{values[0]:02x}{values[1]:02x}{values[2]:02x}" + + +def _template_class_maps(template: Template | None) -> tuple[dict[int, dict[str, Any]], dict[str, dict[str, Any]], dict[str, Any]]: + classes = ((template.mapping_rules or {}).get("classes") if template else None) or [] + by_maskid: dict[int, dict[str, Any]] = {} + by_color: dict[str, dict[str, Any]] = {} + unclassified = dict(RESERVED_UNCLASSIFIED_CLASS) + for index, item in enumerate(classes): + if not isinstance(item, dict): + continue + maskid_value = item.get("maskId", item.get("maskid", item.get("mask_id"))) + try: + maskid = int(maskid_value) + except (TypeError, ValueError): + maskid = index + 1 + color = _normalize_hex_color(item.get("color")) or "#22c55e" + class_meta = { + "id": str(item.get("id") or f"maskid-{maskid}"), + "name": str(item.get("name") or f"类别 {maskid}"), + "color": color, + "zIndex": int(item.get("zIndex", item.get("z_index", index * 10))), + "maskId": maskid, + **({"category": item.get("category")} if item.get("category") else {}), + } + if maskid == 0 or class_meta["id"] == RESERVED_UNCLASSIFIED_CLASS["id"] or class_meta["name"] == RESERVED_UNCLASSIFIED_CLASS["name"]: + unclassified = dict(RESERVED_UNCLASSIFIED_CLASS) + continue + if maskid > 0: + by_maskid[maskid] = class_meta + by_color[color] = class_meta + return by_maskid, by_color, unclassified + + +def _load_frame_image(frame: Frame) -> np.ndarray: + """Download a frame from MinIO and decode it to an RGB numpy array.""" + try: + data = download_file(frame.image_url) + arr = np.frombuffer(data, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_COLOR) + if img is None: + raise ValueError("OpenCV could not decode image") + return cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + except Exception as exc: # noqa: BLE001 + logger.error("Failed to load frame image: %s", exc) + raise HTTPException(status_code=500, detail="Failed to load frame image") from exc + + +def _normalized_contour(contour: np.ndarray, width: int, height: int) -> list[list[float]]: + """Convert a contour to a detailed normalized polygon with a point-count cap.""" + arc_length = cv2.arcLength(contour, True) + epsilon = max(GT_IMPORT_MIN_CONTOUR_EPSILON, arc_length * GT_IMPORT_CONTOUR_EPSILON_RATIO) + approx = cv2.approxPolyDP(contour, epsilon, True) + while len(approx) > GT_IMPORT_MAX_CONTOUR_POINTS and epsilon < arc_length * 0.02: + epsilon *= 1.5 + approx = cv2.approxPolyDP(contour, epsilon, True) + points = approx.reshape(-1, 2) + if len(points) < 3: + points = contour.reshape(-1, 2) + if len(points) > GT_IMPORT_MAX_CONTOUR_POINTS: + step = int(math.ceil(len(points) / GT_IMPORT_MAX_CONTOUR_POINTS)) + points = points[::step] + return [ + [ + min(max(float(x) / max(width, 1), 0.0), 1.0), + min(max(float(y) / max(height, 1), 0.0), 1.0), + ] + for x, y in points + ] + + +def _contour_bbox(contour: np.ndarray, width: int, height: int) -> list[float]: + x, y, w, h = cv2.boundingRect(contour) + return [ + min(max(float(x) / max(width, 1), 0.0), 1.0), + min(max(float(y) / max(height, 1), 0.0), 1.0), + min(max(float(w) / max(width, 1), 0.0), 1.0), + min(max(float(h) / max(height, 1), 0.0), 1.0), + ] + + +def _polygon_bbox(polygon: list[list[float]]) -> list[float]: + xs = [_clamp01(point[0]) for point in polygon] + ys = [_clamp01(point[1]) for point in polygon] + left, right = min(xs), max(xs) + top, bottom = min(ys), max(ys) + return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)] + + +def _polygons_bbox(polygons: list[list[list[float]]]) -> list[float]: + points = [point for polygon in polygons for point in polygon if len(point) >= 2] + if not points: + return [0.0, 0.0, 0.0, 0.0] + xs = [_clamp01(point[0]) for point in points] + ys = [_clamp01(point[1]) for point in points] + left, right = min(xs), max(xs) + top, bottom = min(ys), max(ys) + return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)] + + +def _polygon_area(polygon: list[list[float]]) -> float: + if len(polygon) < 3: + return 0.0 + total = 0.0 + for index, point in enumerate(polygon): + next_point = polygon[(index + 1) % len(polygon)] + total += _clamp01(point[0]) * _clamp01(next_point[1]) + total -= _clamp01(next_point[0]) * _clamp01(point[1]) + return abs(total) / 2.0 + + +def _normalize_polygon(polygon: list[list[float]]) -> list[list[float]]: + return [[_clamp01(point[0]), _clamp01(point[1])] for point in polygon if len(point) >= 2] + + +def _normalize_polygons(polygons: list[list[list[float]]]) -> list[list[list[float]]]: + return [polygon for polygon in (_normalize_polygon(polygon) for polygon in polygons) if len(polygon) >= 3] + + +def _sample_anchor_points(anchors: list[list[float]], limit: int = 64) -> list[list[float]]: + if len(anchors) <= limit: + return anchors + step = max(1, math.ceil(len(anchors) / limit)) + return anchors[::step][:limit] + + +def _analysis_anchor_summary(polygons: list[list[list[float]]]) -> tuple[int, list[list[float]]]: + anchors: list[list[float]] = [] + for polygon in polygons: + if not polygon: + continue + anchors.extend([[_clamp01(point[0]), _clamp01(point[1])] for point in polygon]) + return len(anchors), _sample_anchor_points(anchors) + + +def _normalize_smoothing_options(strength: float | int | None, method: str | None = None) -> dict[str, Any]: + clamped_strength = max(0.0, min(float(strength or 0.0), 100.0)) + normalized_method = (method or "chaikin").lower() + if normalized_method != "chaikin": + normalized_method = "chaikin" + return { + "strength": round(clamped_strength, 2), + "method": normalized_method, + } + + +def _smoothing_ratio(strength: float, curve: float = 1.65) -> float: + normalized = max(0.0, min(float(strength or 0.0), 100.0)) / 100.0 + return normalized ** curve + + +def _chaikin_smooth_polygon(polygon: list[list[float]], iterations: int, corner_cut: float = 0.25) -> list[list[float]]: + points = polygon + q = max(0.02, min(float(corner_cut), 0.25)) + for _ in range(max(0, iterations)): + if len(points) < 3: + break + next_points: list[list[float]] = [] + for index, current in enumerate(points): + following = points[(index + 1) % len(points)] + next_points.append([ + _clamp01((1.0 - q) * current[0] + q * following[0]), + _clamp01((1.0 - q) * current[1] + q * following[1]), + ]) + next_points.append([ + _clamp01(q * current[0] + (1.0 - q) * following[0]), + _clamp01(q * current[1] + (1.0 - q) * following[1]), + ]) + points = next_points + return points + + +def _simplify_polygon(polygon: list[list[float]], strength: float) -> list[list[float]]: + if len(polygon) < 3 or strength <= 0: + return polygon + contour = np.array([[[point[0], point[1]]] for point in polygon], dtype=np.float32) + arc_length = cv2.arcLength(contour, True) + epsilon = arc_length * (0.00015 + _smoothing_ratio(strength) * 0.00735) + approx = cv2.approxPolyDP(contour, epsilon, True).reshape(-1, 2) + if len(approx) < 3: + return polygon + return [[_clamp01(float(x)), _clamp01(float(y))] for x, y in approx] + + +def _smooth_polygon(polygon: list[list[float]], smoothing: dict[str, Any]) -> list[list[float]]: + strength = float(smoothing.get("strength") or 0.0) + if strength <= 0: + return _normalize_polygon(polygon) + effective_strength = _smoothing_ratio(strength, curve=1.45) * 100.0 + if effective_strength >= 85: + iterations = 4 + elif effective_strength >= 55: + iterations = 3 + elif effective_strength >= 25: + iterations = 2 + else: + iterations = 1 + corner_cut = 0.03 + _smoothing_ratio(strength, curve=1.35) * 0.22 + normalized = _normalize_polygon(polygon) + pre_simplified = _simplify_polygon(normalized, effective_strength * 0.25) + smoothed = _chaikin_smooth_polygon(pre_simplified, iterations, corner_cut) + simplified = _simplify_polygon(smoothed, effective_strength) + if len(simplified) > len(normalized): + for fallback_strength in (25.0, 35.0, 50.0, 70.0, 90.0, 100.0): + simplified = _simplify_polygon(simplified, max(effective_strength, fallback_strength)) + if len(simplified) <= len(normalized): + break + return simplified if len(simplified) >= 3 else _normalize_polygon(polygon) + + +def _smooth_polygons(polygons: list[list[list[float]]], smoothing: dict[str, Any]) -> list[list[list[float]]]: + return [polygon for polygon in (_smooth_polygon(polygon, smoothing) for polygon in polygons) if len(polygon) >= 3] + + +def _frame_window( + frames: list[Frame], + source_position: int, + direction: str, + max_frames: int, +) -> tuple[list[Frame], int]: + count = max(1, min(max_frames, len(frames))) + if direction == "backward": + start = max(0, source_position - count + 1) + return frames[start:source_position + 1], source_position - start + if direction == "both": + before = (count - 1) // 2 + after = count - 1 - before + start = max(0, source_position - before) + end = min(len(frames), source_position + after + 1) + while end - start < count and start > 0: + start -= 1 + while end - start < count and end < len(frames): + end += 1 + return frames[start:end], source_position - start + end = min(len(frames), source_position + count) + return frames[source_position:end], 0 + + +def _write_frame_sequence(frames: list[Frame], directory: Path) -> list[str]: + paths = [] + for index, frame in enumerate(frames): + data = download_file(frame.image_url) + path = directory / f"frame_{index:06d}.jpg" + path.write_bytes(data) + paths.append(str(path)) + return paths + + +def _component_seed_point(component_mask: np.ndarray, width: int, height: int) -> list[float]: + """Reduce a binary component to one positive prompt point using distance transform.""" + dist = cv2.distanceTransform(component_mask.astype(np.uint8), cv2.DIST_L2, 5) + _, _, _, max_loc = cv2.minMaxLoc(dist) + x, y = max_loc + return [ + min(max(float(x) / max(width, 1), 0.0), 1.0), + min(max(float(y) / max(height, 1), 0.0), 1.0), + ] + + +def _clamp01(value: float) -> float: + return min(max(float(value), 0.0), 1.0) + + +def _point_in_polygon(point: list[float], polygon: list[list[float]]) -> bool: + """Return whether a normalized point is inside a normalized polygon.""" + if len(polygon) < 3: + return False + x, y = point + inside = False + j = len(polygon) - 1 + for i, current in enumerate(polygon): + xi, yi = current + xj, yj = polygon[j] + intersects = ((yi > y) != (yj > y)) and ( + x < (xj - xi) * (y - yi) / ((yj - yi) or 1e-9) + xi + ) + if intersects: + inside = not inside + j = i + return inside + + +def _crop_bounds_from_points(points: list[list[float]], margin: float) -> tuple[float, float, float, float]: + xs = [_clamp01(point[0]) for point in points] + ys = [_clamp01(point[1]) for point in points] + x1 = max(0.0, min(xs) - margin) + y1 = max(0.0, min(ys) - margin) + x2 = min(1.0, max(xs) + margin) + y2 = min(1.0, max(ys) + margin) + if x2 - x1 < 0.05: + center = (x1 + x2) / 2 + x1 = max(0.0, center - 0.025) + x2 = min(1.0, center + 0.025) + if y2 - y1 < 0.05: + center = (y1 + y2) / 2 + y1 = max(0.0, center - 0.025) + y2 = min(1.0, center + 0.025) + return x1, y1, x2, y2 + + +def _crop_image(image: np.ndarray, bounds: tuple[float, float, float, float]) -> np.ndarray: + height, width = image.shape[:2] + x1, y1, x2, y2 = bounds + left = int(round(x1 * width)) + top = int(round(y1 * height)) + right = max(left + 1, int(round(x2 * width))) + bottom = max(top + 1, int(round(y2 * height))) + return image[top:bottom, left:right] + + +def _to_crop_point(point: list[float], bounds: tuple[float, float, float, float]) -> list[float]: + x1, y1, x2, y2 = bounds + return [ + _clamp01((float(point[0]) - x1) / max(x2 - x1, 1e-9)), + _clamp01((float(point[1]) - y1) / max(y2 - y1, 1e-9)), + ] + + +def _from_crop_polygon( + polygon: list[list[float]], + bounds: tuple[float, float, float, float], +) -> list[list[float]]: + x1, y1, x2, y2 = bounds + return [ + [ + _clamp01(x1 + float(point[0]) * (x2 - x1)), + _clamp01(y1 + float(point[1]) * (y2 - y1)), + ] + for point in polygon + ] + + +def _filter_predictions( + polygons: list[list[list[float]]], + scores: list[float], + options: dict[str, Any], + negative_points: list[list[float]] | None = None, +) -> tuple[list[list[list[float]]], list[float]]: + if not options.get("auto_filter_background"): + return polygons, scores + + min_score = float(options.get("min_score", 0.0) or 0.0) + next_polygons: list[list[list[float]]] = [] + next_scores: list[float] = [] + for index, polygon in enumerate(polygons): + score = scores[index] if index < len(scores) else 0.0 + if score < min_score: + continue + if negative_points and any(_point_in_polygon(point, polygon) for point in negative_points): + continue + next_polygons.append(polygon) + next_scores.append(score) + return next_polygons, next_scores + + +@router.post( + "/predict", + response_model=PredictResponse, + summary="Run SAM inference with a prompt", +) +def predict( + payload: PredictRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> dict: + """Execute selected SAM segmentation given an image and a prompt. + + - **point**: `prompt_data` is either a list of `[[x, y], ...]` normalized + coordinates or `{ "points": [[x, y], ...], "labels": [1, 0, ...] }`. + - **box**: `prompt_data` is `[x1, y1, x2, y2]` normalized coordinates. + - **interactive**: `prompt_data` is `{ "box": [...], "points": [[x, y]], "labels": [1, 0] }`. + - **semantic**: disabled in the current SAM 2.1 point/box product flow. + """ + frame = _shared_frame_or_404(payload.image_id, db, current_user) + + image = _load_frame_image(frame) + prompt_type = payload.prompt_type.lower() + options = payload.options or {} + + polygons: List[List[List[float]]] = [] + scores: List[float] = [] + negative_points: list[list[float]] = [] + + try: + if prompt_type == "point": + point_payload = payload.prompt_data + if isinstance(point_payload, dict): + points = point_payload.get("points") + labels = point_payload.get("labels") + else: + points = point_payload + labels = None + + if not isinstance(points, list) or len(points) == 0: + raise HTTPException(status_code=400, detail="Invalid point prompt data") + if not isinstance(labels, list) or len(labels) != len(points): + labels = [1] * len(points) + negative_points = [ + point for point, label in zip(points, labels) if label == 0 + ] + inference_image = image + inference_points = points + crop_bounds = None + if options.get("crop_to_prompt"): + margin = float(options.get("crop_margin", 0.25) or 0.25) + crop_bounds = _crop_bounds_from_points(points, margin) + inference_image = _crop_image(image, crop_bounds) + inference_points = [_to_crop_point(point, crop_bounds) for point in points] + polygons, scores = sam_registry.predict_points(payload.model, inference_image, inference_points, labels) + if crop_bounds: + polygons = [_from_crop_polygon(polygon, crop_bounds) for polygon in polygons] + + elif prompt_type == "box": + box = payload.prompt_data + if not isinstance(box, list) or len(box) != 4: + raise HTTPException(status_code=400, detail="Invalid box prompt data") + inference_image = image + inference_box = box + crop_bounds = None + if options.get("crop_to_prompt"): + margin = float(options.get("crop_margin", 0.05) or 0.05) + crop_bounds = _crop_bounds_from_points([[box[0], box[1]], [box[2], box[3]]], margin) + inference_image = _crop_image(image, crop_bounds) + inference_box = [ + *_to_crop_point([box[0], box[1]], crop_bounds), + *_to_crop_point([box[2], box[3]], crop_bounds), + ] + polygons, scores = sam_registry.predict_box(payload.model, inference_image, inference_box) + if crop_bounds: + polygons = [_from_crop_polygon(polygon, crop_bounds) for polygon in polygons] + + elif prompt_type == "interactive": + prompt = payload.prompt_data + if not isinstance(prompt, dict): + raise HTTPException(status_code=400, detail="Invalid interactive prompt data") + box = prompt.get("box") + points = prompt.get("points") or [] + labels = prompt.get("labels") + if box is not None and (not isinstance(box, list) or len(box) != 4): + raise HTTPException(status_code=400, detail="Invalid interactive box prompt data") + if not isinstance(points, list): + raise HTTPException(status_code=400, detail="Invalid interactive point prompt data") + if not box and len(points) == 0: + raise HTTPException(status_code=400, detail="Interactive prompt requires a box or points") + if not isinstance(labels, list) or len(labels) != len(points): + labels = [1] * len(points) + negative_points = [ + point for point, label in zip(points, labels) if label == 0 + ] + inference_image = image + inference_box = box + inference_points = points + crop_bounds = None + if options.get("crop_to_prompt"): + margin = float(options.get("crop_margin", 0.05) or 0.05) + crop_points = list(points) + if box: + crop_points.extend([[box[0], box[1]], [box[2], box[3]]]) + crop_bounds = _crop_bounds_from_points(crop_points, margin) + inference_image = _crop_image(image, crop_bounds) + inference_points = [_to_crop_point(point, crop_bounds) for point in points] + if box: + inference_box = [ + *_to_crop_point([box[0], box[1]], crop_bounds), + *_to_crop_point([box[2], box[3]], crop_bounds), + ] + polygons, scores = sam_registry.predict_interactive( + payload.model, + inference_image, + inference_box, + inference_points, + labels, + ) + if crop_bounds: + polygons = [_from_crop_polygon(polygon, crop_bounds) for polygon in polygons] + + elif prompt_type == "semantic": + text = payload.prompt_data if isinstance(payload.prompt_data, str) else "" + min_score = options.get("min_score") + confidence_threshold = None + if min_score is not None: + try: + parsed_min_score = float(min_score) + if parsed_min_score > 0: + confidence_threshold = parsed_min_score + except (TypeError, ValueError): + confidence_threshold = None + polygons, scores = sam_registry.predict_semantic( + payload.model, + image, + text, + confidence_threshold=confidence_threshold, + ) + + else: + raise HTTPException(status_code=400, detail=f"Unsupported prompt_type: {prompt_type}") + except ModelUnavailableError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except NotImplementedError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + polygons, scores = _filter_predictions(polygons, scores, options, negative_points) + logger.info( + "AI predict completed model=%s prompt_type=%s frame_id=%s polygons=%d", + payload.model or "default", + prompt_type, + payload.image_id, + len(polygons), + ) + return {"polygons": polygons, "scores": scores} + + +@router.get( + "/models/status", + response_model=AiRuntimeStatus, + summary="Get SAM model and GPU runtime status", +) +def model_status( + selected_model: str | None = None, + _current_user: User = Depends(get_current_user), +) -> dict: + """Return real runtime availability for GPU and the currently enabled SAM model.""" + try: + return sam_registry.runtime_status(selected_model) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post( + "/analyze-mask", + response_model=MaskAnalysisResponse, + summary="Analyze mask geometry and prompt anchors", +) +def analyze_mask( + payload: MaskAnalysisRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> dict: + """Return backend-computed mask properties for the frontend inspector.""" + if payload.frame_id is not None: + _shared_frame_or_404(payload.frame_id, db, current_user) + + mask_data = payload.mask_data or {} + polygons = mask_data.get("polygons") or [] + if not polygons: + raise HTTPException(status_code=400, detail="Mask analysis requires polygons") + + valid_polygons = _normalize_polygons(polygons) + if not valid_polygons: + raise HTTPException(status_code=400, detail="Mask analysis requires at least one valid polygon") + + area = sum(_polygon_area(polygon) for polygon in valid_polygons) + bbox = payload.bbox or _polygon_bbox(valid_polygons[0]) + source = mask_data.get("source") + raw_score = mask_data.get("score") + confidence: float | None = None + confidence_source = "unavailable" + if isinstance(raw_score, (int, float)): + confidence = max(0.0, min(float(raw_score), 1.0)) + confidence_source = "model_score" + elif source: + confidence_source = "source_without_score" + else: + confidence_source = "manual_or_imported" + + anchor_count, anchors = _analysis_anchor_summary(valid_polygons) + message = "已从后端重新提取几何拓扑锚点" if payload.extract_skeleton else "已读取后端几何属性" + + return { + "confidence": confidence, + "confidence_source": confidence_source, + "topology_anchor_count": anchor_count, + "topology_anchors": anchors, + "area": area, + "bbox": bbox, + "source": source, + "message": message, + } + + +@router.post( + "/smooth-mask", + response_model=SmoothMaskResponse, + summary="Smooth editable mask polygons with backend geometry rules", +) +def smooth_mask( + payload: SmoothMaskRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> dict: + """Return a smoothed polygon mask without persisting it. + + The frontend keeps this as an explicit edit operation: users preview/apply it + to the current mask, then save through the normal annotation endpoint. + """ + if payload.frame_id is not None: + _shared_frame_or_404(payload.frame_id, db, current_user) + + polygons = payload.mask_data.get("polygons") or [] + valid_polygons = _normalize_polygons(polygons) + if not valid_polygons: + raise HTTPException(status_code=400, detail="Mask smoothing requires at least one valid polygon") + + smoothing = _normalize_smoothing_options(payload.strength, payload.method) + smoothed_polygons = _smooth_polygons(valid_polygons, smoothing) + if not smoothed_polygons: + raise HTTPException(status_code=400, detail="Mask smoothing produced no valid polygons") + + area = sum(_polygon_area(polygon) for polygon in smoothed_polygons) + bbox = _polygon_bbox(smoothed_polygons[0]) + anchor_count, anchors = _analysis_anchor_summary(smoothed_polygons) + return { + "polygons": smoothed_polygons, + "topology_anchor_count": anchor_count, + "topology_anchors": anchors, + "area": area, + "bbox": bbox, + "smoothing": smoothing, + "message": f"已应用边缘平滑强度 {smoothing['strength']:.0f}", + } + + +@router.post( + "/propagate", + response_model=PropagateResponse, + summary="Propagate one current-frame region across a video frame segment", +) +def propagate( + payload: PropagateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> dict: + """Track one selected region from the current frame across nearby frames. + + SAM 2 uses the official video predictor with the selected mask as the seed. + SAM 3 video tracking is currently disabled in this product flow. + """ + direction = payload.direction.lower() + if direction not in {"forward", "backward", "both"}: + raise HTTPException(status_code=400, detail="direction must be forward, backward, or both") + max_frames = max(1, min(int(payload.max_frames or 30), 500)) + + _shared_project_or_404(payload.project_id, db, current_user) + source_frame = _shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id) + + seed = payload.seed.model_dump(exclude_none=True) + polygons = seed.get("polygons") or [] + bbox = seed.get("bbox") + points = seed.get("points") or [] + if not polygons and not bbox and not points: + raise HTTPException(status_code=400, detail="Propagation requires seed polygons, bbox, or points") + + frames = db.query(Frame).filter(Frame.project_id == payload.project_id).order_by(Frame.frame_index).all() + source_position = next((index for index, frame in enumerate(frames) if frame.id == source_frame.id), None) + if source_position is None: + raise HTTPException(status_code=404, detail="Source frame is not in project frame sequence") + + selected_frames, source_relative_index = _frame_window(frames, source_position, direction, max_frames) + if len(selected_frames) == 0: + raise HTTPException(status_code=400, detail="No frames available for propagation") + + try: + with tempfile.TemporaryDirectory(prefix=f"seg_propagate_{payload.project_id}_") as tmpdir: + frame_paths = _write_frame_sequence(selected_frames, Path(tmpdir)) + propagated = sam_registry.propagate_video( + payload.model, + frame_paths, + source_relative_index, + seed, + direction, + len(selected_frames), + ) + except ModelUnavailableError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except NotImplementedError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + logger.error("Video propagation failed: %s", exc) + raise HTTPException(status_code=500, detail=f"Video propagation failed: {exc}") from exc + + created: list[Annotation] = [] + if payload.save_annotations: + class_metadata = seed.get("class_metadata") + template_id = seed.get("template_id") + label = seed.get("label") or "Propagated Mask" + color = seed.get("color") or "#06b6d4" + model_id = sam_registry.normalize_model_id(payload.model) + source_annotation_id = seed.get("source_annotation_id") + source_mask_id = seed.get("source_mask_id") + source_instance_id = ( + seed.get("source_instance_id") + or (f"annotation:{source_annotation_id}" if source_annotation_id is not None else None) + or (f"mask:{source_mask_id}" if source_mask_id else None) + ) + seed_smoothing = seed.get("smoothing") + smoothing = _normalize_smoothing_options( + seed_smoothing.get("strength"), + seed_smoothing.get("method"), + ) if isinstance(seed_smoothing, dict) else None + if smoothing and smoothing["strength"] <= 0: + smoothing = None + + for frame_result in propagated: + relative_index = int(frame_result.get("frame_index", -1)) + if relative_index < 0 or relative_index >= len(selected_frames): + continue + frame = selected_frames[relative_index] + if not payload.include_source and frame.id == source_frame.id: + continue + result_polygons = frame_result.get("polygons") or [] + result_holes = frame_result.get("holes") or [] + scores = frame_result.get("scores") or [] + polygons_to_save: list[list[list[float]]] = [] + holes_to_save: list[list[list[list[float]]]] = [] + score_values: list[float] = [] + for polygon_index, polygon in enumerate(result_polygons): + if len(polygon) < 3: + continue + polygons_to_save.append(_smooth_polygon(polygon, smoothing) if smoothing else polygon) + hole_group = result_holes[polygon_index] if polygon_index < len(result_holes) and isinstance(result_holes[polygon_index], list) else [] + holes_to_save.append(hole_group if isinstance(hole_group, list) else []) + if polygon_index < len(scores): + try: + score_values.append(float(scores[polygon_index])) + except (TypeError, ValueError): + pass + if not polygons_to_save: + continue + annotation = Annotation( + project_id=payload.project_id, + frame_id=frame.id, + template_id=template_id, + mask_data={ + "polygons": polygons_to_save, + **({"holes": holes_to_save, "hasHoles": True} if any(holes_to_save) else {}), + "label": label, + "color": color, + "source": f"{model_id}_propagation", + "propagated_from_frame_id": source_frame.id, + "propagated_from_frame_index": source_frame.frame_index, + **({"instance_id": source_instance_id, "source_instance_id": source_instance_id} if source_instance_id else {}), + **({"source_annotation_id": source_annotation_id} if source_annotation_id is not None else {}), + **({"source_mask_id": source_mask_id} if source_mask_id else {}), + "score": max(score_values) if score_values else None, + **({"scores": score_values} if len(score_values) > 1 else {}), + **({"geometry_smoothing": smoothing} if smoothing else {}), + **({"class": class_metadata} if class_metadata else {}), + }, + points=None, + bbox=_polygons_bbox(polygons_to_save), + ) + db.add(annotation) + created.append(annotation) + + db.commit() + for annotation in created: + db.refresh(annotation) + + return { + "model": sam_registry.normalize_model_id(payload.model), + "direction": direction, + "source_frame_id": source_frame.id, + "processed_frame_count": len(selected_frames), + "created_annotation_count": len(created), + "annotations": created, + } + + +@router.post( + "/propagate/task", + status_code=status.HTTP_202_ACCEPTED, + response_model=ProcessingTaskOut, + summary="Queue a background video propagation task", +) +def queue_propagate_task( + payload: PropagateTaskRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> ProcessingTaskOut: + """Queue multiple seed/direction propagation steps as one background task.""" + _shared_project_or_404(payload.project_id, db, current_user) + source_frame = _shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id) + + if not payload.steps: + raise HTTPException(status_code=400, detail="Propagation task requires at least one step") + + try: + model_id = sam_registry.normalize_model_id(payload.model) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + for step in payload.steps: + direction = step.direction.lower() + if direction not in {"forward", "backward"}: + raise HTTPException(status_code=400, detail="direction must be forward or backward") + seed = step.seed.model_dump(exclude_none=True) + if not (seed.get("polygons") or seed.get("bbox") or seed.get("points")): + raise HTTPException(status_code=400, detail="Propagation requires seed polygons, bbox, or points") + + task_payload = payload.model_dump(exclude_none=True) + task_payload["model"] = model_id + task = ProcessingTask( + task_type="propagate_masks", + status=TASK_STATUS_QUEUED, + progress=0, + message="自动传播任务已入队", + project_id=payload.project_id, + payload=task_payload, + ) + db.add(task) + db.commit() + db.refresh(task) + publish_task_progress_event(task) + + async_result = propagate_project_masks.delay(task.id) + task.celery_task_id = async_result.id + db.commit() + db.refresh(task) + publish_task_progress_event(task) + + logger.info("Queued propagation task id=%s project_id=%s celery_id=%s", task.id, payload.project_id, async_result.id) + return task + + +@router.post( + "/auto", + response_model=PredictResponse, + summary="Run automatic segmentation", +) +def auto_segment( + image_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> dict: + """Run automatic mask generation on a frame using a grid of point prompts.""" + frame = _shared_frame_or_404(image_id, db, current_user) + + image = _load_frame_image(frame) + try: + polygons, scores = sam_registry.predict_auto(None, image) + except ModelUnavailableError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + return {"polygons": polygons, "scores": scores} + + +@router.post( + "/annotate", + response_model=AnnotationOut, + status_code=status.HTTP_201_CREATED, + summary="Save an AI-generated annotation", +) +def save_annotation( + payload: AnnotationCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> Annotation: + """Persist an annotation (mask, points, bbox) into the database.""" + _shared_project_or_404(payload.project_id, db, current_user) + + if payload.frame_id: + _shared_frame_or_404(payload.frame_id, db, current_user, payload.project_id) + if payload.template_id: + _visible_template_or_404(payload.template_id, db, current_user) + + annotation = Annotation(**payload.model_dump()) + db.add(annotation) + db.commit() + db.refresh(annotation) + logger.info("Saved annotation id=%s project_id=%s", annotation.id, annotation.project_id) + return annotation + + +@router.post( + "/import-gt-mask", + response_model=List[AnnotationOut], + status_code=status.HTTP_201_CREATED, + summary="Import a GT mask and reduce components to editable point regions", +) +async def import_gt_mask( + project_id: int = Form(...), + frame_id: int = Form(...), + template_id: int | None = Form(None), + label: str = Form("GT Mask"), + color: str = Form("#22c55e"), + unknown_color_policy: str = Form("undefined"), + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> List[Annotation]: + """Convert a binary/label mask image into persisted polygon annotations. + + Each connected component becomes one annotation. The `points` field stores a + positive seed point at the component's distance-transform center, which gives + the frontend an editable point-region representation instead of a static + bitmap layer. + """ + _shared_project_or_404(project_id, db, current_user) + frame = _shared_frame_or_404(frame_id, db, current_user, project_id) + + if unknown_color_policy not in {"discard", "undefined"}: + raise HTTPException(status_code=400, detail="unknown_color_policy must be discard or undefined") + + template: Template | None = None + if template_id is not None: + template = _visible_template_or_404(template_id, db, current_user) + + data = await file.read() + image = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_UNCHANGED) + if image is None: + raise HTTPException(status_code=400, detail="Invalid mask image") + + invalid_format_detail = ( + "GT Mask 图片不符合要求:仅支持 8-bit 灰度图,或 8-bit RGB 三通道完全相同的 maskid 图" + "(背景 0,像素值为 1-255 的 maskid)。" + ) + if image.dtype != np.uint8: + raise HTTPException(status_code=400, detail=invalid_format_detail) + + if image.ndim == 2: + label_image = image + elif image.ndim == 3 and image.shape[2] >= 3: + channels = image[:, :, :3] + # GT label images are maskid maps: either grayscale or RGB/BGR where + # all three color channels contain the same maskid value [X, X, X]. + if not (np.array_equal(channels[:, :, 0], channels[:, :, 1]) and np.array_equal(channels[:, :, 1], channels[:, :, 2])): + raise HTTPException(status_code=400, detail=invalid_format_detail) + label_image = channels[:, :, 0] + else: + raise HTTPException(status_code=400, detail=invalid_format_detail) + + width = int(frame.width or image.shape[1]) + height = int(frame.height or image.shape[0]) + original_height, original_width = int(label_image.shape[0]), int(label_image.shape[1]) + resized_to_frame = original_width != width or original_height != height + if resized_to_frame: + label_image = cv2.resize(label_image, (width, height), interpolation=cv2.INTER_NEAREST) + + by_maskid, _by_color, unclassified_class = _template_class_maps(template) + has_template_classes = bool(by_maskid) + fallback_color = _normalize_hex_color(color) or "#22c55e" + + import_items: list[dict[str, Any]] = [] + skipped_unknown = 0 + label_values = [int(value) for value in np.unique(label_image) if int(value) > 0] + for label_value in label_values: + class_meta = by_maskid.get(label_value) + is_unknown = has_template_classes and class_meta is None + if is_unknown and unknown_color_policy == "discard": + skipped_unknown += 1 + continue + if class_meta: + annotation_label = class_meta["name"] + annotation_color = class_meta["color"] + elif is_unknown: + annotation_label = unclassified_class["name"] + annotation_color = unclassified_class["color"] + class_meta = unclassified_class + else: + annotation_label = f"{label} {label_value}" if len(label_values) > 1 else label + annotation_color = fallback_color + import_items.append({ + "token": label_value, + "binary": np.where(label_image == label_value, 255, 0).astype(np.uint8), + "label": annotation_label, + "color": annotation_color, + "class": class_meta, + "unknown": is_unknown, + "metadata": { + "gt_label_value": label_value, + "gt_original_size": {"width": original_width, "height": original_height}, + "gt_resized_to_frame": resized_to_frame, + }, + }) + + if not import_items: + if skipped_unknown > 0: + raise HTTPException(status_code=400, detail="No matching GT mask classes found") + raise HTTPException(status_code=400, detail=GT_MASK_EMPTY_DETAIL) + + annotations: list[Annotation] = [] + for item in import_items: + binary = item["binary"] + contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) + + for contour in contours: + if cv2.contourArea(contour) < 1: + continue + + polygon = _normalized_contour(contour, binary.shape[1], binary.shape[0]) + if len(polygon) < 3: + continue + + component = np.zeros_like(binary, dtype=np.uint8) + cv2.drawContours(component, [contour], -1, 1, thickness=-1) + seed_point = _component_seed_point(component, binary.shape[1], binary.shape[0]) + bbox = _contour_bbox(contour, binary.shape[1], binary.shape[0]) + mask_data = { + "polygons": [polygon], + "label": item["label"], + "color": item["color"], + "source": "gt_mask", + "image_size": {"width": width, "height": height}, + **item["metadata"], + } + if item["class"]: + mask_data["class"] = item["class"] + if item["unknown"]: + mask_data["gt_unknown_class"] = True + + annotation = Annotation( + project_id=project_id, + frame_id=frame_id, + template_id=template_id, + mask_data=mask_data, + points=[seed_point], + bbox=bbox, + ) + db.add(annotation) + annotations.append(annotation) + + if not annotations: + raise HTTPException(status_code=400, detail=GT_MASK_EMPTY_DETAIL) + + db.commit() + for annotation in annotations: + db.refresh(annotation) + logger.info("Imported %s GT mask annotations for project_id=%s frame_id=%s", len(annotations), project_id, frame_id) + return annotations + + +@router.get( + "/annotations", + response_model=List[AnnotationOut], + summary="List saved annotations for a project", +) +def list_annotations( + project_id: int, + frame_id: int | None = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> List[Annotation]: + """Return persisted annotations for a project, optionally scoped to one frame.""" + _shared_project_or_404(project_id, db, current_user) + + query = db.query(Annotation).filter(Annotation.project_id == project_id) + if frame_id is not None: + _shared_frame_or_404(frame_id, db, current_user, project_id) + query = query.filter(Annotation.frame_id == frame_id) + return query.order_by(Annotation.id).all() + + +@router.patch( + "/annotations/{annotation_id}", + response_model=AnnotationOut, + summary="Update a saved annotation", +) +def update_annotation( + annotation_id: int, + payload: AnnotationUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> Annotation: + """Update mutable annotation fields persisted in the database.""" + annotation = ( + db.query(Annotation) + .join(Project, Project.id == Annotation.project_id) + .filter(Annotation.id == annotation_id) + .first() + ) + if not annotation: + raise HTTPException(status_code=404, detail="Annotation not found") + + updates = payload.model_dump(exclude_unset=True) + if "template_id" in updates and updates["template_id"] is not None: + _visible_template_or_404(updates["template_id"], db, current_user) + + for field, value in updates.items(): + setattr(annotation, field, value) + + db.commit() + db.refresh(annotation) + logger.info("Updated annotation id=%s", annotation.id) + return annotation + + +@router.delete( + "/annotations/{annotation_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a saved annotation", +) +def delete_annotation( + annotation_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> Response: + """Delete an annotation and its derived mask rows through ORM cascade.""" + annotation = ( + db.query(Annotation) + .join(Project, Project.id == Annotation.project_id) + .filter(Annotation.id == annotation_id) + .first() + ) + if not annotation: + raise HTTPException(status_code=404, detail="Annotation not found") + + db.delete(annotation) + db.commit() + logger.info("Deleted annotation id=%s", annotation_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..f65cbf5 --- /dev/null +++ b/backend/routers/auth.py @@ -0,0 +1,222 @@ +"""Authentication endpoints and dependencies.""" + +from datetime import datetime, timedelta, timezone +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from config import settings +from database import get_db +from models import AuditLog, User +from schemas import LoginResponse, UserOut + +router = APIRouter(prefix="/api/auth", tags=["Auth"]) +password_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") +bearer_scheme = HTTPBearer(auto_error=False) +SUPPORTED_ROLES = {"admin", "annotator"} + + +class LoginRequest(BaseModel): + username: str + password: str + + +def hash_password(password: str) -> str: + """Hash a plain password for storage.""" + return password_context.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + """Verify a plain password against a stored hash.""" + return password_context.verify(password, password_hash) + + +def create_access_token(user: User, expires_delta: timedelta | None = None) -> str: + """Create a signed JWT access token for a user.""" + expire = datetime.now(timezone.utc) + ( + expires_delta or timedelta(minutes=settings.access_token_expire_minutes) + ) + payload: dict[str, Any] = { + "sub": str(user.id), + "username": user.username, + "role": user.role, + "exp": expire, + } + return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + + +def ensure_default_admin(db: Session) -> User: + """Create and enforce the single default administrator account.""" + existing = db.query(User).filter(User.username == settings.default_admin_username).first() + if existing: + changed = False + if existing.role != "admin": + existing.role = "admin" + changed = True + if not existing.is_active: + existing.is_active = 1 + changed = True + extra_admins = db.query(User).filter( + User.role == "admin", + User.id != existing.id, + ).all() + for user in extra_admins: + user.role = "annotator" + changed = True + if changed: + db.commit() + db.refresh(existing) + return existing + user = User( + username=settings.default_admin_username, + password_hash=hash_password(settings.default_admin_password), + role="admin", + is_active=1, + ) + db.add(user) + db.commit() + db.refresh(user) + extra_admins = db.query(User).filter( + User.role == "admin", + User.id != user.id, + ).all() + if extra_admins: + for extra_user in extra_admins: + extra_user.role = "annotator" + db.commit() + db.refresh(user) + return user + + +def normalize_user_role(db: Session, user: User) -> User: + """Keep legacy accounts within the current two-role policy.""" + desired_role = "admin" if user.username == settings.default_admin_username else "annotator" + changed = False + if user.role != desired_role: + user.role = desired_role + changed = True + if user.username == settings.default_admin_username and not user.is_active: + user.is_active = 1 + changed = True + if changed: + db.commit() + db.refresh(user) + return user + + +def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + db: Session = Depends(get_db), +) -> User: + """Resolve and validate the current user from the Bearer token.""" + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode( + credentials.credentials, + settings.jwt_secret_key, + algorithms=[settings.jwt_algorithm], + ) + user_id = int(payload.get("sub")) + except (JWTError, TypeError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + user = db.query(User).filter(User.id == user_id).first() + if user: + user = normalize_user_role(db, user) + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Inactive or missing user", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user + + +def require_admin(current_user: User = Depends(get_current_user)) -> User: + """Require the current user to have the administrator role.""" + if current_user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required") + return current_user + + +def require_editor(current_user: User = Depends(get_current_user)) -> User: + """Require a user role that can modify segmentation data.""" + if current_user.role not in SUPPORTED_ROLES: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Edit permission required") + return current_user + + +def write_audit_log( + db: Session, + *, + actor: User | None, + action: str, + target_type: str | None = None, + target_id: str | int | None = None, + detail: dict[str, Any] | None = None, +) -> AuditLog: + """Persist a compact audit event.""" + log = AuditLog( + actor_user_id=actor.id if actor else None, + action=action, + target_type=target_type, + target_id=str(target_id) if target_id is not None else None, + detail=detail or {}, + ) + db.add(log) + db.commit() + db.refresh(log) + return log + + +@router.post("/login", response_model=LoginResponse) +def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict: + """Authenticate a user and return a signed JWT.""" + ensure_default_admin(db) + user = db.query(User).filter(User.username == payload.username).first() + if user: + user = normalize_user_role(db, user) + if not user or not user.is_active or not verify_password(payload.password, user.password_hash): + write_audit_log( + db, + actor=None, + action="auth.login_failed", + target_type="user", + target_id=payload.username, + detail={"username": payload.username}, + ) + raise HTTPException(status_code=401, detail="Invalid credentials") + write_audit_log( + db, + actor=user, + action="auth.login_success", + target_type="user", + target_id=user.id, + detail={"username": user.username}, + ) + return { + "token": create_access_token(user), + "token_type": "bearer", + "username": user.username, + "user": user, + } + + +@router.get("/me", response_model=UserOut) +def read_current_user(current_user: User = Depends(get_current_user)) -> User: + """Return the authenticated user profile.""" + return current_user diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py new file mode 100644 index 0000000..6f09f91 --- /dev/null +++ b/backend/routers/dashboard.py @@ -0,0 +1,164 @@ +"""Dashboard overview endpoints.""" + +import os +from datetime import datetime, timezone +from typing import Any + +from fastapi import APIRouter, Depends +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from database import get_db +from models import Annotation, Frame, ProcessingTask, Project, Template, User +from routers.auth import get_current_user + +router = APIRouter(prefix="/api/dashboard", tags=["Dashboard"]) + +ACTIVE_TASK_STATUSES = {"queued", "running"} +MONITORED_TASK_STATUSES = {"queued", "running", "success", "failed", "cancelled"} + + +def _system_load_percent() -> int: + """Return a real host load estimate without adding a psutil dependency.""" + try: + load_1m = os.getloadavg()[0] + cpu_count = os.cpu_count() or 1 + return min(100, max(0, round((load_1m / cpu_count) * 100))) + except (AttributeError, OSError): + return 0 + + +def _iso_or_none(value: datetime | None) -> str | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.isoformat() + + +def _task_payload(task: ProcessingTask) -> dict[str, Any]: + result = task.result or {} + return { + "id": f"task-{task.id}", + "task_id": task.id, + "project_id": task.project_id or 0, + "name": task.project.name if task.project else f"任务 {task.id}", + "progress": task.progress, + "status": task.message or task.status, + "raw_status": task.status, + "frame_count": result.get("frames_extracted", result.get("processed_frame_count", 0)), + "error": task.error, + "updated_at": _iso_or_none(task.updated_at), + } + + +@router.get("/overview", summary="Get dashboard overview") +def get_dashboard_overview( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> dict[str, Any]: + """Return live dashboard data derived from persisted backend records.""" + shared_project_ids_query = db.query(Project.id) + project_count = db.query(func.count(Project.id)).scalar() or 0 + frame_count = db.query(func.count(Frame.id)).filter(Frame.project_id.in_(shared_project_ids_query)).scalar() or 0 + annotation_count = ( + db.query(func.count(Annotation.id)) + .filter(Annotation.project_id.in_(shared_project_ids_query)) + .scalar() + or 0 + ) + template_count = ( + db.query(func.count(Template.id)) + .filter(or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None))) + .scalar() + or 0 + ) + active_task_count = ( + db.query(func.count(ProcessingTask.id)) + .outerjoin(Project, Project.id == ProcessingTask.project_id) + .filter(ProcessingTask.status.in_(ACTIVE_TASK_STATUSES)) + .scalar() + or 0 + ) + + projects = ( + db.query(Project) + .order_by(Project.updated_at.desc()) + .all() + ) + recent_tasks = ( + db.query(ProcessingTask) + .outerjoin(Project, Project.id == ProcessingTask.project_id) + .order_by(ProcessingTask.created_at.desc()) + .limit(50) + .all() + ) + tasks = [_task_payload(task) for task in recent_tasks if task.status in MONITORED_TASK_STATUSES] + + activities: list[dict[str, Any]] = [] + for task in recent_tasks[:10]: + project_name = task.project.name if task.project else f"项目 {task.project_id}" + activities.append({ + "id": f"task-{task.id}", + "kind": "task", + "time": _iso_or_none(task.updated_at), + "message": task.message or f"任务状态: {task.status}", + "project": project_name, + }) + + for project in projects[:10]: + activities.append({ + "id": f"project-{project.id}", + "kind": "project", + "time": _iso_or_none(project.updated_at), + "message": f"项目状态: {project.status}", + "project": project.name, + }) + + recent_annotations = ( + db.query(Annotation) + .filter(Annotation.project_id.in_(shared_project_ids_query)) + .order_by(Annotation.updated_at.desc()) + .limit(10) + .all() + ) + for annotation in recent_annotations: + project_name = annotation.project.name if annotation.project else f"项目 {annotation.project_id}" + activities.append({ + "id": f"annotation-{annotation.id}", + "kind": "annotation", + "time": _iso_or_none(annotation.updated_at), + "message": f"标注已更新 #{annotation.id}", + "project": project_name, + }) + + recent_templates = ( + db.query(Template) + .filter(or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None))) + .order_by(Template.created_at.desc()) + .limit(10) + .all() + ) + for template in recent_templates: + activities.append({ + "id": f"template-{template.id}", + "kind": "template", + "time": _iso_or_none(template.created_at), + "message": f"模板可用: {template.name}", + "project": "系统", + }) + + activities.sort(key=lambda item: item["time"] or "", reverse=True) + + return { + "summary": { + "project_count": project_count, + "parsing_task_count": active_task_count, + "annotation_count": annotation_count, + "frame_count": frame_count, + "template_count": template_count, + "system_load_percent": _system_load_percent(), + }, + "tasks": tasks, + "activity": activities[:10], + } diff --git a/backend/routers/export.py b/backend/routers/export.py new file mode 100644 index 0000000..ebc2514 --- /dev/null +++ b/backend/routers/export.py @@ -0,0 +1,764 @@ +"""Annotation export endpoints (COCO, PNG masks).""" + +import io +import json +import logging +import os +import re +import zipfile +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List +from urllib.parse import quote + +import numpy as np +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from database import get_db +from minio_client import download_file +from models import Project, Annotation, Frame, Template, User +from routers.auth import get_current_user + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/export", tags=["Export"]) + + +def _mask_from_polygon( + polygon: List[List[float]], + width: int, + height: int, +) -> np.ndarray: + """Render a normalized polygon to a binary mask.""" + import cv2 + + pts = np.array( + [[int(p[0] * width), int(p[1] * height)] for p in polygon], + dtype=np.int32, + ) + mask = np.zeros((height, width), dtype=np.uint8) + cv2.fillPoly(mask, [pts], 255) + return mask + + +def _annotation_z_index(annotation: Annotation) -> int: + class_meta = (annotation.mask_data or {}).get("class") or {} + if isinstance(class_meta, dict) and class_meta.get("zIndex") is not None: + try: + return int(class_meta["zIndex"]) + except (TypeError, ValueError): + pass + if annotation.template and annotation.template.z_index is not None: + return int(annotation.template.z_index) + return 0 + + +def _annotation_mask_id(annotation: Annotation) -> int | None: + class_meta = (annotation.mask_data or {}).get("class") or {} + if isinstance(class_meta, dict): + for key in ("maskId", "maskid", "mask_id"): + if class_meta.get(key) is None: + continue + try: + value = int(class_meta[key]) + except (TypeError, ValueError): + continue + if value >= 0: + return value + return None + + +def _annotation_category_name(annotation: Annotation) -> str: + class_meta = (annotation.mask_data or {}).get("class") or {} + if isinstance(class_meta, dict) and class_meta.get("category"): + return str(class_meta["category"]) + if annotation.template and annotation.template.name: + return str(annotation.template.name) + return "" + + +def _annotation_class_key(annotation: Annotation) -> str: + class_meta = (annotation.mask_data or {}).get("class") or {} + if isinstance(class_meta, dict): + if class_meta.get("id"): + return f"class:{class_meta['id']}" + if class_meta.get("name"): + return f"name:{class_meta['name']}" + if annotation.template_id: + return f"template:{annotation.template_id}" + return f"annotation:{annotation.id}" + + +def _annotation_label(annotation: Annotation) -> str: + mask_data = annotation.mask_data or {} + class_meta = mask_data.get("class") or {} + if isinstance(class_meta, dict) and class_meta.get("name"): + return str(class_meta["name"]) + if mask_data.get("label"): + return str(mask_data["label"]) + if annotation.template and annotation.template.name: + return str(annotation.template.name) + return f"Annotation {annotation.id}" + + +def _annotation_color(annotation: Annotation) -> str: + mask_data = annotation.mask_data or {} + class_meta = mask_data.get("class") or {} + if isinstance(class_meta, dict) and class_meta.get("color"): + return str(class_meta["color"]) + if mask_data.get("color"): + return str(mask_data["color"]) + if annotation.template and annotation.template.color: + return str(annotation.template.color) + return "#ffffff" + + +def _hex_to_rgb(color: str) -> list[int]: + value = str(color or "").strip() + if value.startswith("#"): + value = value[1:] + if len(value) == 3: + value = "".join(part * 2 for part in value) + if len(value) != 6: + return [255, 255, 255] + try: + return [int(value[i:i + 2], 16) for i in (0, 2, 4)] + except ValueError: + return [255, 255, 255] + + +def _safe_filename_part(value: Any, fallback: str = "unknown") -> str: + text = str(value or "").strip() + if not text: + text = fallback + text = re.sub(r"[\\/:*?\"<>|\s]+", "_", text) + text = re.sub(r"_+", "_", text).strip("._") + return text or fallback + + +def _project_video_name(project: Project) -> str: + if project.video_path: + stem = Path(project.video_path).name + if "." in stem: + stem = ".".join(stem.split(".")[:-1]) + if stem: + return _safe_filename_part(stem, f"project_{project.id}") + return _safe_filename_part(project.name, f"project_{project.id}") + + +def _project_export_name(project: Project) -> str: + return _safe_filename_part(project.name, f"project_{project.id}") + + +def _frame_timestamp_ms(frame: Frame, project: Project) -> float: + if frame.timestamp_ms is not None: + return float(frame.timestamp_ms) + fps = project.parse_fps or project.original_fps or 30.0 + return float(frame.frame_index) * 1000.0 / max(float(fps), 1.0) + + +def _project_frame_number(frame: Frame) -> int: + return int(frame.frame_index) + 1 + + +def _format_timestamp_ms(value: float) -> str: + total_ms = max(0, int(round(float(value)))) + hours = total_ms // 3_600_000 + minutes = (total_ms % 3_600_000) // 60_000 + seconds = (total_ms % 60_000) // 1_000 + milliseconds = total_ms % 1_000 + return f"{hours}h{minutes:02d}m{seconds:02d}s{milliseconds:03d}ms" + + +def _frame_export_stem(project: Project, frame: Frame) -> str: + return "_".join([ + _project_video_name(project), + _format_timestamp_ms(_frame_timestamp_ms(frame, project)), + f"frame{_project_frame_number(frame):06d}", + ]) + + +def _segmentation_results_filename(project: Project, frames: list[Frame]) -> str: + if not frames: + return f"{_project_export_name(project)}_seg_T_0h00m00s000ms-0h00m00s000ms_P_0-0.zip" + first_frame = frames[0] + last_frame = frames[-1] + return ( + f"{_project_export_name(project)}" + f"_seg_T_{_format_timestamp_ms(_frame_timestamp_ms(first_frame, project))}" + f"-{_format_timestamp_ms(_frame_timestamp_ms(last_frame, project))}" + f"_P_{_project_frame_number(first_frame)}-{_project_frame_number(last_frame)}.zip" + ) + + +def _download_content_disposition(filename: str) -> str: + ascii_fallback = filename.encode("ascii", "ignore").decode("ascii") or "segmentation_results.zip" + ascii_fallback = _safe_filename_part(ascii_fallback, "segmentation_results.zip") + if not ascii_fallback.endswith(".zip") and filename.endswith(".zip"): + ascii_fallback = f"{ascii_fallback}.zip" + return f"attachment; filename=\"{ascii_fallback}\"; filename*=UTF-8''{quote(filename)}" + + +def _frame_image_extension(frame: Frame) -> str: + suffix = Path(frame.image_url or "").suffix.lower() + return suffix if suffix in {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff"} else ".jpg" + + +def _project_or_404(project_id: int, db: Session, current_user: User) -> Project: + _ = current_user + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +def _project_frames(project_id: int, db: Session) -> list[Frame]: + return ( + db.query(Frame) + .filter(Frame.project_id == project_id) + .order_by(Frame.frame_index) + .all() + ) + + +def _filter_frames( + frames: list[Frame], + *, + scope: str = "all", + start_frame: int | None = None, + end_frame: int | None = None, + frame_id: int | None = None, +) -> list[Frame]: + if scope == "current": + if frame_id is None: + raise HTTPException(status_code=400, detail="frame_id is required for current-frame export") + selected = [frame for frame in frames if frame.id == frame_id] + if not selected: + raise HTTPException(status_code=404, detail="Frame not found") + return selected + + if scope == "range": + if start_frame is None or end_frame is None: + raise HTTPException(status_code=400, detail="start_frame and end_frame are required for range export") + start = max(1, min(int(start_frame), int(end_frame))) + end = max(1, max(int(start_frame), int(end_frame))) + return frames[start - 1:end] + + return frames + + +def _filtered_annotations(project_id: int, frame_ids: set[int], db: Session) -> list[Annotation]: + if not frame_ids: + return [] + return ( + db.query(Annotation) + .filter(Annotation.project_id == project_id) + .filter(Annotation.frame_id.in_(frame_ids)) + .all() + ) + + +def _build_coco(project: Project, frames: list[Frame], annotations: list[Annotation], templates: list[Template]) -> dict[str, Any]: + images = [] + for frame in frames: + images.append({ + "id": frame.id, + "file_name": frame.image_url, + "width": frame.width or 1920, + "height": frame.height or 1080, + "frame_index": frame.frame_index, + }) + + categories = [] + template_id_to_cat_id: Dict[int, int] = {} + for cat_idx, tmpl in enumerate(templates, start=1): + categories.append({ + "id": cat_idx, + "name": tmpl.name, + "color": tmpl.color, + }) + template_id_to_cat_id[tmpl.id] = cat_idx + + coco_annotations = [] + ann_id = 1 + selected_frame_ids = {frame.id for frame in frames} + for ann in annotations: + if ann.frame_id not in selected_frame_ids or not ann.mask_data: + continue + polygons = ann.mask_data.get("polygons", []) + if not polygons: + continue + + first_poly = polygons[0] + xs = [p[0] for p in first_poly] + ys = [p[1] for p in first_poly] + width = ann.frame.width if ann.frame else 1920 + height = ann.frame.height if ann.frame else 1080 + bbox = [ + min(xs) * width, + min(ys) * height, + (max(xs) - min(xs)) * width, + (max(ys) - min(ys)) * height, + ] + area = bbox[2] * bbox[3] + + segmentation = [] + for poly in polygons: + flat = [] + for p in poly: + flat.append(p[0] * width) + flat.append(p[1] * height) + segmentation.append(flat) + + coco_annotations.append({ + "id": ann_id, + "image_id": ann.frame_id, + "category_id": template_id_to_cat_id.get(ann.template_id, 0), + "segmentation": segmentation, + "area": area, + "bbox": bbox, + "iscrowd": 0, + }) + ann_id += 1 + + return { + "info": { + "description": f"Annotations for {project.name}", + "version": "1.0", + "year": datetime.now().year, + "date_created": datetime.now().isoformat(), + }, + "images": images, + "annotations": coco_annotations, + "categories": categories, + } + + +def _class_mapping_entry(annotation: Annotation) -> dict[str, Any]: + return { + "key": _annotation_class_key(annotation), + "className": _annotation_label(annotation), + "chineseName": _annotation_label(annotation), + "categoryName": _annotation_category_name(annotation), + "color": _annotation_color(annotation), + "internalPriority": _annotation_z_index(annotation), + "maskidHint": _annotation_mask_id(annotation), + "template_id": annotation.template_id, + } + + +def _build_gt_class_mapping(annotations: list[Annotation]) -> tuple[dict[str, int], list[dict[str, Any]]]: + entries_by_key: dict[str, dict[str, Any]] = {} + for annotation in annotations: + if not annotation.mask_data or not annotation.mask_data.get("polygons"): + continue + entry = _class_mapping_entry(annotation) + entries_by_key.setdefault(entry["key"], entry) + + ordered = sorted( + entries_by_key.values(), + key=lambda item: ( + item["maskidHint"] if isinstance(item.get("maskidHint"), int) and item["maskidHint"] >= 0 else 10_000_000, + str(item["className"]), + str(item["key"]), + ), + ) + key_to_value: dict[str, int] = {} + classes: list[dict[str, Any]] = [] + used_maskids: set[int] = set() + next_maskid = 1 + + def next_available_maskid() -> int: + nonlocal next_maskid + while next_maskid in used_maskids: + next_maskid += 1 + if next_maskid > 255: + raise HTTPException(status_code=400, detail="GT_label 仅支持 8-bit maskid,类别值必须在 1-255 之间") + value = next_maskid + used_maskids.add(value) + next_maskid += 1 + return value + + for entry in ordered: + hinted_maskid = entry.get("maskidHint") + if isinstance(hinted_maskid, int) and hinted_maskid > 255: + raise HTTPException(status_code=400, detail="GT_label 仅支持 8-bit maskid,类别值必须在 1-255 之间") + if isinstance(hinted_maskid, int) and hinted_maskid == 0: + maskid = 0 + used_maskids.add(maskid) + elif isinstance(hinted_maskid, int) and 0 < hinted_maskid <= 255 and hinted_maskid not in used_maskids: + maskid = hinted_maskid + used_maskids.add(maskid) + else: + maskid = next_available_maskid() + key_to_value[entry["key"]] = maskid + classes.append({ + "gt_pixel_value": maskid, + "maskid": maskid, + "chineseName": entry["chineseName"], + "className": entry["className"], + "categoryName": entry["categoryName"], + "rgb": _hex_to_rgb(entry["color"]), + "color": entry["color"], + "key": entry["key"], + "template_id": entry["template_id"], + }) + return key_to_value, classes + + +def _parse_result_outputs(mask_type: str, outputs: str | None) -> set[str]: + allowed = {"separate", "gt_label", "pro_label", "mix_label"} + if outputs: + parsed = {item.strip() for item in outputs.split(",") if item.strip()} + invalid = parsed - allowed + if invalid: + raise HTTPException(status_code=400, detail=f"Invalid outputs: {', '.join(sorted(invalid))}") + return parsed or allowed + + if mask_type == "separate": + return {"separate"} + if mask_type == "gt_label": + return {"gt_label"} + if mask_type == "pro_label": + return {"pro_label"} + if mask_type == "mix_label": + return {"mix_label"} + return allowed + + +def _write_original_frames( + zf: zipfile.ZipFile, + project: Project, + frames: list[Frame], +) -> dict[int, bytes]: + image_bytes_by_frame: dict[int, bytes] = {} + for frame in frames: + image_bytes = download_file(frame.image_url) + image_bytes_by_frame[frame.id] = image_bytes + zf.writestr( + f"原始图片/{_frame_export_stem(project, frame)}{_frame_image_extension(frame)}", + image_bytes, + ) + return image_bytes_by_frame + + +def _decode_original_image(image_bytes: bytes | None, width: int, height: int) -> np.ndarray: + import cv2 + + if image_bytes: + decoded = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR) + if decoded is not None: + if decoded.shape[1] != width or decoded.shape[0] != height: + decoded = cv2.resize(decoded, (width, height), interpolation=cv2.INTER_AREA) + return decoded + return np.zeros((height, width, 3), dtype=np.uint8) + + +def _write_result_mask_outputs( + zf: zipfile.ZipFile, + project: Project, + frames: list[Frame], + annotations: list[Annotation], + *, + outputs: set[str], + class_values: dict[str, int], + class_mapping: list[dict[str, Any]], + original_images: dict[int, bytes], + mix_opacity: float, +) -> None: + import cv2 + + include_individual = "separate" in outputs + include_semantic = "gt_label" in outputs + include_pro_label = "pro_label" in outputs + include_mix_label = "mix_label" in outputs + class_rgb_by_key = { + item["key"]: item.get("rgb") or _hex_to_rgb(item.get("color", "#ffffff")) + for item in class_mapping + } + annotations_by_frame: dict[int, list[Annotation]] = {} + selected_frame_ids = {frame.id for frame in frames} + for annotation in annotations: + if annotation.frame_id not in selected_frame_ids or not annotation.mask_data: + continue + if not annotation.mask_data.get("polygons"): + continue + annotations_by_frame.setdefault(annotation.frame_id, []).append(annotation) + + for frame in frames: + frame_annotations = annotations_by_frame.get(frame.id, []) + if not frame_annotations: + continue + width = frame.width or 1920 + height = frame.height or 1080 + frame_stem = _frame_export_stem(project, frame) + + if include_individual: + class_masks: dict[str, np.ndarray] = {} + class_meta: dict[str, dict[str, Any]] = {} + for annotation in frame_annotations: + key = _annotation_class_key(annotation) + combined = class_masks.setdefault(key, np.zeros((height, width), dtype=np.uint8)) + for poly in (annotation.mask_data or {}).get("polygons", []): + combined[:] = np.maximum(combined, _mask_from_polygon(poly, width, height)) + class_meta.setdefault(key, _class_mapping_entry(annotation)) + + folder = f"分开Mask分割结果/{frame_stem}_分别导出" + for key, mask in sorted(class_masks.items(), key=lambda item: int(class_meta[item[0]]["internalPriority"])): + meta = class_meta[key] + maskid = class_values.get(key) + if maskid is None: + continue + _, encoded = cv2.imencode(".png", mask) + class_name = _safe_filename_part(meta["className"], "class") + zf.writestr( + f"{folder}/{frame_stem}_{class_name}_maskid{maskid}.png", + encoded.tobytes(), + ) + + needs_fused_output = include_semantic or include_pro_label or include_mix_label + semantic = np.zeros((height, width), dtype=np.uint8) if needs_fused_output else None + pro_label = np.zeros((height, width, 3), dtype=np.uint8) if (include_pro_label or include_mix_label) else None + + if needs_fused_output: + for annotation in sorted(frame_annotations, key=_annotation_z_index): + key = _annotation_class_key(annotation) + value = class_values.get(key) + if value is None: + continue + combined = np.zeros((height, width), dtype=np.uint8) + for poly in (annotation.mask_data or {}).get("polygons", []): + combined = np.maximum(combined, _mask_from_polygon(poly, width, height)) + if semantic is not None: + semantic[combined > 0] = value + if pro_label is not None: + rgb = class_rgb_by_key.get(key, [255, 255, 255]) + bgr = np.array([rgb[2], rgb[1], rgb[0]], dtype=np.uint8) + pro_label[combined > 0] = bgr + + if include_semantic and semantic is not None: + _, encoded = cv2.imencode(".png", semantic) + zf.writestr(f"GT_label图/{frame_stem}.png", encoded.tobytes()) + + if include_pro_label and pro_label is not None: + _, encoded = cv2.imencode(".png", pro_label) + zf.writestr(f"Pro_label彩色分割结果/{frame_stem}.png", encoded.tobytes()) + + if include_mix_label and pro_label is not None: + original = _decode_original_image(original_images.get(frame.id), width, height) + mask_pixels = np.any(pro_label > 0, axis=2) + mixed = original.copy() + opacity = min(max(float(mix_opacity), 0.0), 1.0) + mixed[mask_pixels] = ( + original[mask_pixels].astype(np.float32) * (1.0 - opacity) + + pro_label[mask_pixels].astype(np.float32) * opacity + ).clip(0, 255).astype(np.uint8) + _, encoded = cv2.imencode(".png", mixed) + zf.writestr(f"Mix_label重叠覆盖彩色分割结果/{frame_stem}.png", encoded.tobytes()) + + +def _write_mask_pngs( + zf: zipfile.ZipFile, + frames: list[Frame], + annotations: list[Annotation], + *, + mask_type: str, + individual_prefix: str = "", + semantic_prefix: str = "", + semantic_file_stem: str = "semantic_frame", + semantic_dtype: Any = np.uint8, +) -> list[dict[str, Any]]: + import cv2 + + class_values: dict[str, int] = {} + semantic_classes: list[dict[str, Any]] = [] + + def class_value(annotation: Annotation) -> int: + key = _annotation_class_key(annotation) + if key not in class_values: + value = len(class_values) + 1 + class_values[key] = value + semantic_classes.append({ + "value": value, + "key": key, + "label": _annotation_label(annotation), + "color": _annotation_color(annotation), + "zIndex": _annotation_z_index(annotation), + "template_id": annotation.template_id, + }) + return class_values[key] + + include_individual = mask_type in {"separate", "both"} + include_semantic = mask_type in {"gt_label", "both"} + frame_masks: dict[int, list[tuple[Annotation, np.ndarray]]] = {} + selected_frame_ids = {frame.id for frame in frames} + + for ann in annotations: + if ann.frame_id not in selected_frame_ids or not ann.mask_data: + continue + polygons = ann.mask_data.get("polygons", []) + if not polygons: + continue + + width = ann.frame.width if ann.frame else 1920 + height = ann.frame.height if ann.frame else 1080 + combined = np.zeros((height, width), dtype=np.uint8) + for poly in polygons: + mask = _mask_from_polygon(poly, width, height) + combined = np.maximum(combined, mask) + + if include_individual: + _, encoded = cv2.imencode(".png", combined) + zf.writestr(f"{individual_prefix}mask_{ann.id:06d}.png", encoded.tobytes()) + if include_semantic and ann.frame_id is not None: + frame_masks.setdefault(ann.frame_id, []).append((ann, combined)) + + if include_semantic: + for frame in frames: + entries = frame_masks.get(frame.id, []) + if not entries: + continue + width = frame.width or 1920 + height = frame.height or 1080 + semantic = np.zeros((height, width), dtype=semantic_dtype) + for ann, mask in sorted(entries, key=lambda item: _annotation_z_index(item[0])): + semantic[mask > 0] = class_value(ann) + _, encoded = cv2.imencode(".png", semantic) + zf.writestr(f"{semantic_prefix}{semantic_file_stem}_{frame.frame_index:06d}.png", encoded.tobytes()) + + if include_semantic: + zf.writestr( + f"{semantic_prefix}semantic_classes.json", + json.dumps({"classes": semantic_classes}, ensure_ascii=False, indent=2).encode("utf-8"), + ) + return semantic_classes + + +@router.get( + "/{project_id}/coco", + summary="Export annotations in COCO format", +) +def export_coco( + project_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> StreamingResponse: + """Export all annotations for a project as a COCO-format JSON file.""" + project = _project_or_404(project_id, db, current_user) + frames = _project_frames(project_id, db) + annotations = _filtered_annotations(project_id, {frame.id for frame in frames}, db) + templates = db.query(Template).all() + coco = _build_coco(project, frames, annotations, templates) + + data = json.dumps(coco, ensure_ascii=False, indent=2).encode("utf-8") + filename = f"project_{project_id}_coco.json" + + return StreamingResponse( + io.BytesIO(data), + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get( + "/{project_id}/masks", + summary="Export PNG masks as a ZIP archive", +) +def export_masks( + project_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> StreamingResponse: + """Export individual masks plus z-index fused semantic masks inside a ZIP.""" + _project_or_404(project_id, db, current_user) + frames = _project_frames(project_id, db) + annotations = _filtered_annotations(project_id, {frame.id for frame in frames}, db) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + _write_mask_pngs( + zf, + frames, + annotations, + mask_type="both", + semantic_prefix="", + individual_prefix="", + ) + + zip_buffer.seek(0) + filename = f"project_{project_id}_masks.zip" + + return StreamingResponse( + zip_buffer, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get( + "/{project_id}/results", + summary="Export segmentation results as a ZIP archive", +) +def export_results( + project_id: int, + scope: str = Query("all", pattern="^(all|range|current)$"), + mask_type: str = Query("both", pattern="^(separate|gt_label|pro_label|mix_label|both|all)$"), + outputs: str | None = Query(None), + mix_opacity: float = Query(0.3, ge=0.0, le=1.0), + start_frame: int | None = Query(None, ge=1), + end_frame: int | None = Query(None, ge=1), + frame_id: int | None = Query(None, ge=1), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> StreamingResponse: + """Export JSON annotations plus selected PNG mask outputs inside one ZIP. + + `scope=all` exports the whole video. `scope=range` uses 1-based frame + numbers from the sorted project frame sequence. `scope=current` uses the + concrete backend `frame_id`. + """ + project = _project_or_404(project_id, db, current_user) + frames = _filter_frames( + _project_frames(project_id, db), + scope=scope, + start_frame=start_frame, + end_frame=end_frame, + frame_id=frame_id, + ) + annotations = _filtered_annotations(project_id, {frame.id for frame in frames}, db) + templates = db.query(Template).all() + coco = _build_coco(project, frames, annotations, templates) + class_values, class_mapping = _build_gt_class_mapping(annotations) + selected_outputs = _parse_result_outputs(mask_type, outputs) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr( + "annotations_coco.json", + json.dumps(coco, ensure_ascii=False, indent=2).encode("utf-8"), + ) + zf.writestr( + "maskid_GT像素值_类别映射.json", + json.dumps({"classes": class_mapping}, ensure_ascii=False, indent=2).encode("utf-8"), + ) + original_images = _write_original_frames(zf, project, frames) + _write_result_mask_outputs( + zf, + project, + frames, + annotations, + outputs=selected_outputs, + class_values=class_values, + class_mapping=class_mapping, + original_images=original_images, + mix_opacity=mix_opacity, + ) + + zip_buffer.seek(0) + filename = _segmentation_results_filename(project, frames) + return StreamingResponse( + zip_buffer, + media_type="application/zip", + headers={"Content-Disposition": _download_content_disposition(filename)}, + ) diff --git a/backend/routers/media.py b/backend/routers/media.py new file mode 100644 index 0000000..b476d62 --- /dev/null +++ b/backend/routers/media.py @@ -0,0 +1,234 @@ +"""Media upload and parsing endpoints.""" + +import logging +import re +from pathlib import Path +from typing import List, Optional + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status +from sqlalchemy.orm import Session + +from database import get_db +from minio_client import upload_file, get_presigned_url +from models import ProcessingTask, Project, User +from progress_events import publish_task_progress_event +from routers.auth import require_editor +from schemas import ProcessingTaskOut +from statuses import PROJECT_STATUS_PARSING, PROJECT_STATUS_PENDING, TASK_STATUS_QUEUED +from worker_tasks import parse_project_media + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/media", tags=["Media"]) + +ALLOWED_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".png", ".jpg", ".jpeg", ".dcm"} + + +def natural_filename_key(filename: str) -> tuple[object, ...]: + return tuple( + int(part) if part.isdigit() else part.casefold() + for part in re.split(r"(\d+)", Path(filename).name) + ) + + +def _get_ext(filename: str) -> str: + return Path(filename).suffix.lower() + + +@router.post( + "/upload", + status_code=status.HTTP_201_CREATED, + summary="Upload a media file", +) +async def upload_media( + file: UploadFile = File(...), + project_id: Optional[int] = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> dict: + """Accept a video, image, or DICOM file and store it in MinIO. + + If project_id is provided, the video_path of the project is updated. + Returns the presigned URL of the uploaded object. + """ + if not file.filename: + raise HTTPException(status_code=400, detail="Missing filename") + + ext = _get_ext(file.filename) + if ext not in ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=f"Unsupported file type: {ext}", + ) + + data = await file.read() + object_name = f"uploads/{project_id or 'general'}/{file.filename}" + + try: + upload_file(object_name, data, content_type=file.content_type or "application/octet-stream", length=len(data)) + except Exception as exc: # noqa: BLE001 + logger.error("Upload failed: %s", exc) + raise HTTPException(status_code=500, detail="Upload to storage failed") from exc + + file_url = get_presigned_url(object_name, expires=3600) + + if project_id: + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + project.video_path = object_name + db.commit() + logger.info("Linked upload to project_id=%s", project_id) + else: + # Auto-create a project named after the file + project = Project( + name=file.filename, + description="Auto-created from upload", + status=PROJECT_STATUS_PENDING, + video_path=object_name, + source_type="video", + owner_user_id=current_user.id, + ) + db.add(project) + db.commit() + db.refresh(project) + project_id = project.id + object_name = f"uploads/{project_id}/{file.filename}" + # Re-upload with corrected path + upload_file(object_name, data, content_type=file.content_type or "application/octet-stream", length=len(data)) + project.video_path = object_name + db.commit() + logger.info("Auto-created project id=%s for upload %s", project_id, file.filename) + + logger.info("Upload complete: %s (size=%d bytes). Async parsing queued.", object_name, len(data)) + + return { + "object_name": object_name, + "file_url": file_url, + "size": len(data), + "project_id": project_id, + "message": "Upload successful. Parsing job queued.", + } + + +@router.post( + "/upload/dicom", + status_code=status.HTTP_201_CREATED, + summary="Upload multiple DICOM files", +) +async def upload_dicom_batch( + files: List[UploadFile] = File(...), + project_id: Optional[int] = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> dict: + """Upload multiple .dcm files for a DICOM series. + + If project_id is provided, files are added to the existing project. + Otherwise a new DICOM project is created. + """ + if not files: + raise HTTPException(status_code=400, detail="No files uploaded") + + sorted_files = sorted( + [file for file in files if file.filename and file.filename.lower().endswith(".dcm")], + key=lambda file: natural_filename_key(file.filename or ""), + ) + if not sorted_files: + raise HTTPException(status_code=400, detail="No valid DICOM files uploaded") + uploaded = [] + + if project_id: + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + else: + # Create new DICOM project + first_name = sorted_files[0].filename or "DICOM_Series" + project = Project( + name=first_name, + description=f"DICOM series with {len(sorted_files)} files", + status=PROJECT_STATUS_PENDING, + source_type="dicom", + owner_user_id=current_user.id, + ) + db.add(project) + db.commit() + db.refresh(project) + project_id = project.id + logger.info("Auto-created DICOM project id=%s", project_id) + + for file in sorted_files: + data = await file.read() + object_name = f"uploads/{project_id}/dicom/{file.filename}" + try: + upload_file(object_name, data, content_type="application/dicom", length=len(data)) + uploaded.append(object_name) + except Exception as exc: # noqa: BLE001 + logger.error("Failed to upload DICOM %s: %s", file.filename, exc) + + project.video_path = f"uploads/{project_id}/dicom" + db.commit() + + return { + "project_id": project_id, + "uploaded_count": len(uploaded), + "message": f"Uploaded {len(uploaded)} DICOM files. Parsing job queued.", + } + + +@router.post( + "/parse", + status_code=status.HTTP_202_ACCEPTED, + response_model=ProcessingTaskOut, + summary="Trigger frame extraction", +) +def parse_media( + project_id: int, + source_type: Optional[str] = None, + parse_fps: Optional[float] = Query(None, gt=0, le=120), + max_frames: Optional[int] = Query(None, gt=0), + target_width: int = Query(640, ge=64, le=4096), + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> ProcessingTask: + """Create a background task for media frame extraction. + + The Celery worker performs the heavy FFmpeg/OpenCV/pydicom work and + updates the persisted task record as it progresses. + """ + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if not project.video_path: + raise HTTPException(status_code=400, detail="Project has no media uploaded") + + effective_source = source_type or project.source_type or "video" + effective_parse_fps = parse_fps or project.parse_fps or 30.0 + task = ProcessingTask( + task_type=f"parse_{effective_source}", + status=TASK_STATUS_QUEUED, + progress=0, + message="解析任务已入队", + project_id=project_id, + payload={ + "source_type": effective_source, + "parse_fps": effective_parse_fps, + "max_frames": max_frames, + "target_width": target_width, + }, + ) + project.parse_fps = effective_parse_fps + project.status = PROJECT_STATUS_PARSING + db.add(task) + db.commit() + db.refresh(task) + publish_task_progress_event(task) + + async_result = parse_project_media.delay(task.id) + task.celery_task_id = async_result.id + db.commit() + db.refresh(task) + + logger.info("Queued parse task id=%s project_id=%s celery_id=%s", task.id, project_id, async_result.id) + return task diff --git a/backend/routers/projects.py b/backend/routers/projects.py new file mode 100644 index 0000000..5659bcd --- /dev/null +++ b/backend/routers/projects.py @@ -0,0 +1,310 @@ +"""Project and Frame CRUD endpoints.""" + +import logging +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from database import get_db +from models import Annotation, Mask, Project, Frame, User +from routers.auth import get_current_user, require_editor +from schemas import ProjectCopyRequest, ProjectCreate, ProjectOut, ProjectUpdate, FrameCreate, FrameOut +from minio_client import get_presigned_url + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["Projects"]) + + +def _next_project_copy_name(db: Session, source_name: str) -> str: + base_name = f"{source_name} 副本" + existing_names = { + row[0] + for row in db.query(Project.name) + .filter(Project.name.like(f"{base_name}%")) + .all() + } + if base_name not in existing_names: + return base_name + suffix = 2 + while f"{base_name} {suffix}" in existing_names: + suffix += 1 + return f"{base_name} {suffix}" + + +def _prepare_project_response(project: Project) -> Project: + project.frame_count = len(project.frames) + if project.thumbnail_url: + project.thumbnail_url = get_presigned_url(project.thumbnail_url, expires=3600) + return project + + +# --------------------------------------------------------------------------- +# Projects +# --------------------------------------------------------------------------- +@router.post( + "", + response_model=ProjectOut, + status_code=status.HTTP_201_CREATED, + summary="Create a new project", +) +def create_project( + payload: ProjectCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> Project: + """Create a new segmentation project.""" + project = Project(**payload.model_dump(), owner_user_id=current_user.id) + db.add(project) + db.commit() + db.refresh(project) + logger.info("Created project id=%s name=%s", project.id, project.name) + return project + + +@router.get( + "", + response_model=List[ProjectOut], + summary="List all projects", +) +def list_projects( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> List[Project]: + """Retrieve a paginated list of projects.""" + projects = ( + db.query(Project) + .offset(skip) + .limit(limit) + .all() + ) + for p in projects: + _prepare_project_response(p) + return projects + + +@router.get( + "/{project_id}", + response_model=ProjectOut, + summary="Get a single project", +) +def get_project( + project_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> Project: + """Retrieve a project by its ID.""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return _prepare_project_response(project) + + +@router.post( + "/{project_id}/copy", + response_model=ProjectOut, + status_code=status.HTTP_201_CREATED, + summary="Copy a project", +) +def copy_project( + project_id: int, + payload: ProjectCopyRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> Project: + """Copy a project. Reset copies media/frame sequence; full also copies annotations and mask metadata.""" + source = db.query(Project).filter(Project.id == project_id).first() + if not source: + raise HTTPException(status_code=404, detail="Project not found") + + next_name = (payload.name or "").strip() if payload.name is not None else "" + if not next_name: + next_name = _next_project_copy_name(db, source.name) + + copied = Project( + name=next_name, + description=source.description, + video_path=source.video_path, + thumbnail_url=source.thumbnail_url, + status=source.status, + source_type=source.source_type, + original_fps=source.original_fps, + parse_fps=source.parse_fps, + owner_user_id=current_user.id, + ) + db.add(copied) + db.flush() + + frame_id_map: dict[int, int] = {} + for frame in sorted(source.frames, key=lambda item: item.frame_index): + copied_frame = Frame( + project_id=copied.id, + frame_index=frame.frame_index, + image_url=frame.image_url, + width=frame.width, + height=frame.height, + timestamp_ms=frame.timestamp_ms, + source_frame_number=frame.source_frame_number, + ) + db.add(copied_frame) + db.flush() + frame_id_map[frame.id] = copied_frame.id + + if payload.mode == "full": + for annotation in sorted(source.annotations, key=lambda item: item.id): + copied_annotation = Annotation( + project_id=copied.id, + frame_id=frame_id_map.get(annotation.frame_id) if annotation.frame_id is not None else None, + template_id=annotation.template_id, + mask_data=annotation.mask_data, + points=annotation.points, + bbox=annotation.bbox, + ) + db.add(copied_annotation) + db.flush() + for mask in annotation.masks: + db.add(Mask( + annotation_id=copied_annotation.id, + mask_url=mask.mask_url, + format=mask.format, + )) + + db.commit() + db.refresh(copied) + logger.info("Copied project id=%s to id=%s mode=%s", project_id, copied.id, payload.mode) + return _prepare_project_response(copied) + + +@router.patch( + "/{project_id}", + response_model=ProjectOut, + summary="Update a project", +) +def update_project( + project_id: int, + payload: ProjectUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> Project: + """Update project fields partially.""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + for key, value in payload.model_dump(exclude_unset=True).items(): + if key == "name": + value = (value or "").strip() + if not value: + raise HTTPException(status_code=400, detail="Project name is required") + setattr(project, key, value) + + db.commit() + db.refresh(project) + logger.info("Updated project id=%s", project_id) + return project + + +@router.delete( + "/{project_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a project", +) +def delete_project( + project_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> None: + """Delete a project and all related frames and annotations.""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + db.delete(project) + db.commit() + logger.info("Deleted project id=%s", project_id) + + +# --------------------------------------------------------------------------- +# Frames +# --------------------------------------------------------------------------- +@router.post( + "/{project_id}/frames", + response_model=FrameOut, + status_code=status.HTTP_201_CREATED, + summary="Add a frame to a project", +) +def create_frame( + project_id: int, + payload: FrameCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> Frame: + """Register a new frame under a project.""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + frame = Frame(project_id=project_id, **payload.model_dump(exclude={"project_id"})) + db.add(frame) + db.commit() + db.refresh(frame) + return frame + + +@router.get( + "/{project_id}/frames", + response_model=List[FrameOut], + summary="List frames for a project", +) +def list_frames( + project_id: int, + skip: int = 0, + limit: Optional[int] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> List[Frame]: + """Retrieve all frames belonging to a project.""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + query = ( + db.query(Frame) + .filter(Frame.project_id == project_id) + .order_by(Frame.frame_index) + .offset(skip) + ) + if limit is not None: + query = query.limit(limit) + frames = query.all() + for frame in frames: + frame.image_url = get_presigned_url(frame.image_url, expires=3600) + return frames + + +@router.get( + "/{project_id}/frames/{frame_id}", + response_model=FrameOut, + summary="Get a single frame", +) +def get_frame( + project_id: int, + frame_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> Frame: + """Retrieve a specific frame by ID.""" + frame = ( + db.query(Frame) + .join(Project, Project.id == Frame.project_id) + .filter( + Frame.project_id == project_id, + Frame.id == frame_id, + ) + .first() + ) + if not frame: + raise HTTPException(status_code=404, detail="Frame not found") + return frame diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py new file mode 100644 index 0000000..2995c58 --- /dev/null +++ b/backend/routers/tasks.py @@ -0,0 +1,161 @@ +"""Processing task query endpoints.""" + +import logging +from datetime import datetime, timezone +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from celery_app import celery_app +from database import get_db +from models import ProcessingTask, Project, User +from progress_events import publish_task_progress_event +from routers.auth import get_current_user, require_editor +from schemas import ProcessingTaskOut +from statuses import ( + PROJECT_STATUS_PARSING, + PROJECT_STATUS_PENDING, + PROJECT_STATUS_READY, + TASK_ACTIVE_STATUSES, + TASK_STATUS_CANCELLED, + TASK_STATUS_FAILED, + TASK_STATUS_QUEUED, +) +from worker_tasks import parse_project_media, propagate_project_masks + +router = APIRouter(prefix="/api/tasks", tags=["Tasks"]) +logger = logging.getLogger(__name__) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _get_task_or_404(task_id: int, db: Session, current_user: User) -> ProcessingTask: + _ = current_user + task = ( + db.query(ProcessingTask) + .outerjoin(Project, Project.id == ProcessingTask.project_id) + .filter(ProcessingTask.id == task_id) + .first() + ) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return task + + +def _project_status_after_stop(project: Project) -> str: + return PROJECT_STATUS_READY if project.frames else PROJECT_STATUS_PENDING + + +@router.get("", response_model=List[ProcessingTaskOut], summary="List processing tasks") +def list_tasks( + project_id: int | None = None, + status: str | None = None, + limit: int = 50, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> List[ProcessingTask]: + """Return recent background processing tasks.""" + _ = current_user + query = db.query(ProcessingTask).outerjoin(Project, Project.id == ProcessingTask.project_id) + if project_id is not None: + query = query.filter(ProcessingTask.project_id == project_id) + if status is not None: + query = query.filter(ProcessingTask.status == status) + return query.order_by(ProcessingTask.created_at.desc()).limit(limit).all() + + +@router.get("/{task_id}", response_model=ProcessingTaskOut, summary="Get processing task") +def get_task( + task_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> ProcessingTask: + """Return one background task by id.""" + return _get_task_or_404(task_id, db, current_user) + + +@router.post("/{task_id}/cancel", response_model=ProcessingTaskOut, summary="Cancel processing task") +def cancel_task( + task_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> ProcessingTask: + """Cancel a queued/running background task and revoke the Celery job when possible.""" + task = _get_task_or_404(task_id, db, current_user) + if task.status not in TASK_ACTIVE_STATUSES: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Task is not cancellable in status: {task.status}", + ) + + if task.celery_task_id: + try: + celery_app.control.revoke(task.celery_task_id, terminate=True, signal="SIGTERM") + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to revoke celery task %s: %s", task.celery_task_id, exc) + + task.status = TASK_STATUS_CANCELLED + task.progress = 100 + task.message = "任务已取消" + task.error = "Cancelled by user" + task.finished_at = _now() + if task.project: + task.project.status = _project_status_after_stop(task.project) + + db.commit() + db.refresh(task) + publish_task_progress_event(task) + return task + + +@router.post("/{task_id}/retry", response_model=ProcessingTaskOut, status_code=status.HTTP_202_ACCEPTED, summary="Retry processing task") +def retry_task( + task_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> ProcessingTask: + """Create a fresh queued task from a failed or cancelled task.""" + previous = _get_task_or_404(task_id, db, current_user) + if previous.status not in {TASK_STATUS_FAILED, TASK_STATUS_CANCELLED}: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Task is not retryable in status: {previous.status}", + ) + if previous.project_id is None: + raise HTTPException(status_code=400, detail="Task has no project_id") + + project = db.query(Project).filter(Project.id == previous.project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + is_propagation_task = previous.task_type == "propagate_masks" + if not is_propagation_task and not project.video_path: + raise HTTPException(status_code=400, detail="Project has no media uploaded") + + payload = dict(previous.payload or {}) + payload.setdefault("source_type", project.source_type or "video") + payload["retry_of"] = previous.id + + task = ProcessingTask( + task_type=previous.task_type, + status=TASK_STATUS_QUEUED, + progress=0, + message=f"重试任务已入队(源任务 #{previous.id})", + project_id=project.id, + payload=payload, + ) + if not is_propagation_task: + project.status = PROJECT_STATUS_PARSING + db.add(task) + db.commit() + db.refresh(task) + publish_task_progress_event(task) + + async_result = propagate_project_masks.delay(task.id) if is_propagation_task else parse_project_media.delay(task.id) + task.celery_task_id = async_result.id + db.commit() + db.refresh(task) + publish_task_progress_event(task) + return task diff --git a/backend/routers/templates.py b/backend/routers/templates.py new file mode 100644 index 0000000..7bcd075 --- /dev/null +++ b/backend/routers/templates.py @@ -0,0 +1,183 @@ +"""Template (Ontology) CRUD endpoints.""" + +import logging +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from database import get_db +from models import Template, User +from routers.auth import get_current_user, require_editor +from schemas import TemplateCreate, TemplateOut, TemplateUpdate + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/templates", tags=["Templates"]) +RESERVED_UNCLASSIFIED_CLASS = { + "id": "reserved-unclassified", + "name": "待分类", + "color": "#000000", + "zIndex": 0, + "maskId": 0, + "category": "系统保留", +} + + +def _is_reserved_class(item: dict) -> bool: + return ( + item.get("id") == RESERVED_UNCLASSIFIED_CLASS["id"] + or item.get("name") == RESERVED_UNCLASSIFIED_CLASS["name"] + or item.get("maskId") == 0 + ) + + +def _normalize_template_classes(classes: list[dict] | None) -> list[dict]: + normalized = [item for item in (classes or []) if not _is_reserved_class(item)] + return [*normalized, dict(RESERVED_UNCLASSIFIED_CLASS)] + + +def _pack_mapping_rules(data: dict) -> dict: + """Pack classes/rules into mapping_rules for DB storage.""" + mapping = data.get("mapping_rules") or {} + if "classes" in data and data["classes"] is not None: + mapping["classes"] = _normalize_template_classes(data.pop("classes")) + if "rules" in data and data["rules"] is not None: + mapping["rules"] = data.pop("rules") + if "classes" in mapping: + mapping["classes"] = _normalize_template_classes(mapping.get("classes")) + data["mapping_rules"] = mapping + return data + + +def _unpack_template(template: Template) -> Template: + """Unpack mapping_rules into classes/rules for response.""" + mapping = template.mapping_rules or {} + # Set as attributes so Pydantic from_attributes can pick them up + template.classes = _normalize_template_classes(mapping.get("classes", [])) + template.rules = mapping.get("rules", []) + return template + + +@router.post( + "", + response_model=TemplateOut, + status_code=status.HTTP_201_CREATED, + summary="Create a new template", +) +def create_template( + payload: TemplateCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> Template: + """Create a new ontology template / segmentation class.""" + data = payload.model_dump() + data = _pack_mapping_rules(data) + template = Template(**data, owner_user_id=current_user.id) + db.add(template) + db.commit() + db.refresh(template) + _unpack_template(template) + logger.info("Created template id=%s name=%s", template.id, template.name) + return template + + +@router.get( + "", + response_model=List[TemplateOut], + summary="List all templates", +) +def list_templates( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> List[Template]: + """Retrieve all ontology templates.""" + templates = ( + db.query(Template) + .filter(or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None))) + .offset(skip) + .limit(limit) + .all() + ) + for t in templates: + _unpack_template(t) + return templates + + +@router.get( + "/{template_id}", + response_model=TemplateOut, + summary="Get a single template", +) +def get_template( + template_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> Template: + """Retrieve a template by its ID.""" + template = db.query(Template).filter( + Template.id == template_id, + or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None)), + ).first() + if not template: + raise HTTPException(status_code=404, detail="Template not found") + _unpack_template(template) + return template + + +@router.patch( + "/{template_id}", + response_model=TemplateOut, + summary="Update a template", +) +def update_template( + template_id: int, + payload: TemplateUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> Template: + """Update template fields partially.""" + template = db.query(Template).filter( + Template.id == template_id, + or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None)), + ).first() + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + data = payload.model_dump(exclude_unset=True) + if "classes" in data or "rules" in data: + data = _pack_mapping_rules(data) + + for key, value in data.items(): + setattr(template, key, value) + + db.commit() + db.refresh(template) + _unpack_template(template) + logger.info("Updated template id=%s", template_id) + return template + + +@router.delete( + "/{template_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a template", +) +def delete_template( + template_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_editor), +) -> None: + """Delete a template. Associated annotations will have template_id set to NULL.""" + template = db.query(Template).filter( + Template.id == template_id, + or_(Template.owner_user_id == current_user.id, Template.owner_user_id.is_(None)), + ).first() + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + db.delete(template) + db.commit() + logger.info("Deleted template id=%s", template_id) diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..32dfdd0 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,385 @@ +"""Pydantic schemas for request/response validation.""" + +from datetime import datetime +from typing import Literal, Optional, Any +from pydantic import BaseModel, ConfigDict + + +# --------------------------------------------------------------------------- +# Auth / user schemas +# --------------------------------------------------------------------------- +class UserOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + role: str + is_active: int + + +class LoginResponse(BaseModel): + token: str + token_type: str = "bearer" + username: str + user: UserOut + + +class AdminUserCreate(BaseModel): + username: str + password: str + role: str = "annotator" + is_active: bool = True + + +class AdminUserUpdate(BaseModel): + username: Optional[str] = None + password: Optional[str] = None + role: Optional[str] = None + is_active: Optional[bool] = None + + +class AuditLogOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + actor_user_id: Optional[int] = None + action: str + target_type: Optional[str] = None + target_id: Optional[str] = None + detail: Optional[dict[str, Any]] = None + created_at: datetime + + +class DemoFactoryResetRequest(BaseModel): + confirmation: str + + +# --------------------------------------------------------------------------- +# Project schemas +# --------------------------------------------------------------------------- +class ProjectBase(BaseModel): + name: str + description: Optional[str] = None + video_path: Optional[str] = None + thumbnail_url: Optional[str] = None + status: Optional[str] = "pending" + source_type: Optional[str] = "video" + original_fps: Optional[float] = None + parse_fps: Optional[float] = 30.0 + + +class ProjectCreate(ProjectBase): + pass + + +class ProjectUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + video_path: Optional[str] = None + thumbnail_url: Optional[str] = None + status: Optional[str] = None + source_type: Optional[str] = None + original_fps: Optional[float] = None + parse_fps: Optional[float] = None + + +class ProjectCopyRequest(BaseModel): + mode: Literal["reset", "full"] = "reset" + name: Optional[str] = None + + +class ProjectOut(ProjectBase): + model_config = ConfigDict(from_attributes=True) + + id: int + owner_user_id: Optional[int] = None + created_at: datetime + updated_at: datetime + frame_count: int = 0 + + +class DemoFactoryResetOut(BaseModel): + admin_user: UserOut + project: ProjectOut + projects: list[ProjectOut] + deleted_counts: dict[str, int] + message: str + + +# --------------------------------------------------------------------------- +# Frame schemas +# --------------------------------------------------------------------------- +class FrameBase(BaseModel): + frame_index: int + image_url: str + width: Optional[int] = None + height: Optional[int] = None + timestamp_ms: Optional[float] = None + source_frame_number: Optional[int] = None + + +class FrameCreate(FrameBase): + project_id: int + + +class FrameOut(FrameBase): + model_config = ConfigDict(from_attributes=True) + + id: int + project_id: int + created_at: datetime + + +# --------------------------------------------------------------------------- +# Template schemas +# --------------------------------------------------------------------------- +class TemplateBase(BaseModel): + name: str + description: Optional[str] = None + color: str + z_index: int = 0 + mapping_rules: Optional[dict[str, Any]] = None + classes: Optional[list[dict[str, Any]]] = None + rules: Optional[list[dict[str, Any]]] = None + + +class TemplateCreate(TemplateBase): + pass + + +class TemplateUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + color: Optional[str] = None + z_index: Optional[int] = None + mapping_rules: Optional[dict[str, Any]] = None + classes: Optional[list[dict[str, Any]]] = None + rules: Optional[list[dict[str, Any]]] = None + + +class TemplateOut(TemplateBase): + model_config = ConfigDict(from_attributes=True) + + id: int + owner_user_id: Optional[int] = None + created_at: datetime + + +# --------------------------------------------------------------------------- +# Annotation schemas +# --------------------------------------------------------------------------- +class AnnotationBase(BaseModel): + project_id: int + frame_id: Optional[int] = None + template_id: Optional[int] = None + mask_data: Optional[dict[str, Any]] = None + points: Optional[list[list[float]]] = None + bbox: Optional[list[float]] = None + + +class AnnotationCreate(AnnotationBase): + pass + + +class AnnotationUpdate(BaseModel): + mask_data: Optional[dict[str, Any]] = None + points: Optional[list[list[float]]] = None + bbox: Optional[list[float]] = None + template_id: Optional[int] = None + + +class AnnotationOut(AnnotationBase): + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + updated_at: datetime + + +# --------------------------------------------------------------------------- +# Mask schemas +# --------------------------------------------------------------------------- +class MaskBase(BaseModel): + annotation_id: int + mask_url: str + format: str = "png" + + +class MaskCreate(MaskBase): + pass + + +class MaskOut(MaskBase): + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + + +# --------------------------------------------------------------------------- +# Processing task schemas +# --------------------------------------------------------------------------- +class ProcessingTaskOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + task_type: str + status: str + progress: int + message: Optional[str] = None + project_id: Optional[int] = None + celery_task_id: Optional[str] = None + payload: Optional[dict[str, Any]] = None + result: Optional[dict[str, Any]] = None + error: Optional[str] = None + created_at: datetime + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + updated_at: datetime + + +# --------------------------------------------------------------------------- +# AI schemas +# --------------------------------------------------------------------------- +class PredictRequest(BaseModel): + image_id: int + prompt_type: str # point / box / semantic + prompt_data: Any + model: Optional[str] = None + options: Optional[dict[str, Any]] = None + + +class PredictResponse(BaseModel): + polygons: list[list[list[float]]] + scores: Optional[list[float]] = None + + +class MaskAnalysisRequest(BaseModel): + frame_id: Optional[int] = None + mask_data: dict[str, Any] + points: Optional[list[list[float]]] = None + bbox: Optional[list[float]] = None + extract_skeleton: bool = False + + +class MaskAnalysisResponse(BaseModel): + confidence: Optional[float] = None + confidence_source: str + topology_anchor_count: int + topology_anchors: list[list[float]] + area: float + bbox: Optional[list[float]] = None + source: Optional[str] = None + message: str + + +class SmoothMaskRequest(BaseModel): + frame_id: Optional[int] = None + mask_data: dict[str, Any] + points: Optional[list[list[float]]] = None + bbox: Optional[list[float]] = None + strength: float = 0.0 + method: str = "chaikin" + + +class SmoothMaskResponse(BaseModel): + polygons: list[list[list[float]]] + topology_anchor_count: int + topology_anchors: list[list[float]] + area: float + bbox: Optional[list[float]] = None + smoothing: dict[str, Any] + message: str + + +class PropagationSeed(BaseModel): + polygons: Optional[list[list[list[float]]]] = None + holes: Optional[list[list[list[list[float]]]]] = None + bbox: Optional[list[float]] = None + points: Optional[list[list[float]]] = None + labels: Optional[list[int]] = None + label: Optional[str] = None + color: Optional[str] = None + class_metadata: Optional[dict[str, Any]] = None + template_id: Optional[int] = None + source_mask_id: Optional[str] = None + source_annotation_id: Optional[int] = None + source_instance_id: Optional[str] = None + propagation_seed_signature: Optional[str] = None + smoothing: Optional[dict[str, Any]] = None + + +class PropagateRequest(BaseModel): + project_id: int + frame_id: int + model: Optional[str] = "sam2.1_hiera_tiny" + seed: PropagationSeed + direction: str = "forward" + max_frames: int = 30 + include_source: bool = False + save_annotations: bool = True + + +class PropagateResponse(BaseModel): + model: str + direction: str + source_frame_id: int + processed_frame_count: int + created_annotation_count: int + annotations: list[AnnotationOut] + + +class PropagateTaskStep(BaseModel): + seed: PropagationSeed + direction: str = "forward" + max_frames: int = 30 + + +class PropagateTaskRequest(BaseModel): + project_id: int + frame_id: int + model: Optional[str] = "sam2.1_hiera_tiny" + steps: list[PropagateTaskStep] + include_source: bool = False + save_annotations: bool = True + + +class AiModelStatus(BaseModel): + id: str + label: str + available: bool + loaded: bool = False + device: str + supports: list[str] + message: str + package_available: bool = False + checkpoint_exists: bool = False + checkpoint_path: Optional[str] = None + python_ok: bool = True + torch_ok: bool = True + cuda_required: bool = False + external_available: bool = False + external_python: Optional[str] = None + + +class GpuStatus(BaseModel): + available: bool + device: str + name: Optional[str] = None + torch_available: bool + torch_version: Optional[str] = None + cuda_version: Optional[str] = None + + +class AiRuntimeStatus(BaseModel): + selected_model: str + gpu: GpuStatus + models: list[AiModelStatus] + + +# --------------------------------------------------------------------------- +# Export schemas +# --------------------------------------------------------------------------- +class ExportStatus(BaseModel): + url: str + format: str diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/default_templates.py b/backend/services/default_templates.py new file mode 100644 index 0000000..0436b6e --- /dev/null +++ b/backend/services/default_templates.py @@ -0,0 +1,164 @@ +"""Bundled system ontology templates and restore helpers.""" + +from __future__ import annotations + +from copy import deepcopy + +from sqlalchemy.orm import Session + +from models import Template + +RESERVED_UNCLASSIFIED_CLASS = { + "id": "reserved-unclassified", + "name": "待分类", + "color": "#000000", + "zIndex": 0, + "maskId": 0, + "category": "系统保留", +} + + +def _with_reserved_unclassified_class(classes: list[dict]) -> list[dict]: + filtered = [ + item for item in classes + if item.get("id") != RESERVED_UNCLASSIFIED_CLASS["id"] + and item.get("name") != RESERVED_UNCLASSIFIED_CLASS["name"] + and item.get("maskId") != 0 + ] + return [*filtered, dict(RESERVED_UNCLASSIFIED_CLASS)] + + +def _template_classes( + template_name: str, + names: list[str], + colors: list[tuple[int, int, int]], + *, + id_prefix: str, +) -> list[dict]: + classes = [] + for idx, (rgb, name) in enumerate(zip(colors, names)): + color_hex = f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}" + classes.append({ + "id": f"{id_prefix}-{idx}", + "name": name, + "color": color_hex, + "zIndex": (len(names) - idx) * 10, + "maskId": idx + 1, + "category": template_name, + }) + return classes + + +def bundled_default_template_definitions() -> list[dict]: + """Return fresh definitions for all bundled system templates.""" + return [ + { + "name": "腹腔镜胆囊切除术", + "description": "腹腔镜胆囊切除术(LC)手术器械与解剖结构语义分割模板,共35个分类", + "color": "#06b6d4", + "z_index": 0, + "classes": _with_reserved_unclassified_class(_template_classes( + "腹腔镜胆囊切除术", + [ + "针", "线", "肿瘤", "血管阻断夹", "棉球", "双极电凝", + "肝脏", "胆囊", "分离钳", "脂肪", "止血海绵", "肝总管", + "吸引器", "剪刀", "超声刀", "止血纱布", "胆总管", "生物夹", + "无损伤钳", "钳夹", "喷洒", "胆囊管", "动脉", "电凝", + "静脉", "标本袋", "引流管", "纱布", "金属钛夹", "韧带", + "肝蒂", "推结器", "乳胶管-血管阻断", "吻合器", "术中超声", + ], + [ + (134, 124, 118), (0, 157, 142), (245, 161, 0), (255, 172, 159), (146, 175, 236), (155, 62, 0), + (255, 91, 0), (255, 234, 0), (85, 111, 181), (155, 132, 0), (181, 227, 14), (72, 0, 255), + (255, 0, 255), (29, 32, 136), (240, 16, 116), (160, 15, 95), (0, 155, 33), (0, 160, 233), + (52, 184, 178), (66, 115, 82), (90, 120, 41), (255, 0, 0), (117, 0, 0), (167, 24, 233), + (42, 8, 66), (112, 113, 150), (0, 255, 0), (255, 255, 255), (0, 255, 255), (181, 85, 105), + (113, 102, 140), (202, 202, 200), (197, 83, 181), (136, 162, 196), (138, 251, 213), + ], + id_prefix="cls-lap", + )), + }, + { + "name": "头颈部CT分割", + "description": "头颈部CT分割", + "color": "#ef4444", + "z_index": 10, + "classes": _with_reserved_unclassified_class(_template_classes( + "头颈部CT分割", + [ + "肿瘤/结节", + "下颌骨", + "甲状腺", + "气管", + "颈椎", + "颈动脉", + "颈静脉", + "腮腺", + "下颌下腺", + "舌骨", + ], + [ + (255, 0, 0), + (0, 255, 0), + (0, 0, 255), + (255, 255, 0), + (255, 0, 255), + (0, 255, 255), + (255, 128, 0), + (128, 0, 128), + (0, 128, 128), + (128, 128, 0), + ], + id_prefix="cls-head-neck-ct", + )), + }, + ] + + +def _has_legacy_head_neck_english_labels(template: Template) -> bool: + if template.name != "头颈部CT分割": + return False + classes = (template.mapping_rules or {}).get("classes") or [] + return any( + isinstance(item, dict) + and isinstance(item.get("name"), str) + and "(" in item["name"] + and ")" in item["name"] + for item in classes + ) + + +def ensure_default_templates(db: Session, *, restore_existing: bool = False) -> list[Template]: + """Create bundled system templates, optionally restoring existing ones exactly.""" + templates: list[Template] = [] + for definition in bundled_default_template_definitions(): + existing = db.query(Template).filter( + Template.name == definition["name"], + Template.owner_user_id.is_(None), + ).first() + if existing is None: + existing = Template(owner_user_id=None) + db.add(existing) + elif not restore_existing and not _has_legacy_head_neck_english_labels(existing): + templates.append(existing) + continue + + existing.name = definition["name"] + existing.description = definition["description"] + existing.color = definition["color"] + existing.z_index = definition["z_index"] + existing.owner_user_id = None + existing.mapping_rules = { + "classes": deepcopy(definition["classes"]), + "rules": [], + } + templates.append(existing) + db.commit() + for template in templates: + db.refresh(template) + return templates + + +def restore_default_templates(db: Session) -> list[Template]: + """Restore bundled system templates after demo factory reset.""" + return ensure_default_templates(db, restore_existing=True) diff --git a/backend/services/demo_media.py b/backend/services/demo_media.py new file mode 100644 index 0000000..9721e75 --- /dev/null +++ b/backend/services/demo_media.py @@ -0,0 +1,217 @@ +"""Helpers for seeding the bundled demo media project.""" + +from __future__ import annotations + +import os +import shutil +import tempfile +from pathlib import Path + +import cv2 +from sqlalchemy.orm import Session + +from minio_client import upload_file +from models import Frame, Project, User +from services.frame_parser import ( + extract_thumbnail, + natural_filename_key, + parse_dicom, + parse_video, + upload_frames_to_minio, +) +from statuses import PROJECT_STATUS_PENDING, PROJECT_STATUS_READY + +DEMO_DICOM_PROJECT_NAME = "演视DICOM序列" +DEMO_DICOM_PARSE_FPS = 30.0 +DEMO_VIDEO_PROJECT_NAME = "演视LC视频序列" +DEMO_VIDEO_PARSE_FPS = 30.0 +DEMO_VIDEO_TARGET_WIDTH = 640 +LEGACY_DEMO_VIDEO_PROJECT_NAMES = {"Data_MyVideo_1"} +LEGACY_DEMO_DICOM_PROJECT_NAMES = {"演示DICOM序列"} + + +def demo_dicom_files(dicom_dir: str) -> list[Path]: + """Return .dcm files in natural file-name order.""" + root = Path(dicom_dir) + if not root.exists() or not root.is_dir(): + return [] + return sorted( + [path for path in root.iterdir() if path.is_file() and path.name.lower().endswith(".dcm")], + key=lambda path: natural_filename_key(path.name), + ) + + +def create_unparsed_video_demo_project( + db: Session, + *, + owner: User, + video_path: str, + project_name: str = DEMO_VIDEO_PROJECT_NAME, +) -> Project: + """Create the bundled demo video project without extracting frames.""" + source = Path(video_path) + if not source.exists() or not source.is_file(): + raise FileNotFoundError(f"Demo video not found: {video_path}") + + project = Project( + name=project_name, + description="默认演示视频,尚未生成帧", + status=PROJECT_STATUS_PENDING, + source_type="video", + parse_fps=30.0, + original_fps=None, + owner_user_id=owner.id, + ) + db.add(project) + db.flush() + + data = source.read_bytes() + object_name = f"uploads/{project.id}/{source.name}" + upload_file(object_name, data, content_type="video/mp4", length=len(data)) + project.video_path = object_name + project.thumbnail_url = None + db.commit() + db.refresh(project) + return project + + +def create_parsed_video_demo_project( + db: Session, + *, + owner: User, + video_path: str, + project_name: str = DEMO_VIDEO_PROJECT_NAME, +) -> Project: + """Create the bundled demo video project and register its extracted frame sequence.""" + source = Path(video_path) + if not source.exists() or not source.is_file(): + raise FileNotFoundError(f"Demo video not found: {video_path}") + + project = Project( + name=project_name, + description="默认演示视频,已生成帧", + status=PROJECT_STATUS_PENDING, + source_type="video", + parse_fps=DEMO_VIDEO_PARSE_FPS, + original_fps=None, + owner_user_id=owner.id, + ) + db.add(project) + db.flush() + + data = source.read_bytes() + object_name = f"uploads/{project.id}/{source.name}" + upload_file(object_name, data, content_type="video/mp4", length=len(data)) + project.video_path = object_name + + tmp_dir = tempfile.mkdtemp(prefix=f"seg_demo_video_{project.id}_") + try: + output_dir = os.path.join(tmp_dir, "frames") + frame_files, original_fps = parse_video( + str(source), + output_dir, + fps=int(DEMO_VIDEO_PARSE_FPS), + target_width=DEMO_VIDEO_TARGET_WIDTH, + ) + project.original_fps = original_fps + object_names = upload_frames_to_minio(frame_files, project.id) + + for idx, obj_name in enumerate(object_names): + image = cv2.imread(frame_files[idx]) + height, width = image.shape[:2] if image is not None else (None, None) + db.add(Frame( + project_id=project.id, + frame_index=idx, + image_url=obj_name, + width=width, + height=height, + timestamp_ms=idx * 1000.0 / DEMO_VIDEO_PARSE_FPS, + source_frame_number=idx, + )) + + thumbnail_path = os.path.join(tmp_dir, "thumbnail.jpg") + try: + extract_thumbnail(str(source), thumbnail_path) + with open(thumbnail_path, "rb") as thumbnail_file: + thumbnail_data = thumbnail_file.read() + thumbnail_object = f"projects/{project.id}/thumbnail.jpg" + upload_file( + thumbnail_object, + thumbnail_data, + content_type="image/jpeg", + length=len(thumbnail_data), + ) + project.thumbnail_url = thumbnail_object + except Exception: # noqa: BLE001 + if object_names: + project.thumbnail_url = object_names[0] + + project.status = PROJECT_STATUS_READY + db.commit() + db.refresh(project) + return project + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + +def create_parsed_dicom_demo_project( + db: Session, + *, + owner: User, + dicom_dir: str, + project_name: str = DEMO_DICOM_PROJECT_NAME, +) -> Project: + """Create the demo DICOM project, upload the series, and register parsed frames.""" + dcm_files = demo_dicom_files(dicom_dir) + if not dcm_files: + raise FileNotFoundError(f"Demo DICOM series not found: {dicom_dir}") + + project = Project( + name=project_name, + description=f"默认演示 DICOM 序列,已按文件名自然顺序生成 {len(dcm_files)} 帧", + status=PROJECT_STATUS_PENDING, + source_type="dicom", + parse_fps=DEMO_DICOM_PARSE_FPS, + original_fps=None, + owner_user_id=owner.id, + ) + db.add(project) + db.flush() + + dicom_prefix = f"uploads/{project.id}/dicom" + for dcm_file in dcm_files: + data = dcm_file.read_bytes() + upload_file( + f"{dicom_prefix}/{dcm_file.name}", + data, + content_type="application/dicom", + length=len(data), + ) + project.video_path = dicom_prefix + + tmp_dir = tempfile.mkdtemp(prefix=f"seg_demo_dicom_{project.id}_") + try: + output_dir = os.path.join(tmp_dir, "frames") + frame_files = parse_dicom(dicom_dir, output_dir) + object_names = upload_frames_to_minio(frame_files, project.id) + + for idx, obj_name in enumerate(object_names): + image = cv2.imread(frame_files[idx]) + height, width = image.shape[:2] if image is not None else (None, None) + db.add(Frame( + project_id=project.id, + frame_index=idx, + image_url=obj_name, + width=width, + height=height, + timestamp_ms=idx * 1000.0 / DEMO_DICOM_PARSE_FPS, + source_frame_number=idx, + )) + if object_names: + project.thumbnail_url = object_names[0] + project.status = PROJECT_STATUS_READY + db.commit() + db.refresh(project) + return project + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/backend/services/frame_parser.py b/backend/services/frame_parser.py new file mode 100644 index 0000000..fa61a0e --- /dev/null +++ b/backend/services/frame_parser.py @@ -0,0 +1,237 @@ +"""Video/DICOM frame parsing and MinIO upload utilities.""" + +import logging +import os +import re +import shutil +import subprocess +from pathlib import Path +from typing import List, Optional, Tuple + +import cv2 +import numpy as np +from pydicom import dcmread + +from minio_client import upload_file, BUCKET_NAME + +logger = logging.getLogger(__name__) + + +def natural_filename_key(filename: str) -> Tuple[object, ...]: + """Sort file names by their visible numeric order instead of pure lexicographic order.""" + return tuple( + int(part) if part.isdigit() else part.casefold() + for part in re.split(r"(\d+)", Path(filename).name) + ) + + +def get_video_fps(video_path: str) -> float: + """Read the original frame rate of a video file.""" + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + return 30.0 + fps = cap.get(cv2.CAP_PROP_FPS) + cap.release() + return fps if fps > 0 else 30.0 + + +def extract_thumbnail(video_path: str, output_path: str, width: int = 640) -> str: + """Extract the first frame of a video as a thumbnail JPEG.""" + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + raise RuntimeError(f"Cannot open video for thumbnail: {video_path}") + ret, frame = cap.read() + cap.release() + if not ret or frame is None: + raise RuntimeError(f"Cannot read first frame from: {video_path}") + + h, w = frame.shape[:2] + if w > width: + scale = width / w + new_w = int(w * scale) + new_h = int(h * scale) + frame = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_AREA) + + cv2.imwrite(output_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 85]) + return output_path + + +def parse_video( + video_path: str, + output_dir: str, + fps: int = 30, + max_frames: Optional[int] = None, + target_width: int = 640, +) -> Tuple[List[str], float]: + """Extract frames from a video file using FFmpeg or OpenCV fallback. + + Args: + video_path: Path to the input video file. + output_dir: Directory to save extracted frames. + fps: Target frame extraction rate. + max_frames: Optional maximum number of frames to extract. + target_width: Output frame width for model-friendly frame sequences. + + Returns: + Tuple of (frame_paths, original_fps). + """ + os.makedirs(output_dir, exist_ok=True) + frame_paths: List[str] = [] + original_fps = get_video_fps(video_path) + safe_fps = max(int(fps), 1) + safe_width = max(int(target_width), 1) + + # Try FFmpeg first + if shutil.which("ffmpeg"): + try: + pattern = os.path.join(output_dir, "frame_%06d.jpg") + cmd = [ + "ffmpeg", + "-i", video_path, + "-vf", f"fps={safe_fps},scale={safe_width}:-1", + "-start_number", "0", + "-q:v", "5", + "-y", + pattern, + ] + logger.info("Running FFmpeg: %s", " ".join(cmd)) + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode == 0: + frame_paths = sorted( + [os.path.join(output_dir, f) for f in os.listdir(output_dir) if f.endswith(".jpg")] + ) + if max_frames: + frame_paths = frame_paths[:max_frames] + logger.info("Extracted %d frames via FFmpeg", len(frame_paths)) + return frame_paths, original_fps + else: + logger.warning("FFmpeg failed: %s", result.stderr) + except Exception as exc: # noqa: BLE001 + logger.warning("FFmpeg exception: %s", exc) + + # OpenCV fallback + logger.info("Falling back to OpenCV frame extraction") + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + raise RuntimeError(f"Cannot open video: {video_path}") + + video_fps = cap.get(cv2.CAP_PROP_FPS) or 30 + interval = max(1, int(round(video_fps / safe_fps))) + count = 0 + saved = 0 + + while True: + ret, frame = cap.read() + if not ret: + break + if count % interval == 0: + path = os.path.join(output_dir, f"frame_{saved:06d}.jpg") + h, w = frame.shape[:2] + if safe_width > 0 and w != safe_width: + scale = safe_width / max(w, 1) + frame = cv2.resize(frame, (safe_width, max(1, int(round(h * scale)))), interpolation=cv2.INTER_AREA) + cv2.imwrite(path, frame, [cv2.IMWRITE_JPEG_QUALITY, 80]) + frame_paths.append(path) + saved += 1 + if max_frames and saved >= max_frames: + break + count += 1 + + cap.release() + logger.info("Extracted %d frames via OpenCV", len(frame_paths)) + return frame_paths, original_fps + + +def parse_dicom( + dicom_dir: str, + output_dir: str, + max_frames: Optional[int] = None, +) -> List[str]: + """Extract frames from DICOM files in a directory. + + Args: + dicom_dir: Directory containing .dcm files. + output_dir: Directory to save extracted frames. + max_frames: Optional maximum number of frames to extract. + + Returns: + List of paths to extracted frame images. + """ + os.makedirs(output_dir, exist_ok=True) + dcm_files = sorted( + [f for f in os.listdir(dicom_dir) if f.lower().endswith(".dcm")], + key=natural_filename_key, + ) + + frame_paths: List[str] = [] + for idx, fname in enumerate(dcm_files): + if max_frames and idx >= max_frames: + break + path = os.path.join(dicom_dir, fname) + try: + ds = dcmread(path) + pixel_array = ds.pixel_array + + # Normalize to 8-bit + if pixel_array.dtype != np.uint8: + pixel_array = pixel_array.astype(np.float32) + pixel_array = ( + (pixel_array - pixel_array.min()) + / (pixel_array.max() - pixel_array.min() + 1e-8) + * 255 + ) + pixel_array = pixel_array.astype(np.uint8) + + # Handle multi-frame DICOM + if pixel_array.ndim == 3: + for f in range(pixel_array.shape[0]): + out_path = os.path.join(output_dir, f"frame_{idx:06d}_{f:03d}.jpg") + cv2.imwrite(out_path, pixel_array[f], [cv2.IMWRITE_JPEG_QUALITY, 85]) + frame_paths.append(out_path) + else: + out_path = os.path.join(output_dir, f"frame_{idx:06d}.jpg") + cv2.imwrite(out_path, pixel_array, [cv2.IMWRITE_JPEG_QUALITY, 85]) + frame_paths.append(out_path) + except Exception as exc: # noqa: BLE001 + logger.error("Failed to read DICOM %s: %s", path, exc) + + logger.info("Extracted %d frames from DICOM", len(frame_paths)) + return frame_paths + + +def upload_frames_to_minio( + frames: List[str], + project_id: int, + object_prefix: Optional[str] = None, +) -> List[str]: + """Upload a list of local frame images to MinIO. + + Args: + frames: List of local file paths. + project_id: Project ID used for bucket path organization. + object_prefix: Optional prefix override. + + Returns: + List of object names (paths) in MinIO. + """ + prefix = object_prefix or f"projects/{project_id}/frames" + object_names: List[str] = [] + + for frame_path in frames: + fname = os.path.basename(frame_path) + object_name = f"{prefix}/{fname}" + try: + with open(frame_path, "rb") as f: + data = f.read() + upload_file( + object_name, + data, + content_type="image/jpeg", + length=len(data), + ) + object_names.append(object_name) + except Exception as exc: # noqa: BLE001 + logger.error("Failed to upload %s: %s", frame_path, exc) + + logger.info("Uploaded %d/%d frames to MinIO", len(object_names), len(frames)) + return object_names diff --git a/backend/services/media_task_runner.py b/backend/services/media_task_runner.py new file mode 100644 index 0000000..33351ab --- /dev/null +++ b/backend/services/media_task_runner.py @@ -0,0 +1,340 @@ +"""Background media parsing runner used by Celery workers.""" + +import logging +import os +import shutil +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from sqlalchemy.orm import Session + +from minio_client import BUCKET_NAME, download_file, get_minio_client, upload_file +from models import Annotation, Frame, Mask, ProcessingTask, Project +from progress_events import publish_task_progress_event +from services.frame_parser import ( + extract_thumbnail, + natural_filename_key, + parse_dicom, + parse_video, + upload_frames_to_minio, +) +from statuses import ( + PROJECT_STATUS_PENDING, + PROJECT_STATUS_ERROR, + PROJECT_STATUS_PARSING, + PROJECT_STATUS_READY, + TASK_STATUS_CANCELLED, + TASK_STATUS_FAILED, + TASK_STATUS_RUNNING, + TASK_STATUS_SUCCESS, +) + +logger = logging.getLogger(__name__) + + +class TaskCancelled(RuntimeError): + """Raised internally when a persisted task has been cancelled.""" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _set_task_state( + db: Session, + task: ProcessingTask, + *, + status: str | None = None, + progress: int | None = None, + message: str | None = None, + result: dict[str, Any] | None = None, + error: str | None = None, + started: bool = False, + finished: bool = False, +) -> None: + if status is not None: + task.status = status + if progress is not None: + task.progress = max(0, min(100, progress)) + if message is not None: + task.message = message + if result is not None: + task.result = result + if error is not None: + task.error = error + if started: + task.started_at = _now() + if finished: + task.finished_at = _now() + db.commit() + db.refresh(task) + publish_task_progress_event(task) + + +def _project_status_after_stop(project: Project) -> str: + return PROJECT_STATUS_READY if project.frames else PROJECT_STATUS_PENDING + + +def _positive_int(value: Any, default: int | None = None) -> int | None: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed > 0 else default + + +def _positive_float(value: Any, default: float) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + return default + return parsed if parsed > 0 else default + + +def _frame_sequence_metadata( + index: int, + parse_fps: float, + original_fps: float | None, +) -> dict[str, float | int | None]: + safe_parse_fps = max(float(parse_fps or 1.0), 1e-6) + timestamp_ms = index * 1000.0 / safe_parse_fps + source_frame_number = None + if original_fps and original_fps > 0: + source_frame_number = int(round(index * original_fps / safe_parse_fps)) + return { + "timestamp_ms": timestamp_ms, + "source_frame_number": source_frame_number, + } + + +def _clear_existing_project_outputs(db: Session, project: Project) -> None: + """Remove stale frame sequence and annotations before regenerating frames.""" + annotation_ids = db.query(Annotation.id).filter(Annotation.project_id == project.id) + db.query(Mask).filter(Mask.annotation_id.in_(annotation_ids)).delete(synchronize_session=False) + db.query(Annotation).filter(Annotation.project_id == project.id).delete(synchronize_session=False) + db.query(Frame).filter(Frame.project_id == project.id).delete(synchronize_session=False) + project.thumbnail_url = None + db.commit() + + +def _ensure_not_cancelled(db: Session, task: ProcessingTask) -> None: + db.refresh(task) + if task.status == TASK_STATUS_CANCELLED: + raise TaskCancelled("Task was cancelled") + + +def run_parse_media_task(db: Session, task_id: int) -> dict[str, Any]: + """Parse one project's media and update task progress in the database.""" + task = db.query(ProcessingTask).filter(ProcessingTask.id == task_id).first() + if not task: + raise ValueError(f"Task not found: {task_id}") + + if task.status == TASK_STATUS_CANCELLED: + return { + "task_id": task.id, + "status": TASK_STATUS_CANCELLED, + "message": task.message or "任务已取消", + } + + if task.project_id is None: + _set_task_state( + db, + task, + status=TASK_STATUS_FAILED, + progress=100, + message="任务缺少 project_id", + error="Task has no project_id", + finished=True, + ) + raise ValueError("Task has no project_id") + + project = db.query(Project).filter(Project.id == task.project_id).first() + if not project: + _set_task_state( + db, + task, + status=TASK_STATUS_FAILED, + progress=100, + message="项目不存在", + error="Project not found", + finished=True, + ) + raise ValueError(f"Project not found: {task.project_id}") + + if not project.video_path: + _set_task_state( + db, + task, + status=TASK_STATUS_FAILED, + progress=100, + message="项目没有可解析媒体", + error="Project has no media uploaded", + finished=True, + ) + project.status = PROJECT_STATUS_ERROR + db.commit() + raise ValueError("Project has no media uploaded") + + _ensure_not_cancelled(db, task) + project.status = PROJECT_STATUS_PARSING + _clear_existing_project_outputs(db, project) + _set_task_state(db, task, status=TASK_STATUS_RUNNING, progress=5, message="后台解析已启动", started=True) + + payload = task.payload or {} + effective_source = payload.get("source_type") or project.source_type or "video" + parse_fps = _positive_float(payload.get("parse_fps"), project.parse_fps or 30.0) + max_frames = _positive_int(payload.get("max_frames")) + target_width = _positive_int(payload.get("target_width"), 640) or 640 + project.parse_fps = parse_fps + tmp_dir = tempfile.mkdtemp(prefix=f"seg_parse_{project.id}_") + output_dir = os.path.join(tmp_dir, "frames") + os.makedirs(output_dir, exist_ok=True) + + try: + _ensure_not_cancelled(db, task) + _set_task_state(db, task, progress=15, message="正在下载媒体文件") + if effective_source == "dicom": + dcm_dir = os.path.join(tmp_dir, "dcm") + os.makedirs(dcm_dir, exist_ok=True) + + client = get_minio_client() + objects = sorted( + list(client.list_objects(BUCKET_NAME, prefix=project.video_path, recursive=True)), + key=lambda obj: natural_filename_key(obj.object_name), + ) + for obj in objects: + _ensure_not_cancelled(db, task) + if obj.object_name.lower().endswith(".dcm"): + data = download_file(obj.object_name) + local_dcm = os.path.join(dcm_dir, os.path.basename(obj.object_name)) + with open(local_dcm, "wb") as f: + f.write(data) + + _ensure_not_cancelled(db, task) + _set_task_state(db, task, progress=35, message="正在解析 DICOM 序列") + frame_files = parse_dicom(dcm_dir, output_dir, max_frames=max_frames) + else: + _ensure_not_cancelled(db, task) + media_bytes = download_file(project.video_path) + local_path = os.path.join(tmp_dir, Path(project.video_path).name) + with open(local_path, "wb") as f: + f.write(media_bytes) + + _ensure_not_cancelled(db, task) + _set_task_state(db, task, progress=35, message="正在使用 FFmpeg/OpenCV 拆帧") + frame_files, original_fps = parse_video( + local_path, + output_dir, + fps=int(parse_fps), + max_frames=max_frames, + target_width=target_width, + ) + project.original_fps = original_fps + + thumbnail_path = os.path.join(tmp_dir, "thumbnail.jpg") + try: + extract_thumbnail(local_path, thumbnail_path) + with open(thumbnail_path, "rb") as f: + thumb_data = f.read() + thumb_object = f"projects/{project.id}/thumbnail.jpg" + upload_file(thumb_object, thumb_data, content_type="image/jpeg", length=len(thumb_data)) + project.thumbnail_url = thumb_object + except Exception as exc: # noqa: BLE001 + logger.warning("Thumbnail extraction failed: %s", exc) + + _ensure_not_cancelled(db, task) + _set_task_state(db, task, progress=70, message="正在上传帧到对象存储") + object_names = upload_frames_to_minio(frame_files, project.id) + + _ensure_not_cancelled(db, task) + _set_task_state(db, task, progress=85, message="正在写入帧索引") + frames_out = [] + for idx, obj_name in enumerate(object_names): + _ensure_not_cancelled(db, task) + local_frame = frame_files[idx] + try: + import cv2 + + img = cv2.imread(local_frame) + h, w = img.shape[:2] if img is not None else (None, None) + except Exception: # noqa: BLE001 + h, w = None, None + + sequence_meta = _frame_sequence_metadata(idx, parse_fps, project.original_fps) + frame = Frame( + project_id=project.id, + frame_index=idx, + image_url=obj_name, + width=w, + height=h, + timestamp_ms=sequence_meta["timestamp_ms"], + source_frame_number=sequence_meta["source_frame_number"], + ) + db.add(frame) + frames_out.append(frame) + + project.status = PROJECT_STATUS_READY + db.commit() + + result = { + "project_id": project.id, + "frames_extracted": len(frames_out), + "status": PROJECT_STATUS_READY, + "message": "Frame extraction completed successfully.", + "frame_sequence": { + "original_fps": project.original_fps, + "parse_fps": parse_fps, + "frame_count": len(frames_out), + "duration_ms": (len(frames_out) - 1) * 1000.0 / parse_fps if frames_out else 0, + "target_width": target_width, + "frame_width": frames_out[0].width if frames_out else None, + "frame_height": frames_out[0].height if frames_out else None, + "max_frames": max_frames, + "object_prefix": f"projects/{project.id}/frames", + }, + } + _set_task_state( + db, + task, + status=TASK_STATUS_SUCCESS, + progress=100, + message="解析完成", + result=result, + finished=True, + ) + logger.info("Parsed %d frames for project_id=%s", len(frames_out), project.id) + return result + except TaskCancelled: + project.status = _project_status_after_stop(project) + task.status = TASK_STATUS_CANCELLED + task.progress = 100 + task.message = task.message or "任务已取消" + task.error = task.error or "Cancelled by user" + task.finished_at = task.finished_at or _now() + db.commit() + db.refresh(task) + publish_task_progress_event(task) + logger.info("Parse task cancelled: task_id=%s project_id=%s", task.id, project.id) + return { + "task_id": task.id, + "project_id": project.id, + "status": TASK_STATUS_CANCELLED, + "message": task.message, + } + except Exception as exc: # noqa: BLE001 + project.status = PROJECT_STATUS_ERROR + _set_task_state( + db, + task, + status=TASK_STATUS_FAILED, + progress=100, + message="解析失败", + error=str(exc), + finished=True, + ) + logger.error("Frame extraction failed: %s", exc) + raise + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/backend/services/propagation_task_runner.py b/backend/services/propagation_task_runner.py new file mode 100644 index 0000000..13d27ef --- /dev/null +++ b/backend/services/propagation_task_runner.py @@ -0,0 +1,842 @@ +"""Background SAM video propagation runner used by Celery workers.""" + +import hashlib +import json +import logging +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import cv2 +import numpy as np +from sqlalchemy.orm import Session + +from minio_client import download_file +from models import Annotation, Frame, ProcessingTask, Project +from progress_events import publish_task_progress_event +from services.sam_registry import ModelUnavailableError, sam_registry +from statuses import ( + TASK_STATUS_CANCELLED, + TASK_STATUS_FAILED, + TASK_STATUS_RUNNING, + TASK_STATUS_SUCCESS, +) + +logger = logging.getLogger(__name__) + + +class PropagationTaskCancelled(RuntimeError): + """Raised internally when a persisted propagation task has been cancelled.""" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _set_task_state( + db: Session, + task: ProcessingTask, + *, + status: str | None = None, + progress: int | None = None, + message: str | None = None, + result: dict[str, Any] | None = None, + error: str | None = None, + started: bool = False, + finished: bool = False, +) -> None: + if status is not None: + task.status = status + if progress is not None: + task.progress = max(0, min(100, progress)) + if message is not None: + task.message = message + if result is not None: + task.result = result + if error is not None: + task.error = error + if started: + task.started_at = _now() + if finished: + task.finished_at = _now() + db.commit() + db.refresh(task) + publish_task_progress_event(task) + + +def _ensure_not_cancelled(db: Session, task: ProcessingTask) -> None: + db.refresh(task) + if task.status == TASK_STATUS_CANCELLED: + raise PropagationTaskCancelled("Task was cancelled") + + +def _clamp01(value: float) -> float: + return min(max(float(value), 0.0), 1.0) + + +def _polygon_bbox(polygon: list[list[float]]) -> list[float]: + xs = [_clamp01(point[0]) for point in polygon] + ys = [_clamp01(point[1]) for point in polygon] + left, right = min(xs), max(xs) + top, bottom = min(ys), max(ys) + return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)] + + +def _polygons_bbox(polygons: list[list[list[float]]]) -> list[float]: + points = [point for polygon in polygons for point in polygon if len(point) >= 2] + if not points: + return [0.0, 0.0, 0.0, 0.0] + xs = [_clamp01(point[0]) for point in points] + ys = [_clamp01(point[1]) for point in points] + left, right = min(xs), max(xs) + top, bottom = min(ys), max(ys) + return [left, top, max(right - left, 0.0), max(bottom - top, 0.0)] + + +def _normalize_polygon(polygon: list[list[float]]) -> list[list[float]]: + return [[_clamp01(point[0]), _clamp01(point[1])] for point in polygon if len(point) >= 2] + + +def _normalize_smoothing_options(value: Any) -> dict[str, Any] | None: + if not isinstance(value, dict): + return None + try: + strength = max(0.0, min(float(value.get("strength") or 0.0), 100.0)) + except (TypeError, ValueError): + strength = 0.0 + if strength <= 0: + return None + method = str(value.get("method") or "chaikin").lower() + if method != "chaikin": + method = "chaikin" + return {"strength": round(strength, 2), "method": method} + + +def _smoothing_ratio(strength: float, curve: float = 1.65) -> float: + normalized = max(0.0, min(float(strength or 0.0), 100.0)) / 100.0 + return normalized ** curve + + +def _chaikin_smooth_polygon(polygon: list[list[float]], iterations: int, corner_cut: float = 0.25) -> list[list[float]]: + points = _normalize_polygon(polygon) + q = max(0.02, min(float(corner_cut), 0.25)) + for _ in range(max(0, iterations)): + if len(points) < 3: + break + next_points: list[list[float]] = [] + for index, current in enumerate(points): + following = points[(index + 1) % len(points)] + next_points.append([ + _clamp01((1.0 - q) * current[0] + q * following[0]), + _clamp01((1.0 - q) * current[1] + q * following[1]), + ]) + next_points.append([ + _clamp01(q * current[0] + (1.0 - q) * following[0]), + _clamp01(q * current[1] + (1.0 - q) * following[1]), + ]) + points = next_points + return points + + +def _simplify_polygon(polygon: list[list[float]], strength: float) -> list[list[float]]: + if len(polygon) < 3: + return polygon + contour = np.array([[[point[0], point[1]]] for point in polygon], dtype=np.float32) + arc_length = cv2.arcLength(contour, True) + epsilon = arc_length * (0.00015 + _smoothing_ratio(strength) * 0.00735) + approx = cv2.approxPolyDP(contour, epsilon, True).reshape(-1, 2) + if len(approx) < 3: + return polygon + return [[_clamp01(float(x)), _clamp01(float(y))] for x, y in approx] + + +def _smooth_polygon(polygon: list[list[float]], smoothing: dict[str, Any] | None) -> list[list[float]]: + if not smoothing: + return _normalize_polygon(polygon) + strength = float(smoothing.get("strength") or 0.0) + if strength <= 0: + return _normalize_polygon(polygon) + effective_strength = _smoothing_ratio(strength, curve=1.45) * 100.0 + if effective_strength >= 85: + iterations = 4 + elif effective_strength >= 55: + iterations = 3 + elif effective_strength >= 25: + iterations = 2 + else: + iterations = 1 + corner_cut = 0.03 + _smoothing_ratio(strength, curve=1.35) * 0.22 + normalized = _normalize_polygon(polygon) + pre_simplified = _simplify_polygon(normalized, effective_strength * 0.25) + smoothed = _chaikin_smooth_polygon(pre_simplified, iterations, corner_cut) + simplified = _simplify_polygon(smoothed, effective_strength) + if len(simplified) > len(normalized): + for fallback_strength in (25.0, 35.0, 50.0, 70.0, 90.0, 100.0): + simplified = _simplify_polygon(simplified, max(effective_strength, fallback_strength)) + if len(simplified) <= len(normalized): + break + return simplified if len(simplified) >= 3 else _normalize_polygon(polygon) + + +def _bbox_area(bbox: list[float]) -> float: + return max(float(bbox[2]), 0.0) * max(float(bbox[3]), 0.0) + + +def _bbox_overlap_ratio(a: list[float], b: list[float]) -> float: + ax1, ay1, aw, ah = a + bx1, by1, bw, bh = b + ax2 = ax1 + aw + ay2 = ay1 + ah + bx2 = bx1 + bw + by2 = by1 + bh + overlap_width = max(0.0, min(ax2, bx2) - max(ax1, bx1)) + overlap_height = max(0.0, min(ay2, by2) - max(ay1, by1)) + overlap_area = overlap_width * overlap_height + smallest_area = min(_bbox_area(a), _bbox_area(b)) + return overlap_area / smallest_area if smallest_area > 0 else 0.0 + + +def _stable_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _canonicalize_signature_value(value: Any) -> Any: + if isinstance(value, float): + return round(value, 6) + if isinstance(value, list): + return [_canonicalize_signature_value(item) for item in value] + if isinstance(value, dict): + return {key: _canonicalize_signature_value(value[key]) for key in sorted(value)} + return value + + +def _seed_signature(seed: dict[str, Any]) -> str: + """Return a stable signature for seed geometry and semantic attrs.""" + inherited_signature = seed.get("propagation_seed_signature") + if inherited_signature: + return str(inherited_signature) + signature_payload = { + "polygons": seed.get("polygons") or [], + "holes": seed.get("holes") or [], + "bbox": seed.get("bbox") or [], + "points": seed.get("points") or [], + "labels": seed.get("labels") or [], + "label": seed.get("label"), + "color": seed.get("color"), + "class_metadata": seed.get("class_metadata") or {}, + "template_id": seed.get("template_id"), + "smoothing": _normalize_smoothing_options(seed.get("smoothing")), + } + return hashlib.sha256(_stable_json(_canonicalize_signature_value(signature_payload)).encode("utf-8")).hexdigest() + + +def _seed_key(seed: dict[str, Any]) -> str: + """Prefer stable persisted ids; fall back to semantic attrs for legacy callers.""" + source_instance_id = seed.get("source_instance_id") + if source_instance_id: + return f"instance:{source_instance_id}" + source_annotation_id = seed.get("source_annotation_id") + if source_annotation_id is not None: + return f"annotation:{source_annotation_id}" + source_mask_id = seed.get("source_mask_id") + if source_mask_id: + return f"mask:{source_mask_id}" + class_metadata = seed.get("class_metadata") or {} + class_id = class_metadata.get("id") or class_metadata.get("name") + return _stable_json({ + "template_id": seed.get("template_id"), + "class_id": class_id, + "label": seed.get("label"), + "color": seed.get("color"), + }) + + +def _semantic_seed_matches(mask_data: dict[str, Any], seed: dict[str, Any]) -> bool: + """Best-effort match when a manually edited replacement lacks old lineage ids.""" + class_metadata = seed.get("class_metadata") or {} + previous_class = mask_data.get("class") or {} + previous_class_id = previous_class.get("id") or previous_class.get("name") + class_id = class_metadata.get("id") or class_metadata.get("name") + if previous_class_id and class_id and str(previous_class_id) != str(class_id): + return False + return ( + mask_data.get("label") == seed.get("label") + and mask_data.get("color") == seed.get("color") + ) + + +def _legacy_seed_matches(mask_data: dict[str, Any], seed: dict[str, Any]) -> bool: + """Best-effort match for propagation annotations created before seed keys.""" + class_metadata = seed.get("class_metadata") or {} + previous_class = mask_data.get("class") or {} + previous_class_id = previous_class.get("id") or previous_class.get("name") + class_id = class_metadata.get("id") or class_metadata.get("name") + return ( + mask_data.get("label") == seed.get("label") + and mask_data.get("color") == seed.get("color") + and previous_class_id == class_id + ) + + +def _source_model_matches(mask_data: dict[str, Any], model_id: str) -> bool: + return str(mask_data.get("source") or "") == f"{model_id}_propagation" + + +def _seed_identity_matches(mask_data: dict[str, Any], seed_key: str, seed: dict[str, Any]) -> bool: + previous_seed_key = mask_data.get("propagation_seed_key") + if previous_seed_key == seed_key: + return True + source_instance_id = seed.get("source_instance_id") + if source_instance_id and ( + mask_data.get("source_instance_id") == source_instance_id + or mask_data.get("instance_id") == source_instance_id + ): + return True + source_annotation_id = seed.get("source_annotation_id") + if source_annotation_id is not None and str(mask_data.get("source_annotation_id") or "") == str(source_annotation_id): + return True + source_mask_id = seed.get("source_mask_id") + if source_mask_id and mask_data.get("source_mask_id") == source_mask_id: + return True + has_persisted_seed_identity = bool(source_instance_id) or source_annotation_id is not None or bool(source_mask_id) + has_previous_identity = ( + bool(previous_seed_key) + or mask_data.get("source_instance_id") is not None + or mask_data.get("instance_id") is not None + or mask_data.get("source_annotation_id") is not None + or bool(mask_data.get("source_mask_id")) + ) + if has_persisted_seed_identity or has_previous_identity: + return False + return _legacy_seed_matches(mask_data, seed) + + +def _seed_identity_markers(seed: dict[str, Any]) -> set[str]: + markers = {f"seed:{_seed_key(seed)}"} + source_instance_id = seed.get("source_instance_id") + if source_instance_id: + markers.add(f"instance:{source_instance_id}") + source_annotation_id = seed.get("source_annotation_id") + if source_annotation_id is not None: + markers.add(f"annotation:{source_annotation_id}") + source_mask_id = seed.get("source_mask_id") + if source_mask_id: + markers.add(f"mask:{source_mask_id}") + return markers + + +def _mask_identity_markers(mask_data: dict[str, Any]) -> set[str]: + markers: set[str] = set() + previous_seed_key = mask_data.get("propagation_seed_key") + if previous_seed_key: + markers.add(f"seed:{previous_seed_key}") + source_instance_id = mask_data.get("source_instance_id") + if source_instance_id: + markers.add(f"instance:{source_instance_id}") + instance_id = mask_data.get("instance_id") + if instance_id: + markers.add(f"instance:{instance_id}") + source_annotation_id = mask_data.get("source_annotation_id") + if source_annotation_id is not None: + markers.add(f"annotation:{source_annotation_id}") + source_mask_id = mask_data.get("source_mask_id") + if source_mask_id: + markers.add(f"mask:{source_mask_id}") + return markers + + +def _payload_seed_identity_markers(payload: dict[str, Any]) -> set[str]: + markers: set[str] = set() + for step in payload.get("steps") or []: + seed = step.get("seed") or {} + markers.update(_seed_identity_markers(seed)) + return markers + + +def _is_propagation_annotation(annotation: Annotation, seed_key: str, seed: dict[str, Any]) -> bool: + mask_data = annotation.mask_data or {} + source = str(mask_data.get("source") or "") + if not source.endswith("_propagation"): + return False + return _seed_identity_matches(mask_data, seed_key, seed) + + +def _direction_matches(mask_data: dict[str, Any], direction: str) -> bool: + previous_direction = mask_data.get("propagation_direction") + return previous_direction in {None, direction} + + +def _annotation_spatially_matches(annotation: Annotation, polygon: list[list[float]]) -> bool: + """Use target-frame overlap as a final guard before replacing same-object propagation.""" + candidate_bbox = _polygon_bbox(polygon) + for previous_polygon in (annotation.mask_data or {}).get("polygons") or []: + if len(previous_polygon) < 3: + continue + if _bbox_overlap_ratio(_polygon_bbox(previous_polygon), candidate_bbox) >= 0.15: + return True + return False + + +def _delete_replaced_frame_annotations( + db: Session, + *, + payload: dict[str, Any], + frame_id: int, + seed_key: str, + seed: dict[str, Any], + polygon: list[list[float]], +) -> int: + """Delete old propagated masks for the same object immediately before writing a new result.""" + previous_annotations = ( + db.query(Annotation) + .filter(Annotation.project_id == int(payload["project_id"])) + .filter(Annotation.frame_id == frame_id) + .all() + ) + deleted_count = 0 + current_seed_markers = _seed_identity_markers(seed) + task_seed_markers = _payload_seed_identity_markers(payload) + for annotation in previous_annotations: + mask_data = annotation.mask_data or {} + source = str(mask_data.get("source") or "") + if not source.endswith("_propagation"): + continue + source_instance_id = seed.get("source_instance_id") + mask_instance_ids = { + str(value) + for value in (mask_data.get("source_instance_id"), mask_data.get("instance_id")) + if value + } + if source_instance_id and mask_instance_ids and str(source_instance_id) not in mask_instance_ids: + continue + mask_markers = _mask_identity_markers(mask_data) + # Keep sibling seeds in the same propagation task from deleting each other. + if mask_markers and mask_markers.isdisjoint(current_seed_markers) and not mask_markers.isdisjoint(task_seed_markers): + continue + same_lineage = _seed_identity_matches(mask_data, seed_key, seed) + same_manual_replacement = ( + _semantic_seed_matches(mask_data, seed) + and _annotation_spatially_matches(annotation, polygon) + ) + if same_lineage or same_manual_replacement: + db.delete(annotation) + deleted_count += 1 + if deleted_count: + db.commit() + return deleted_count + + +def _prepare_seed_propagation( + db: Session, + *, + payload: dict[str, Any], + model_id: str, + seed: dict[str, Any], + direction: str, + target_frame_ids: set[int], +) -> dict[str, Any]: + seed_key = _seed_key(seed) + seed_signature = _seed_signature(seed) + if not target_frame_ids: + return { + "skip": True, + "seed_key": seed_key, + "seed_signature": seed_signature, + "deleted_annotation_count": 0, + } + previous_annotations = ( + db.query(Annotation) + .filter(Annotation.project_id == int(payload["project_id"])) + .filter(Annotation.frame_id.in_(target_frame_ids)) + .all() + ) + matching = [ + annotation for annotation in previous_annotations + if _is_propagation_annotation(annotation, seed_key, seed) + and _direction_matches(annotation.mask_data or {}, direction) + ] + covered_frame_ids = {int(annotation.frame_id) for annotation in matching} + if matching and all( + (annotation.mask_data or {}).get("propagation_seed_signature") == seed_signature + and _source_model_matches(annotation.mask_data or {}, model_id) + for annotation in matching + ) and target_frame_ids.issubset(covered_frame_ids): + return { + "skip": True, + "seed_key": seed_key, + "seed_signature": seed_signature, + "deleted_annotation_count": 0, + } + + deleted_count = 0 + if matching: + for annotation in matching: + db.delete(annotation) + deleted_count += 1 + db.commit() + + return { + "skip": False, + "seed_key": seed_key, + "seed_signature": seed_signature, + "deleted_annotation_count": deleted_count, + } + + +def _frame_window( + frames: list[Frame], + source_position: int, + direction: str, + max_frames: int, +) -> tuple[list[Frame], int]: + count = max(1, min(max_frames, len(frames))) + if direction == "backward": + start = max(0, source_position - count + 1) + return frames[start:source_position + 1], source_position - start + end = min(len(frames), source_position + count) + return frames[source_position:end], 0 + + +def _write_frame_sequence(frames: list[Frame], directory: Path) -> list[str]: + paths = [] + for index, frame in enumerate(frames): + data = download_file(frame.image_url) + # SAM2VideoPredictor sorts frames by converting the filename stem to int. + path = directory / f"{index:06d}.jpg" + path.write_bytes(data) + paths.append(str(path)) + return paths + + +def _save_propagated_annotations( + db: Session, + *, + payload: dict[str, Any], + selected_frames: list[Frame], + source_frame: Frame, + propagated: list[dict[str, Any]], + seed: dict[str, Any], +) -> tuple[list[Annotation], int]: + created: list[Annotation] = [] + if payload.get("save_annotations", True) is False: + return created, 0 + + class_metadata = seed.get("class_metadata") + template_id = seed.get("template_id") + label = seed.get("label") or "Propagated Mask" + color = seed.get("color") or "#06b6d4" + model_id = sam_registry.normalize_model_id(payload.get("model")) + include_source = bool(payload.get("include_source", False)) + seed_key = _seed_key(seed) + seed_signature = _seed_signature(seed) + source_annotation_id = seed.get("source_annotation_id") + source_mask_id = seed.get("source_mask_id") + source_instance_id = seed.get("source_instance_id") or seed_key + smoothing = _normalize_smoothing_options(seed.get("smoothing")) + direction = str(payload.get("current_direction") or "") + deleted_count = 0 + cleaned_frame_ids: set[int] = set() + + for frame_result in propagated: + relative_index = int(frame_result.get("frame_index", -1)) + if relative_index < 0 or relative_index >= len(selected_frames): + continue + frame = selected_frames[relative_index] + if not include_source and frame.id == source_frame.id: + continue + result_polygons = frame_result.get("polygons") or [] + result_holes = frame_result.get("holes") or [] + scores = frame_result.get("scores") or [] + prepared_polygons = [ + (polygon_index, _smooth_polygon(polygon, smoothing)) + for polygon_index, polygon in enumerate(result_polygons) + if len(polygon) >= 3 + ] + cleanup_polygon = next((polygon for _polygon_index, polygon in prepared_polygons if len(polygon) >= 3), None) + if cleanup_polygon is not None and frame.id not in cleaned_frame_ids: + deleted_count += _delete_replaced_frame_annotations( + db, + payload=payload, + frame_id=int(frame.id), + seed_key=seed_key, + seed=seed, + polygon=cleanup_polygon, + ) + cleaned_frame_ids.add(int(frame.id)) + polygons_to_save: list[list[list[float]]] = [] + holes_to_save: list[list[list[list[float]]]] = [] + score_values: list[float] = [] + for polygon_index, polygon in prepared_polygons: + if len(polygon) < 3: + continue + polygons_to_save.append(polygon) + hole_group = result_holes[polygon_index] if polygon_index < len(result_holes) and isinstance(result_holes[polygon_index], list) else [] + holes_to_save.append(hole_group if isinstance(hole_group, list) else []) + if polygon_index < len(scores): + try: + score_values.append(float(scores[polygon_index])) + except (TypeError, ValueError): + pass + if not polygons_to_save: + continue + annotation = Annotation( + project_id=int(payload["project_id"]), + frame_id=frame.id, + template_id=template_id, + mask_data={ + "polygons": polygons_to_save, + **({"holes": holes_to_save, "hasHoles": True} if any(holes_to_save) else {}), + "label": label, + "color": color, + "source": f"{model_id}_propagation", + "propagated_from_frame_id": source_frame.id, + "propagated_from_frame_index": source_frame.frame_index, + "propagation_seed_key": seed_key, + "propagation_seed_signature": seed_signature, + "propagation_direction": direction, + "instance_id": source_instance_id, + "source_instance_id": source_instance_id, + "source_annotation_id": source_annotation_id, + "source_mask_id": source_mask_id, + "score": max(score_values) if score_values else None, + **({"scores": score_values} if len(score_values) > 1 else {}), + **({"geometry_smoothing": smoothing} if smoothing else {}), + **({"class": class_metadata} if class_metadata else {}), + }, + points=None, + bbox=_polygons_bbox(polygons_to_save), + ) + db.add(annotation) + created.append(annotation) + + db.commit() + for annotation in created: + db.refresh(annotation) + return created, deleted_count + + +def _run_one_step( + db: Session, + *, + payload: dict[str, Any], + frames: list[Frame], + source_frame: Frame, + source_position: int, + step: dict[str, Any], +) -> dict[str, Any]: + direction = str(step.get("direction") or "forward").lower() + if direction not in {"forward", "backward"}: + raise ValueError("direction must be forward or backward") + max_frames = max(1, min(int(step.get("max_frames") or payload.get("max_frames") or 30), 500)) + seed = step.get("seed") or {} + if not (seed.get("polygons") or seed.get("bbox") or seed.get("points")): + raise ValueError("Propagation requires seed polygons, bbox, or points") + + model_id = sam_registry.normalize_model_id(payload.get("model")) + selected_frames, source_relative_index = _frame_window(frames, source_position, direction, max_frames) + include_source = bool(payload.get("include_source", False)) + target_frame_ids = { + int(frame.id) + for frame in selected_frames + if include_source or frame.id != source_frame.id + } + seed_state = _prepare_seed_propagation( + db, + payload=payload, + model_id=model_id, + seed=seed, + direction=direction, + target_frame_ids=target_frame_ids, + ) + if seed_state["skip"]: + return { + "model": model_id, + "direction": direction, + "processed_frame_count": 0, + "created_annotation_count": 0, + "deleted_annotation_count": 0, + "skipped_seed_count": 1, + "seed_label": seed.get("label"), + "seed_key": seed_state["seed_key"], + } + + with tempfile.TemporaryDirectory(prefix=f"seg_propagate_{payload['project_id']}_") as tmpdir: + frame_paths = _write_frame_sequence(selected_frames, Path(tmpdir)) + propagated = sam_registry.propagate_video( + model_id, + frame_paths, + source_relative_index, + seed, + direction, + len(selected_frames), + ) + + save_payload = {**payload, "current_direction": direction} + created, write_cleanup_count = _save_propagated_annotations( + db, + payload=save_payload, + selected_frames=selected_frames, + source_frame=source_frame, + propagated=propagated, + seed=seed, + ) + return { + "model": model_id, + "direction": direction, + "processed_frame_count": len(selected_frames), + "created_annotation_count": len(created), + "deleted_annotation_count": int(seed_state["deleted_annotation_count"]) + write_cleanup_count, + "skipped_seed_count": 0, + "seed_label": seed.get("label"), + "seed_key": seed_state["seed_key"], + } + + +def run_propagate_project_task(db: Session, task_id: int) -> dict[str, Any]: + """Run one queued SAM propagation task and update persisted progress.""" + task = db.query(ProcessingTask).filter(ProcessingTask.id == task_id).first() + if not task: + raise ValueError(f"Task not found: {task_id}") + + if task.status == TASK_STATUS_CANCELLED: + return {"task_id": task.id, "status": TASK_STATUS_CANCELLED, "message": task.message or "任务已取消"} + + payload = task.payload or {} + project_id = int(payload.get("project_id") or task.project_id or 0) + source_frame_id = int(payload.get("frame_id") or 0) + try: + model_id = sam_registry.normalize_model_id(payload.get("model")) + except ValueError as exc: + _set_task_state(db, task, status=TASK_STATUS_FAILED, progress=100, message="自动传播失败", error=str(exc), finished=True) + raise + + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + _set_task_state(db, task, status=TASK_STATUS_FAILED, progress=100, message="项目不存在", error="Project not found", finished=True) + raise ValueError(f"Project not found: {project_id}") + + source_frame = db.query(Frame).filter(Frame.id == source_frame_id, Frame.project_id == project_id).first() + if not source_frame: + _set_task_state(db, task, status=TASK_STATUS_FAILED, progress=100, message="参考帧不存在", error="Frame not found", finished=True) + raise ValueError(f"Frame not found: {source_frame_id}") + + frames = db.query(Frame).filter(Frame.project_id == project_id).order_by(Frame.frame_index).all() + source_position = next((index for index, frame in enumerate(frames) if frame.id == source_frame.id), None) + if source_position is None: + _set_task_state(db, task, status=TASK_STATUS_FAILED, progress=100, message="参考帧不在项目帧序列中", error="Source frame is not in project frame sequence", finished=True) + raise ValueError("Source frame is not in project frame sequence") + + steps = payload.get("steps") or [] + if not steps: + _set_task_state(db, task, status=TASK_STATUS_FAILED, progress=100, message="传播任务缺少步骤", error="Propagation task has no steps", finished=True) + raise ValueError("Propagation task has no steps") + + _ensure_not_cancelled(db, task) + _set_task_state(db, task, status=TASK_STATUS_RUNNING, progress=5, message="自动传播任务已启动", started=True) + + step_results: list[dict[str, Any]] = [] + created_count = 0 + processed_count = 0 + deleted_count = 0 + skipped_count = 0 + total_steps = len(steps) + + try: + for index, step in enumerate(steps, start=1): + _ensure_not_cancelled(db, task) + seed_label = (step.get("seed") or {}).get("label") or "mask" + direction_label = "向前传播" if step.get("direction") == "backward" else "向后传播" + progress_before = 5 + int(((index - 1) / total_steps) * 90) + _set_task_state( + db, + task, + progress=progress_before, + message=f"{direction_label} {seed_label} ({index}/{total_steps})", + result={ + "project_id": project_id, + "source_frame_id": source_frame_id, + "model": model_id, + "total_steps": total_steps, + "completed_steps": index - 1, + "processed_frame_count": processed_count, + "created_annotation_count": created_count, + "deleted_annotation_count": deleted_count, + "skipped_seed_count": skipped_count, + "steps": step_results, + }, + ) + + result = _run_one_step( + db, + payload=payload, + frames=frames, + source_frame=source_frame, + source_position=source_position, + step=step, + ) + step_results.append(result) + created_count += int(result["created_annotation_count"]) + processed_count += int(result["processed_frame_count"]) + deleted_count += int(result.get("deleted_annotation_count") or 0) + skipped_count += int(result.get("skipped_seed_count") or 0) + _set_task_state( + db, + task, + progress=5 + int((index / total_steps) * 90), + message=f"{direction_label} {seed_label} 完成 ({index}/{total_steps})", + result={ + "project_id": project_id, + "source_frame_id": source_frame_id, + "model": model_id, + "total_steps": total_steps, + "completed_steps": index, + "processed_frame_count": processed_count, + "created_annotation_count": created_count, + "deleted_annotation_count": deleted_count, + "skipped_seed_count": skipped_count, + "steps": step_results, + }, + ) + + result = { + "project_id": project_id, + "source_frame_id": source_frame_id, + "model": model_id, + "total_steps": total_steps, + "completed_steps": total_steps, + "processed_frame_count": processed_count, + "created_annotation_count": created_count, + "deleted_annotation_count": deleted_count, + "skipped_seed_count": skipped_count, + "steps": step_results, + } + _set_task_state( + db, + task, + status=TASK_STATUS_SUCCESS, + progress=100, + message="自动传播完成" if created_count > 0 else ( + "自动传播完成,未改变的 mask 已跳过" if skipped_count > 0 else "自动传播完成,但没有生成新的 mask" + ), + result=result, + finished=True, + ) + return result + except PropagationTaskCancelled: + task.status = TASK_STATUS_CANCELLED + task.progress = 100 + task.message = task.message or "任务已取消" + task.error = task.error or "Cancelled by user" + task.finished_at = task.finished_at or _now() + db.commit() + db.refresh(task) + publish_task_progress_event(task) + return {"task_id": task.id, "project_id": project_id, "status": TASK_STATUS_CANCELLED, "message": task.message} + except (ModelUnavailableError, NotImplementedError, ValueError) as exc: + _set_task_state(db, task, status=TASK_STATUS_FAILED, progress=100, message="自动传播失败", error=str(exc), finished=True) + raise + except Exception as exc: # noqa: BLE001 + logger.exception("Propagation task failed: task_id=%s", task.id) + _set_task_state(db, task, status=TASK_STATUS_FAILED, progress=100, message="自动传播失败", error=str(exc), finished=True) + raise diff --git a/backend/services/sam2_engine.py b/backend/services/sam2_engine.py new file mode 100644 index 0000000..9bfc1b5 --- /dev/null +++ b/backend/services/sam2_engine.py @@ -0,0 +1,690 @@ +"""SAM 2 engine wrapper with lazy loading and explicit runtime status.""" + +import logging +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import numpy as np + +from config import settings + +logger = logging.getLogger(__name__) + +DEFAULT_SAM2_MODEL_ID = "sam2.1_hiera_tiny" + + +@dataclass(frozen=True) +class SAM2Variant: + """One selectable SAM 2.1 runtime variant.""" + + id: str + label: str + short_label: str + config: str + legacy_config: str + checkpoint_filename: str + legacy_checkpoint_filename: str + + +SAM2_VARIANTS: dict[str, SAM2Variant] = { + "sam2.1_hiera_tiny": SAM2Variant( + id="sam2.1_hiera_tiny", + label="SAM 2.1 Tiny", + short_label="tiny", + config="configs/sam2.1/sam2.1_hiera_t.yaml", + legacy_config="configs/sam2/sam2_hiera_t.yaml", + checkpoint_filename="sam2.1_hiera_tiny.pt", + legacy_checkpoint_filename="sam2_hiera_tiny.pt", + ), + "sam2.1_hiera_small": SAM2Variant( + id="sam2.1_hiera_small", + label="SAM 2.1 Small", + short_label="small", + config="configs/sam2.1/sam2.1_hiera_s.yaml", + legacy_config="configs/sam2/sam2_hiera_s.yaml", + checkpoint_filename="sam2.1_hiera_small.pt", + legacy_checkpoint_filename="sam2_hiera_small.pt", + ), + "sam2.1_hiera_base_plus": SAM2Variant( + id="sam2.1_hiera_base_plus", + label="SAM 2.1 Base+", + short_label="base+", + config="configs/sam2.1/sam2.1_hiera_b+.yaml", + legacy_config="configs/sam2/sam2_hiera_b+.yaml", + checkpoint_filename="sam2.1_hiera_base_plus.pt", + legacy_checkpoint_filename="sam2_hiera_base_plus.pt", + ), + "sam2.1_hiera_large": SAM2Variant( + id="sam2.1_hiera_large", + label="SAM 2.1 Large", + short_label="large", + config="configs/sam2.1/sam2.1_hiera_l.yaml", + legacy_config="configs/sam2/sam2_hiera_l.yaml", + checkpoint_filename="sam2.1_hiera_large.pt", + legacy_checkpoint_filename="sam2_hiera_large.pt", + ), +} + +SAM2_MODEL_ALIASES = { + "sam2": DEFAULT_SAM2_MODEL_ID, + "sam2.1": DEFAULT_SAM2_MODEL_ID, + "sam2_tiny": DEFAULT_SAM2_MODEL_ID, +} + +# --------------------------------------------------------------------------- +# Attempt to import PyTorch and SAM 2; fall back to stubs if unavailable. +# --------------------------------------------------------------------------- +try: + import torch + + TORCH_AVAILABLE = True +except Exception as exc: # noqa: BLE001 + TORCH_AVAILABLE = False + torch = None # type: ignore[assignment] + logger.warning("PyTorch import failed (%s). SAM2 will be unavailable.", exc) + +try: + from sam2.build_sam import build_sam2 + from sam2.build_sam import build_sam2_video_predictor + from sam2.sam2_image_predictor import SAM2ImagePredictor + + SAM2_AVAILABLE = True + logger.info("SAM2 library imported successfully.") +except Exception as exc: # noqa: BLE001 + SAM2_AVAILABLE = False + logger.warning("SAM2 import failed (%s). Using stub engine.", exc) + + +class SAM2Engine: + """Lazy-loaded SAM 2 inference engine.""" + + def __init__(self) -> None: + self._predictors: dict[str, Optional[SAM2ImagePredictor]] = {} + self._video_predictors: dict[str, object | None] = {} + self._model_loaded: dict[str, bool] = {} + self._video_model_loaded: dict[str, bool] = {} + self._loaded_device: dict[str, str] = {} + self._last_error: dict[str, str | None] = {} + self._video_last_error: dict[str, str | None] = {} + + # ----------------------------------------------------------------------- + # Internal helpers + # ----------------------------------------------------------------------- + def variant_ids(self) -> list[str]: + return list(SAM2_VARIANTS.keys()) + + def normalize_model_id(self, model_id: str | None) -> str: + selected = (model_id or settings.sam_default_model or DEFAULT_SAM2_MODEL_ID).lower() + selected = SAM2_MODEL_ALIASES.get(selected, selected) + if selected not in SAM2_VARIANTS: + raise ValueError(f"Unsupported SAM2 model: {model_id}") + return selected + + def is_sam2_model(self, model_id: str | None) -> bool: + try: + self.normalize_model_id(model_id) + return True + except ValueError: + return False + + def _models_dir(self) -> Path: + configured_path = Path(settings.sam_model_path) + return configured_path.parent if configured_path.parent else Path("models") + + def _variant(self, model_id: str | None) -> SAM2Variant: + return SAM2_VARIANTS[self.normalize_model_id(model_id)] + + def _checkpoint_config(self, model_id: str | None) -> tuple[str, str]: + variant_id = self.normalize_model_id(model_id) + variant = SAM2_VARIANTS[variant_id] + models_dir = self._models_dir() + candidates: list[tuple[str, str]] = [] + + configured_path = Path(settings.sam_model_path) + if variant_id == DEFAULT_SAM2_MODEL_ID and configured_path.is_file(): + candidates.append((settings.sam_model_config, str(configured_path))) + + candidates.extend([ + (variant.config, str(models_dir / variant.checkpoint_filename)), + (variant.legacy_config, str(models_dir / variant.legacy_checkpoint_filename)), + ]) + + for config, checkpoint_path in candidates: + if os.path.isfile(checkpoint_path): + return config, checkpoint_path + return candidates[0] + + def _load_model(self, model_id: str | None = None) -> None: + """Load the SAM 2 model and predictor on first use.""" + variant_id = self.normalize_model_id(model_id) + if self._model_loaded.get(variant_id): + return + + if not TORCH_AVAILABLE: + self._last_error[variant_id] = "PyTorch is not installed." + logger.warning("PyTorch not available; skipping SAM2 model load.") + self._model_loaded[variant_id] = True + return + + if not SAM2_AVAILABLE: + self._last_error[variant_id] = "sam2 package is not installed." + logger.warning("SAM2 not available; skipping model load.") + self._model_loaded[variant_id] = True + return + + config, checkpoint_path = self._checkpoint_config(variant_id) + if not os.path.isfile(checkpoint_path): + self._last_error[variant_id] = f"SAM2 checkpoint not found: {checkpoint_path}" + logger.error("SAM checkpoint not found at %s", checkpoint_path) + self._model_loaded[variant_id] = True + return + + try: + device = self._best_device() + model = build_sam2( + config, + checkpoint_path, + device=device, + ) + self._predictors[variant_id] = SAM2ImagePredictor(model) + self._model_loaded[variant_id] = True + self._loaded_device[variant_id] = device + self._last_error[variant_id] = None + logger.info("SAM 2 model %s loaded from %s on %s", variant_id, checkpoint_path, device) + except Exception as exc: # noqa: BLE001 + self._last_error[variant_id] = str(exc) + logger.error("Failed to load SAM 2 model %s: %s", variant_id, exc) + self._model_loaded[variant_id] = True # Prevent repeated load attempts + + def _load_video_model(self, model_id: str | None = None) -> None: + """Load the SAM 2 video predictor on first propagation use.""" + variant_id = self.normalize_model_id(model_id) + if self._video_model_loaded.get(variant_id): + return + + if not TORCH_AVAILABLE: + self._video_last_error[variant_id] = "PyTorch is not installed." + self._video_model_loaded[variant_id] = True + return + if not SAM2_AVAILABLE: + self._video_last_error[variant_id] = "sam2 package is not installed." + self._video_model_loaded[variant_id] = True + return + + config, checkpoint_path = self._checkpoint_config(variant_id) + if not os.path.isfile(checkpoint_path): + self._video_last_error[variant_id] = f"SAM2 checkpoint not found: {checkpoint_path}" + self._video_model_loaded[variant_id] = True + return + + try: + device = self._best_device() + self._video_predictors[variant_id] = build_sam2_video_predictor( + config, + checkpoint_path, + device=device, + ) + self._video_model_loaded[variant_id] = True + self._loaded_device[variant_id] = device + self._video_last_error[variant_id] = None + logger.info("SAM 2 video predictor %s loaded from %s on %s", variant_id, checkpoint_path, device) + except Exception as exc: # noqa: BLE001 + self._video_last_error[variant_id] = str(exc) + self._video_model_loaded[variant_id] = True + logger.error("Failed to load SAM 2 video predictor %s: %s", variant_id, exc) + + def _best_device(self) -> str: + if TORCH_AVAILABLE and torch is not None and torch.cuda.is_available(): + return "cuda" + return "cpu" + + def _ensure_ready(self, model_id: str | None = None) -> bool: + """Ensure the model is loaded; return whether it is usable.""" + variant_id = self.normalize_model_id(model_id) + self._load_model(variant_id) + return SAM2_AVAILABLE and self._predictors.get(variant_id) is not None + + def _ensure_video_ready(self, model_id: str | None = None) -> bool: + """Ensure the video predictor is loaded; return whether it is usable.""" + variant_id = self.normalize_model_id(model_id) + self._load_video_model(variant_id) + return SAM2_AVAILABLE and self._video_predictors.get(variant_id) is not None + + def status(self, model_id: str | None = None) -> dict: + """Return lightweight, real runtime status without forcing model load.""" + variant_id = self.normalize_model_id(model_id) + variant = SAM2_VARIANTS[variant_id] + _, checkpoint_path = self._checkpoint_config(variant_id) + checkpoint_exists = os.path.isfile(checkpoint_path) + using_legacy_checkpoint = Path(checkpoint_path).name == variant.legacy_checkpoint_filename + predictor = self._predictors.get(variant_id) + device = self._loaded_device.get(variant_id) or self._best_device() + available = bool(TORCH_AVAILABLE and SAM2_AVAILABLE and checkpoint_exists) + if predictor is not None: + message = f"{variant.label} model loaded and ready." + elif available: + message = f"{variant.label} dependencies and checkpoint are present; model will load on first inference." + if using_legacy_checkpoint: + message += " Using legacy SAM 2 checkpoint fallback." + else: + missing = [] + if not TORCH_AVAILABLE: + missing.append("PyTorch") + if not SAM2_AVAILABLE: + missing.append("sam2 package") + if not checkpoint_exists: + missing.append("checkpoint") + message = f"{variant.label} unavailable: missing {', '.join(missing)}." + last_error = self._last_error.get(variant_id) + if last_error and not predictor: + message = last_error + return { + "id": variant.id, + "label": variant.label, + "available": available, + "loaded": predictor is not None, + "device": device, + "supports": ["point", "box", "interactive", "auto", "propagate"], + "message": message, + "package_available": SAM2_AVAILABLE, + "checkpoint_exists": checkpoint_exists, + "checkpoint_path": checkpoint_path, + "python_ok": True, + "torch_ok": TORCH_AVAILABLE, + "cuda_required": False, + } + + # ----------------------------------------------------------------------- + # Public API + # ----------------------------------------------------------------------- + def predict_points( + self, + model_id: str | None, + image: np.ndarray, + points: list[list[float]], + labels: list[int], + ) -> tuple[list[list[list[float]]], list[float]]: + """Run point-prompt segmentation. + + Args: + image: HWC numpy array (uint8). + points: List of [x, y] normalized coordinates (0-1). + labels: 1 for foreground, 0 for background. + + Returns: + Tuple of (polygons, scores). + """ + variant_id = self.normalize_model_id(model_id) + if not self._ensure_ready(variant_id): + logger.warning("SAM2 not ready; returning dummy masks.") + return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5] + + try: + predictor = self._predictors[variant_id] + h, w = image.shape[:2] + pts = np.array([[p[0] * w, p[1] * h] for p in points], dtype=np.float32) + lbls = np.array(labels, dtype=np.int32) + + with torch.inference_mode(): # type: ignore[name-defined] + predictor.set_image(image) + masks, scores, _ = predictor.predict( + point_coords=pts, + point_labels=lbls, + multimask_output=False, + ) + + polygons = [] + for m in masks: + poly = self._mask_to_polygon(m) + if poly: + polygons.append(poly) + + return polygons, scores.tolist() + except Exception as exc: # noqa: BLE001 + logger.error("SAM2 point prediction failed: %s", exc) + return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5] + + def predict_box( + self, + model_id: str | None, + image: np.ndarray, + box: list[float], + ) -> tuple[list[list[list[float]]], list[float]]: + """Run box-prompt segmentation. + + Args: + image: HWC numpy array (uint8). + box: [x1, y1, x2, y2] normalized coordinates. + + Returns: + Tuple of (polygons, scores). + """ + variant_id = self.normalize_model_id(model_id) + if not self._ensure_ready(variant_id): + logger.warning("SAM2 not ready; returning dummy masks.") + return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5] + + try: + predictor = self._predictors[variant_id] + h, w = image.shape[:2] + bbox = np.array( + [box[0] * w, box[1] * h, box[2] * w, box[3] * h], + dtype=np.float32, + ) + + with torch.inference_mode(): # type: ignore[name-defined] + predictor.set_image(image) + masks, scores, _ = predictor.predict( + box=bbox[None, :], + multimask_output=False, + ) + + polygons = [] + for m in masks: + poly = self._mask_to_polygon(m) + if poly: + polygons.append(poly) + + return polygons, scores.tolist() + except Exception as exc: # noqa: BLE001 + logger.error("SAM2 box prediction failed: %s", exc) + return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5] + + def predict_interactive( + self, + model_id: str | None, + image: np.ndarray, + box: list[float] | None, + points: list[list[float]], + labels: list[int], + ) -> tuple[list[list[list[float]]], list[float]]: + """Run combined box and point prompt segmentation for refinement.""" + variant_id = self.normalize_model_id(model_id) + if not self._ensure_ready(variant_id): + logger.warning("SAM2 not ready; returning dummy masks.") + return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5] + + try: + predictor = self._predictors[variant_id] + h, w = image.shape[:2] + bbox = None + if box: + bbox = np.array( + [box[0] * w, box[1] * h, box[2] * w, box[3] * h], + dtype=np.float32, + ) + pts = None + lbls = None + if points: + pts = np.array([[p[0] * w, p[1] * h] for p in points], dtype=np.float32) + lbls = np.array(labels, dtype=np.int32) + + with torch.inference_mode(): # type: ignore[name-defined] + predictor.set_image(image) + masks, scores, _ = predictor.predict( + point_coords=pts, + point_labels=lbls, + box=bbox, + multimask_output=False, + ) + + polygons = [] + for m in masks: + poly = self._mask_to_polygon(m) + if poly: + polygons.append(poly) + + return polygons, scores.tolist() + except Exception as exc: # noqa: BLE001 + logger.error("SAM2 interactive prediction failed: %s", exc) + return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5] + + def predict_auto(self, model_id: str | None, image: np.ndarray) -> tuple[list[list[list[float]]], list[float]]: + """Run automatic mask generation (grid of points). + + Args: + image: HWC numpy array (uint8). + + Returns: + Tuple of (polygons, scores). + """ + variant_id = self.normalize_model_id(model_id) + if not self._ensure_ready(variant_id): + logger.warning("SAM2 not ready; returning dummy masks.") + return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5] + + try: + predictor = self._predictors[variant_id] + with torch.inference_mode(): # type: ignore[name-defined] + predictor.set_image(image) + # Generate a uniform 16x16 grid of point prompts + h, w = image.shape[:2] + grid = np.mgrid[0:1:17j, 0:1:17j].reshape(2, -1).T + pts = grid * np.array([w, h]) + lbls = np.ones(pts.shape[0], dtype=np.int32) + + masks, scores, _ = predictor.predict( + point_coords=pts, + point_labels=lbls, + multimask_output=False, + ) + + polygons = [] + for m in masks[:1]: + poly = self._mask_to_polygon(m) + if poly: + polygons.append(poly) + + return polygons, scores[:1].tolist() + except Exception as exc: # noqa: BLE001 + logger.error("SAM2 auto prediction failed: %s", exc) + return self._dummy_polygons(image.shape[1], image.shape[0]), [0.5] + + def propagate_video( + self, + model_id: str | None, + frame_paths: list[str], + source_frame_index: int, + seed: dict, + direction: str = "forward", + max_frames: int | None = None, + ) -> list[dict]: + """Propagate one seed mask across a prepared frame directory with SAM 2 video.""" + variant_id = self.normalize_model_id(model_id) + if not self._ensure_video_ready(variant_id): + raise RuntimeError(self._video_last_error.get(variant_id) or self.status(variant_id)["message"]) + video_predictor = self._video_predictors[variant_id] + if not frame_paths: + return [] + if source_frame_index < 0 or source_frame_index >= len(frame_paths): + raise ValueError("source_frame_index is outside the frame sequence.") + + import cv2 + + source_image = cv2.imread(frame_paths[source_frame_index]) + if source_image is None: + raise RuntimeError("Failed to decode source frame for SAM 2 propagation.") + height, width = source_image.shape[:2] + seed_mask = self._polygons_to_mask(seed.get("polygons") or [], width, height, seed.get("holes") or []) + if not seed_mask.any(): + bbox = seed.get("bbox") + if isinstance(bbox, list) and len(bbox) == 4: + seed_mask = self._bbox_to_mask(bbox, width, height) + if not seed_mask.any(): + raise ValueError("SAM 2 propagation requires a non-empty seed polygon or bbox.") + + inference_state = video_predictor.init_state( + video_path=os.path.dirname(frame_paths[0]), + offload_video_to_cpu=True, + offload_state_to_cpu=True, + ) + video_predictor.add_new_mask( + inference_state, + frame_idx=source_frame_index, + obj_id=1, + mask=seed_mask, + ) + + results: dict[int, dict] = {} + + def collect(reverse: bool) -> None: + for out_frame_idx, out_obj_ids, out_mask_logits in video_predictor.propagate_in_video( + inference_state, + start_frame_idx=source_frame_index, + max_frame_num_to_track=max_frames, + reverse=reverse, + ): + masks = out_mask_logits + if hasattr(masks, "detach"): + masks = masks.detach().cpu().numpy() + masks = np.asarray(masks) + if masks.ndim == 4: + masks = masks[:, 0] + polygons = [] + holes = [] + scores = [] + for mask in masks: + mask_polygons, mask_holes = self._mask_to_polygon_data(mask > 0) + for polygon_index, polygon in enumerate(mask_polygons): + polygons.append(polygon) + holes.append(mask_holes[polygon_index] if polygon_index < len(mask_holes) else []) + scores.append(1.0) + results[int(out_frame_idx)] = { + "frame_index": int(out_frame_idx), + "polygons": polygons, + "holes": holes, + "scores": scores, + "object_ids": [int(obj_id) for obj_id in list(out_obj_ids)], + } + + normalized_direction = direction.lower() + if normalized_direction in {"forward", "both"}: + collect(reverse=False) + if normalized_direction in {"backward", "both"}: + collect(reverse=True) + + try: + video_predictor.reset_state(inference_state) + except Exception: # noqa: BLE001 + pass + return [results[index] for index in sorted(results)] + + # ----------------------------------------------------------------------- + # Helpers + # ----------------------------------------------------------------------- + @staticmethod + def _mask_to_polygon(mask: np.ndarray) -> list[list[float]]: + """Convert a binary mask to a normalized polygon.""" + polygons, _holes = SAM2Engine._mask_to_polygon_data(mask) + return polygons[0] if polygons else [] + + @staticmethod + def _mask_to_polygon_data(mask: np.ndarray) -> tuple[list[list[list[float]]], list[list[list[list[float]]]]]: + """Convert a binary mask to normalized outer polygons and aligned hole rings.""" + import cv2 + + if mask.dtype != np.uint8: + mask = (mask > 0).astype(np.uint8) + contours, hierarchy = cv2.findContours(mask, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE) + h, w = mask.shape[:2] + if hierarchy is None: + return [], [] + + def contour_to_polygon(contour: np.ndarray) -> list[list[float]]: + if len(contour) < 3: + return [] + return [[float(pt[0][0]) / w, float(pt[0][1]) / h] for pt in contour] + + hierarchy_rows = hierarchy[0] + outer_indices = [ + index for index, row in enumerate(hierarchy_rows) + if int(row[3]) < 0 and len(contours[index]) >= 3 + ] + outer_indices.sort(key=lambda index: cv2.contourArea(contours[index]), reverse=True) + + polygons: list[list[list[float]]] = [] + holes: list[list[list[list[float]]]] = [] + for outer_index in outer_indices: + outer = contour_to_polygon(contours[outer_index]) + if not outer: + continue + child_index = int(hierarchy_rows[outer_index][2]) + hole_group: list[list[list[float]]] = [] + while child_index >= 0: + hole = contour_to_polygon(contours[child_index]) + if hole: + hole_group.append(hole) + child_index = int(hierarchy_rows[child_index][0]) + polygons.append(outer) + holes.append(hole_group) + return polygons, holes + + @staticmethod + def _dummy_polygons(w: int, h: int) -> list[list[list[float]]]: + """Return a dummy rectangle polygon for fallback mode.""" + return [ + [ + [0.25, 0.25], + [0.75, 0.25], + [0.75, 0.75], + [0.25, 0.75], + ] + ] + + @staticmethod + def _polygons_to_mask( + polygons: list[list[list[float]]], + width: int, + height: int, + holes_by_polygon: list[list[list[list[float]]]] | None = None, + ) -> np.ndarray: + import cv2 + + mask = np.zeros((height, width), dtype=np.uint8) + for polygon_index, polygon in enumerate(polygons): + if len(polygon) < 3: + continue + pts = np.array( + [ + [ + int(round(min(max(float(x), 0.0), 1.0) * max(width - 1, 1))), + int(round(min(max(float(y), 0.0), 1.0) * max(height - 1, 1))), + ] + for x, y in polygon + ], + dtype=np.int32, + ) + cv2.fillPoly(mask, [pts], 1) + holes = holes_by_polygon[polygon_index] if holes_by_polygon and polygon_index < len(holes_by_polygon) else [] + for hole in holes: + if len(hole) < 3: + continue + hole_pts = np.array( + [ + [ + int(round(min(max(float(x), 0.0), 1.0) * max(width - 1, 1))), + int(round(min(max(float(y), 0.0), 1.0) * max(height - 1, 1))), + ] + for x, y in hole + ], + dtype=np.int32, + ) + cv2.fillPoly(mask, [hole_pts], 0) + return mask.astype(bool) + + @staticmethod + def _bbox_to_mask(bbox: list[float], width: int, height: int) -> np.ndarray: + x, y, w, h = [min(max(float(value), 0.0), 1.0) for value in bbox] + left = int(round(x * max(width - 1, 1))) + top = int(round(y * max(height - 1, 1))) + right = int(round(min(x + w, 1.0) * max(width - 1, 1))) + bottom = int(round(min(y + h, 1.0) * max(height - 1, 1))) + mask = np.zeros((height, width), dtype=bool) + mask[top:max(bottom + 1, top + 1), left:max(right + 1, left + 1)] = True + return mask + + +# Singleton instance +sam_engine = SAM2Engine() diff --git a/backend/services/sam3_engine.py b/backend/services/sam3_engine.py new file mode 100644 index 0000000..da90f4c --- /dev/null +++ b/backend/services/sam3_engine.py @@ -0,0 +1,447 @@ +"""SAM 3 engine adapter and runtime status. + +The official facebookresearch/sam3 package currently targets Python 3.12+ +and CUDA-capable PyTorch. This adapter reports those requirements honestly and +only performs inference when the local runtime can actually import and execute +the package. +""" + +from __future__ import annotations + +import importlib.util +import json +import logging +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Any + +import numpy as np +from PIL import Image + +from config import settings +from services.sam2_engine import SAM2Engine + +logger = logging.getLogger(__name__) + +try: + import torch + + TORCH_AVAILABLE = True +except Exception as exc: # noqa: BLE001 + TORCH_AVAILABLE = False + torch = None # type: ignore[assignment] + logger.warning("PyTorch import failed (%s). SAM3 will be unavailable.", exc) + +SAM3_PACKAGE_AVAILABLE = importlib.util.find_spec("sam3") is not None + + +class SAM3Engine: + """Lazy SAM 3 image inference adapter.""" + + def __init__(self) -> None: + self._model: Any | None = None + self._processor: Any | None = None + self._model_loaded = False + self._last_error: str | None = None + self._external_status_cache: dict[str, Any] | None = None + self._external_status_checked_at = 0.0 + + def _python_ok(self) -> bool: + return sys.version_info >= (3, 12) + + def _gpu_ok(self) -> bool: + return bool(TORCH_AVAILABLE and torch is not None and torch.cuda.is_available()) + + def _checkpoint_path(self) -> str | None: + path = settings.sam3_checkpoint_path.strip() + return path if path else None + + def _checkpoint_exists(self) -> bool: + path = self._checkpoint_path() + return bool(path and os.path.isfile(path)) + + def _can_load(self) -> bool: + return bool( + SAM3_PACKAGE_AVAILABLE + and TORCH_AVAILABLE + and self._python_ok() + and self._gpu_ok() + and self._checkpoint_exists() + ) + + def _worker_path(self) -> Path: + return Path(__file__).with_name("sam3_external_worker.py") + + def _external_python_exists(self) -> bool: + return bool(settings.sam3_external_enabled and os.path.isfile(settings.sam3_external_python)) + + def _external_status(self, force: bool = False) -> dict[str, Any]: + now = time.monotonic() + if ( + not force + and self._external_status_cache is not None + and now - self._external_status_checked_at < settings.sam3_status_cache_seconds + ): + return self._external_status_cache + + if not settings.sam3_external_enabled: + status = { + "available": False, + "package_available": False, + "python_ok": False, + "torch_ok": False, + "cuda_available": False, + "device": "unavailable", + "message": "SAM 3 external runtime is disabled.", + } + elif not self._external_python_exists(): + status = { + "available": False, + "package_available": False, + "python_ok": False, + "torch_ok": False, + "cuda_available": False, + "device": "unavailable", + "message": f"SAM 3 external Python not found: {settings.sam3_external_python}", + } + else: + try: + env = os.environ.copy() + env["SAM3_MODEL_VERSION"] = settings.sam3_model_version + if self._checkpoint_path(): + env["SAM3_CHECKPOINT_PATH"] = self._checkpoint_path() or "" + completed = subprocess.run( + [settings.sam3_external_python, str(self._worker_path()), "--status"], + capture_output=True, + text=True, + timeout=min(settings.sam3_timeout_seconds, 30), + check=False, + env=env, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + status = { + "available": False, + "package_available": False, + "python_ok": False, + "torch_ok": False, + "cuda_available": False, + "device": "unavailable", + "message": f"SAM 3 external status failed: {detail}", + } + else: + status = json.loads(completed.stdout) + except Exception as exc: # noqa: BLE001 + status = { + "available": False, + "package_available": False, + "python_ok": False, + "torch_ok": False, + "cuda_available": False, + "device": "unavailable", + "message": f"SAM 3 external status failed: {exc}", + } + + self._external_status_cache = status + self._external_status_checked_at = now + return status + + def _load_model(self) -> None: + if self._model_loaded: + return + if not self._can_load(): + self._last_error = self._status_message() + self._model_loaded = True + return + + try: + from sam3.model.sam3_image_processor import Sam3Processor + from sam3.model_builder import build_sam3_image_model + + self._model = build_sam3_image_model( + checkpoint_path=self._checkpoint_path(), + load_from_HF=False, + ) + self._processor = Sam3Processor(self._model) + self._model_loaded = True + self._last_error = None + logger.info("SAM 3 image model loaded with version setting %s", settings.sam3_model_version) + except Exception as exc: # noqa: BLE001 + self._last_error = str(exc) + self._model_loaded = True + logger.error("Failed to load SAM 3 model: %s", exc) + + def _ensure_ready(self) -> bool: + self._load_model() + return self._processor is not None + + def _status_message(self) -> str: + missing = [] + if not SAM3_PACKAGE_AVAILABLE: + missing.append("sam3 package") + if not self._python_ok(): + missing.append("Python 3.12+ runtime") + if not TORCH_AVAILABLE: + missing.append("PyTorch") + if not self._gpu_ok(): + missing.append("CUDA GPU") + if not self._checkpoint_exists(): + missing.append(f"local checkpoint ({settings.sam3_checkpoint_path})") + if missing: + return f"SAM 3 unavailable: missing {', '.join(missing)}." + return "SAM 3 dependencies are present; model will load on first inference." + + def status(self) -> dict: + external_status = self._external_status() + available = bool(self._can_load() or external_status.get("available")) + external_ready = bool(external_status.get("available")) + message = self._last_error or self._status_message() + if self._processor is not None: + message = "SAM 3 model loaded and ready." + elif external_ready: + message = "SAM 3 external runtime is ready; local checkpoint will load in the helper process on inference." + elif external_status.get("message") and not self._can_load(): + message = str(external_status["message"]) + return { + "id": "sam3", + "label": "SAM 3", + "available": available, + "loaded": self._processor is not None, + "device": "cuda" if self._gpu_ok() else str(external_status.get("device", "unavailable")), + "supports": ["semantic", "box", "video_track"], + "message": message, + "package_available": bool(SAM3_PACKAGE_AVAILABLE or external_status.get("package_available")), + "checkpoint_exists": bool(self._checkpoint_exists() or external_status.get("checkpoint_access")), + "checkpoint_path": self._checkpoint_path() or f"official/HuggingFace ({settings.sam3_model_version})", + "python_ok": bool(self._python_ok() or external_status.get("python_ok")), + "torch_ok": bool(TORCH_AVAILABLE or external_status.get("torch_ok")), + "cuda_required": True, + "external_available": external_ready, + "external_python": settings.sam3_external_python if settings.sam3_external_enabled else None, + } + + def _xyxy_to_cxcywh(self, box: list[float]) -> list[float]: + if len(box) != 4: + raise ValueError("SAM 3 box prompt requires [x1, y1, x2, y2].") + x1, y1, x2, y2 = [min(max(float(value), 0.0), 1.0) for value in box] + left, right = sorted([x1, x2]) + top, bottom = sorted([y1, y2]) + width = max(right - left, 1e-6) + height = max(bottom - top, 1e-6) + return [left + width / 2, top + height / 2, width, height] + + def _prediction_to_polygons(self, output: Any) -> tuple[list[list[list[float]]], list[float]]: + masks = output.get("masks", []) + scores = output.get("scores", []) + polygons = [] + for mask in masks: + if hasattr(mask, "detach"): + mask = mask.detach().cpu().numpy() + if mask.ndim == 3: + mask = mask[0] + poly = SAM2Engine._mask_to_polygon(mask) + if poly: + polygons.append(poly) + + if hasattr(scores, "detach"): + scores = scores.detach().cpu().tolist() + elif hasattr(scores, "tolist"): + scores = scores.tolist() + return polygons, list(scores) + + def _predict_external( + self, + image: np.ndarray, + prompt_type: str, + *, + text: str = "", + box: list[float] | None = None, + confidence_threshold: float | None = None, + ) -> tuple[list[list[list[float]]], list[float]]: + status = self._external_status(force=True) + if not status.get("available"): + raise RuntimeError(status.get("message") or "SAM 3 external runtime is unavailable.") + + with tempfile.TemporaryDirectory(prefix="sam3_") as tmpdir: + tmp_path = Path(tmpdir) + image_path = tmp_path / "image.png" + request_path = tmp_path / "request.json" + Image.fromarray(image).save(image_path) + request_path.write_text( + json.dumps( + { + "image_path": str(image_path), + "prompt_type": prompt_type, + "text": text.strip(), + "box": box, + "model_version": settings.sam3_model_version, + "checkpoint_path": self._checkpoint_path(), + "confidence_threshold": ( + confidence_threshold + if confidence_threshold is not None + else settings.sam3_confidence_threshold + ), + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + env = os.environ.copy() + env["SAM3_MODEL_VERSION"] = settings.sam3_model_version + if self._checkpoint_path(): + env["SAM3_CHECKPOINT_PATH"] = self._checkpoint_path() or "" + completed = subprocess.run( + [settings.sam3_external_python, str(self._worker_path()), "--request", str(request_path)], + capture_output=True, + text=True, + timeout=settings.sam3_timeout_seconds, + check=False, + env=env, + ) + + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + try: + parsed = json.loads(detail) + detail = parsed.get("error", detail) + except Exception: # noqa: BLE001 + pass + raise RuntimeError(f"SAM 3 external inference failed: {detail}") + + payload = json.loads(completed.stdout) + if payload.get("error"): + raise RuntimeError(str(payload["error"])) + return payload.get("polygons", []), payload.get("scores", []) + + def _predict_semantic_external( + self, + image: np.ndarray, + text: str, + confidence_threshold: float | None = None, + ) -> tuple[list[list[list[float]]], list[float]]: + return self._predict_external( + image, + "semantic", + text=text, + confidence_threshold=confidence_threshold, + ) + + def _predict_box_external(self, image: np.ndarray, box: list[float]) -> tuple[list[list[list[float]]], list[float]]: + return self._predict_external(image, "box", box=box) + + def _propagate_video_external( + self, + frame_paths: list[str], + source_frame_index: int, + seed: dict[str, Any], + direction: str, + max_frames: int | None, + ) -> list[dict[str, Any]]: + status = self._external_status(force=True) + if not status.get("available"): + raise RuntimeError(status.get("message") or "SAM 3 external runtime is unavailable.") + if not frame_paths: + return [] + + with tempfile.TemporaryDirectory(prefix="sam3_video_") as tmpdir: + request_path = Path(tmpdir) / "request.json" + request_path.write_text( + json.dumps( + { + "prompt_type": "video_track", + "frame_dir": str(Path(frame_paths[0]).parent), + "source_frame_index": source_frame_index, + "seed": seed, + "direction": direction, + "max_frames": max_frames, + "model_version": settings.sam3_model_version, + "checkpoint_path": self._checkpoint_path(), + "confidence_threshold": settings.sam3_confidence_threshold, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + env = os.environ.copy() + env["SAM3_MODEL_VERSION"] = settings.sam3_model_version + if self._checkpoint_path(): + env["SAM3_CHECKPOINT_PATH"] = self._checkpoint_path() or "" + completed = subprocess.run( + [settings.sam3_external_python, str(self._worker_path()), "--request", str(request_path)], + capture_output=True, + text=True, + timeout=settings.sam3_timeout_seconds, + check=False, + env=env, + ) + + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + try: + parsed = json.loads(detail) + detail = parsed.get("error", detail) + except Exception: # noqa: BLE001 + pass + raise RuntimeError(f"SAM 3 external video tracking failed: {detail}") + + payload = json.loads(completed.stdout) + if payload.get("error"): + raise RuntimeError(str(payload["error"])) + return payload.get("frames", []) + + def predict_semantic( + self, + image: np.ndarray, + text: str, + confidence_threshold: float | None = None, + ) -> tuple[list[list[list[float]]], list[float]]: + if not text.strip(): + raise ValueError("SAM 3 semantic prompt requires non-empty text.") + if not self._can_load() and self._external_status().get("available"): + return self._predict_semantic_external(image, text, confidence_threshold=confidence_threshold) + if not self._ensure_ready(): + raise RuntimeError(self.status()["message"]) + + pil_image = Image.fromarray(image) + with torch.inference_mode(): # type: ignore[union-attr] + state = self._processor.set_image(pil_image) + output = self._processor.set_text_prompt(state=state, prompt=text.strip()) + + return self._prediction_to_polygons(output) + + def predict_points(self, *_args: Any, **_kwargs: Any) -> tuple[list[list[list[float]]], list[float]]: + raise NotImplementedError("This backend currently exposes SAM 3 semantic text inference; use SAM 2 for point prompts.") + + def predict_box(self, image: np.ndarray, box: list[float]) -> tuple[list[list[list[float]]], list[float]]: + if not self._can_load() and self._external_status().get("available"): + return self._predict_box_external(image, box) + if not self._ensure_ready(): + raise RuntimeError(self.status()["message"]) + + pil_image = Image.fromarray(image) + with torch.inference_mode(): # type: ignore[union-attr] + state = self._processor.set_image(pil_image) + output = self._processor.add_geometric_prompt( + state=state, + box=self._xyxy_to_cxcywh(box), + label=True, + ) + + return self._prediction_to_polygons(output) + + def propagate_video( + self, + frame_paths: list[str], + source_frame_index: int, + seed: dict[str, Any], + direction: str = "forward", + max_frames: int | None = None, + ) -> list[dict[str, Any]]: + return self._propagate_video_external(frame_paths, source_frame_index, seed, direction, max_frames) + + +sam3_engine = SAM3Engine() diff --git a/backend/services/sam3_external_worker.py b/backend/services/sam3_external_worker.py new file mode 100644 index 0000000..ffceb07 --- /dev/null +++ b/backend/services/sam3_external_worker.py @@ -0,0 +1,343 @@ +"""Standalone SAM 3 helper for the dedicated Python 3.12 runtime. + +The main FastAPI backend can keep running in the existing Python 3.11/SAM 2 +environment while this helper is executed with a separate conda env that meets +SAM 3's stricter runtime requirements. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys +from pathlib import Path +from typing import Any + +import numpy as np +from PIL import Image + + +def _torch_status() -> tuple[bool, str | None, str | None, str | None]: + try: + import torch + + cuda_available = bool(torch.cuda.is_available()) + return ( + cuda_available, + getattr(torch, "__version__", None), + getattr(torch.version, "cuda", None), + torch.cuda.get_device_name(0) if cuda_available else None, + ) + except Exception: # noqa: BLE001 + return False, None, None, None + + +def _compact_error(exc: Exception) -> str: + lines = [line.strip() for line in str(exc).splitlines() if line.strip()] + for line in lines: + if "Access to model" in line or "Cannot access gated repo" in line: + return line + return lines[0] if lines else exc.__class__.__name__ + + +def _checkpoint_access(model_version: str) -> tuple[bool, str | None]: + checkpoint_path = os.environ.get("SAM3_CHECKPOINT_PATH", "").strip() + if checkpoint_path: + path = Path(checkpoint_path) + if path.is_file(): + return True, None + return False, f"local checkpoint not found: {checkpoint_path}" + + try: + from huggingface_hub import hf_hub_download + + repo_id = "facebook/sam3.1" if model_version == "sam3.1" else "facebook/sam3" + hf_hub_download(repo_id=repo_id, filename="config.json") + return True, None + except Exception as exc: # noqa: BLE001 + return False, _compact_error(exc) + + +def runtime_status() -> dict[str, Any]: + model_version = os.environ.get("SAM3_MODEL_VERSION", "sam3") + checkpoint_path = os.environ.get("SAM3_CHECKPOINT_PATH", "").strip() or None + package_error = None + package_available = importlib.util.find_spec("sam3") is not None + if package_available: + try: + import sam3 # noqa: F401 + except Exception as exc: # noqa: BLE001 + package_available = False + package_error = str(exc) + cuda_available, torch_version, cuda_version, device_name = _torch_status() + python_ok = sys.version_info >= (3, 12) + checkpoint_access = False + checkpoint_error = None + if package_available: + checkpoint_access, checkpoint_error = _checkpoint_access(model_version) + available = bool(package_available and python_ok and cuda_available and checkpoint_access) + missing = [] + if not python_ok: + missing.append("Python 3.12+ runtime") + if not package_available: + missing.append(f"sam3 package ({package_error})" if package_error else "sam3 package") + if torch_version is None: + missing.append("PyTorch") + if not cuda_available: + missing.append("CUDA GPU") + if package_available and not checkpoint_access: + missing.append(f"Hugging Face checkpoint access ({checkpoint_error})") + return { + "available": available, + "package_available": package_available, + "checkpoint_access": checkpoint_access, + "checkpoint_path": checkpoint_path or f"official/HuggingFace ({model_version})", + "python_ok": python_ok, + "torch_ok": torch_version is not None, + "torch_version": torch_version, + "cuda_version": cuda_version, + "cuda_available": cuda_available, + "device": "cuda" if cuda_available else "unavailable", + "device_name": device_name, + "message": ( + "SAM 3 external runtime is ready." + if available + else f"SAM 3 external runtime unavailable: missing {', '.join(missing)}." + ), + } + + +def _mask_to_polygon(mask: np.ndarray) -> list[list[float]]: + import cv2 + + if mask.dtype != np.uint8: + mask = (mask > 0).astype(np.uint8) + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + height, width = mask.shape[:2] + largest = [] + for contour in contours: + if len(contour) > len(largest): + largest = contour + if len(largest) < 3: + return [] + return [[float(point[0][0]) / width, float(point[0][1]) / height] for point in largest] + + +def _to_numpy(value: Any) -> np.ndarray: + if hasattr(value, "detach"): + value = value.detach() + if hasattr(value, "is_floating_point") and value.is_floating_point(): + value = value.float() + value = value.cpu().numpy() + elif hasattr(value, "cpu"): + value = value.cpu() + if hasattr(value, "is_floating_point") and value.is_floating_point(): + value = value.float() + value = value.numpy() + return np.asarray(value) + + +def _xyxy_to_cxcywh(box: list[float]) -> list[float]: + if len(box) != 4: + raise ValueError("SAM 3 box prompt requires [x1, y1, x2, y2].") + x1, y1, x2, y2 = [min(max(float(value), 0.0), 1.0) for value in box] + left, right = sorted([x1, x2]) + top, bottom = sorted([y1, y2]) + width = max(right - left, 1e-6) + height = max(bottom - top, 1e-6) + return [left + width / 2, top + height / 2, width, height] + + +def _bbox_from_seed(seed: dict[str, Any]) -> list[float]: + bbox = seed.get("bbox") + if isinstance(bbox, list) and len(bbox) == 4: + return [min(max(float(value), 0.0), 1.0) for value in bbox] + + polygons = seed.get("polygons") or [] + points = [point for polygon in polygons for point in polygon if len(point) >= 2] + if not points: + raise ValueError("SAM 3 video tracking requires seed bbox or polygons.") + xs = [min(max(float(point[0]), 0.0), 1.0) for point in points] + ys = [min(max(float(point[1]), 0.0), 1.0) for point in points] + left, right = min(xs), max(xs) + top, bottom = min(ys), max(ys) + return [left, top, max(right - left, 1e-6), max(bottom - top, 1e-6)] + + +def _video_outputs_to_response(outputs: dict[str, Any]) -> dict[str, Any]: + masks = _to_numpy(outputs.get("out_binary_masks", [])) + scores = _to_numpy(outputs.get("out_probs", [])) + obj_ids = _to_numpy(outputs.get("out_obj_ids", [])) + if masks.ndim == 4: + masks = masks[:, 0] + elif masks.ndim == 2: + masks = masks[None, ...] + + polygons = [] + out_scores = [] + out_ids = [] + for index, mask in enumerate(masks): + polygon = _mask_to_polygon(mask) + if polygon: + polygons.append(polygon) + out_scores.append(float(scores[index]) if scores.size > index else 1.0) + out_ids.append(int(obj_ids[index]) if obj_ids.size > index else index + 1) + return {"polygons": polygons, "scores": out_scores, "object_ids": out_ids} + + +def _prediction_to_response(output: dict[str, Any]) -> dict[str, Any]: + masks = _to_numpy(output.get("masks", [])) + scores = _to_numpy(output.get("scores", [])) + if masks.ndim == 2: + masks = masks[None, :, :] + elif masks.ndim == 4: + masks = masks[:, 0] + elif masks.ndim == 3 and masks.shape[0] == 1: + masks = masks[None, 0] + + polygons = [] + for mask in masks: + polygon = _mask_to_polygon(mask) + if polygon: + polygons.append(polygon) + + return { + "polygons": polygons, + "scores": scores.astype(float).tolist() if scores.size else [], + } + + +def predict_video(request_path: Path) -> dict[str, Any]: + import torch + from sam3.model_builder import build_sam3_video_predictor + + payload = json.loads(request_path.read_text(encoding="utf-8")) + frame_dir = Path(payload["frame_dir"]) + source_frame_index = int(payload.get("source_frame_index", 0)) + seed = payload.get("seed") or {} + direction = str(payload.get("direction") or "forward").lower() + max_frames = payload.get("max_frames") + max_frames = int(max_frames) if max_frames else None + checkpoint_path = str(payload.get("checkpoint_path") or os.environ.get("SAM3_CHECKPOINT_PATH", "")).strip() + threshold = float(payload.get("confidence_threshold", 0.5)) + if direction not in {"forward", "backward", "both"}: + raise ValueError(f"Unsupported propagation direction: {direction}") + + torch.backends.cuda.matmul.allow_tf32 = True + torch.backends.cudnn.allow_tf32 = True + + predictor = build_sam3_video_predictor( + checkpoint_path=checkpoint_path or None, + async_loading_frames=False, + ) + session_id = None + try: + with torch.inference_mode(), torch.autocast("cuda", dtype=torch.bfloat16): + session = predictor.handle_request( + { + "type": "start_session", + "resource_path": str(frame_dir), + "offload_video_to_cpu": True, + "offload_state_to_cpu": True, + } + ) + session_id = session["session_id"] + predictor.handle_request( + { + "type": "add_prompt", + "session_id": session_id, + "frame_index": source_frame_index, + "bounding_boxes": [_bbox_from_seed(seed)], + "bounding_box_labels": [1], + "output_prob_thresh": threshold, + "rel_coordinates": True, + } + ) + frames = [] + for item in predictor.handle_stream_request( + { + "type": "propagate_in_video", + "session_id": session_id, + "propagation_direction": direction, + "start_frame_index": source_frame_index, + "max_frame_num_to_track": max_frames, + "output_prob_thresh": threshold, + } + ): + frame_response = _video_outputs_to_response(item.get("outputs") or {}) + frame_response["frame_index"] = int(item["frame_index"]) + frames.append(frame_response) + finally: + if session_id: + predictor.handle_request({"type": "close_session", "session_id": session_id}) + + return {"frames": frames} + + +def predict(request_path: Path) -> dict[str, Any]: + import torch + from sam3.model.sam3_image_processor import Sam3Processor + from sam3.model_builder import build_sam3_image_model + + payload = json.loads(request_path.read_text(encoding="utf-8")) + if str(payload.get("prompt_type") or "").strip().lower() == "video_track": + return predict_video(request_path) + + image_path = Path(payload["image_path"]) + prompt_type = str(payload.get("prompt_type") or "semantic").strip().lower() + text = str(payload.get("text") or "").strip() + threshold = float(payload.get("confidence_threshold", 0.5)) + checkpoint_path = str(payload.get("checkpoint_path") or os.environ.get("SAM3_CHECKPOINT_PATH", "")).strip() + if prompt_type == "semantic" and not text: + raise ValueError("SAM 3 semantic prompt requires non-empty text.") + if prompt_type not in {"semantic", "box"}: + raise ValueError(f"Unsupported SAM 3 prompt type: {prompt_type}") + + torch.backends.cuda.matmul.allow_tf32 = True + torch.backends.cudnn.allow_tf32 = True + + image = Image.open(image_path).convert("RGB") + with torch.inference_mode(), torch.autocast("cuda", dtype=torch.bfloat16): + model = build_sam3_image_model( + checkpoint_path=checkpoint_path or None, + load_from_HF=not bool(checkpoint_path), + ) + processor = Sam3Processor(model, confidence_threshold=threshold) + state = processor.set_image(image) + if prompt_type == "box": + output = processor.add_geometric_prompt( + state=state, + box=_xyxy_to_cxcywh(payload.get("box") or []), + label=True, + ) + else: + output = processor.set_text_prompt(state=state, prompt=text) + + return _prediction_to_response(output) + + +def main() -> int: + parser = argparse.ArgumentParser(description="SAM 3 external runtime helper") + parser.add_argument("--status", action="store_true") + parser.add_argument("--request", type=Path) + args = parser.parse_args() + + try: + if args.status: + print(json.dumps(runtime_status(), ensure_ascii=False)) + return 0 + if args.request: + print(json.dumps(predict(args.request), ensure_ascii=False)) + return 0 + parser.error("Use --status or --request") + except Exception as exc: # noqa: BLE001 + print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr) + return 1 + + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/services/sam_registry.py b/backend/services/sam_registry.py new file mode 100644 index 0000000..983b947 --- /dev/null +++ b/backend/services/sam_registry.py @@ -0,0 +1,130 @@ +"""Model registry for SAM runtimes and GPU status.""" + +from __future__ import annotations + +from typing import Any + +from config import settings +from services.sam2_engine import DEFAULT_SAM2_MODEL_ID, TORCH_AVAILABLE, sam_engine as sam2_engine + +# SAM 3 integration is intentionally disabled for the current product flow. +# The source files are kept in the repository so the integration can be +# restored later, but the active registry only exposes SAM 2. +# from services.sam3_engine import sam3_engine + +try: + import torch +except Exception: # noqa: BLE001 + torch = None # type: ignore[assignment] + + +class ModelUnavailableError(RuntimeError): + """Raised when a selected model cannot run in this environment.""" + + +class SAMRegistry: + """Dispatch predictions to the selected SAM backend.""" + + def __init__(self) -> None: + self._engines = { + "sam2": sam2_engine, + # "sam3": sam3_engine, + } + + def normalize_model_id(self, model_id: str | None) -> str: + selected = (model_id or settings.sam_default_model or DEFAULT_SAM2_MODEL_ID).lower() + if self._engines["sam2"].is_sam2_model(selected): + return self._engines["sam2"].normalize_model_id(selected) + if selected not in self._engines: + raise ValueError(f"Unsupported model: {model_id}") + return selected + + def runtime_status(self, selected_model: str | None = None) -> dict[str, Any]: + selected = self.normalize_model_id(selected_model) + return { + "selected_model": selected, + "gpu": self.gpu_status(), + "models": [sam2_engine.status(model_id) for model_id in sam2_engine.variant_ids()], + } + + def gpu_status(self) -> dict[str, Any]: + cuda_available = bool(TORCH_AVAILABLE and torch is not None and torch.cuda.is_available()) + return { + "available": cuda_available, + "device": "cuda" if cuda_available else "cpu", + "name": torch.cuda.get_device_name(0) if cuda_available else None, + "torch_available": bool(TORCH_AVAILABLE), + "torch_version": getattr(torch, "__version__", None) if torch is not None else None, + "cuda_version": getattr(torch.version, "cuda", None) if torch is not None else None, + } + + def _engine(self, model_id: str | None) -> Any: + normalized = self.normalize_model_id(model_id) + if self._engines["sam2"].is_sam2_model(normalized): + return self._engines["sam2"] + return self._engines[normalized] + + def _ensure_available(self, model_id: str | None) -> Any: + normalized = self.normalize_model_id(model_id) + engine = self._engine(model_id) + status = engine.status(normalized) if engine is sam2_engine else engine.status() + if not status["available"]: + raise ModelUnavailableError(status["message"]) + return engine + + def predict_points(self, model_id: str | None, image: Any, points: list[list[float]], labels: list[int]): + model = self.normalize_model_id(model_id) + return self._ensure_available(model).predict_points(model, image, points, labels) + + def predict_box(self, model_id: str | None, image: Any, box: list[float]): + model = self.normalize_model_id(model_id) + return self._ensure_available(model).predict_box(model, image, box) + + def predict_interactive( + self, + model_id: str | None, + image: Any, + box: list[float] | None, + points: list[list[float]], + labels: list[int], + ): + model = self.normalize_model_id(model_id) + if not sam2_engine.is_sam2_model(model): + raise NotImplementedError("Interactive box + point refinement is currently supported by SAM 2.") + return self._ensure_available(model).predict_interactive(model, image, box, points, labels) + + def predict_auto(self, model_id: str | None, image: Any): + model = self.normalize_model_id(model_id) + return self._ensure_available(model).predict_auto(model, image) + + def predict_semantic( + self, + model_id: str | None, + image: Any, + text: str, + confidence_threshold: float | None = None, + ): + self.normalize_model_id(model_id) + raise NotImplementedError("Semantic text prompting is disabled; use SAM 2 point or box prompts.") + + def propagate_video( + self, + model_id: str | None, + frame_paths: list[str], + source_frame_index: int, + seed: dict[str, Any], + direction: str, + max_frames: int | None, + ): + model = self.normalize_model_id(model_id) + return self._ensure_available(model).propagate_video( + model, + frame_paths, + source_frame_index, + seed, + direction=direction, + max_frames=max_frames, + ) + + +sam_registry = SAMRegistry() diff --git a/backend/statuses.py b/backend/statuses.py new file mode 100644 index 0000000..8d45153 --- /dev/null +++ b/backend/statuses.py @@ -0,0 +1,15 @@ +"""Shared status constants used across backend project/task flows.""" + +PROJECT_STATUS_PENDING = "pending" +PROJECT_STATUS_PARSING = "parsing" +PROJECT_STATUS_READY = "ready" +PROJECT_STATUS_ERROR = "error" + +TASK_STATUS_QUEUED = "queued" +TASK_STATUS_RUNNING = "running" +TASK_STATUS_SUCCESS = "success" +TASK_STATUS_FAILED = "failed" +TASK_STATUS_CANCELLED = "cancelled" + +TASK_ACTIVE_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING} +TASK_TERMINAL_STATUSES = {TASK_STATUS_SUCCESS, TASK_STATUS_FAILED, TASK_STATUS_CANCELLED} diff --git a/backend/worker_tasks.py b/backend/worker_tasks.py new file mode 100644 index 0000000..5ef3319 --- /dev/null +++ b/backend/worker_tasks.py @@ -0,0 +1,36 @@ +"""Celery task definitions.""" + +import logging + +from celery_app import celery_app +from database import SessionLocal +from services.media_task_runner import run_parse_media_task +from services.propagation_task_runner import run_propagate_project_task + +logger = logging.getLogger(__name__) + + +@celery_app.task(name="media.parse_project") +def parse_project_media(task_id: int) -> dict: + """Run media parsing for one queued task.""" + db = SessionLocal() + try: + return run_parse_media_task(db, task_id) + except Exception as exc: # noqa: BLE001 + logger.exception("Parse media task failed: task_id=%s", task_id) + raise exc + finally: + db.close() + + +@celery_app.task(name="ai.propagate_project") +def propagate_project_masks(task_id: int) -> dict: + """Run SAM video propagation for one queued task.""" + db = SessionLocal() + try: + return run_propagate_project_task(db, task_id) + except Exception as exc: # noqa: BLE001 + logger.exception("Propagation task failed: task_id=%s", task_id) + raise exc + finally: + db.close() diff --git a/demo/演视DICOM序列/1.dcm b/demo/演视DICOM序列/1.dcm new file mode 100644 index 0000000..13dd1cf Binary files /dev/null and b/demo/演视DICOM序列/1.dcm differ diff --git a/demo/演视DICOM序列/10.dcm b/demo/演视DICOM序列/10.dcm new file mode 100644 index 0000000..640fbdf Binary files /dev/null and b/demo/演视DICOM序列/10.dcm differ diff --git a/demo/演视DICOM序列/100.dcm b/demo/演视DICOM序列/100.dcm new file mode 100644 index 0000000..c89ce30 Binary files /dev/null and b/demo/演视DICOM序列/100.dcm differ diff --git a/demo/演视DICOM序列/101.dcm b/demo/演视DICOM序列/101.dcm new file mode 100644 index 0000000..9d3653e Binary files /dev/null and b/demo/演视DICOM序列/101.dcm differ diff --git a/demo/演视DICOM序列/102.dcm b/demo/演视DICOM序列/102.dcm new file mode 100644 index 0000000..a64cd2f Binary files /dev/null and b/demo/演视DICOM序列/102.dcm differ diff --git a/demo/演视DICOM序列/103.dcm b/demo/演视DICOM序列/103.dcm new file mode 100644 index 0000000..ff24e00 Binary files /dev/null and b/demo/演视DICOM序列/103.dcm differ diff --git a/demo/演视DICOM序列/104.dcm b/demo/演视DICOM序列/104.dcm new file mode 100644 index 0000000..ea647fc Binary files /dev/null and b/demo/演视DICOM序列/104.dcm differ diff --git a/demo/演视DICOM序列/105.dcm b/demo/演视DICOM序列/105.dcm new file mode 100644 index 0000000..8ed49bc Binary files /dev/null and b/demo/演视DICOM序列/105.dcm differ diff --git a/demo/演视DICOM序列/106.dcm b/demo/演视DICOM序列/106.dcm new file mode 100644 index 0000000..2a8096e Binary files /dev/null and b/demo/演视DICOM序列/106.dcm differ diff --git a/demo/演视DICOM序列/107.dcm b/demo/演视DICOM序列/107.dcm new file mode 100644 index 0000000..88abac6 Binary files /dev/null and b/demo/演视DICOM序列/107.dcm differ diff --git a/demo/演视DICOM序列/108.dcm b/demo/演视DICOM序列/108.dcm new file mode 100644 index 0000000..511dbfb Binary files /dev/null and b/demo/演视DICOM序列/108.dcm differ diff --git a/demo/演视DICOM序列/109.dcm b/demo/演视DICOM序列/109.dcm new file mode 100644 index 0000000..77f6ee7 Binary files /dev/null and b/demo/演视DICOM序列/109.dcm differ diff --git a/demo/演视DICOM序列/11.dcm b/demo/演视DICOM序列/11.dcm new file mode 100644 index 0000000..5d3e7df Binary files /dev/null and b/demo/演视DICOM序列/11.dcm differ diff --git a/demo/演视DICOM序列/110.dcm b/demo/演视DICOM序列/110.dcm new file mode 100644 index 0000000..58fbead Binary files /dev/null and b/demo/演视DICOM序列/110.dcm differ diff --git a/demo/演视DICOM序列/111.dcm b/demo/演视DICOM序列/111.dcm new file mode 100644 index 0000000..137c475 Binary files /dev/null and b/demo/演视DICOM序列/111.dcm differ diff --git a/demo/演视DICOM序列/112.dcm b/demo/演视DICOM序列/112.dcm new file mode 100644 index 0000000..4699ebe Binary files /dev/null and b/demo/演视DICOM序列/112.dcm differ diff --git a/demo/演视DICOM序列/113.dcm b/demo/演视DICOM序列/113.dcm new file mode 100644 index 0000000..f45d0a1 Binary files /dev/null and b/demo/演视DICOM序列/113.dcm differ diff --git a/demo/演视DICOM序列/114.dcm b/demo/演视DICOM序列/114.dcm new file mode 100644 index 0000000..dca3f2b Binary files /dev/null and b/demo/演视DICOM序列/114.dcm differ diff --git a/demo/演视DICOM序列/115.dcm b/demo/演视DICOM序列/115.dcm new file mode 100644 index 0000000..9a7396a Binary files /dev/null and b/demo/演视DICOM序列/115.dcm differ diff --git a/demo/演视DICOM序列/116.dcm b/demo/演视DICOM序列/116.dcm new file mode 100644 index 0000000..a315120 Binary files /dev/null and b/demo/演视DICOM序列/116.dcm differ diff --git a/demo/演视DICOM序列/117.dcm b/demo/演视DICOM序列/117.dcm new file mode 100644 index 0000000..11cdb80 Binary files /dev/null and b/demo/演视DICOM序列/117.dcm differ diff --git a/demo/演视DICOM序列/118.dcm b/demo/演视DICOM序列/118.dcm new file mode 100644 index 0000000..c299ade Binary files /dev/null and b/demo/演视DICOM序列/118.dcm differ diff --git a/demo/演视DICOM序列/119.dcm b/demo/演视DICOM序列/119.dcm new file mode 100644 index 0000000..0a55d54 Binary files /dev/null and b/demo/演视DICOM序列/119.dcm differ diff --git a/demo/演视DICOM序列/12.dcm b/demo/演视DICOM序列/12.dcm new file mode 100644 index 0000000..bf83ab0 Binary files /dev/null and b/demo/演视DICOM序列/12.dcm differ diff --git a/demo/演视DICOM序列/120.dcm b/demo/演视DICOM序列/120.dcm new file mode 100644 index 0000000..9a287d1 Binary files /dev/null and b/demo/演视DICOM序列/120.dcm differ diff --git a/demo/演视DICOM序列/121.dcm b/demo/演视DICOM序列/121.dcm new file mode 100644 index 0000000..7f0a42d Binary files /dev/null and b/demo/演视DICOM序列/121.dcm differ diff --git a/demo/演视DICOM序列/122.dcm b/demo/演视DICOM序列/122.dcm new file mode 100644 index 0000000..0a91416 Binary files /dev/null and b/demo/演视DICOM序列/122.dcm differ diff --git a/demo/演视DICOM序列/123.dcm b/demo/演视DICOM序列/123.dcm new file mode 100644 index 0000000..0bebc57 Binary files /dev/null and b/demo/演视DICOM序列/123.dcm differ diff --git a/demo/演视DICOM序列/124.dcm b/demo/演视DICOM序列/124.dcm new file mode 100644 index 0000000..0faf36c Binary files /dev/null and b/demo/演视DICOM序列/124.dcm differ diff --git a/demo/演视DICOM序列/125.dcm b/demo/演视DICOM序列/125.dcm new file mode 100644 index 0000000..ab99528 Binary files /dev/null and b/demo/演视DICOM序列/125.dcm differ diff --git a/demo/演视DICOM序列/126.dcm b/demo/演视DICOM序列/126.dcm new file mode 100644 index 0000000..f3c606c Binary files /dev/null and b/demo/演视DICOM序列/126.dcm differ diff --git a/demo/演视DICOM序列/127.dcm b/demo/演视DICOM序列/127.dcm new file mode 100644 index 0000000..9ef177b Binary files /dev/null and b/demo/演视DICOM序列/127.dcm differ diff --git a/demo/演视DICOM序列/128.dcm b/demo/演视DICOM序列/128.dcm new file mode 100644 index 0000000..9121696 Binary files /dev/null and b/demo/演视DICOM序列/128.dcm differ diff --git a/demo/演视DICOM序列/129.dcm b/demo/演视DICOM序列/129.dcm new file mode 100644 index 0000000..d6f9e75 Binary files /dev/null and b/demo/演视DICOM序列/129.dcm differ diff --git a/demo/演视DICOM序列/13.dcm b/demo/演视DICOM序列/13.dcm new file mode 100644 index 0000000..dc8c538 Binary files /dev/null and b/demo/演视DICOM序列/13.dcm differ diff --git a/demo/演视DICOM序列/130.dcm b/demo/演视DICOM序列/130.dcm new file mode 100644 index 0000000..58b09cb Binary files /dev/null and b/demo/演视DICOM序列/130.dcm differ diff --git a/demo/演视DICOM序列/131.dcm b/demo/演视DICOM序列/131.dcm new file mode 100644 index 0000000..faef818 Binary files /dev/null and b/demo/演视DICOM序列/131.dcm differ diff --git a/demo/演视DICOM序列/132.dcm b/demo/演视DICOM序列/132.dcm new file mode 100644 index 0000000..39f932a Binary files /dev/null and b/demo/演视DICOM序列/132.dcm differ diff --git a/demo/演视DICOM序列/133.dcm b/demo/演视DICOM序列/133.dcm new file mode 100644 index 0000000..3da1a7a Binary files /dev/null and b/demo/演视DICOM序列/133.dcm differ diff --git a/demo/演视DICOM序列/134.dcm b/demo/演视DICOM序列/134.dcm new file mode 100644 index 0000000..38258ec Binary files /dev/null and b/demo/演视DICOM序列/134.dcm differ diff --git a/demo/演视DICOM序列/135.dcm b/demo/演视DICOM序列/135.dcm new file mode 100644 index 0000000..bfb4284 Binary files /dev/null and b/demo/演视DICOM序列/135.dcm differ diff --git a/demo/演视DICOM序列/136.dcm b/demo/演视DICOM序列/136.dcm new file mode 100644 index 0000000..76f898c Binary files /dev/null and b/demo/演视DICOM序列/136.dcm differ diff --git a/demo/演视DICOM序列/137.dcm b/demo/演视DICOM序列/137.dcm new file mode 100644 index 0000000..cbe88d8 Binary files /dev/null and b/demo/演视DICOM序列/137.dcm differ diff --git a/demo/演视DICOM序列/138.dcm b/demo/演视DICOM序列/138.dcm new file mode 100644 index 0000000..bb819b8 Binary files /dev/null and b/demo/演视DICOM序列/138.dcm differ diff --git a/demo/演视DICOM序列/139.dcm b/demo/演视DICOM序列/139.dcm new file mode 100644 index 0000000..465b865 Binary files /dev/null and b/demo/演视DICOM序列/139.dcm differ diff --git a/demo/演视DICOM序列/14.dcm b/demo/演视DICOM序列/14.dcm new file mode 100644 index 0000000..1322baa Binary files /dev/null and b/demo/演视DICOM序列/14.dcm differ diff --git a/demo/演视DICOM序列/140.dcm b/demo/演视DICOM序列/140.dcm new file mode 100644 index 0000000..0239572 Binary files /dev/null and b/demo/演视DICOM序列/140.dcm differ diff --git a/demo/演视DICOM序列/141.dcm b/demo/演视DICOM序列/141.dcm new file mode 100644 index 0000000..3619ed8 Binary files /dev/null and b/demo/演视DICOM序列/141.dcm differ diff --git a/demo/演视DICOM序列/142.dcm b/demo/演视DICOM序列/142.dcm new file mode 100644 index 0000000..2709849 Binary files /dev/null and b/demo/演视DICOM序列/142.dcm differ diff --git a/demo/演视DICOM序列/143.dcm b/demo/演视DICOM序列/143.dcm new file mode 100644 index 0000000..f46706d Binary files /dev/null and b/demo/演视DICOM序列/143.dcm differ diff --git a/demo/演视DICOM序列/144.dcm b/demo/演视DICOM序列/144.dcm new file mode 100644 index 0000000..04744f5 Binary files /dev/null and b/demo/演视DICOM序列/144.dcm differ diff --git a/demo/演视DICOM序列/145.dcm b/demo/演视DICOM序列/145.dcm new file mode 100644 index 0000000..c4d351c Binary files /dev/null and b/demo/演视DICOM序列/145.dcm differ diff --git a/demo/演视DICOM序列/146.dcm b/demo/演视DICOM序列/146.dcm new file mode 100644 index 0000000..7f71428 Binary files /dev/null and b/demo/演视DICOM序列/146.dcm differ diff --git a/demo/演视DICOM序列/147.dcm b/demo/演视DICOM序列/147.dcm new file mode 100644 index 0000000..44009ff Binary files /dev/null and b/demo/演视DICOM序列/147.dcm differ diff --git a/demo/演视DICOM序列/148.dcm b/demo/演视DICOM序列/148.dcm new file mode 100644 index 0000000..8aa3d12 Binary files /dev/null and b/demo/演视DICOM序列/148.dcm differ diff --git a/demo/演视DICOM序列/149.dcm b/demo/演视DICOM序列/149.dcm new file mode 100644 index 0000000..ae4c733 Binary files /dev/null and b/demo/演视DICOM序列/149.dcm differ diff --git a/demo/演视DICOM序列/15.dcm b/demo/演视DICOM序列/15.dcm new file mode 100644 index 0000000..c1b235f Binary files /dev/null and b/demo/演视DICOM序列/15.dcm differ diff --git a/demo/演视DICOM序列/150.dcm b/demo/演视DICOM序列/150.dcm new file mode 100644 index 0000000..b4cb604 Binary files /dev/null and b/demo/演视DICOM序列/150.dcm differ diff --git a/demo/演视DICOM序列/151.dcm b/demo/演视DICOM序列/151.dcm new file mode 100644 index 0000000..f241d9a Binary files /dev/null and b/demo/演视DICOM序列/151.dcm differ diff --git a/demo/演视DICOM序列/152.dcm b/demo/演视DICOM序列/152.dcm new file mode 100644 index 0000000..0558a86 Binary files /dev/null and b/demo/演视DICOM序列/152.dcm differ diff --git a/demo/演视DICOM序列/153.dcm b/demo/演视DICOM序列/153.dcm new file mode 100644 index 0000000..11efd2d Binary files /dev/null and b/demo/演视DICOM序列/153.dcm differ diff --git a/demo/演视DICOM序列/154.dcm b/demo/演视DICOM序列/154.dcm new file mode 100644 index 0000000..e19268e Binary files /dev/null and b/demo/演视DICOM序列/154.dcm differ diff --git a/demo/演视DICOM序列/155.dcm b/demo/演视DICOM序列/155.dcm new file mode 100644 index 0000000..ec487c1 Binary files /dev/null and b/demo/演视DICOM序列/155.dcm differ diff --git a/demo/演视DICOM序列/156.dcm b/demo/演视DICOM序列/156.dcm new file mode 100644 index 0000000..990baba Binary files /dev/null and b/demo/演视DICOM序列/156.dcm differ diff --git a/demo/演视DICOM序列/157.dcm b/demo/演视DICOM序列/157.dcm new file mode 100644 index 0000000..c1c6572 Binary files /dev/null and b/demo/演视DICOM序列/157.dcm differ diff --git a/demo/演视DICOM序列/158.dcm b/demo/演视DICOM序列/158.dcm new file mode 100644 index 0000000..d9e047f Binary files /dev/null and b/demo/演视DICOM序列/158.dcm differ diff --git a/demo/演视DICOM序列/159.dcm b/demo/演视DICOM序列/159.dcm new file mode 100644 index 0000000..4b48aa3 Binary files /dev/null and b/demo/演视DICOM序列/159.dcm differ diff --git a/demo/演视DICOM序列/16.dcm b/demo/演视DICOM序列/16.dcm new file mode 100644 index 0000000..1670cbc Binary files /dev/null and b/demo/演视DICOM序列/16.dcm differ diff --git a/demo/演视DICOM序列/160.dcm b/demo/演视DICOM序列/160.dcm new file mode 100644 index 0000000..4cff048 Binary files /dev/null and b/demo/演视DICOM序列/160.dcm differ diff --git a/demo/演视DICOM序列/161.dcm b/demo/演视DICOM序列/161.dcm new file mode 100644 index 0000000..c852e44 Binary files /dev/null and b/demo/演视DICOM序列/161.dcm differ diff --git a/demo/演视DICOM序列/162.dcm b/demo/演视DICOM序列/162.dcm new file mode 100644 index 0000000..e99d335 Binary files /dev/null and b/demo/演视DICOM序列/162.dcm differ diff --git a/demo/演视DICOM序列/163.dcm b/demo/演视DICOM序列/163.dcm new file mode 100644 index 0000000..6fae2a9 Binary files /dev/null and b/demo/演视DICOM序列/163.dcm differ diff --git a/demo/演视DICOM序列/164.dcm b/demo/演视DICOM序列/164.dcm new file mode 100644 index 0000000..cd40e6b Binary files /dev/null and b/demo/演视DICOM序列/164.dcm differ diff --git a/demo/演视DICOM序列/165.dcm b/demo/演视DICOM序列/165.dcm new file mode 100644 index 0000000..146325c Binary files /dev/null and b/demo/演视DICOM序列/165.dcm differ diff --git a/demo/演视DICOM序列/166.dcm b/demo/演视DICOM序列/166.dcm new file mode 100644 index 0000000..bac11a2 Binary files /dev/null and b/demo/演视DICOM序列/166.dcm differ diff --git a/demo/演视DICOM序列/167.dcm b/demo/演视DICOM序列/167.dcm new file mode 100644 index 0000000..fa12c49 Binary files /dev/null and b/demo/演视DICOM序列/167.dcm differ diff --git a/demo/演视DICOM序列/168.dcm b/demo/演视DICOM序列/168.dcm new file mode 100644 index 0000000..a4c01fa Binary files /dev/null and b/demo/演视DICOM序列/168.dcm differ diff --git a/demo/演视DICOM序列/169.dcm b/demo/演视DICOM序列/169.dcm new file mode 100644 index 0000000..5f042f1 Binary files /dev/null and b/demo/演视DICOM序列/169.dcm differ diff --git a/demo/演视DICOM序列/17.dcm b/demo/演视DICOM序列/17.dcm new file mode 100644 index 0000000..c3c1976 Binary files /dev/null and b/demo/演视DICOM序列/17.dcm differ diff --git a/demo/演视DICOM序列/170.dcm b/demo/演视DICOM序列/170.dcm new file mode 100644 index 0000000..24b38e5 Binary files /dev/null and b/demo/演视DICOM序列/170.dcm differ diff --git a/demo/演视DICOM序列/171.dcm b/demo/演视DICOM序列/171.dcm new file mode 100644 index 0000000..026bf41 Binary files /dev/null and b/demo/演视DICOM序列/171.dcm differ diff --git a/demo/演视DICOM序列/172.dcm b/demo/演视DICOM序列/172.dcm new file mode 100644 index 0000000..5b13db7 Binary files /dev/null and b/demo/演视DICOM序列/172.dcm differ diff --git a/demo/演视DICOM序列/173.dcm b/demo/演视DICOM序列/173.dcm new file mode 100644 index 0000000..73a0870 Binary files /dev/null and b/demo/演视DICOM序列/173.dcm differ diff --git a/demo/演视DICOM序列/174.dcm b/demo/演视DICOM序列/174.dcm new file mode 100644 index 0000000..959f587 Binary files /dev/null and b/demo/演视DICOM序列/174.dcm differ diff --git a/demo/演视DICOM序列/175.dcm b/demo/演视DICOM序列/175.dcm new file mode 100644 index 0000000..60efc34 Binary files /dev/null and b/demo/演视DICOM序列/175.dcm differ diff --git a/demo/演视DICOM序列/176.dcm b/demo/演视DICOM序列/176.dcm new file mode 100644 index 0000000..08a7024 Binary files /dev/null and b/demo/演视DICOM序列/176.dcm differ diff --git a/demo/演视DICOM序列/177.dcm b/demo/演视DICOM序列/177.dcm new file mode 100644 index 0000000..319e057 Binary files /dev/null and b/demo/演视DICOM序列/177.dcm differ diff --git a/demo/演视DICOM序列/178.dcm b/demo/演视DICOM序列/178.dcm new file mode 100644 index 0000000..0ee2078 Binary files /dev/null and b/demo/演视DICOM序列/178.dcm differ diff --git a/demo/演视DICOM序列/179.dcm b/demo/演视DICOM序列/179.dcm new file mode 100644 index 0000000..f750f27 Binary files /dev/null and b/demo/演视DICOM序列/179.dcm differ diff --git a/demo/演视DICOM序列/18.dcm b/demo/演视DICOM序列/18.dcm new file mode 100644 index 0000000..87984f7 Binary files /dev/null and b/demo/演视DICOM序列/18.dcm differ diff --git a/demo/演视DICOM序列/180.dcm b/demo/演视DICOM序列/180.dcm new file mode 100644 index 0000000..82eb4b4 Binary files /dev/null and b/demo/演视DICOM序列/180.dcm differ diff --git a/demo/演视DICOM序列/181.dcm b/demo/演视DICOM序列/181.dcm new file mode 100644 index 0000000..3b57869 Binary files /dev/null and b/demo/演视DICOM序列/181.dcm differ diff --git a/demo/演视DICOM序列/182.dcm b/demo/演视DICOM序列/182.dcm new file mode 100644 index 0000000..c6eefa3 Binary files /dev/null and b/demo/演视DICOM序列/182.dcm differ diff --git a/demo/演视DICOM序列/183.dcm b/demo/演视DICOM序列/183.dcm new file mode 100644 index 0000000..0339d6c Binary files /dev/null and b/demo/演视DICOM序列/183.dcm differ diff --git a/demo/演视DICOM序列/184.dcm b/demo/演视DICOM序列/184.dcm new file mode 100644 index 0000000..c5e061c Binary files /dev/null and b/demo/演视DICOM序列/184.dcm differ diff --git a/demo/演视DICOM序列/185.dcm b/demo/演视DICOM序列/185.dcm new file mode 100644 index 0000000..76c9e0b Binary files /dev/null and b/demo/演视DICOM序列/185.dcm differ diff --git a/demo/演视DICOM序列/186.dcm b/demo/演视DICOM序列/186.dcm new file mode 100644 index 0000000..b4e0cda Binary files /dev/null and b/demo/演视DICOM序列/186.dcm differ diff --git a/demo/演视DICOM序列/187.dcm b/demo/演视DICOM序列/187.dcm new file mode 100644 index 0000000..1d78339 Binary files /dev/null and b/demo/演视DICOM序列/187.dcm differ diff --git a/demo/演视DICOM序列/188.dcm b/demo/演视DICOM序列/188.dcm new file mode 100644 index 0000000..df933cc Binary files /dev/null and b/demo/演视DICOM序列/188.dcm differ diff --git a/demo/演视DICOM序列/189.dcm b/demo/演视DICOM序列/189.dcm new file mode 100644 index 0000000..9739302 Binary files /dev/null and b/demo/演视DICOM序列/189.dcm differ diff --git a/demo/演视DICOM序列/19.dcm b/demo/演视DICOM序列/19.dcm new file mode 100644 index 0000000..2931f6e Binary files /dev/null and b/demo/演视DICOM序列/19.dcm differ diff --git a/demo/演视DICOM序列/190.dcm b/demo/演视DICOM序列/190.dcm new file mode 100644 index 0000000..620f47e Binary files /dev/null and b/demo/演视DICOM序列/190.dcm differ diff --git a/demo/演视DICOM序列/191.dcm b/demo/演视DICOM序列/191.dcm new file mode 100644 index 0000000..de5052b Binary files /dev/null and b/demo/演视DICOM序列/191.dcm differ diff --git a/demo/演视DICOM序列/192.dcm b/demo/演视DICOM序列/192.dcm new file mode 100644 index 0000000..5690a2b Binary files /dev/null and b/demo/演视DICOM序列/192.dcm differ diff --git a/demo/演视DICOM序列/193.dcm b/demo/演视DICOM序列/193.dcm new file mode 100644 index 0000000..13d5b07 Binary files /dev/null and b/demo/演视DICOM序列/193.dcm differ diff --git a/demo/演视DICOM序列/194.dcm b/demo/演视DICOM序列/194.dcm new file mode 100644 index 0000000..8347685 Binary files /dev/null and b/demo/演视DICOM序列/194.dcm differ diff --git a/demo/演视DICOM序列/195.dcm b/demo/演视DICOM序列/195.dcm new file mode 100644 index 0000000..c7962a2 Binary files /dev/null and b/demo/演视DICOM序列/195.dcm differ diff --git a/demo/演视DICOM序列/196.dcm b/demo/演视DICOM序列/196.dcm new file mode 100644 index 0000000..d7b73cf Binary files /dev/null and b/demo/演视DICOM序列/196.dcm differ diff --git a/demo/演视DICOM序列/197.dcm b/demo/演视DICOM序列/197.dcm new file mode 100644 index 0000000..96a85df Binary files /dev/null and b/demo/演视DICOM序列/197.dcm differ diff --git a/demo/演视DICOM序列/198.dcm b/demo/演视DICOM序列/198.dcm new file mode 100644 index 0000000..b152bac Binary files /dev/null and b/demo/演视DICOM序列/198.dcm differ diff --git a/demo/演视DICOM序列/199.dcm b/demo/演视DICOM序列/199.dcm new file mode 100644 index 0000000..088716f Binary files /dev/null and b/demo/演视DICOM序列/199.dcm differ diff --git a/demo/演视DICOM序列/2.dcm b/demo/演视DICOM序列/2.dcm new file mode 100644 index 0000000..3637dad Binary files /dev/null and b/demo/演视DICOM序列/2.dcm differ diff --git a/demo/演视DICOM序列/20.dcm b/demo/演视DICOM序列/20.dcm new file mode 100644 index 0000000..85b553c Binary files /dev/null and b/demo/演视DICOM序列/20.dcm differ diff --git a/demo/演视DICOM序列/200.dcm b/demo/演视DICOM序列/200.dcm new file mode 100644 index 0000000..48b8ad5 Binary files /dev/null and b/demo/演视DICOM序列/200.dcm differ diff --git a/demo/演视DICOM序列/201.dcm b/demo/演视DICOM序列/201.dcm new file mode 100644 index 0000000..78d453f Binary files /dev/null and b/demo/演视DICOM序列/201.dcm differ diff --git a/demo/演视DICOM序列/202.dcm b/demo/演视DICOM序列/202.dcm new file mode 100644 index 0000000..16cd5fc Binary files /dev/null and b/demo/演视DICOM序列/202.dcm differ diff --git a/demo/演视DICOM序列/203.dcm b/demo/演视DICOM序列/203.dcm new file mode 100644 index 0000000..2b823ba Binary files /dev/null and b/demo/演视DICOM序列/203.dcm differ diff --git a/demo/演视DICOM序列/204.dcm b/demo/演视DICOM序列/204.dcm new file mode 100644 index 0000000..5c31835 Binary files /dev/null and b/demo/演视DICOM序列/204.dcm differ diff --git a/demo/演视DICOM序列/205.dcm b/demo/演视DICOM序列/205.dcm new file mode 100644 index 0000000..e77ce11 Binary files /dev/null and b/demo/演视DICOM序列/205.dcm differ diff --git a/demo/演视DICOM序列/206.dcm b/demo/演视DICOM序列/206.dcm new file mode 100644 index 0000000..d8cea43 Binary files /dev/null and b/demo/演视DICOM序列/206.dcm differ diff --git a/demo/演视DICOM序列/207.dcm b/demo/演视DICOM序列/207.dcm new file mode 100644 index 0000000..f679f55 Binary files /dev/null and b/demo/演视DICOM序列/207.dcm differ diff --git a/demo/演视DICOM序列/208.dcm b/demo/演视DICOM序列/208.dcm new file mode 100644 index 0000000..a87e028 Binary files /dev/null and b/demo/演视DICOM序列/208.dcm differ diff --git a/demo/演视DICOM序列/209.dcm b/demo/演视DICOM序列/209.dcm new file mode 100644 index 0000000..464a142 Binary files /dev/null and b/demo/演视DICOM序列/209.dcm differ diff --git a/demo/演视DICOM序列/21.dcm b/demo/演视DICOM序列/21.dcm new file mode 100644 index 0000000..af3f470 Binary files /dev/null and b/demo/演视DICOM序列/21.dcm differ diff --git a/demo/演视DICOM序列/210.dcm b/demo/演视DICOM序列/210.dcm new file mode 100644 index 0000000..0e179dd Binary files /dev/null and b/demo/演视DICOM序列/210.dcm differ diff --git a/demo/演视DICOM序列/211.dcm b/demo/演视DICOM序列/211.dcm new file mode 100644 index 0000000..cff043f Binary files /dev/null and b/demo/演视DICOM序列/211.dcm differ diff --git a/demo/演视DICOM序列/212.dcm b/demo/演视DICOM序列/212.dcm new file mode 100644 index 0000000..ab8c11b Binary files /dev/null and b/demo/演视DICOM序列/212.dcm differ diff --git a/demo/演视DICOM序列/213.dcm b/demo/演视DICOM序列/213.dcm new file mode 100644 index 0000000..d87d888 Binary files /dev/null and b/demo/演视DICOM序列/213.dcm differ diff --git a/demo/演视DICOM序列/214.dcm b/demo/演视DICOM序列/214.dcm new file mode 100644 index 0000000..511605b Binary files /dev/null and b/demo/演视DICOM序列/214.dcm differ diff --git a/demo/演视DICOM序列/215.dcm b/demo/演视DICOM序列/215.dcm new file mode 100644 index 0000000..f12da29 Binary files /dev/null and b/demo/演视DICOM序列/215.dcm differ diff --git a/demo/演视DICOM序列/216.dcm b/demo/演视DICOM序列/216.dcm new file mode 100644 index 0000000..e593bf2 Binary files /dev/null and b/demo/演视DICOM序列/216.dcm differ diff --git a/demo/演视DICOM序列/217.dcm b/demo/演视DICOM序列/217.dcm new file mode 100644 index 0000000..bf68c6e Binary files /dev/null and b/demo/演视DICOM序列/217.dcm differ diff --git a/demo/演视DICOM序列/218.dcm b/demo/演视DICOM序列/218.dcm new file mode 100644 index 0000000..64554f0 Binary files /dev/null and b/demo/演视DICOM序列/218.dcm differ diff --git a/demo/演视DICOM序列/219.dcm b/demo/演视DICOM序列/219.dcm new file mode 100644 index 0000000..d3c62ab Binary files /dev/null and b/demo/演视DICOM序列/219.dcm differ diff --git a/demo/演视DICOM序列/22.dcm b/demo/演视DICOM序列/22.dcm new file mode 100644 index 0000000..a01ca1b Binary files /dev/null and b/demo/演视DICOM序列/22.dcm differ diff --git a/demo/演视DICOM序列/220.dcm b/demo/演视DICOM序列/220.dcm new file mode 100644 index 0000000..4f4d496 Binary files /dev/null and b/demo/演视DICOM序列/220.dcm differ diff --git a/demo/演视DICOM序列/221.dcm b/demo/演视DICOM序列/221.dcm new file mode 100644 index 0000000..d3c5938 Binary files /dev/null and b/demo/演视DICOM序列/221.dcm differ diff --git a/demo/演视DICOM序列/222.dcm b/demo/演视DICOM序列/222.dcm new file mode 100644 index 0000000..0e861eb Binary files /dev/null and b/demo/演视DICOM序列/222.dcm differ diff --git a/demo/演视DICOM序列/223.dcm b/demo/演视DICOM序列/223.dcm new file mode 100644 index 0000000..1052fb7 Binary files /dev/null and b/demo/演视DICOM序列/223.dcm differ diff --git a/demo/演视DICOM序列/224.dcm b/demo/演视DICOM序列/224.dcm new file mode 100644 index 0000000..c59c02e Binary files /dev/null and b/demo/演视DICOM序列/224.dcm differ diff --git a/demo/演视DICOM序列/225.dcm b/demo/演视DICOM序列/225.dcm new file mode 100644 index 0000000..c4fb077 Binary files /dev/null and b/demo/演视DICOM序列/225.dcm differ diff --git a/demo/演视DICOM序列/226.dcm b/demo/演视DICOM序列/226.dcm new file mode 100644 index 0000000..25dd0a1 Binary files /dev/null and b/demo/演视DICOM序列/226.dcm differ diff --git a/demo/演视DICOM序列/227.dcm b/demo/演视DICOM序列/227.dcm new file mode 100644 index 0000000..c3e8262 Binary files /dev/null and b/demo/演视DICOM序列/227.dcm differ diff --git a/demo/演视DICOM序列/228.dcm b/demo/演视DICOM序列/228.dcm new file mode 100644 index 0000000..2ac3d1a Binary files /dev/null and b/demo/演视DICOM序列/228.dcm differ diff --git a/demo/演视DICOM序列/229.dcm b/demo/演视DICOM序列/229.dcm new file mode 100644 index 0000000..63c5901 Binary files /dev/null and b/demo/演视DICOM序列/229.dcm differ diff --git a/demo/演视DICOM序列/23.dcm b/demo/演视DICOM序列/23.dcm new file mode 100644 index 0000000..c7abe53 Binary files /dev/null and b/demo/演视DICOM序列/23.dcm differ diff --git a/demo/演视DICOM序列/230.dcm b/demo/演视DICOM序列/230.dcm new file mode 100644 index 0000000..4733500 Binary files /dev/null and b/demo/演视DICOM序列/230.dcm differ diff --git a/demo/演视DICOM序列/231.dcm b/demo/演视DICOM序列/231.dcm new file mode 100644 index 0000000..6c99a07 Binary files /dev/null and b/demo/演视DICOM序列/231.dcm differ diff --git a/demo/演视DICOM序列/232.dcm b/demo/演视DICOM序列/232.dcm new file mode 100644 index 0000000..8b50242 Binary files /dev/null and b/demo/演视DICOM序列/232.dcm differ diff --git a/demo/演视DICOM序列/233.dcm b/demo/演视DICOM序列/233.dcm new file mode 100644 index 0000000..df2dc61 Binary files /dev/null and b/demo/演视DICOM序列/233.dcm differ diff --git a/demo/演视DICOM序列/234.dcm b/demo/演视DICOM序列/234.dcm new file mode 100644 index 0000000..0b53f99 Binary files /dev/null and b/demo/演视DICOM序列/234.dcm differ diff --git a/demo/演视DICOM序列/235.dcm b/demo/演视DICOM序列/235.dcm new file mode 100644 index 0000000..4dc353a Binary files /dev/null and b/demo/演视DICOM序列/235.dcm differ diff --git a/demo/演视DICOM序列/236.dcm b/demo/演视DICOM序列/236.dcm new file mode 100644 index 0000000..6bbfc9f Binary files /dev/null and b/demo/演视DICOM序列/236.dcm differ diff --git a/demo/演视DICOM序列/237.dcm b/demo/演视DICOM序列/237.dcm new file mode 100644 index 0000000..b2f1411 Binary files /dev/null and b/demo/演视DICOM序列/237.dcm differ diff --git a/demo/演视DICOM序列/238.dcm b/demo/演视DICOM序列/238.dcm new file mode 100644 index 0000000..34f1654 Binary files /dev/null and b/demo/演视DICOM序列/238.dcm differ diff --git a/demo/演视DICOM序列/239.dcm b/demo/演视DICOM序列/239.dcm new file mode 100644 index 0000000..bafc393 Binary files /dev/null and b/demo/演视DICOM序列/239.dcm differ diff --git a/demo/演视DICOM序列/24.dcm b/demo/演视DICOM序列/24.dcm new file mode 100644 index 0000000..3c0da92 Binary files /dev/null and b/demo/演视DICOM序列/24.dcm differ diff --git a/demo/演视DICOM序列/240.dcm b/demo/演视DICOM序列/240.dcm new file mode 100644 index 0000000..0ffb58a Binary files /dev/null and b/demo/演视DICOM序列/240.dcm differ diff --git a/demo/演视DICOM序列/241.dcm b/demo/演视DICOM序列/241.dcm new file mode 100644 index 0000000..f0bc9a8 Binary files /dev/null and b/demo/演视DICOM序列/241.dcm differ diff --git a/demo/演视DICOM序列/242.dcm b/demo/演视DICOM序列/242.dcm new file mode 100644 index 0000000..18452d0 Binary files /dev/null and b/demo/演视DICOM序列/242.dcm differ diff --git a/demo/演视DICOM序列/243.dcm b/demo/演视DICOM序列/243.dcm new file mode 100644 index 0000000..df46c6c Binary files /dev/null and b/demo/演视DICOM序列/243.dcm differ diff --git a/demo/演视DICOM序列/244.dcm b/demo/演视DICOM序列/244.dcm new file mode 100644 index 0000000..09c1c1e Binary files /dev/null and b/demo/演视DICOM序列/244.dcm differ diff --git a/demo/演视DICOM序列/245.dcm b/demo/演视DICOM序列/245.dcm new file mode 100644 index 0000000..51b9e76 Binary files /dev/null and b/demo/演视DICOM序列/245.dcm differ diff --git a/demo/演视DICOM序列/246.dcm b/demo/演视DICOM序列/246.dcm new file mode 100644 index 0000000..de1bb99 Binary files /dev/null and b/demo/演视DICOM序列/246.dcm differ diff --git a/demo/演视DICOM序列/247.dcm b/demo/演视DICOM序列/247.dcm new file mode 100644 index 0000000..bcf9693 Binary files /dev/null and b/demo/演视DICOM序列/247.dcm differ diff --git a/demo/演视DICOM序列/248.dcm b/demo/演视DICOM序列/248.dcm new file mode 100644 index 0000000..017535d Binary files /dev/null and b/demo/演视DICOM序列/248.dcm differ diff --git a/demo/演视DICOM序列/249.dcm b/demo/演视DICOM序列/249.dcm new file mode 100644 index 0000000..d4e7da2 Binary files /dev/null and b/demo/演视DICOM序列/249.dcm differ diff --git a/demo/演视DICOM序列/25.dcm b/demo/演视DICOM序列/25.dcm new file mode 100644 index 0000000..9e7198f Binary files /dev/null and b/demo/演视DICOM序列/25.dcm differ diff --git a/demo/演视DICOM序列/250.dcm b/demo/演视DICOM序列/250.dcm new file mode 100644 index 0000000..0022d1f Binary files /dev/null and b/demo/演视DICOM序列/250.dcm differ diff --git a/demo/演视DICOM序列/251.dcm b/demo/演视DICOM序列/251.dcm new file mode 100644 index 0000000..596ac03 Binary files /dev/null and b/demo/演视DICOM序列/251.dcm differ diff --git a/demo/演视DICOM序列/252.dcm b/demo/演视DICOM序列/252.dcm new file mode 100644 index 0000000..03fab4d Binary files /dev/null and b/demo/演视DICOM序列/252.dcm differ diff --git a/demo/演视DICOM序列/253.dcm b/demo/演视DICOM序列/253.dcm new file mode 100644 index 0000000..40dac6f Binary files /dev/null and b/demo/演视DICOM序列/253.dcm differ diff --git a/demo/演视DICOM序列/254.dcm b/demo/演视DICOM序列/254.dcm new file mode 100644 index 0000000..1f71e6c Binary files /dev/null and b/demo/演视DICOM序列/254.dcm differ diff --git a/demo/演视DICOM序列/255.dcm b/demo/演视DICOM序列/255.dcm new file mode 100644 index 0000000..e4d1058 Binary files /dev/null and b/demo/演视DICOM序列/255.dcm differ diff --git a/demo/演视DICOM序列/256.dcm b/demo/演视DICOM序列/256.dcm new file mode 100644 index 0000000..0ce1eec Binary files /dev/null and b/demo/演视DICOM序列/256.dcm differ diff --git a/demo/演视DICOM序列/257.dcm b/demo/演视DICOM序列/257.dcm new file mode 100644 index 0000000..24590e1 Binary files /dev/null and b/demo/演视DICOM序列/257.dcm differ diff --git a/demo/演视DICOM序列/258.dcm b/demo/演视DICOM序列/258.dcm new file mode 100644 index 0000000..65e55fe Binary files /dev/null and b/demo/演视DICOM序列/258.dcm differ diff --git a/demo/演视DICOM序列/259.dcm b/demo/演视DICOM序列/259.dcm new file mode 100644 index 0000000..326c316 Binary files /dev/null and b/demo/演视DICOM序列/259.dcm differ diff --git a/demo/演视DICOM序列/26.dcm b/demo/演视DICOM序列/26.dcm new file mode 100644 index 0000000..a45d55e Binary files /dev/null and b/demo/演视DICOM序列/26.dcm differ diff --git a/demo/演视DICOM序列/260.dcm b/demo/演视DICOM序列/260.dcm new file mode 100644 index 0000000..0f42f8a Binary files /dev/null and b/demo/演视DICOM序列/260.dcm differ diff --git a/demo/演视DICOM序列/261.dcm b/demo/演视DICOM序列/261.dcm new file mode 100644 index 0000000..479b0c9 Binary files /dev/null and b/demo/演视DICOM序列/261.dcm differ diff --git a/demo/演视DICOM序列/262.dcm b/demo/演视DICOM序列/262.dcm new file mode 100644 index 0000000..2df0437 Binary files /dev/null and b/demo/演视DICOM序列/262.dcm differ diff --git a/demo/演视DICOM序列/263.dcm b/demo/演视DICOM序列/263.dcm new file mode 100644 index 0000000..2d08b91 Binary files /dev/null and b/demo/演视DICOM序列/263.dcm differ diff --git a/demo/演视DICOM序列/264.dcm b/demo/演视DICOM序列/264.dcm new file mode 100644 index 0000000..f366087 Binary files /dev/null and b/demo/演视DICOM序列/264.dcm differ diff --git a/demo/演视DICOM序列/265.dcm b/demo/演视DICOM序列/265.dcm new file mode 100644 index 0000000..05a80c6 Binary files /dev/null and b/demo/演视DICOM序列/265.dcm differ diff --git a/demo/演视DICOM序列/266.dcm b/demo/演视DICOM序列/266.dcm new file mode 100644 index 0000000..72cbf69 Binary files /dev/null and b/demo/演视DICOM序列/266.dcm differ diff --git a/demo/演视DICOM序列/267.dcm b/demo/演视DICOM序列/267.dcm new file mode 100644 index 0000000..db78e53 Binary files /dev/null and b/demo/演视DICOM序列/267.dcm differ diff --git a/demo/演视DICOM序列/268.dcm b/demo/演视DICOM序列/268.dcm new file mode 100644 index 0000000..c4e7540 Binary files /dev/null and b/demo/演视DICOM序列/268.dcm differ diff --git a/demo/演视DICOM序列/269.dcm b/demo/演视DICOM序列/269.dcm new file mode 100644 index 0000000..b0b32b7 Binary files /dev/null and b/demo/演视DICOM序列/269.dcm differ diff --git a/demo/演视DICOM序列/27.dcm b/demo/演视DICOM序列/27.dcm new file mode 100644 index 0000000..2ff5aa4 Binary files /dev/null and b/demo/演视DICOM序列/27.dcm differ diff --git a/demo/演视DICOM序列/270.dcm b/demo/演视DICOM序列/270.dcm new file mode 100644 index 0000000..6e2f4d3 Binary files /dev/null and b/demo/演视DICOM序列/270.dcm differ diff --git a/demo/演视DICOM序列/271.dcm b/demo/演视DICOM序列/271.dcm new file mode 100644 index 0000000..9f2f36d Binary files /dev/null and b/demo/演视DICOM序列/271.dcm differ diff --git a/demo/演视DICOM序列/272.dcm b/demo/演视DICOM序列/272.dcm new file mode 100644 index 0000000..0674f58 Binary files /dev/null and b/demo/演视DICOM序列/272.dcm differ diff --git a/demo/演视DICOM序列/273.dcm b/demo/演视DICOM序列/273.dcm new file mode 100644 index 0000000..9d0db95 Binary files /dev/null and b/demo/演视DICOM序列/273.dcm differ diff --git a/demo/演视DICOM序列/274.dcm b/demo/演视DICOM序列/274.dcm new file mode 100644 index 0000000..eac7bbe Binary files /dev/null and b/demo/演视DICOM序列/274.dcm differ diff --git a/demo/演视DICOM序列/275.dcm b/demo/演视DICOM序列/275.dcm new file mode 100644 index 0000000..51990c2 Binary files /dev/null and b/demo/演视DICOM序列/275.dcm differ diff --git a/demo/演视DICOM序列/276.dcm b/demo/演视DICOM序列/276.dcm new file mode 100644 index 0000000..29b3071 Binary files /dev/null and b/demo/演视DICOM序列/276.dcm differ diff --git a/demo/演视DICOM序列/277.dcm b/demo/演视DICOM序列/277.dcm new file mode 100644 index 0000000..668b78e Binary files /dev/null and b/demo/演视DICOM序列/277.dcm differ diff --git a/demo/演视DICOM序列/278.dcm b/demo/演视DICOM序列/278.dcm new file mode 100644 index 0000000..00dd934 Binary files /dev/null and b/demo/演视DICOM序列/278.dcm differ diff --git a/demo/演视DICOM序列/279.dcm b/demo/演视DICOM序列/279.dcm new file mode 100644 index 0000000..c096d12 Binary files /dev/null and b/demo/演视DICOM序列/279.dcm differ diff --git a/demo/演视DICOM序列/28.dcm b/demo/演视DICOM序列/28.dcm new file mode 100644 index 0000000..3520a5c Binary files /dev/null and b/demo/演视DICOM序列/28.dcm differ diff --git a/demo/演视DICOM序列/280.dcm b/demo/演视DICOM序列/280.dcm new file mode 100644 index 0000000..908f792 Binary files /dev/null and b/demo/演视DICOM序列/280.dcm differ diff --git a/demo/演视DICOM序列/281.dcm b/demo/演视DICOM序列/281.dcm new file mode 100644 index 0000000..a9e84b3 Binary files /dev/null and b/demo/演视DICOM序列/281.dcm differ diff --git a/demo/演视DICOM序列/282.dcm b/demo/演视DICOM序列/282.dcm new file mode 100644 index 0000000..b170ffe Binary files /dev/null and b/demo/演视DICOM序列/282.dcm differ diff --git a/demo/演视DICOM序列/283.dcm b/demo/演视DICOM序列/283.dcm new file mode 100644 index 0000000..0f3e277 Binary files /dev/null and b/demo/演视DICOM序列/283.dcm differ diff --git a/demo/演视DICOM序列/284.dcm b/demo/演视DICOM序列/284.dcm new file mode 100644 index 0000000..1792234 Binary files /dev/null and b/demo/演视DICOM序列/284.dcm differ diff --git a/demo/演视DICOM序列/285.dcm b/demo/演视DICOM序列/285.dcm new file mode 100644 index 0000000..8e16ba9 Binary files /dev/null and b/demo/演视DICOM序列/285.dcm differ diff --git a/demo/演视DICOM序列/286.dcm b/demo/演视DICOM序列/286.dcm new file mode 100644 index 0000000..e71de2d Binary files /dev/null and b/demo/演视DICOM序列/286.dcm differ diff --git a/demo/演视DICOM序列/287.dcm b/demo/演视DICOM序列/287.dcm new file mode 100644 index 0000000..9e43041 Binary files /dev/null and b/demo/演视DICOM序列/287.dcm differ diff --git a/demo/演视DICOM序列/288.dcm b/demo/演视DICOM序列/288.dcm new file mode 100644 index 0000000..7320251 Binary files /dev/null and b/demo/演视DICOM序列/288.dcm differ diff --git a/demo/演视DICOM序列/289.dcm b/demo/演视DICOM序列/289.dcm new file mode 100644 index 0000000..ae6bca2 Binary files /dev/null and b/demo/演视DICOM序列/289.dcm differ diff --git a/demo/演视DICOM序列/29.dcm b/demo/演视DICOM序列/29.dcm new file mode 100644 index 0000000..2bf04b5 Binary files /dev/null and b/demo/演视DICOM序列/29.dcm differ diff --git a/demo/演视DICOM序列/290.dcm b/demo/演视DICOM序列/290.dcm new file mode 100644 index 0000000..599c322 Binary files /dev/null and b/demo/演视DICOM序列/290.dcm differ diff --git a/demo/演视DICOM序列/291.dcm b/demo/演视DICOM序列/291.dcm new file mode 100644 index 0000000..674be56 Binary files /dev/null and b/demo/演视DICOM序列/291.dcm differ diff --git a/demo/演视DICOM序列/292.dcm b/demo/演视DICOM序列/292.dcm new file mode 100644 index 0000000..429ec9f Binary files /dev/null and b/demo/演视DICOM序列/292.dcm differ diff --git a/demo/演视DICOM序列/293.dcm b/demo/演视DICOM序列/293.dcm new file mode 100644 index 0000000..779ffb6 Binary files /dev/null and b/demo/演视DICOM序列/293.dcm differ diff --git a/demo/演视DICOM序列/294.dcm b/demo/演视DICOM序列/294.dcm new file mode 100644 index 0000000..61f015b Binary files /dev/null and b/demo/演视DICOM序列/294.dcm differ diff --git a/demo/演视DICOM序列/295.dcm b/demo/演视DICOM序列/295.dcm new file mode 100644 index 0000000..34a58c6 Binary files /dev/null and b/demo/演视DICOM序列/295.dcm differ diff --git a/demo/演视DICOM序列/296.dcm b/demo/演视DICOM序列/296.dcm new file mode 100644 index 0000000..7c641b1 Binary files /dev/null and b/demo/演视DICOM序列/296.dcm differ diff --git a/demo/演视DICOM序列/297.dcm b/demo/演视DICOM序列/297.dcm new file mode 100644 index 0000000..74de6de Binary files /dev/null and b/demo/演视DICOM序列/297.dcm differ diff --git a/demo/演视DICOM序列/298.dcm b/demo/演视DICOM序列/298.dcm new file mode 100644 index 0000000..45600b5 Binary files /dev/null and b/demo/演视DICOM序列/298.dcm differ diff --git a/demo/演视DICOM序列/299.dcm b/demo/演视DICOM序列/299.dcm new file mode 100644 index 0000000..b71622b Binary files /dev/null and b/demo/演视DICOM序列/299.dcm differ diff --git a/demo/演视DICOM序列/3.dcm b/demo/演视DICOM序列/3.dcm new file mode 100644 index 0000000..dc72f8b Binary files /dev/null and b/demo/演视DICOM序列/3.dcm differ diff --git a/demo/演视DICOM序列/30.dcm b/demo/演视DICOM序列/30.dcm new file mode 100644 index 0000000..a7b2fdf Binary files /dev/null and b/demo/演视DICOM序列/30.dcm differ diff --git a/demo/演视DICOM序列/300.dcm b/demo/演视DICOM序列/300.dcm new file mode 100644 index 0000000..44bfce3 Binary files /dev/null and b/demo/演视DICOM序列/300.dcm differ diff --git a/demo/演视DICOM序列/31.dcm b/demo/演视DICOM序列/31.dcm new file mode 100644 index 0000000..c166739 Binary files /dev/null and b/demo/演视DICOM序列/31.dcm differ diff --git a/demo/演视DICOM序列/32.dcm b/demo/演视DICOM序列/32.dcm new file mode 100644 index 0000000..4d37969 Binary files /dev/null and b/demo/演视DICOM序列/32.dcm differ diff --git a/demo/演视DICOM序列/33.dcm b/demo/演视DICOM序列/33.dcm new file mode 100644 index 0000000..675db57 Binary files /dev/null and b/demo/演视DICOM序列/33.dcm differ diff --git a/demo/演视DICOM序列/34.dcm b/demo/演视DICOM序列/34.dcm new file mode 100644 index 0000000..e073f60 Binary files /dev/null and b/demo/演视DICOM序列/34.dcm differ diff --git a/demo/演视DICOM序列/35.dcm b/demo/演视DICOM序列/35.dcm new file mode 100644 index 0000000..1d15aee Binary files /dev/null and b/demo/演视DICOM序列/35.dcm differ diff --git a/demo/演视DICOM序列/36.dcm b/demo/演视DICOM序列/36.dcm new file mode 100644 index 0000000..7e915da Binary files /dev/null and b/demo/演视DICOM序列/36.dcm differ diff --git a/demo/演视DICOM序列/37.dcm b/demo/演视DICOM序列/37.dcm new file mode 100644 index 0000000..8954ed4 Binary files /dev/null and b/demo/演视DICOM序列/37.dcm differ diff --git a/demo/演视DICOM序列/38.dcm b/demo/演视DICOM序列/38.dcm new file mode 100644 index 0000000..3e05328 Binary files /dev/null and b/demo/演视DICOM序列/38.dcm differ diff --git a/demo/演视DICOM序列/39.dcm b/demo/演视DICOM序列/39.dcm new file mode 100644 index 0000000..f213f42 Binary files /dev/null and b/demo/演视DICOM序列/39.dcm differ diff --git a/demo/演视DICOM序列/4.dcm b/demo/演视DICOM序列/4.dcm new file mode 100644 index 0000000..ee226ac Binary files /dev/null and b/demo/演视DICOM序列/4.dcm differ diff --git a/demo/演视DICOM序列/40.dcm b/demo/演视DICOM序列/40.dcm new file mode 100644 index 0000000..b4dcd69 Binary files /dev/null and b/demo/演视DICOM序列/40.dcm differ diff --git a/demo/演视DICOM序列/41.dcm b/demo/演视DICOM序列/41.dcm new file mode 100644 index 0000000..dab712d Binary files /dev/null and b/demo/演视DICOM序列/41.dcm differ diff --git a/demo/演视DICOM序列/42.dcm b/demo/演视DICOM序列/42.dcm new file mode 100644 index 0000000..acbb50d Binary files /dev/null and b/demo/演视DICOM序列/42.dcm differ diff --git a/demo/演视DICOM序列/43.dcm b/demo/演视DICOM序列/43.dcm new file mode 100644 index 0000000..530c0ff Binary files /dev/null and b/demo/演视DICOM序列/43.dcm differ diff --git a/demo/演视DICOM序列/44.dcm b/demo/演视DICOM序列/44.dcm new file mode 100644 index 0000000..eae71cf Binary files /dev/null and b/demo/演视DICOM序列/44.dcm differ diff --git a/demo/演视DICOM序列/45.dcm b/demo/演视DICOM序列/45.dcm new file mode 100644 index 0000000..f9b5a64 Binary files /dev/null and b/demo/演视DICOM序列/45.dcm differ diff --git a/demo/演视DICOM序列/46.dcm b/demo/演视DICOM序列/46.dcm new file mode 100644 index 0000000..f86f493 Binary files /dev/null and b/demo/演视DICOM序列/46.dcm differ diff --git a/demo/演视DICOM序列/47.dcm b/demo/演视DICOM序列/47.dcm new file mode 100644 index 0000000..3ddfec3 Binary files /dev/null and b/demo/演视DICOM序列/47.dcm differ diff --git a/demo/演视DICOM序列/48.dcm b/demo/演视DICOM序列/48.dcm new file mode 100644 index 0000000..8cb792b Binary files /dev/null and b/demo/演视DICOM序列/48.dcm differ diff --git a/demo/演视DICOM序列/49.dcm b/demo/演视DICOM序列/49.dcm new file mode 100644 index 0000000..a0bbc87 Binary files /dev/null and b/demo/演视DICOM序列/49.dcm differ diff --git a/demo/演视DICOM序列/5.dcm b/demo/演视DICOM序列/5.dcm new file mode 100644 index 0000000..3e55147 Binary files /dev/null and b/demo/演视DICOM序列/5.dcm differ diff --git a/demo/演视DICOM序列/50.dcm b/demo/演视DICOM序列/50.dcm new file mode 100644 index 0000000..5974179 Binary files /dev/null and b/demo/演视DICOM序列/50.dcm differ diff --git a/demo/演视DICOM序列/51.dcm b/demo/演视DICOM序列/51.dcm new file mode 100644 index 0000000..0e76a68 Binary files /dev/null and b/demo/演视DICOM序列/51.dcm differ diff --git a/demo/演视DICOM序列/52.dcm b/demo/演视DICOM序列/52.dcm new file mode 100644 index 0000000..e16fc8e Binary files /dev/null and b/demo/演视DICOM序列/52.dcm differ diff --git a/demo/演视DICOM序列/53.dcm b/demo/演视DICOM序列/53.dcm new file mode 100644 index 0000000..5a21f80 Binary files /dev/null and b/demo/演视DICOM序列/53.dcm differ diff --git a/demo/演视DICOM序列/54.dcm b/demo/演视DICOM序列/54.dcm new file mode 100644 index 0000000..c2498b6 Binary files /dev/null and b/demo/演视DICOM序列/54.dcm differ diff --git a/demo/演视DICOM序列/55.dcm b/demo/演视DICOM序列/55.dcm new file mode 100644 index 0000000..a50f539 Binary files /dev/null and b/demo/演视DICOM序列/55.dcm differ diff --git a/demo/演视DICOM序列/56.dcm b/demo/演视DICOM序列/56.dcm new file mode 100644 index 0000000..cb24fc0 Binary files /dev/null and b/demo/演视DICOM序列/56.dcm differ diff --git a/demo/演视DICOM序列/57.dcm b/demo/演视DICOM序列/57.dcm new file mode 100644 index 0000000..c6c2df5 Binary files /dev/null and b/demo/演视DICOM序列/57.dcm differ diff --git a/demo/演视DICOM序列/58.dcm b/demo/演视DICOM序列/58.dcm new file mode 100644 index 0000000..e4e9a0d Binary files /dev/null and b/demo/演视DICOM序列/58.dcm differ diff --git a/demo/演视DICOM序列/59.dcm b/demo/演视DICOM序列/59.dcm new file mode 100644 index 0000000..11b21cc Binary files /dev/null and b/demo/演视DICOM序列/59.dcm differ diff --git a/demo/演视DICOM序列/6.dcm b/demo/演视DICOM序列/6.dcm new file mode 100644 index 0000000..14bea1c Binary files /dev/null and b/demo/演视DICOM序列/6.dcm differ diff --git a/demo/演视DICOM序列/60.dcm b/demo/演视DICOM序列/60.dcm new file mode 100644 index 0000000..88ec3a6 Binary files /dev/null and b/demo/演视DICOM序列/60.dcm differ diff --git a/demo/演视DICOM序列/61.dcm b/demo/演视DICOM序列/61.dcm new file mode 100644 index 0000000..905c3d7 Binary files /dev/null and b/demo/演视DICOM序列/61.dcm differ diff --git a/demo/演视DICOM序列/62.dcm b/demo/演视DICOM序列/62.dcm new file mode 100644 index 0000000..8f0ceaa Binary files /dev/null and b/demo/演视DICOM序列/62.dcm differ diff --git a/demo/演视DICOM序列/63.dcm b/demo/演视DICOM序列/63.dcm new file mode 100644 index 0000000..3c1f716 Binary files /dev/null and b/demo/演视DICOM序列/63.dcm differ diff --git a/demo/演视DICOM序列/64.dcm b/demo/演视DICOM序列/64.dcm new file mode 100644 index 0000000..4944dc6 Binary files /dev/null and b/demo/演视DICOM序列/64.dcm differ diff --git a/demo/演视DICOM序列/65.dcm b/demo/演视DICOM序列/65.dcm new file mode 100644 index 0000000..84521a4 Binary files /dev/null and b/demo/演视DICOM序列/65.dcm differ diff --git a/demo/演视DICOM序列/66.dcm b/demo/演视DICOM序列/66.dcm new file mode 100644 index 0000000..28b06e7 Binary files /dev/null and b/demo/演视DICOM序列/66.dcm differ diff --git a/demo/演视DICOM序列/67.dcm b/demo/演视DICOM序列/67.dcm new file mode 100644 index 0000000..63827da Binary files /dev/null and b/demo/演视DICOM序列/67.dcm differ diff --git a/demo/演视DICOM序列/68.dcm b/demo/演视DICOM序列/68.dcm new file mode 100644 index 0000000..d01b341 Binary files /dev/null and b/demo/演视DICOM序列/68.dcm differ diff --git a/demo/演视DICOM序列/69.dcm b/demo/演视DICOM序列/69.dcm new file mode 100644 index 0000000..38023a1 Binary files /dev/null and b/demo/演视DICOM序列/69.dcm differ diff --git a/demo/演视DICOM序列/7.dcm b/demo/演视DICOM序列/7.dcm new file mode 100644 index 0000000..9e975af Binary files /dev/null and b/demo/演视DICOM序列/7.dcm differ diff --git a/demo/演视DICOM序列/70.dcm b/demo/演视DICOM序列/70.dcm new file mode 100644 index 0000000..02067da Binary files /dev/null and b/demo/演视DICOM序列/70.dcm differ diff --git a/demo/演视DICOM序列/71.dcm b/demo/演视DICOM序列/71.dcm new file mode 100644 index 0000000..2c67398 Binary files /dev/null and b/demo/演视DICOM序列/71.dcm differ diff --git a/demo/演视DICOM序列/72.dcm b/demo/演视DICOM序列/72.dcm new file mode 100644 index 0000000..31f09ba Binary files /dev/null and b/demo/演视DICOM序列/72.dcm differ diff --git a/demo/演视DICOM序列/73.dcm b/demo/演视DICOM序列/73.dcm new file mode 100644 index 0000000..3942ea7 Binary files /dev/null and b/demo/演视DICOM序列/73.dcm differ diff --git a/demo/演视DICOM序列/74.dcm b/demo/演视DICOM序列/74.dcm new file mode 100644 index 0000000..7d1ccad Binary files /dev/null and b/demo/演视DICOM序列/74.dcm differ diff --git a/demo/演视DICOM序列/75.dcm b/demo/演视DICOM序列/75.dcm new file mode 100644 index 0000000..1cb641c Binary files /dev/null and b/demo/演视DICOM序列/75.dcm differ diff --git a/demo/演视DICOM序列/76.dcm b/demo/演视DICOM序列/76.dcm new file mode 100644 index 0000000..bfc00a4 Binary files /dev/null and b/demo/演视DICOM序列/76.dcm differ diff --git a/demo/演视DICOM序列/77.dcm b/demo/演视DICOM序列/77.dcm new file mode 100644 index 0000000..36dacaa Binary files /dev/null and b/demo/演视DICOM序列/77.dcm differ diff --git a/demo/演视DICOM序列/78.dcm b/demo/演视DICOM序列/78.dcm new file mode 100644 index 0000000..215a06c Binary files /dev/null and b/demo/演视DICOM序列/78.dcm differ diff --git a/demo/演视DICOM序列/79.dcm b/demo/演视DICOM序列/79.dcm new file mode 100644 index 0000000..481c09e Binary files /dev/null and b/demo/演视DICOM序列/79.dcm differ diff --git a/demo/演视DICOM序列/8.dcm b/demo/演视DICOM序列/8.dcm new file mode 100644 index 0000000..6e7e66d Binary files /dev/null and b/demo/演视DICOM序列/8.dcm differ diff --git a/demo/演视DICOM序列/80.dcm b/demo/演视DICOM序列/80.dcm new file mode 100644 index 0000000..dfd20b7 Binary files /dev/null and b/demo/演视DICOM序列/80.dcm differ diff --git a/demo/演视DICOM序列/81.dcm b/demo/演视DICOM序列/81.dcm new file mode 100644 index 0000000..138de4b Binary files /dev/null and b/demo/演视DICOM序列/81.dcm differ diff --git a/demo/演视DICOM序列/82.dcm b/demo/演视DICOM序列/82.dcm new file mode 100644 index 0000000..b494d96 Binary files /dev/null and b/demo/演视DICOM序列/82.dcm differ diff --git a/demo/演视DICOM序列/83.dcm b/demo/演视DICOM序列/83.dcm new file mode 100644 index 0000000..5f81261 Binary files /dev/null and b/demo/演视DICOM序列/83.dcm differ diff --git a/demo/演视DICOM序列/84.dcm b/demo/演视DICOM序列/84.dcm new file mode 100644 index 0000000..6f11283 Binary files /dev/null and b/demo/演视DICOM序列/84.dcm differ diff --git a/demo/演视DICOM序列/85.dcm b/demo/演视DICOM序列/85.dcm new file mode 100644 index 0000000..a7ad690 Binary files /dev/null and b/demo/演视DICOM序列/85.dcm differ diff --git a/demo/演视DICOM序列/86.dcm b/demo/演视DICOM序列/86.dcm new file mode 100644 index 0000000..aa17a59 Binary files /dev/null and b/demo/演视DICOM序列/86.dcm differ diff --git a/demo/演视DICOM序列/87.dcm b/demo/演视DICOM序列/87.dcm new file mode 100644 index 0000000..1ac2150 Binary files /dev/null and b/demo/演视DICOM序列/87.dcm differ diff --git a/demo/演视DICOM序列/88.dcm b/demo/演视DICOM序列/88.dcm new file mode 100644 index 0000000..1a6e809 Binary files /dev/null and b/demo/演视DICOM序列/88.dcm differ diff --git a/demo/演视DICOM序列/89.dcm b/demo/演视DICOM序列/89.dcm new file mode 100644 index 0000000..c695561 Binary files /dev/null and b/demo/演视DICOM序列/89.dcm differ diff --git a/demo/演视DICOM序列/9.dcm b/demo/演视DICOM序列/9.dcm new file mode 100644 index 0000000..42af268 Binary files /dev/null and b/demo/演视DICOM序列/9.dcm differ diff --git a/demo/演视DICOM序列/90.dcm b/demo/演视DICOM序列/90.dcm new file mode 100644 index 0000000..a3574f0 Binary files /dev/null and b/demo/演视DICOM序列/90.dcm differ diff --git a/demo/演视DICOM序列/91.dcm b/demo/演视DICOM序列/91.dcm new file mode 100644 index 0000000..7d90faf Binary files /dev/null and b/demo/演视DICOM序列/91.dcm differ diff --git a/demo/演视DICOM序列/92.dcm b/demo/演视DICOM序列/92.dcm new file mode 100644 index 0000000..ffe362b Binary files /dev/null and b/demo/演视DICOM序列/92.dcm differ diff --git a/demo/演视DICOM序列/93.dcm b/demo/演视DICOM序列/93.dcm new file mode 100644 index 0000000..7aceefc Binary files /dev/null and b/demo/演视DICOM序列/93.dcm differ diff --git a/demo/演视DICOM序列/94.dcm b/demo/演视DICOM序列/94.dcm new file mode 100644 index 0000000..0374108 Binary files /dev/null and b/demo/演视DICOM序列/94.dcm differ diff --git a/demo/演视DICOM序列/95.dcm b/demo/演视DICOM序列/95.dcm new file mode 100644 index 0000000..37843f8 Binary files /dev/null and b/demo/演视DICOM序列/95.dcm differ diff --git a/demo/演视DICOM序列/96.dcm b/demo/演视DICOM序列/96.dcm new file mode 100644 index 0000000..f14da4c Binary files /dev/null and b/demo/演视DICOM序列/96.dcm differ diff --git a/demo/演视DICOM序列/97.dcm b/demo/演视DICOM序列/97.dcm new file mode 100644 index 0000000..73b74a6 Binary files /dev/null and b/demo/演视DICOM序列/97.dcm differ diff --git a/demo/演视DICOM序列/98.dcm b/demo/演视DICOM序列/98.dcm new file mode 100644 index 0000000..65330fe Binary files /dev/null and b/demo/演视DICOM序列/98.dcm differ diff --git a/demo/演视DICOM序列/99.dcm b/demo/演视DICOM序列/99.dcm new file mode 100644 index 0000000..0522dbf Binary files /dev/null and b/demo/演视DICOM序列/99.dcm differ diff --git a/demo/演视LC视频序列.mp4 b/demo/演视LC视频序列.mp4 new file mode 100644 index 0000000..78fa6be Binary files /dev/null and b/demo/演视LC视频序列.mp4 differ diff --git a/doc/01-architecture.md b/doc/01-architecture.md new file mode 100644 index 0000000..509b37a --- /dev/null +++ b/doc/01-architecture.md @@ -0,0 +1,51 @@ +# 01 架构说明 + +## 服务拓扑 + +最小 Docker 部署由 6 个服务组成: + +| 服务 | 作用 | 对外端口 | +|------|------|----------| +| `frontend` | Nginx 托管 React 生产构建 | `3000 -> 80` | +| `backend` | FastAPI API、WebSocket、数据库初始化、默认模板 seed | `8000 -> 8000` | +| `worker` | Celery 后台任务,执行拆帧、DICOM 解析、传播任务 | 无 | +| `postgres` | PostgreSQL 业务数据库 | 不默认暴露 | +| `redis` | Celery broker/result backend 和进度事件 | 不默认暴露 | +| `minio` | 上传媒体、帧图片、导出素材对象存储 | `9000` / `9001` | + +## 数据持久化 + +Compose 使用两个命名卷: + +- `postgres-data`:数据库。 +- `minio-data`:对象存储。 + +项目目录下还有两个可选挂载目录: + +- `models/`:SAM2 权重。 +- `demo/`:演示视频和 DICOM 序列。 + +## 前后端地址推导 + +前端运行在浏览器中,默认按当前页面 hostname 推导后端地址: + +- `http://<页面hostname>:8000` +- WebSocket: `ws://<页面hostname>:8000/ws/progress` + +因此局域网部署时,应使用同一个 `PUBLIC_HOST` 访问前端、后端和 MinIO。 + +## MinIO 公网地址 + +后端上传/下载对象使用内部地址: + +```text +MINIO_ENDPOINT=minio:9000 +``` + +生成给浏览器使用的预签名 URL 使用公网地址: + +```text +MINIO_PUBLIC_ENDPOINT=:9000 +``` + +这是 Docker 部署包对原项目做的最小部署适配,避免浏览器拿到 `http://minio:9000/...` 这种不可解析地址。 diff --git a/doc/01-purpose-and-word-summary.md b/doc/01-purpose-and-word-summary.md new file mode 100644 index 0000000..2028cd4 --- /dev/null +++ b/doc/01-purpose-and-word-summary.md @@ -0,0 +1,57 @@ +# 目的与 Word 方案摘要 + +## 为什么要做这个系统 + +Word 文档《语义分割系统构建方案.docx》的核心目标是建设一个面向视频和连续帧的智能语义分割标注系统,解决传统标注工具在以下场景中的痛点: + +- 视频或连续帧数量大,逐帧人工画 mask 成本高。 +- 高分辨率图像上同时存在底图、点、框、多边形和遮罩,DOM 渲染难以支撑重交互。 +- AI 分割需要低延迟点选/框选反馈,普通 REST 往返在密集交互场景下体验较差。 +- 语义分割要求一个像素只能归属一个类别,因此需要模板、颜色、z-index 和类别优先级来解决遮罩重叠。 +- 历史 GT mask 如果只是作为静态像素图层叠加,后续修改不灵活;Word 方案希望把 mask 降维成可编辑的点区域。 + +所以这个系统的业务目的不是单纯播放视频,而是把“视频/DICOM 数据接入、拆帧、AI 辅助分割、语义分类、标注导出”串成一个工作台。 + +## Word 中的目标架构 + +Word 方案描述的理想系统包含: + +- React/Vue + Konva 的高性能 Canvas 工作台。 +- FastAPI 后端,使用 WebSocket 处理实时交互与任务进度。 +- Celery + Redis 处理视频拆帧等长任务。 +- FFmpeg/OpenCV 解析视频,pydicom 解析医学影像。 +- 本地 CUDA 上的 SAM 推理;当前产品实现启用可选 SAM 2.1 tiny/small/base+/large,SAM 3 因没有文本提示入口而暂时禁用。 +- GT mask 导入后通过距离变换、骨架提取、聚类等算法降维为点区域。 +- 模板库管理分类、颜色和 z-index,用于语义分割遮罩重叠裁决。 +- PostgreSQL 存储项目、帧、模板和点区域数据。 + +## 当前代码已落地的部分 + +| 目标 | 当前代码状态 | 依据 | +|------|--------------|------| +| React 前端工作台 | 已落地 | `src/App.tsx`、`src/components/*.tsx` | +| Konva Canvas | 已落地 | `CanvasArea.tsx`、`AISegmentation.tsx` 使用 `react-konva` | +| FastAPI 后端 | 已落地 | `backend/main.py` | +| PostgreSQL ORM | 已落地 | `backend/database.py`、`backend/models.py` | +| MinIO 对象存储 | 已落地 | `backend/minio_client.py` | +| Redis 连接 | 已落地 | 用于 Celery broker/result backend,并通过 `seg:progress` pub/sub 转发任务进度 | +| 视频拆帧 | 已落地 | `backend/services/frame_parser.py`、`backend/routers/media.py` | +| DICOM 批量导入 | 已落地 | 上传、文件名自然排序、解析任务创建和项目库解析进度回显均已接入 | +| WebSocket 进度 | 已落地 | 拆帧进度写入任务表后发布到 Redis `seg:progress`,FastAPI 广播到 `/ws/progress` | +| SAM 推理 | 部分落地 | 当前产品入口启用 SAM 2.1 tiny/small/base+/large 和真实 GPU/SAM2.1 状态接口;SAM 2.1 已接 point/box/interactive 和 video predictor 片段传播。SAM 3 桥接源码保留,但前端入口和后端 registry 已禁用 | +| 模板库 | 部分落地 | 分类、颜色、maskid、JSON 批量导入预览和拖拽排序能存储和编辑;右侧语义分类树也可拖拽调整内部覆盖顺序;PNG mask 导出时会按内部优先级做语义融合裁决,前端预览裁决尚未落地 | +| 标注持久化 | 部分落地 | 后端有 `Annotation` 表,前端已接入新增、回显、分类更新、传播链前后帧同目标同步换类、当前帧删除、手工绘制、GT mask 导入、polygon 顶点拖动/删除、边中点插点和多 polygon 子区域编辑;复杂洞结构编辑未落地 | +| COCO / Mask 导出 | 已落地基础能力 | `backend/routers/export.py`;COCO JSON、兼容 PNG mask ZIP 和统一分割结果 ZIP 均已接入;统一 ZIP 包含 maskid/GT 像素值映射、原始图片、按帧/类别合并的分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;GT_label 固定为 8-bit uint8 PNG,像素值使用类别真实 maskid,其中 `maskid:0` 的“待分类”和背景同为 0,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出 | + +## 当前代码尚未落地的目标 + +- SAM 3:`sam3_engine.py`、`sam3_external_worker.py` 和 `setup_sam3_env.sh` 作为历史实现保留;由于当前系统不给文本提示,前端不再展示 SAM 3,后端 registry 也不暴露 `sam3`。官方没有 SAM 3 tiny/small 权重,当前可选最小真实 SAM 权重仍是 SAM 2.1 tiny。 +- GT mask 导入:当前仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图导入,后端会按 maskid 拆分区域,生成高精度 polygon 标注;超出现有类别的 maskid 可舍弃或导入为黑色 `maskid:0` 的“待分类”;16-bit/uint16 GT_label 和普通彩色类别图会被拒绝,尺寸不一致会自动最近邻拉伸到当前帧;骨架提取、HDBSCAN 和更复杂的模板自动映射尚未实现。 +- Mask 到点区域的拓扑降维:后端保留 distance transform seed point 数据兼容;前端不再显示黄色 seed point,也不提供 seed point 拖拽编辑;骨架提取、HDBSCAN 等增强尚未实现。 +- 类别优先级融合:PNG mask 导出时已按内部优先级生成语义融合 mask;前端裁决预览尚未实现。 +- 撤销/重做:当前已有全局 mask 历史栈。 +- 保存状态按钮:工作区按钮按待保存数量显示“保存 X 个改动”或“已全部保存”,并调用 `POST /api/ai/annotate` 保存当前未归档 mask,通过 `PATCH /api/ai/annotations/{id}` 更新 dirty mask。 + +## 结论 + +当前项目已经从 UI 原型推进到“可上传、可异步拆帧、可取消/重试任务、可查看失败详情、可实时查看任务进度、可浏览项目帧、可维护模板、可手工绘制、可逐点编辑 polygon、可边中点插点、可多 polygon 子区域编辑、可区域合并/去除、可用可选 SAM 2.1 做点/框 AI 推理、可对点/框 prompt 做裁剪推理和背景过滤、可用 SAM 2.1 后台任务进行视频片段传播、可导入多类别 GT mask、可保存标注、可导出 COCO/语义 mask ZIP、可查看 Dashboard 后端概览”的全栈雏形。下一阶段最重要的是继续补齐复杂洞结构编辑、GT mask 骨架/聚类增强和前端语义融合预览。 diff --git a/doc/02-current-implementation-map.md b/doc/02-current-implementation-map.md new file mode 100644 index 0000000..c8963f0 --- /dev/null +++ b/doc/02-current-implementation-map.md @@ -0,0 +1,117 @@ +# 当前实现地图 + +## 运行入口 + +### 前端入口 + +- React 挂载:`src/main.tsx` +- 根组件:`src/App.tsx` +- 前端服务:`server.ts` +- 默认访问:`http://localhost:3000` + +`server.ts` 的角色比较特殊:它既负责在开发模式下创建 Vite middleware,也在生产模式下服务 `dist/`。当前旧版 `/api/login`、`/api/projects`、`/api/templates` mock 已清理;前端业务 API 走 `src/lib/api.ts` 指向的 FastAPI。 + +### 后端入口 + +- FastAPI 应用:`backend/main.py` +- 默认访问:`http://localhost:8000` +- API 文档:`http://localhost:8000/docs` +- 健康检查:`GET /health` + +后端启动时会通过 lifespan 执行: + +- 创建数据库表。 +- 检查 MinIO bucket。 +- 测试 Redis。 +- Seed 默认模板。 +- 如果存在 `demo/演视LC视频序列.mp4` 和 `demo/演视DICOM序列/`,创建名为“演视LC视频序列”的默认演示视频项目和名为“演视DICOM序列”的演示 DICOM 项目,视频和 DICOM 都会生成帧,DICOM 按文件名自然顺序读取;启动时会把旧显示名 `Data_MyVideo_1` / `演示DICOM序列` 迁移为新显示名。 + +## 前端模块切换 + +`App.tsx` 使用 Zustand 中的 `activeModule` 做模块切换,没有使用路由库。 +`useStore` 默认 `activeModule` 为 `dashboard`,因此用户登录后默认进入“总体概况”页。 + +| activeModule | 组件 | 页面 | +|--------------|------|------| +| `dashboard` | `Dashboard` | 系统概况 | +| `projects` | `ProjectLibrary` | 项目库 | +| `workspace` | `VideoWorkspace` | 分割工作区 | +| `ai` | `AISegmentation` | AI 智能分割页 | +| `templates` | `TemplateRegistry` | 模板库 | +| `admin` | `UserAdmin` | 管理员用户后台,仅 `role=admin` 可见 | + +未登录时,`App.tsx` 直接渲染 `Login`。 + +## 全局状态 + +全局状态在 `src/store/useStore.ts` 中,主要包括: + +- 登录状态:`isAuthenticated`、`token`、`currentUser` +- 项目:`projects`、`currentProject` +- 工作区:`activeModule`、`activeTool`、`frames`、`currentFrameIndex` +- 标注与 mask:`annotations`、`masks` +- 模板:`templates`、`activeTemplateId` +- UI:`isLoading`、`error` + +当前状态管理主要是前端内存状态;登录 token 会持久化到 `localStorage`,刷新后再通过 `/api/auth/me` 恢复当前用户。 + +## 数据流 + +### 登录 + +1. `Login.tsx` 调用 `login()`。 +2. `src/lib/api.ts` 请求 `POST /api/auth/login`。 +3. FastAPI `backend/routers/auth.py` 查询 `users` 表并校验密码哈希。 +4. 前端把返回 JWT 写入 localStorage,并把用户资料写入 store。 +5. 后续业务请求带 `Authorization: Bearer `,后端按当前用户过滤项目资源。 +6. 系统只支持唯一默认 `admin` 和 `annotator`;`admin/annotator` 可调用写入类业务接口;`/api/admin/*` 仅允许默认 `admin`。 + +### 管理员用户管理 + +1. `Sidebar.tsx` 仅对 `currentUser.role === 'admin'` 显示“用户管理”。 +2. `UserAdmin.tsx` 调用 `GET/POST/PATCH/DELETE /api/admin/users` 完成标注员新增、停用/启用、改密码和删除用户;不提供观察员或第二个管理员入口。 +3. `UserAdmin.tsx` 调用 `GET /api/admin/audit-logs` 展示登录成功/失败以及用户管理操作审计。 +4. `UserAdmin.tsx` 危险区“恢复演示出厂设置”需要浏览器确认和输入 `RESET_DEMO_FACTORY`,随后调用 `POST /api/admin/demo-factory-reset`。 +5. 后端 `backend/routers/admin.py` 会阻止管理员删除、停用、改名或降级自己;项目库已共享,因此删除标注员不会删除或迁移项目;演示出厂重置会清空其它用户、项目帧、标注、任务和私有模板,直接从 `demo/` 重新创建名为“演视LC视频序列”的已生成帧演示视频项目和名为“演视DICOM序列”的已自然排序演示 DICOM 项目。 + +### 项目与拆帧 + +1. `ProjectLibrary.tsx` 调用 `getProjects()` 获取项目。 +2. 上传视频时先 `createProject()`,再 `uploadMedia()`;导入视频不自动调用 `parseMedia()`。 +3. 后端 `media.py` 把原始文件上传到 MinIO。 +4. 用户在项目库点击“生成帧”或“重新生成帧”并选择 FPS 后,`parseMedia()` 创建 `processing_tasks` 记录并投递 Celery worker;已有帧的视频重新生成时,worker 会先删除旧帧、旧标注和旧 mask,再写入新的帧序列。 +5. Celery worker 下载 MinIO 文件,调用 `frame_parser.py` 拆帧。 +6. worker 把拆出的帧重新上传 MinIO,写入 `frames` 表,并更新任务状态。 +7. 工作区只通过 `GET /api/projects/{id}/frames` 获取完整预签名图片 URL 列表;若项目有源视频但无帧,会提示先回项目库生成帧。 +8. Dashboard 可通过 `POST /api/tasks/{id}/cancel` 取消 queued/running 任务,通过 `POST /api/tasks/{id}/retry` 重试 failed/cancelled 任务,并用 `GET /api/tasks/{id}` 查看失败详情。 + +### 工作区浏览 + +1. `VideoWorkspace.tsx` 根据 `currentProject.id` 加载帧。 +2. `CanvasArea.tsx` 用当前帧 URL 加载底图。 +3. `FrameTimeline.tsx` 显示缩略图和当前帧索引。 +4. 播放按钮会推进 `currentFrameIndex`,从而更新画布底图。 + +### 模板管理 + +1. `TemplateRegistry.tsx` 调用模板 API。 +2. 后端 `templates.py` 把 `classes` 和 `rules` 打包进 `mapping_rules` JSON 字段。 +3. `OntologyInspector.tsx` 读取全局 `templates` 和 `activeTemplateId` 展示分类树。 + +## 后端数据模型 + +| 模型 | 表 | 用途 | +|------|----|------| +| `Project` | `projects` | 项目元数据,包含视频路径、缩略图、状态、fps | +| `Frame` | `frames` | 拆帧后的图片记录 | +| `Template` | `templates` | 模板、本体类别、颜色、z-index、mapping_rules | +| `Annotation` | `annotations` | 标注数据、点、bbox、mask_data | +| `Mask` | `masks` | mask 文件元数据 | + +## 当前主要风险点 + +- 前端 API/WS 地址虽然已支持环境变量和 hostname 推导,但部署时仍需要确认浏览器可访问 `:8000` 后端。 +- AI 当前启用 SAM 2.1 tiny/small/base+/large 点/框/interactive 路径;语义文本提示和 SAM 3 产品入口已禁用,`model=sam3` 会被后端拒绝。SAM 3 源码保留但不计入当前可用功能。 +- 工作区顶部“分割结果导出”和保存状态按钮、左侧工具栏“导入 GT Mask”已接入统一导出、GT 多类别导入、标注新增和 dirty 标注更新;导入 GT Mask 仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,未知 maskid 可由用户选择舍弃或导入为黑色 `maskid:0` 的“待分类”,16-bit/uint16 GT_label 和普通彩色类别图会被拒绝,尺寸不同会自动最近邻拉伸到当前帧;GT 连通域会生成高精度 polygon,导入后和普通 mask 一样不显示黄色 seed point,并与普通 mask 共用拓扑统计、边缘平滑、编辑和保存链路。保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;统一导出可选择整体视频、特定范围帧或当前图片,并勾选分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;特定范围帧导出支持直接输入起止帧,也支持在播放进度条或视频处理进度条上点击/拖拽选择范围;Mix_label 支持默认 0.3 的透明度调节和首帧预览;后端统一导出 ZIP 固定包含 maskid/GT 像素值映射 JSON 与原始图片文件夹,GT_label 固定输出 8-bit uint8 PNG,像素值使用类别真实 maskid,其中 `maskid:0` 的“待分类”和背景同为 0,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出,并按客户命名规则输出分开 Mask、GT_label、Pro_label 和 Mix_label 文件夹;清空当前帧遮罩会删除对应后端标注,存在传播链时同一弹窗提供取消/当前帧/按帧范围选择/所有传播帧,按范围清空复用时间轴范围选择和最终确认;按范围或全部清空遇到人工/AI 标注帧时会二次确认,选择保留则整帧保留。手工绘制、polygon 顶点拖动/删除、区域合并/去除和撤销重做已经落到前端 mask 数据结构;多边形、矩形、圆和画笔创建遵循“有选中 mask 则并入选中 mask、无选中 mask 才新建”的规则,即使新几何和旧区域不重叠也会组成同一个多 polygon mask;无选中分类的新建多边形/矩形/圆会默认归入 `maskid:0` 的“待分类”,画笔无选中 mask 时仍要求右侧语义分类树有 active class;`Esc` 只取消选区和临时绘制状态,不删除已有 mask。 +- Dashboard 初始统计、队列和活动日志来自后端聚合接口;解析队列来自 `processing_tasks`,worker 进度通过 Redis `seg:progress` 转发到 WebSocket。任务取消、重试和失败详情已接入前后端。 +- 后端已接入 Bearer JWT 鉴权、共享项目库和角色权限;写入类业务接口要求 `admin/annotator`,管理员用户后台要求默认 `admin`。当前审计覆盖登录和用户管理操作,全业务级审计仍可继续扩展。 diff --git a/doc/02-deployment.md b/doc/02-deployment.md new file mode 100644 index 0000000..31dfdc7 --- /dev/null +++ b/doc/02-deployment.md @@ -0,0 +1,96 @@ +# 02 部署步骤 + +## 前置条件 + +- Docker Engine 24+ +- Docker Compose v2+ +- Linux 主机建议至少 4 核 CPU、8 GB 内存;启用 SAM2/GPU 时按模型权重另行评估。 + +## 本机部署 + +```bash +cd /home/wkmgc/Desktop/Seg_Server_Docker +cp .env.example .env +docker compose up -d --build +``` + +验证: + +```bash +curl http://localhost:8000/health +docker compose ps +``` + +## 局域网部署 + +1. 获取服务器 IP,例如 `192.168.3.11`。 +2. 修改 `.env`: + +```env +PUBLIC_HOST=192.168.3.11 +CORS_ORIGINS=["http://192.168.3.11:3000","http://localhost:3000","http://127.0.0.1:3000"] +``` + +3. 重新创建后端和 worker: + +```bash +docker compose up -d --build backend worker frontend +``` + +4. 访问: + +```text +http://192.168.3.11:3000 +``` + +需要放行端口:`3000`、`8000`、`9000`、可选 `9001`。 + +## 演示出厂设置数据 + +恢复演示出厂设置会检查: + +```text +demo/演视LC视频序列.mp4 +demo/演视DICOM序列/*.dcm +``` + +部署包当前已经包含这些演示文件。如需替换为其它演示数据,可按相同命名覆盖: + +```bash +mkdir -p demo/演视DICOM序列 +cp /path/to/video.mp4 demo/演视LC视频序列.mp4 +cp /path/to/dicom-series/*.dcm demo/演视DICOM序列/ +docker compose restart backend worker +``` + +## SAM2 / GPU 扩展 + +最小镜像没有安装 PyTorch 和 SAM2,因此 AI 推理会显示不可用,但普通项目、模板、手工标注、上传、拆帧、导出仍可运行。 + +启用 SAM2 的一种方式: + +1. 基于 `Dockerfile.backend` 创建 GPU 版 Dockerfile。 +2. 安装与你 CUDA 匹配的 PyTorch 和 `sam2`。 +3. 将权重放入 `models/`,例如: + +```text +models/sam2.1_hiera_tiny.pt +``` + +4. 在 compose 中为 `backend` 和 `worker` 增加 GPU runtime/device 配置。 +5. 保持 `.env` / compose 中: + +```env +SAM_MODEL_PATH=/app/models/sam2.1_hiera_tiny.pt +SAM_MODEL_CONFIG=configs/sam2.1/sam2.1_hiera_t.yaml +``` + +## 更新部署包 + +从源码仓库更新后,重新构建: + +```bash +docker compose up -d --build +``` + +数据库 schema 会由后端启动时执行 `create_all` 和兼容列检查。重要生产环境仍建议先备份数据库和 MinIO 数据。 diff --git a/doc/03-frontend-element-audit.md b/doc/03-frontend-element-audit.md new file mode 100644 index 0000000..2876945 --- /dev/null +++ b/doc/03-frontend-element-audit.md @@ -0,0 +1,190 @@ +# 前端逐元素审计 + +状态说明: + +- 真实可用:接真实状态或后端接口,可以完成主要动作。 +- 部分可用:能展示或完成一部分,但存在关键缺口。 +- Mock / UI-only:只有展示或本地状态变化,没有真实业务效果。 +- 接口不通:前端调用与后端接口不一致,按当前代码大概率失败。 + +## App 与导航 + +| 元素 | 位置 | 状态 | 说明 | +|------|------|------|------| +| 登录拦截 | `App.tsx` | 真实可用 | 未登录显示 `Login`,登录后显示主界面 | +| 模块切换 | `Sidebar.tsx` + `App.tsx` | 真实可用 | 切换 `dashboard/projects/workspace/ai/templates`;“AI智能分割”入口使用 Bot + Sparkles 组合图标,强化 AI 语义 | +| Logo | `Login.tsx` / `Sidebar.tsx` | 真实可用 | 登录页、侧边栏和 favicon 都使用 `public/logo.png`,运行时访问路径为 `/logo.png` | +| GPU 状态圆标 | `Sidebar.tsx` | 真实可用 | 通过 `GET /api/ai/models/status` 显示 GPU/CPU 和当前模型可用性 | + +## 登录页 + +| 元素 | 状态 | 说明 | +|------|------|------| +| 用户名/密码输入 | 真实可用 | 默认填入 `admin / 123456`,用户名使用 `autocomplete=username`,密码使用 `autocomplete=current-password` | +| 安全登录按钮 | 真实可用 | 调用 `POST /api/auth/login`,后端校验 `users` 表密码哈希并返回签名 JWT | +| 错误提示 | 真实可用 | 捕获后端错误并显示 | +| 登录态恢复 / 退出 | 真实可用 | 页面刷新后用 `/api/auth/me` 恢复当前用户;侧栏底部使用退出图标显示当前用户名并可退出登录,退出提示不接收鼠标事件,避免悬浮到工作区按钮时误弹出 | +| 安全审计说明文字 | 部分可用 | 登录和用户管理操作已有 `audit_logs` 记录;登录页“端到端加密”等安全文案仍是展示性说明,不代表已接入完整企业级安全审计 | + +## 管理员用户后台 + +| 元素 | 状态 | 说明 | +|------|------|------| +| 侧栏“用户管理”入口 | 真实可用 | 仅当前用户 `role=admin` 时显示;非管理员无法看到入口,后端 `/api/admin/*` 也会返回 403 | +| 用户列表 | 真实可用 | 调用 `GET /api/admin/users`,展示用户 id、用户名、角色、启停用状态和创建时间 | +| 新增用户 | 真实可用 | 调用 `POST /api/admin/users`,支持设置用户名和初始密码,新用户固定为标注员;后端校验用户名唯一、密码长度,并拒绝第二个管理员或观察员角色 | +| 启停用 / 改密码 | 真实可用 | 调用 `PATCH /api/admin/users/{id}`;后端禁止管理员把自己降级、改名或停用,避免锁死后台 | +| 删除用户 | 真实可用 | 调用 `DELETE /api/admin/users/{id}`;后端禁止删除自己,且用户名下仍有项目时返回 409,避免悬空项目数据 | +| 审计日志 | 真实可用 | 调用 `GET /api/admin/audit-logs`,展示登录成功/失败、用户新增、修改和删除等管理操作 | +| 恢复演示出厂设置 | 真实可用 | 管理员点击危险区按钮后先浏览器确认,再输入 `RESET_DEMO_FACTORY`;前端调用 `POST /api/admin/demo-factory-reset`,后端直接从 `demo/` 读取“演视LC视频序列”和“演视DICOM序列”,只保留默认 admin、已生成帧的演示视频项目和一个已按文件名自然顺序生成帧的演示 DICOM 项目,并清空用户、项目帧、标注、任务和私有模板等演示数据;“腹腔镜胆囊切除术”和“头颈部CT分割”系统模板会按内置默认定义重建或覆盖恢复 | + +## Dashboard 系统概况 + +| 元素 | 状态 | 说明 | +|------|------|------| +| WebSocket 连接状态 | 真实可用 | 前端通过 `src/lib/config.ts` 推导或读取 `VITE_WS_PROGRESS_URL`,后端有 `/ws/progress`;Dashboard 卸载或切页导致的主动断开不会触发自动重连,也不会继续输出“Connection closed”噪音 | +| 任务进度 | 真实可用 | 初始数据来自 `GET /api/dashboard/overview`,按 `processing_tasks` queued/running/success/failed/cancelled 任务生成;统计卡片中的处理中任务数只计算 queued/running | +| 任务取消 | 真实可用 | queued/running 任务显示取消按钮,调用 `POST /api/tasks/{task_id}/cancel` | +| 任务重试 | 真实可用 | failed/cancelled 任务显示重试按钮,调用 `POST /api/tasks/{task_id}/retry` 创建新任务 | +| 失败详情 | 真实可用 | 任务详情按钮调用 `GET /api/tasks/{task_id}`,弹窗展示 error、payload、result、Celery ID 和时间 | +| WebSocket 更新任务 | 真实可用 | Celery worker 更新 `processing_tasks` 后发布 Redis `seg:progress`,FastAPI 广播 progress/complete/error/cancelled | +| 项目、任务、标注、系统负载统计 | 真实可用 | 初始数据来自 `GET /api/dashboard/overview`,系统负载按主机 load average 估算 | +| 近期实时流转记录 | 真实可用 | 初始数据来自任务、项目、标注和模板记录;WebSocket status/complete/error 会继续追加 | + +## 项目库 ProjectLibrary + +| 元素 | 状态 | 说明 | +|------|------|------| +| 项目列表 | 真实可用 | 调用 `GET /api/projects` | +| 项目卡片缩略图 | 真实可用 | 后端返回 MinIO 预签名 `thumbnail_url` 时显示 | +| 点击项目进入工作区 | 真实可用 | 设置 `currentProject` 后切到 `workspace` | +| 新建项目 | 已移除入口 | 项目库不再展示独立“新建项目”按钮;导入视频/DICOM 时自动创建项目,后端 `POST /api/projects` 保留给导入流程和兼容调用 | +| 导入视频文件 | 真实可用 | 创建项目、上传源视频、刷新项目列表;不会自动拆帧;上传期间显示项目库导入进度条、百分比和已上传字节 | +| 生成帧/重新生成帧按钮 | 真实可用 | 对已导入源视频且非 parsing 状态的项目显示,调用 `parseMedia(projectId, { parseFps })`;已有帧时显示“重新生成帧”,后端会先清空旧帧、标注和 mask;任务入队后项目库继续轮询 `GET /api/tasks/{task_id}`,解析成功后立即重新拉取项目列表,使后端新写入的 `thumbnail_url` 自动刷新到项目封面 | +| 生成帧 FPS 滑块 | 真实可用 | 值传入 `/api/media/parse?parse_fps=...`,决定后台拆帧目标 FPS | +| 项目卡片 FPS 徽标 | 真实可用 | 右上角显示关键帧序列目标 `parse_fps`;原始视频帧率只在卡片底部以“原 xx fps”显示 | +| 导入 DICOM 序列 | 真实可用 | 可上传 `.dcm` 并触发解析;上传前按文件名自然顺序排序,后端解析也保持同一顺序;上传期间显示导入进度条、有效 DICOM 文件数量和已上传字节,上传完成后继续显示解析任务进度直到完成、失败或取消 | +| 项目状态徽标 | 真实可用 | 项目状态统一为 `pending/parsing/ready/error`,前端兼容归一化旧状态值 | +| 删除项目按钮 | 真实可用 | 点击垃圾桶按钮会确认删除,调用 `DELETE /api/projects/{id}`,成功后从项目库移除;若删除的是当前项目,会清空工作区当前项目、帧、mask 和选区 | +| 操作成功/失败提示 | 真实可用 | 使用非阻塞 `TransientNotice` 浮层,自动消失,不会拦截后续按钮、输入框或画布操作 | + +## 工作区 VideoWorkspace + +| 元素 | 状态 | 说明 | +|------|------|------| +| 当前项目名 | 真实可用 | 读取 `currentProject.name` | +| 顶栏操作提示 | 真实可用 | 保存、导出、传播范围选择等短反馈会自动消失;保存/导出/传播进行中和无帧项目提示会保留到状态变化 | +| 自动加载项目帧 | 真实可用 | 调用 `GET /api/projects/{id}/frames` | +| 无帧项目提示 | 真实可用 | 如果 `video_path` 存在但无帧,只提示回到项目库生成帧,不自动创建拆帧任务 | +| SAM 模型状态徽标 | 真实可用 | 左侧 Sidebar 底部保留紧凑 GPU/CPU 状态徽标;工作区顶栏不再重复显示该徽标,传播权重下拉和自动传播范围摘要只在进入自动传播后显示 | +| 已保存标注回显 | 真实可用 | 加载工作区帧后调用 `GET /api/ai/annotations` 并渲染已保存 mask;回显时保留当前项目帧里尚未保存的 AI/手工 draft mask,避免从 AI 页推送的候选被覆盖 | +| “分割结果导出”按钮 | 真实可用 | 原“导出 JSON 标注集”和“导出 PNG Mask ZIP”已合并为一个入口;按钮使用 `FileDown` 图标和绿色强调背景,区别于普通灰色操作按钮;点击后可选择整体视频、特定范围帧或当前图片,默认导出范围为当前图片,并勾选导出分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;选择“特定范围帧”后会进入时间轴范围选择模式,可在播放进度条或视频处理进度条上点击/拖拽选择导出起止帧,也可直接修改起止帧输入框;选择 Mix_label 时可调透明度,默认 0.3,并显示当前/待导出第一帧预览;提交前会保存未归档 mask,然后调用 `GET /api/export/{project_id}/results` 下载 ZIP;浏览器下载名和后端 `Content-Disposition` 均使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;时间戳格式为 `0h00m00s000ms`,帧序号来自项目抽帧后的 1-based 顺序,不使用原视频帧号;包内固定包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json` 和 `原始图片/`;选择分开 mask 时包含按帧子目录组织且同类合并的 `分开Mask分割结果/`,选择 GT_label/Pro_label/Mix_label 时分别包含 `GT_label图/`、`Pro_label彩色分割结果/`、`Mix_label重叠覆盖彩色分割结果/`。GT_label 图固定为 8-bit uint8 PNG,背景为 0,语义类别值使用类别真实 maskid,`maskid: 0` 的“待分类”与背景同为 0,Pro_label 中也与背景同为黑色 `[0,0,0]`,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出 | +| “导入 GT Mask”按钮 | 真实可用 | 入口已从工作区顶栏移动到左侧工具栏“重叠区域去除”之后,使用紫色图标底色;选择图片后先弹出导入结果预览和未知 maskid 策略选择,可舍弃未知类别或导入为黑色 `maskid:0` 的“待分类”;随后调用 `POST /api/ai/import-gt-mask`,后端仅支持 8-bit 二值/灰度 maskid 图和 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,不符合 8-bit 灰度/maskid 图要求时返回错误,16-bit/uint16 GT_label 会被拒绝;尺寸不同会自动最近邻拉伸到当前帧,再按类别/连通域生成高精度 polygon 标注,最后回显到工作区;导入 mask 与普通 mask 一样不显示黄色 seed point,并共用拓扑锚点统计、边缘平滑、编辑、分类和保存链路 | +| 参考帧/起止帧/传播权重/AI自动推理 | 真实可用 | 当前打开帧即参考帧,前端会使用该帧全部 mask 作为 seed;左侧工具栏橡皮擦下方有彩色 AI 大脑图标“AI自动推理”入口,点击后进入时间轴范围选择模式,顶栏才显示独立“传播权重”下拉,可在传播前二次选择 SAM 2.1 tiny/small/base+/large 权重,不提供 SAM2/SAM3 家族切换,不影响 AI 智能分割页的单帧推理权重选择;工作区会读取 `GET /api/ai/models/status`,当所有 SAM 2.1 变体不可用时禁用“AI自动推理”,当所选传播权重不可用时禁用“开始传播”,传播权重下拉中不可用变体也不可选择,避免提交后没有任何推理结果;传播权重下拉使用深色背景和青色文字,避免默认灰底白字不可读;播放进度条和视频处理进度条都可点击/拖拽回填传播起始帧和传播结束帧,顶栏会显示当前传播权重以及相对参考帧的向前/向后帧数,再点击“开始传播”提交;用户也可直接改数字框后点击按钮传播。提交后前端把传播权重 id、seed mask、seed 实例 id、未编辑传播结果的原始 seed 签名和前/后方向步骤提交到 `POST /api/ai/propagate/task`,后端先规范化/校验权重 id,再创建 `processing_tasks` 并由 Celery 执行对应 SAM 2.1 video predictor;同一参考帧多个同类别 seed 会优先按 `source_instance_id/instance_id` 分开传播,语义 `maskid` 只用于类别/导出;worker 会在本次目标帧段内按 seed 来源和几何/语义签名做幂等判断,未改变且目标帧已有结果的 seed 直接跳过,已改变、目标帧只部分覆盖或换权重时会先删除本次目标帧段内同源旧自动传播标注再重新传播;历史或外部 seed 若仍带边缘平滑参数,后端仍按完整签名兼容处理;当前前端平滑应用会直接改写 polygon,因此传播以新几何参与签名;中间帧人工新增/修改同一物体后重新传播时,后端会按语义和目标帧空间重叠清理旧传播结果,写入前清理不受旧结果 `propagation_direction` 限制,避免 backward 重传时与旧 forward mask 重叠;传播中顶栏蓝色进度面板显示任务进度、已处理帧次、删除旧区域数和已保存区域数,同一任务 message 不再同时显示在左侧灰色状态文字里;前端轮询 `GET /api/tasks/{task_id}` 并刷新已保存标注;任务可取消,若完成后 0 个新区域会明确提示没有生成新 mask 或已跳过未改变 mask | +| 清空片段遮罩 | 已移除 | 顶栏不再提供重复的“清空片段遮罩”;当前帧清空和 DEL 删除只从左侧工具栏或键盘触发,存在传播链时在同一弹窗提供取消/只清当前帧/按帧范围选择/清空所有传播帧 | +| 保存状态按钮 | 真实可用 | 顶栏按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;未保存 mask 写入 `POST /api/ai/annotate`,dirty mask 写入 `PATCH /api/ai/annotations/{id}`;保存成功后会重新拉取后端标注,并用 saved annotation 替换本次提交的 draft mask,避免仍显示未保存 | + +## CanvasArea 画布 + +| 元素 | 状态 | 说明 | +|------|------|------| +| 当前帧底图显示 | 真实可用 | `useImage(frameUrl)` 加载当前帧 URL;切换帧或容器尺寸变化时会按 86% 适配比例居中放大显示,默认留出画布边距,不铺满整个画布 | +| 滚轮缩放 | 真实可用 | 改变 Konva Stage scale | +| 拖拽平移 | 真实可用 | activeTool 为 `move` 时 Stage draggable,拖拽结束会回写 React position state,避免 Konva 节点位置和前端状态脱节 | +| 光标坐标显示 | 真实可用 | 根据 pointer position 计算 | +| 正向/反向选点 | 真实可用 | UI 能加点,并按当前帧 `frame.id` 调用 `/api/ai/predict`;结果需点击归档保存才持久化 | +| 框选 | 真实可用 | UI 能画框,并把框坐标归一化后调用后端推理;结果需点击归档保存才持久化 | +| AI 推理中提示 | 真实可用 | 请求期间会显示 | +| 手工多边形/矩形/圆/画笔/橡皮擦 | 真实可用 | 多边形点击取点后可按 Enter 完成,也可在三点后点击首节点闭合;矩形/圆拖拽生成 polygon;切换到多边形/矩形/圆会保留当前 mask 选区,有选中 mask 时新创建的多边形/矩形/圆会通过 polygon union 并入该 mask,即使两块区域不重叠也合并为同一个多 polygon mask;没有选中 mask 时才创建新 mask,未选语义分类时自动归入黑色 `maskid:0` 的“待分类”;画笔按当前语义分类或当前选中 mask 生成连续圆形笔触,松开后有选中 mask 则并入选中 mask,没有选中 mask 才创建新的当前类别 mask;画笔闭合形成中空区域时保留内洞 ring,使用 even-odd 渲染并显示内外圈顶点;按 `Esc` 或点击左侧“取消选中”按钮可清空选区和临时绘制状态;橡皮擦从选中 mask 中扣除笔触区域;均写入 `Mask.segmentation`,可归档保存 | +| 画布上下文提示 | 真实可用 | 切换到多边形、矩形、圆、画笔、橡皮擦、区域合并/去除、调整多边形等隐性操作工具时,画布左上角显示当前工具的完成/取消/选择顺序提示;提示会在数秒后自动隐藏,避免长期遮挡待编辑图像,工具或操作状态变化时会重新出现 | +| Mask 渲染 | 真实可用 | 前端会把推理、手工绘制、GT 导入和已保存标注转成 Konva `pathData` 渲染;普通 mask 和导入 mask 都不显示黄色 seed point;未选中特定 mask 时,当前帧 mask 会按右侧“语义分类树”拖拽得到的内部覆盖优先级从低到高渲染,使高优先级类别显示在上层;有选中 mask 时保留编辑态置顶行为,方便操作 | +| Mask 透明度 | 真实可用 | 右侧语义分类树上方的“遮罩透明度”滑杆写入全局 `maskPreviewOpacity`,工作区 Canvas 和 AI 智能分割页都会使用该值调整 mask 预览透明度,选中 mask 会在该基础上略微加亮 | +| 传播链跨帧选区跟随 | 真实可用 | 用户选中某个 mask 后切到同一自动传播结果覆盖的其他帧时,`CanvasArea` 会根据 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key` 查找目标帧对应传播 mask 并自动选中;找不到同链结果时才清空选区 | +| Polygon 逐点编辑 / 删除 | 真实可用 | 点击 mask 后显示 polygon 顶点;多 polygon 或分离区域组成的同一个 mask 会显示所有子区域顶点,不再只显示主区域;按住顶点即可直接拖动并实时重算 `pathData/segmentation/bbox/area`,不需要先单击选中顶点,已保存 mask 标为 dirty;顶点拖拽结束不会触发 Stage 平移,Canvas 当前缩放和位置保持不变;选中顶点后 Delete/Backspace 可删点但保留至少三点;选中 mask 但未选中顶点时 Delete/Backspace 删除整个 mask,左侧 DEL 按钮复用同一链路;已保存 mask 删除前会预检当前后端 annotation id 并只删除仍存在的 id,避免陈旧本地 id 产生 DELETE 404;若删除对象是传播 seed 或传播结果,前端会按 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key` 同步删除同链自动传播 mask,但不删除其他帧独立 AI 推理/人工 mask | +| 应用分类 | 真实可用 | Canvas 右下角不再提供“应用分类”快捷按钮,避免没选区时误改整帧;右侧语义分类树点击分类时,无选中 mask 只设置后续新建 mask 的 active class,不修改已有 mask;有选中 mask 时才改当前已选 mask,并通过 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key` 同步更新同一传播链上的前后传播 mask,同时把已选 mask 移到前端渲染最上层方便继续编辑;已保存 mask 会标为 dirty,归档保存时更新后端 | +| 清空遮罩 | 真实可用 | 工作区只通过左侧工具栏触发清空;当前帧有选中 mask 时清选中 mask,没有选中时清当前帧全部 mask;无传播链结果时直接执行,存在传播链结果时弹窗选择取消、只清当前帧、按帧范围选择或清空所有传播帧;按帧范围选择复用时间轴范围选择和最终确认;按范围清空或清空所有传播帧时若目标范围包含人工/AI 标注帧,会二次确认是否删除,选择否会整帧保留 | +| 保存状态计数 | 真实可用 | 底部显示已保存、未保存、待更新数量 | +| 当前图层信息 | 真实可用 | 根据当前选中 mask 显示真实标签/后端 annotation id;未保存 mask 显示“未保存”,未选中时显示“未选择” | + +## ToolsPalette 工具栏 + +| 元素 | 状态 | 说明 | +|------|------|------| +| 工具分组分隔线 | 真实可用 | 拖拽/选择到创建圆为绘制/基础编辑组,画笔/橡皮擦/AI自动推理为局部与追踪组,区域合并/重叠区域去除/DEL/清空遮罩为布尔与删除组,导入 GT Mask 和 AI 智能分割为外部动作组;组间使用浅灰横线分隔,`data-testid="tool-group-separator"` 位于清空遮罩下方的外部动作组分隔线 | +| 拖拽/选择 | 真实可用 | 控制 Canvas 是否可拖拽 | +| 取消选中 | 真实可用 | 位于拖拽/选择按钮下方,实体按钮等同 `Esc`:清空当前 mask 选区、临时绘制点/笔触和顶点选择,不删除 mask、不清空 active class | +| 调整多边形 | 真实可用 | 选中 polygon mask 后显示顶点和边中点;支持按住顶点直接拖动、点击边中点插点、双击边界按位置插点 | +| 多边形/矩形/圆/画笔/橡皮擦 | 真实可用 | 切换 activeTool 后由 `CanvasArea` 生成或编辑可保存的 polygon mask;画笔/橡皮擦在工具栏显示尺寸滑杆 | +| 区域合并/去除 | 真实可用 | 选择工具后点击多个 mask,右下角显示已选数量和操作按钮;合并/去除模式会隐藏 polygon 编辑手柄,避免手柄抢占多选点击;布尔选择态中第一个选中的主区域用黄色实线轮廓,后续参与合并/扣除的区域用红色虚线轮廓,避免主区域和扣除区域看起来像随机阴影差异;使用 `polygon-clipping` 做 union / difference;若选中的主区域和参与区域存在传播帧对应 mask,会先弹窗选择只处理当前帧、处理所有传播帧或按帧范围选择;按帧范围选择会进入和传播一致的时间轴范围选择,点击顶栏确认后再弹最终确认,只处理范围内存在对应传播链的帧;合并会保留主 mask 并移除被合并 mask,且移除次级 mask 时会同步删除其同链自动传播结果;去除会从主 mask 扣除后续选中 mask;内含扣除会保留 hole ring 并用 even-odd 规则渲染 | +| 导入 GT Mask | 真实可用 | 位于“重叠区域去除”之后,点击后打开文件选择器,并在上传前选择未知类别处理策略;该入口不切换 activeTool | +| AI 智能分割跳转入口 | 真实可用 | 切到 AI 智能分割页;不是直接执行推理 | +| AI 正向选点/反向选点/框选 | 不在工作区工具栏显示 | 这些是 AI 智能分割页功能,工作区左侧工具栏不再提供正向选点、反向选点和边界框选按钮 | +| AI 智能分割入口 | 真实可用 | 位于工作区工具栏底部,使用和侧栏一致的 Bot + Sparkles 组合图标;点击后切到 AI 智能分割页 | +| 撤销/重做 | 真实可用 | 绑定 Zustand `maskHistory/maskFuture`,工作区只保留顶栏按钮和快捷键 `Ctrl/Cmd+Z`、`Ctrl/Cmd+Shift+Z`、`Ctrl/Cmd+Y`,AI 页保留自己的按钮;左侧工具栏不再重复放置撤销/重做;输入框聚焦时不拦截快捷键;工作区顶栏撤销图标使用琥珀色、重做图标使用蓝紫色,提高深色顶栏里的识别度 | +| 紧凑/滚动布局 | 真实可用 | 工具按钮使用较紧凑的垂直间距;左侧高度不足时工具栏自身出现纵向滚动,不挤压画布;外层工具栏扩展到 56px,按钮列仍固定 48px,滚动条占用右侧外扩空间,不挤占图标位置;滚动条使用 `seg-scrollbar`,默认低对比融入深色工具区,hover/focus 时才增强为青色提示 | + +## FrameTimeline 时间轴 + +| 元素 | 状态 | 说明 | +|------|------|------| +| 帧缩略图 | 真实可用 | 使用 `frames[].url` | +| 点击缩略图跳帧 | 真实可用 | 调用 `setCurrentFrame(idx)`;非当前帧中,人工/AI 标注帧使用红色边框,自动传播/推理帧使用蓝色边框;同一帧同时有人工/AI 标注和自动传播结果时,红色标注边框优先保留,蓝色传播状态以内描边表达;当前帧仍用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则在青色外框内增加红色内描边,固定为外层当前帧、内层人工/AI 标注,避免状态颜色互相覆盖 | +| 顶部 range 拖动 | 真实可用 | 改变当前帧 | +| 具体时间显示 | 真实可用 | 根据项目 `parse_fps/original_fps` 显示当前时间和总时长,格式为 `mm:ss.cc` | +| 播放进度条 / 视频处理进度条 | 真实可用 | 播放进度条位于上方,视频处理进度条位于下方;当前帧位置用一条白色竖线贯穿两条进度条,避免和青色播放进度、红/蓝处理状态混淆;视频处理进度条普通状态下可点击跳转到对应帧;根据已保存标注回显的 `mask_data.source`、`propagated_from_frame_id`、`source_annotation_id`、`source_mask_id` 或 `propagation_seed_key` 识别自动传播生成的帧并显示蓝色区段,人工绘制或 AI 智能分割生成的帧显示红色竖线,红/蓝标识也可点击跳转到对应帧;每次自动传播成功处理帧后,工作区会在当前会话记录最近传播范围,并在视频处理进度条上叠加同一蓝色系的纯色片段,按距最新传播的时间顺序逐次变暗,且第 5 次及更早统一为阈值旧记录色,辅助识别第一次、第二次、第 N 次传播;传播历史片段会按当前仍存在的自动传播 mask 自动裁剪或拆分,单独删除传播 mask 后,无任何 mask 的帧不会继续显示红/蓝颜色;未处理背景使用中性灰以和红/蓝/传播历史标记区分;工作区进入自动传播或布尔操作的范围选择模式时,两条进度条显示 amber 选区,并额外用洋红色起始线和黄绿色结束线贯穿两条进度条,表示待处理起止帧,颜色避开附近的青色、红色、蓝色和 amber 元素 | +| 播放/暂停 | 真实可用 | 当前代码按 `parse_fps/original_fps` 推进帧,最多 30fps | +| 方向键切帧 | 真实可用 | 全局监听左右方向键切到上一帧/下一帧;焦点在 input、textarea、select 或 contentEditable 内时不会拦截 | + +## OntologyInspector 本体面板 + +| 元素 | 状态 | 说明 | +|------|------|------| +| 模板选择 | 真实可用 | 读取全局 templates,可切换 activeTemplateId,并会驱动分类树、mask 分类和导出类别信息 | +| 面板滚动条 | 真实可用 | 右侧本体/语义分类面板内容过长时自身滚动;滚动条使用 `seg-scrollbar`,默认低对比融入深色侧栏,hover/focus 时才增强显示 | +| 面板标题 | 已简化 | 原“本体论与属性分类管理树”固定说明栏已移除,右侧面板直接展示模板、透明度和语义分类树 | +| 分类树展示 / 换标签 | 真实可用 | 显示当前模板 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 | +| 目标实例属性标题 | 真实可用 | “特定目标实例属性追踪”下方显示当前选中 mask 的 `className/label`,不再跟随全局 active class,避免点过其他分类后标题固定成旧分类 | +| 当前选中区域计数 | 已移除 | 当前交互以单选 mask 为主,计数长期为 1,属于低价值信息,已从实例属性面板删除 | +| 后端拓扑锚点数量 | 真实可用 | 选中 mask 后调用 `POST /api/ai/analyze-mask`,后端按 polygon 的真实顶点数量返回 `topology_anchor_count`;`topology_anchors` 列表只保留最多 64 个抽样点用于调试展示,避免把真实数量误压成十几个;前端会忽略被浏览器中止或已过期的分析请求,避免切换 mask、拖动平滑预览或卸载组件时出现误报 | +| 边缘平滑强度 / 应用边缘平滑 | 真实可用 | 选中 mask 后调整 0-100 平滑强度会先即时更新滑杆数值,再在用户停止拖动约 220ms 后调用 `POST /api/ai/smooth-mask` 生成预览 polygon,避免拖动时连续请求导致卡顿;预览会临时替换当前 mask 显示但不标 dirty;点“应用边缘平滑”后会把平滑 polygon 作为新的实际 mask 几何写入当前 mask 和同传播链前/后对应 mask,整次应用进入同一个撤销/重做历史步骤,并把相关 mask 标记为 dirty/draft;传播链上的 mask 保存时会保留原传播 lineage metadata,不会因为平滑几何同步而在时间轴上变成人工/AI 红色标注帧;应用后平滑强度重置为 0,后续可继续用“调整多边形”编辑新的 polygon;后端平滑使用缓入强度曲线,低强度只做温和切角和轻量去噪,高强度才逐步增加 Chaikin 迭代、切角比例和简化阈值,避免 20% 前后已经过度平滑 | + +## AISegmentation 独立 AI 页 + +| 元素 | 状态 | 说明 | +|------|------|------| +| SAM 2.1 变体选择 / 模型状态 | 真实可用 | AI 页可选 tiny/small/base+/large,调用 `GET /api/ai/models/status?selected_model=` 展示所选变体和 GPU 状态;只有本地存在 checkpoint 且 PyTorch/SAM2 依赖可用的变体显示可用,不可用变体不能点击,执行按钮显示“当前模型不可用”并阻止推理请求 | +| 正向/反向点 | 真实可用 | 可在当前项目帧上加点并调用 AI 推理接口;AI 页中点击已有候选 mask 时也会继续添加当前正/反向提示点,点击已有提示点会删除该点;SAM 2.1 框选后会携带原始框和累计正/反点细化同一个候选 mask | +| 边界框选 | 真实可用 | AI 页选择工具后可在画布拖拽蓝色虚线框;执行分割时会随 `/api/ai/predict` 发送 `box`,框选后继续添加正/反点会发送 interactive prompt | +| AI 画布上下文提示 | 真实可用 | 选择正向点、反向点、边界框选或视口控制时,画布左上角提示点击/拖拽、删除提示点和执行推理的操作方式 | +| SAM 3 入口 | 当前禁用 | 因当前系统不提供文本提示,前端不再显示 SAM 3 模型选择、文本输入或 SAM 3 框选入口;后端 `model=sam3` 返回不支持 | +| 语义文本输入 | 当前禁用 | AI 页不再提供文本语义输入;后端收到 `semantic` prompt 会返回 400 | +| 参数开关 | 真实可用 | UI 展示为“局部专注模式(自动裁剪无锚区域)”和“严格除杂模式(自动清理干涉点)”,只是为了让用户更容易理解,不重命名内部字段;`cropMode` 会随 `/api/ai/predict` 发送 `crop_to_prompt`,后端对点/框 prompt 裁剪推理区域并回映射 polygon;`autoDeleteBg` 会发送 `auto_filter_background` 和 `min_score`,后端过滤低分结果和覆盖负向点的结果 | +| AI 遮罩透明度 | 真实可用 | 调节共享的 `maskPreviewOpacity`,AI 页候选 mask 和右侧“遮罩透明度”滑杆联动,只影响预览显示,不改变 mask 几何、分类或保存数据 | +| 执行高精度语义分割 | 真实可用 | 使用当前项目帧和所选 SAM 2.1 变体调用 `/api/ai/predict`;SAM 2.1 需要点/框提示且只采用最高分候选;AI 页只渲染本页最新候选,不显示工作区已有 mask,重复执行会替换上一次 AI 页候选而不是叠加;生成结果写入全局 masks 并自动选中,右侧分类树可立即换标签 | +| 推送至工作区编辑 | 真实可用 | 切回工作区并把工具切到“调整多边形”,保留 AI 页选中的未保存 mask 和当前帧视角;推送前会校验当前 AI 候选 mask 必须已有 `classId` 或 `className`,未选择语义分类时会用右上角 error toast 提示用户先点右侧语义分类树,不允许进入工作区;如果用户直接离开 AI 页,未分类 AI 候选会被清理,避免无语义 mask 进入工作区;工作区回显后端标注时不会覆盖这类 draft mask,也不会强制跳回第一帧 | +| 撤销/重做 | 真实可用 | 绑定全局 mask 历史栈 | +| 删除最近锚点 | 真实可用 | 删除 AI 页最近一次放置的正/反向提示点,不影响已生成候选 mask 或工作区 mask | +| 删除选中候选 | 真实可用 | 删除 AI 页当前选中的本页候选 mask;不会删除工作区已有 mask,Delete/Backspace 也遵循同一范围 | +| 清空全体锚点 | 真实可用 | 清空 AI 页提示点和本页生成的候选 mask,不删除工作区已有 mask | +| 背景图 / 空状态 | 真实可用 | 优先显示当前项目帧;没有项目帧时显示空状态提示,不再回退到外部演示图片 | +| AI 画布初始视图 | 真实可用 | 当前帧在 AI 画布中默认居中,并按 86% 适配比例尽量放大但保留边距 | + +## TemplateRegistry 模板库 + +| 元素 | 状态 | 说明 | +|------|------|------| +| 模板列表 | 真实可用 | 调用 `GET /api/templates` | +| 新建方案 | 真实可用 | 调用 `POST /api/templates` | +| 编辑模板 | 真实可用 | 调用 `PATCH /api/templates/{id}` | +| 删除模板 | 真实可用 | 调用 `DELETE /api/templates/{id}` | +| 添加/删除分类 | 真实可用 | 保存在模板 `mapping_rules.classes` | +| 拖拽排序 | 真实可用 | 模板库详情页、模板编辑弹窗和工作区右侧语义分类树都可拖拽调整内部覆盖优先级,保存时写后端;模板库详情页拖拽会刷新当前详情并同步当前工作区同类 mask 的 `classZIndex`,工作区拖拽也会同步当前同类 mask 的 `classZIndex` 并标记待保存;界面只显示类别稳定 maskid,maskid 不作为排序规范;黑色 `maskid: 0` 的“待分类”保留类固定在最后,不可删除或拖拽上移 | +| JSON 批量导入 | 真实可用 | 前端解析 `[[colors], [names]]` 和 `{colors, names}` 两种格式,并兼容带前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号的粘贴内容;显示导入数量、maskid 起点和缺失颜色提示;导入后加入编辑态,保存模板时落库 | +| mapping rules | 部分可用 | 可存 `rules`,但当前没有运行时映射执行引擎;适合后续用于导入外部标签、别名归一化或跨数据集类别映射 | + +## 总体结论 + +当前前端真实可用的主链路是:JWT 登录、刷新恢复用户、退出登录、Dashboard 当前用户概览、当前用户项目列表、上传视频/DICOM、显式生成帧/重新生成帧、浏览帧、播放帧、工作区手工绘制、点/框 AI 推理、视频片段传播、GT mask 导入、标注保存/回显、统一分割结果 ZIP 导出、兼容 COCO/PNG mask ZIP 导出、模板 CRUD。 + +当前最主要的 Mock 或未打通链路是:真正的文本语义分割已因无文本提示入口而暂时禁用;复杂洞结构编辑、骨架/HDBSCAN 级别的 mask 降维增强、任务历史筛选、项目更多菜单、全业务操作审计和 mapping rules 运行时映射执行引擎仍未落地。登录页“端到端加密”等安全文案仍只是 UI 文案;登录和用户管理操作审计已落库并可在管理员后台查看。 diff --git a/doc/03-operations.md b/doc/03-operations.md new file mode 100644 index 0000000..e314b00 --- /dev/null +++ b/doc/03-operations.md @@ -0,0 +1,83 @@ +# 03 运维与排障 + +## 日志 + +```bash +docker compose logs -f backend +docker compose logs -f worker +docker compose logs -f frontend +docker compose logs -f minio +``` + +## 健康检查 + +```bash +curl http://localhost:8000/health +curl -I http://localhost:3000 +curl http://localhost:9000/minio/health/live +``` + +## 备份 + +PostgreSQL: + +```bash +docker compose exec postgres pg_dump -U seguser segserver > segserver.sql +``` + +MinIO 数据: + +```bash +docker run --rm -v seg_server_docker_minio-data:/data -v "$PWD":/backup alpine \ + tar czf /backup/minio-data.tgz -C /data . +``` + +## 恢复 + +PostgreSQL: + +```bash +cat segserver.sql | docker compose exec -T postgres psql -U seguser segserver +``` + +MinIO:先停止服务,再恢复数据卷内容。 + +```bash +docker compose down +docker run --rm -v seg_server_docker_minio-data:/data -v "$PWD":/backup alpine \ + sh -c 'cd /data && tar xzf /backup/minio-data.tgz' +docker compose up -d +``` + +## 常见问题 + +### 页面能打开,但图片或帧缩略图打不开 + +检查 `.env` 的 `PUBLIC_HOST`。它必须是浏览器可访问的主机名或 IP。修改后执行: + +```bash +docker compose up -d --build backend worker +``` + +### 前端请求后端失败 + +检查 `CORS_ORIGINS` 是否包含当前前端访问地址,例如: + +```env +CORS_ORIGINS=["http://192.168.3.11:3000","http://localhost:3000"] +``` + +### AI 模型不可用 + +最小镜像默认不安装 PyTorch/SAM2,也不包含权重。普通标注功能不受影响。需要 AI 推理时按 `doc/02-deployment.md` 扩展 GPU 镜像并挂载权重。 + +### 恢复演示出厂设置失败 + +确认演示文件存在: + +```bash +ls demo/演视LC视频序列.mp4 +ls demo/演视DICOM序列/*.dcm +``` + +缺少演示文件时,系统仍可正常使用,但恢复演示出厂设置会提示找不到演示数据。 diff --git a/doc/04-api-contracts.md b/doc/04-api-contracts.md new file mode 100644 index 0000000..4ca6c37 --- /dev/null +++ b/doc/04-api-contracts.md @@ -0,0 +1,323 @@ +# 接口契约清单 + +## 前端 API 基础配置 + +位置:`src/lib/config.ts`、`src/lib/api.ts` + +```ts +API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://:8000' +timeout: 30000 +``` + +前端 request interceptor 会从 localStorage 读取 `token`,附加: + +```http +Authorization: Bearer +``` + +当前后端业务接口会校验该 header。缺失、过期或无效 token 返回 401;项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库,所有登录用户可读取,`admin/annotator` 可写入。 + +## 前端封装的 API + +| 函数 | 方法与路径 | 状态 | 说明 | +|------|------------|------|------| +| `login(username, password)` | `POST /api/auth/login` | 对齐 | 后端返回 `{ token, token_type, username, user }`,前端保存 token 和当前用户 | +| `getCurrentUser()` | `GET /api/auth/me` | 对齐 | 用已有 Bearer token 恢复当前登录用户 | +| `getProjects()` | `GET /api/projects` | 对齐 | 前端映射 `frame_count`、`thumbnail_url` 等字段 | +| `createProject(payload)` | `POST /api/projects` | 对齐 | 支持 `name`、`description`、`parse_fps` | +| `updateProject(id, payload)` | `PATCH /api/projects/{id}` | 对齐 | 后端是 `PATCH /api/projects/{id}` | +| `deleteProject(id)` | `DELETE /api/projects/{id}` | 对齐 | 项目卡片删除按钮已接入,删除前使用站内确认弹窗 | +| `getTemplates()` | `GET /api/templates` | 对齐 | 前端从 `mapping_rules` 取 classes/rules | +| `createTemplate(payload)` | `POST /api/templates` | 对齐 | 后端会打包 classes/rules 到 mapping_rules | +| `updateTemplate(id, payload)` | `PATCH /api/templates/{id}` | 对齐 | 模板编辑页使用 | +| `deleteTemplate(id)` | `DELETE /api/templates/{id}` | 对齐 | 模板编辑页使用 | +| `uploadMedia(file, projectId, options?)` | `POST /api/media/upload` | 对齐 | multipart form-data;`options.onProgress` 用于项目库上传进度 | +| `uploadDicomBatch(files, projectId, options?)` | `POST /api/media/upload/dicom` | 对齐 | multipart form-data;`options.onProgress` 用于项目库上传进度,上传完成后项目库轮询解析任务进度 | +| `parseMedia(projectId, options?)` | `POST /api/media/parse?project_id=...` | 对齐 | 创建异步拆帧任务并返回 task;由项目库“生成帧/重新生成帧”显式调用,已有帧时 worker 会先清空旧帧、标注和 mask;支持 `parse_fps`、`max_frames`、`target_width` | +| `getTask(taskId)` | `GET /api/tasks/{task_id}` | 对齐 | 查询异步任务状态 | +| `cancelTask(taskId)` | `POST /api/tasks/{task_id}/cancel` | 对齐 | 取消 queued/running 任务,后端写 cancelled 并尝试 revoke Celery | +| `retryTask(taskId)` | `POST /api/tasks/{task_id}/retry` | 对齐 | 对 failed/cancelled 任务创建新的 queued 重试任务 | +| `getProjectFrames(projectId)` | `GET /api/projects/{id}/frames` | 对齐 | 默认返回完整帧列表和预签名 image_url,以及 `timestamp_ms`、`source_frame_number`;可选 `skip/limit` 仅用于显式分页 | +| `predictMask(payload)` | `POST /api/ai/predict` | 对齐 | 前端发送 `image_id/prompt_type/prompt_data/model`,并把后端 `polygons` 转为 `masks[].pathData` | +| `propagateMasks(payload)` | `POST /api/ai/propagate` | 对齐 | 单 seed 同步传播接口,供后端兼容和测试使用 | +| `queuePropagationTask(payload)` | `POST /api/ai/propagate/task` | 对齐 | 工作区“AI自动推理”入口;创建 Celery 后台任务并由任务表/进度流追踪 | +| `getAiModelStatus(selectedModel?)` | `GET /api/ai/models/status` | 对齐 | 返回 GPU 和四个 SAM 2.1 变体状态;`selected_model=sam3` 返回不支持 | +| `analyzeMask(mask, frame, options?)` | `POST /api/ai/analyze-mask` | 对齐 | 后端计算选中 mask 的置信度来源、拓扑锚点数量、面积和 bbox | +| `getProjectAnnotations(projectId, frameId?)` | `GET /api/ai/annotations` | 对齐 | 前端加载工作区时用于回显已保存标注 | +| `saveAnnotation(payload)` | `POST /api/ai/annotate` | 对齐 | 工作区归档保存当前项目未保存 mask | +| `updateAnnotation(annotationId, payload)` | `PATCH /api/ai/annotations/{annotation_id}` | 对齐 | 工作区归档保存 dirty mask;保存链路会先预检后端标注 id,已知缺失则直接用同一几何和 metadata 调用 `saveAnnotation()` 重新创建;预检后仍遇到 404 时也会重新创建并回显替换本地旧 id | +| `deleteAnnotation(annotationId)` | `DELETE /api/ai/annotations/{annotation_id}` | 对齐 | 工作区清空当前帧、关联传播帧、DEL/键盘删除和切换激活模板时删除已保存标注;批量删除前会先读取当前项目 annotation 列表,跳过本地陈旧 id,避免重复 DELETE 产生 404 | +| `importGtMask(file, projectId, frameId, templateId?, options?)` | `POST /api/ai/import-gt-mask` | 对齐 | multipart 上传 GT mask;支持 `unknown_color_policy=discard/undefined`;后端仅接受 8-bit 灰度 maskid 图或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,0 为背景、X 为 1-255 的 maskid;16-bit/uint16 GT_label、全背景 0 图和普通彩色类别图会被拒绝,全背景错误信息固定为“GT Mask 图片中没有非背景 maskid 区域。”;按模板 `maskId` 匹配类别,未知 maskid 可舍弃或导入为黑色 `maskid:0` 的“待分类”;尺寸不同会最近邻拉伸到当前帧,连通域会生成高精度 polygon 标注;导入标注可直接用于 `/api/ai/analyze-mask` 和 `/api/ai/smooth-mask`,前端不显示或拖动 seed point | +| `getDashboardOverview()` | `GET /api/dashboard/overview` | 对齐 | Dashboard 初始统计、队列和活动日志 | +| `exportCoco(projectId)` | `GET /api/export/{projectId}/coco` | 对齐 | 后端实际是 `GET /api/export/{project_id}/coco` | +| `exportMasks(projectId)` | `GET /api/export/{projectId}/masks` | 对齐 | 下载单标注 mask、语义融合 mask 和类别映射 ZIP | +| `exportSegmentationResults(projectId, options)` | `GET /api/export/{projectId}/results` | 对齐 | 新的统一导出入口;支持 `scope=all/range/current`、`outputs=separate,gt_label,pro_label,mix_label`、`mix_opacity`、`start_frame/end_frame` 和 `frame_id` 参数,返回包含 COCO JSON、maskid/GT 像素值映射、原始帧图片和所选 mask PNG 的 ZIP;`mask_type=separate/gt_label/pro_label/mix_label/both` 仍兼容 | + +## 后端 FastAPI 接口 + +以下列表来自当前运行的 OpenAPI: + +| 方法 | 路径 | 用途 | +|------|------|------| +| POST | `/api/auth/login` | 登录 | +| GET | `/api/auth/me` | 当前用户 | +| GET/POST/PATCH/DELETE | `/api/admin/users` | 管理员用户管理 | +| GET | `/api/admin/audit-logs` | 管理员审计日志 | +| POST | `/api/admin/demo-factory-reset` | 演示部署恢复出厂设置;请求体需 `confirmation=RESET_DEMO_FACTORY`;重置后保留默认 admin、从 `demo/演视LC视频序列.mp4` 创建的已生成帧演示视频项目和从 `demo/演视DICOM序列/` 创建的已按文件名自然顺序生成帧的演示 DICOM 项目;同时按内置权威定义重建缺失的“腹腔镜胆囊切除术”“头颈部CT分割”系统模板,并覆盖恢复被修改或删减的默认语义分类树;响应包含兼容单个 `project` 和完整 `projects` 列表 | +| POST | `/api/projects` | 创建项目 | +| GET | `/api/projects` | 项目列表 | +| GET | `/api/projects/{project_id}` | 项目详情 | +| PATCH | `/api/projects/{project_id}` | 更新项目 | +| DELETE | `/api/projects/{project_id}` | 删除项目 | +| POST | `/api/projects/{project_id}/frames` | 添加帧记录 | +| GET | `/api/projects/{project_id}/frames` | 项目帧列表 | +| GET | `/api/projects/{project_id}/frames/{frame_id}` | 单帧详情 | +| POST | `/api/templates` | 创建模板 | +| GET | `/api/templates` | 模板列表 | +| GET | `/api/templates/{template_id}` | 模板详情 | +| PATCH | `/api/templates/{template_id}` | 更新模板 | +| DELETE | `/api/templates/{template_id}` | 删除模板 | +| POST | `/api/media/upload` | 上传视频/图片/DICOM 单文件 | +| POST | `/api/media/upload/dicom` | 批量上传 DICOM | +| POST | `/api/media/parse` | 创建 Celery 拆帧任务;query 支持 `project_id`、`source_type`、`parse_fps`、`max_frames`、`target_width` | +| GET | `/api/tasks` | 查询后台任务列表 | +| GET | `/api/tasks/{task_id}` | 查询单个后台任务 | +| POST | `/api/tasks/{task_id}/cancel` | 取消后台任务 | +| POST | `/api/tasks/{task_id}/retry` | 重试失败或取消的后台任务 | +| POST | `/api/ai/predict` | 当前启用 SAM 2 点/框/interactive 推理 | +| POST | `/api/ai/propagate` | 当前启用 SAM 2 单 seed 同步视频片段传播并保存标注 | +| POST | `/api/ai/propagate/task` | 创建 SAM 2 自动传播后台任务;payload 可包含多个 seed/direction step | +| POST | `/api/ai/analyze-mask` | 分析前端选中 mask 的后端几何属性和拓扑锚点 | +| GET | `/api/ai/models/status` | GPU 和 SAM 模型状态 | +| POST | `/api/ai/auto` | 自动分割 | +| POST | `/api/ai/annotate` | 保存 AI 标注 | +| POST | `/api/ai/import-gt-mask` | 导入 GT mask 并生成标注/seed point | +| GET | `/api/ai/annotations` | 查询项目标注,可选按帧过滤 | +| PATCH | `/api/ai/annotations/{annotation_id}` | 更新已保存标注 | +| DELETE | `/api/ai/annotations/{annotation_id}` | 删除已保存标注 | +| GET | `/api/dashboard/overview` | Dashboard 聚合快照 | +| GET | `/api/export/{project_id}/coco` | 导出 COCO JSON | +| GET | `/api/export/{project_id}/masks` | 导出 PNG mask ZIP | +| GET | `/api/export/{project_id}/results` | 统一导出分割结果 ZIP,包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json`、`原始图片/` 和按参数选择的 `分开Mask分割结果/`、`GT_label图/`、`Pro_label彩色分割结果/`、`Mix_label重叠覆盖彩色分割结果/`;GT_label 固定输出 8-bit uint8 PNG,背景为 0,类别值使用模板中的真实 maskid,`maskid:0` 待分类和背景同为 0,缺失 maskid 的旧标注才补下一个可用正整数;正整数 maskid 超出 1-255 时拒绝导出 | +| GET | `/health` | 健康检查 | +| WS | `/ws/progress` | WebSocket 进度通道,未出现在 OpenAPI paths 中 | + +### WebSocket 进度通道 + +`/ws/progress` 用于 Dashboard 实时接收后台任务状态。前端连接成功后会定时发送 `ping` 作为心跳;后端收到任意文本心跳后返回: + +```json +{ + "type": "status", + "status": "connected", + "message": "Progress stream active", + "timestamp": "2026-05-01T00:00:00+00:00" +} +``` + +后台任务进度由 Celery worker 写入 Redis `seg:progress` 频道,再由 FastAPI 转发到当前活跃 WebSocket 连接。Dashboard 的“WebSocket 已连接/断开”状态来自浏览器 WebSocket 的 `onopen/onclose/onerror`,不再依赖是否刚好收到任务进度事件。 + +## 关键请求体 + +### 登录 + +```json +{ + "username": "admin", + "password": "123456" +} +``` + +### 创建项目 + +```json +{ + "name": "example.mp4", + "description": "导入说明", + "parse_fps": 30 +} +``` + +### 创建标准帧序列拆帧任务 + +```text +POST /api/media/parse?project_id=1&parse_fps=15&max_frames=120&target_width=960 +``` + +任务 `payload` 会记录本次拆帧参数;完成后的 `result.frame_sequence` 返回 `original_fps`、`parse_fps`、`frame_count`、`duration_ms`、`target_width`、帧宽高和 MinIO object prefix。每条 `FrameOut` 包含: + +```json +{ + "frame_index": 0, + "image_url": "http://...", + "width": 960, + "height": 540, + "timestamp_ms": 0, + "source_frame_number": 0 +} +``` + +### 创建/更新模板 + +```json +{ + "name": "腹腔镜胆囊切除术", + "color": "#06b6d4", + "z_index": 0, + "classes": [ + { + "id": "cls-1", + "name": "胆囊", + "color": "#ffae00", + "zIndex": 280, + "maskId": 1, + "category": "腹腔镜胆囊切除术" + } + ], + "rules": [] +} +``` + +### AI 推理请求体 + +前端 `predictMask()` 当前已适配后端 `PredictRequest`: + +```json +{ + "image_id": 123, + "model": "sam2.1_hiera_tiny", + "prompt_type": "point", + "prompt_data": { + "points": [[0.5, 0.5]], + "labels": [1] + } +} +``` + +`prompt_type` 支持: + +- `point` +- `box` +- `interactive`,用于 SAM 2 交互式细化,`prompt_data` 同时携带 `box`、累计 `points` 和 `labels`。 +- `semantic` 当前被禁用;由于产品不提供文本提示,前端不会显示语义文本入口,后端收到 semantic 会返回 400。 + +SAM 2 点提示和 auto fallback 当前只采用最高分候选 mask,避免同一提示下多个备选 mask 被前端叠加显示。 + +工作区 SAM 2 请求包含反向点时,`CanvasArea` 会发送 `options.auto_filter_background=true` 和 `options.min_score=0.05`;如果负向点过滤后没有可用 polygon,前端会移除当前旧候选 mask 并要求重新框选或添加正向点。 + +当前 registry 暴露 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`,并兼容 `sam2` 作为 tiny 别名;发送 `model=sam3` 会返回 400 Unsupported model。SAM 3 源码文件保留在仓库中,但没有接入当前运行时模型列表。 + +可选 `options` 字段: + +- `crop_to_prompt`:对 point/box/interactive prompt 按锚点或框附近区域裁剪后推理,再把 polygon 回映射到原图坐标。 +- `auto_filter_background`:过滤低分结果,并移除包含负向点的 polygon。 +- `min_score`:配合 `auto_filter_background` 使用的最低置信度阈值。 + +后端响应: + +```json +{ + "polygons": [ + [[0.25, 0.25], [0.75, 0.25], [0.75, 0.75], [0.25, 0.75]] + ], + "scores": [0.5] +} +``` + +前端会把上面的 `polygons` 转成: + +```json +{ + "masks": [ + { + "pathData": "M 160 90 L 480 90 L 480 270 L 160 270 Z", + "segmentation": [[160, 90, 480, 90, 480, 270, 160, 270]], + "bbox": [160, 90, 320, 180] + } + ] +} +``` + +### 视频片段传播请求体 + +`POST /api/ai/propagate` 仍是单 seed 同步接口。工作区实际使用 `POST /api/ai/propagate/task`:当前打开帧作为参考帧,该帧全部 mask 作为 seed;用户设置传播起始帧和传播结束帧后,前端会在本地把多个 seed 或前后双向范围拆成 `steps`,一次提交为 `propagate_masks` 后台任务,避免长 HTTP 请求和多个视频 tracker 并发抢占 GPU。 + +单次调用示例: + +```json +{ + "project_id": 1, + "frame_id": 123, + "model": "sam2.1_hiera_tiny", + "direction": "forward", + "max_frames": 30, + "include_source": false, + "save_annotations": true, + "seed": { + "polygons": [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]], + "bbox": [0.1, 0.1, 0.2, 0.2], + "label": "胆囊", + "color": "#ff0000", + "class_metadata": {"id": "c1", "name": "胆囊", "color": "#ff0000", "zIndex": 20, "maskId": 1}, + "template_id": 2, + "source_instance_id": "instance-123" + } +} +``` + +后台任务调用示例: + +```json +{ + "project_id": 1, + "frame_id": 123, + "model": "sam2.1_hiera_tiny", + "include_source": false, + "save_annotations": true, + "steps": [ + { + "direction": "forward", + "max_frames": 30, + "seed": { + "polygons": [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]], + "label": "胆囊", + "color": "#ff0000", + "source_instance_id": "instance-123" + } + } + ] +} +``` + +SAM 2.1 变体使用对应 video predictor 的 mask seed 传播;`model=sam2` 会兼容归一化为 tiny,`model=sam3` 当前不支持。响应会返回已创建的 `annotations`,保存的 `mask_data.source` 为 `_propagation`,前端回显时会把该字段保留到 `Mask.metadata`,用于在视频处理进度条上把自动传播帧显示为蓝色区段。 +后台任务入队接口会先规范化/校验 `model` 字段中的 SAM 2.1 权重 id,再把规范化后的权重 id 写入 `processing_tasks.payload.model`;前端提交传播前会先保存当前项目中的 draft/dirty mask,使 seed 尽量携带稳定的 `source_instance_id`、`source_annotation_id` 和 `source_mask_id`。如果参考 mask 本身来自自动传播且未被编辑,前端会继承其 `source_instance_id` 和 `propagation_seed_signature`,让后端识别它仍是原始 seed 的同一条传播链;如果该 mask 被编辑,保存时只保留 lineage,不继承旧签名,从而触发旧结果清理和重传。worker 保存传播结果时会写入 `instance_id`、`source_instance_id`、`propagation_seed_key`、`propagation_seed_signature` 和 `propagation_direction`。同一目标帧段内,同一 seed、同一权重、同一方向再次传播时,如果所有目标帧已有同签名结果,worker 会跳过该 seed;如果签名变化、目标帧段只部分覆盖或本次改用其他 SAM 2.1 权重,worker 会先删除本次目标帧段内的旧自动传播标注再保存新结果。同一参考帧多个同类别 seed 会优先按 `source_instance_id/instance_id` 区分实例,再兼容 `source_annotation_id`、`source_mask_id` 和 `propagation_seed_key`,避免 label/color/class/maskid 相同的不同 mask 互相清理;旧版本缺少稳定来源 id 的传播结果才走 label/color/class 兼容清理,避免保存后的稳定 id 无法替换旧结果。任务运行中/完成后会写入 `processing_tasks.result.model`、`completed_steps`、`processed_frame_count`、`created_annotation_count`、`deleted_annotation_count`、`skipped_seed_count` 和每个 step 的权重/方向/数量结果;前端通过 `GET /api/tasks/{task_id}` 轮询,Dashboard 同时可通过 Redis/WebSocket 进度流显示该任务。 + +## 已完成的接口对齐 + +- `updateProject()` 已从 `PUT` 改为 `PATCH`。 +- `exportCoco()` 已从 `/api/export/coco/{projectId}` 改为 `/api/export/{projectId}/coco`。 +- Canvas 已使用真实 `frame.id` 作为 `image_id`。 +- 点和框坐标已转成后端需要的归一化坐标。 +- 后端 `polygons` 已在前端转成 Konva 可渲染的 path。 +- `saveAnnotation()` 已接入 `POST /api/ai/annotate`。 +- `getProjectAnnotations()` 已接入 `GET /api/ai/annotations`。 +- `updateAnnotation()` 已接入 `PATCH /api/ai/annotations/{annotationId}`。 +- `deleteAnnotation()` 已接入 `DELETE /api/ai/annotations/{annotationId}`;工作区批量删除前会先用 `GET /api/ai/annotations` 预检存在的 id,跳过本地陈旧 id。 +- `importGtMask()` 已接入 `POST /api/ai/import-gt-mask`,导入后端生成的高精度 polygon 标注、原始 `gt_label_value`、原图尺寸/是否拉伸信息。导入端使用 `cv2.IMREAD_UNCHANGED` 读取后校验 dtype,仅接受 8-bit 灰度图和 8-bit RGB 三通道相等图,并按模板 `maskId` 匹配类别;16-bit/uint16 GT_label、全背景 0 图和普通彩色 RGB 类别图都会返回格式错误,全背景图保留“GT Mask 图片中没有非背景 maskid 区域。”提示;超出现有类别时由 `unknown_color_policy` 决定舍弃或写为黑色 `maskid:0` 的“待分类”,并保留 `gt_unknown_class` 和原始 `gt_label_value`。导入 mask 与普通 mask 共用拓扑统计、边缘平滑和保存更新接口,中空导入结果通过 `mask_data.holes` 和 `metadata.polygonRingCounts` 回显为可编辑内洞,前端不显示黄色 seed point。 +- `exportMasks()` 已接入 `GET /api/export/{projectId}/masks`。 +- `parseMedia()` 已改为创建 Celery 后台任务,并返回 `ProcessingTask`。 +- `queuePropagationTask()` 已接入 `/api/ai/propagate/task`,自动传播不再依赖长时间同步 HTTP 请求;传播 seed 可携带与 `polygons` 对齐的 `holes` 和 `source_instance_id`,后端 seed 签名、SAM 2 seed mask 栅格化和传播结果保存都会保留内洞,并用实例 id 区分同语义多 mask。 +- `getTask()` 已接入 `GET /api/tasks/{taskId}`。 +- `cancelTask()` 已接入 `POST /api/tasks/{taskId}/cancel`。 +- `retryTask()` 已接入 `POST /api/tasks/{taskId}/retry`。 +- `getDashboardOverview()` 已从 `processing_tasks` 聚合解析队列。 +- Dashboard 任务列表已展示 queued/running/success/failed/cancelled 任务,并可通过 `getTask()` 查看失败详情;`summary.parsing_task_count` 仍只统计 queued/running。 +- 工作区“分割结果导出”已调用 `exportSegmentationResults()`,并会先保存未归档 mask;旧的 `exportCoco()` / `exportMasks()` 仍保留为兼容接口。 +- PNG mask ZIP 已包含每帧 `semantic_frame_*.png` 和 `semantic_classes.json`,重叠区域按 zIndex 裁决。 +- 统一导出 ZIP 下载文件名为 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;项目名来自 `Project.name` 并会替换文件系统不安全字符,时间戳来自帧 `timestamp_ms` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based `frame_index + 1`,不使用原视频 `source_frame_number`。ZIP 内包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json` 和 `原始图片/`。原始图片按 `视频名称_时间戳_项目帧序号` 命名;选择分开 mask 时写入 `分开Mask分割结果/{视频名称_时间戳_项目帧序号}_分别导出/{视频名称_时间戳_项目帧序号}_{类别名称}_maskid{maskid}.png`,同一帧同一类别会合并为一张二值 mask;选择 GT_label 图时写入 `GT_label图/{视频名称_时间戳_项目帧序号}.png`,固定为 8-bit uint8 PNG;选择 Pro_label 彩色图时写入 `Pro_label彩色分割结果/{视频名称_时间戳_项目帧序号}.png`;选择 Mix_label 叠加图时写入 `Mix_label重叠覆盖彩色分割结果/{视频名称_时间戳_项目帧序号}.png`,透明度由 `mix_opacity` 控制,默认 0.3。导出时 maskid 与 GT_label 像素值相同;有模板 maskid 的类别保留真实 maskid,其中 `maskid:0` 的“待分类”和背景同为 0,缺失 maskid 的旧标注补下一个可用正整数并写入映射 JSON,跨图一致;正整数 maskid 必须在 1-255 内,超出时拒绝导出;maskid 不参与覆盖排序,覆盖顺序仍使用内部拖拽排序字段。 + +## 仍需处理的接口问题 + +- WebSocket 地址已从 `VITE_WS_PROGRESS_URL` 读取,未配置时从 `API_BASE_URL` 推导;部署时仍要确认浏览器能访问该地址。 +- Celery worker 进度会写 PostgreSQL 任务表,同时发布到 Redis `seg:progress`;FastAPI 订阅后广播到 `/ws/progress`。 +- 已保存标注目前支持分类级更新、polygon 顶点拖动、顶点删除、边中点插入、多 polygon 子区域选择、中空 mask 内洞 ring 编辑后的 PATCH 更新和整帧清空删除;`mask_data.polygons` 保存外圈,`mask_data.holes` 保存与外圈对齐的内洞,`metadata.polygonRingCounts` 支撑前端把外圈/内洞重新组合成可编辑结构。 diff --git a/doc/05-implementation-plan.md b/doc/05-implementation-plan.md new file mode 100644 index 0000000..dc2dd71 --- /dev/null +++ b/doc/05-implementation-plan.md @@ -0,0 +1,157 @@ +# 后续实施建议 + +目标是把当前“能看、能上传、能拆帧”的系统推进到“能真实完成标注闭环”的系统。 + +## 阶段 1:先修接口契约(已完成基础对齐) + +优先级最高。AI 点/框推理和 COCO 导出的基础契约已经按当前代码完成对齐。 + +已完成: + +1. `src/lib/api.ts` 的 `updateProject()` 已改为 `PATCH`。 +2. `exportCoco()` 路径已改为 `/api/export/{projectId}/coco`。 +3. Canvas 调 AI 时已使用当前帧真实 `frame.id` 作为 `image_id`。 +4. Canvas 点/框坐标已转成后端需要的归一化坐标。 +5. 后端 `polygons` 已转成前端可渲染的 Konva path。 + +剩余边界: + +1. SAM 3 相关源码和安装脚本保留,但当前产品入口已禁用:前端不展示 SAM 3,后端 registry 不暴露 `sam3`,`model=sam3` 请求返回不支持。若后续重新需要文本语义提示,再恢复前端入口、registry、状态接口和对应测试。 +2. 标注删除/更新接口已打通基础能力;逐点几何编辑器已支持顶点拖动/删除、边中点插入和多 polygon 子区域选择编辑,复杂洞结构仍待增强。 + +## 阶段 2:打通标注保存(已完成基础闭环) + +当前工作区可将未保存 mask 写入后端标注表,并在加载项目帧后回显。 + +已完成: + +1. 前端根据 `Mask.segmentation` 构造后端需要的 normalized `mask_data.polygons`。 +2. 用户点击顶栏保存状态按钮后,未保存 mask 调用 `POST /api/ai/annotate`,dirty mask 调用 `PATCH /api/ai/annotations/{annotation_id}`;按钮文案会按待保存数量显示“保存 X 个改动”或“已全部保存”。 +3. 后端保存或更新 `project_id`、`frame_id`、`template_id`、`mask_data`、`bbox`;具体分类写入 `mask_data.class`。 +4. 工作区加载帧后调用 `GET /api/ai/annotations` 回显已保存标注。 +5. 工作区“清空遮罩”调用 `DELETE /api/ai/annotations/{annotation_id}` 删除当前帧已保存标注。 + +剩余建议: + +1. 加入保存冲突处理和批量保存错误提示。 +2. 逐点几何编辑器已支持拖动/删除顶点、边中点插入新点和多 polygon 子区域编辑;后续增强为复杂洞结构编辑。 +3. 区域合并/去除已支持基础 union/difference;后续增强为更明确的多选列表、操作预览和冲突确认。 + +## 阶段 3:接入导出按钮(已完成统一分割结果导出) + +当前工作区“分割结果导出”会先保存未归档 mask,再调用后端统一结果导出接口。旧 COCO JSON 和 PNG Mask ZIP 接口保留为兼容路径。 + +已完成: + +1. COCO JSON 调用 `/api/export/{projectId}/coco`。 +2. PNG Mask ZIP 调用 `/api/export/{projectId}/masks`。 +3. 兼容 PNG Mask ZIP 仍保留单标注二值 `mask_*.png`,同时输出 `semantic_frame_*.png` 和 `semantic_classes.json`。 +4. 统一导出调用 `/api/export/{projectId}/results`,支持整体视频、特定范围帧、当前图片三种范围,以及分开 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;ZIP 固定包含 maskid/GT 像素值映射 JSON 和原始图片文件夹,各输出文件夹按客户指定的 `视频名称_0h00m00s000ms_项目帧序号` 规则命名;GT_label 图固定为 8-bit uint8 PNG,背景为 0,类别值优先使用模板中的真实 maskid,其中 `maskid:0` 的“待分类”和背景同为 0,缺失 maskid 的旧标注才补下一个可用正整数,正整数 maskid 超出 1-255 会拒绝导出。 + +剩余建议: + +1. 无标注时给出更明确的空导出提示。 + +## 阶段 4:替换 Dashboard mock + +当前 Dashboard 已通过 `GET /api/dashboard/overview` 读取后端聚合快照,不再使用硬编码初始统计、队列或活动日志。 + +已完成: + +- 聚合项目、帧、标注、模板数量和主机 load average。 +- 按 `processing_tasks` queued/running/failed/cancelled 任务生成解析队列。 +- 按最近任务、项目、标注、模板记录生成活动流。 + +已完成补充: + +1. Dashboard 对 queued/running 任务提供取消按钮。 +2. Dashboard 对 failed/cancelled 任务提供重试按钮。 +3. Dashboard 详情弹窗展示任务 error、payload、result、Celery ID 和时间。 + +剩余建议: + +1. 为 Dashboard 增加任务历史筛选。 + +## 阶段 5:异步拆帧和进度 + +Word 方案中提到 Celery + Redis。当前已经有 Celery app、worker task 和 `processing_tasks` 表。 + +已完成: + +1. 新建 Celery app。 +2. `POST /api/media/parse` 只创建任务并立即返回 task id。 +3. worker 执行 FFmpeg/OpenCV/pydicom。 +4. worker 写 PostgreSQL 任务进度。 +5. worker 发布 Redis `seg:progress`,FastAPI 广播到 `/ws/progress`。 + +已完成补充: + +1. `POST /api/tasks/{task_id}/cancel` 取消 queued/running 任务,并尝试 revoke Celery。 +2. `POST /api/tasks/{task_id}/retry` 为 failed/cancelled 任务创建新的 queued 任务。 +3. worker 在关键阶段检查 cancelled 状态,避免取消后继续写帧。 +4. Redis/WebSocket 进度事件增加 `cancelled` 类型。 + +Dashboard 的解析队列现在已经从“项目状态派生”升级为任务表驱动,实时推送也已通过 Redis/WebSocket 打通;剩余重点是任务历史筛选和更细的 worker 中断粒度。 + +## 阶段 6:GT 导入与点区域(已完成基础增强版) + +Word 方案中的完整版本包含距离变换、骨架提取和聚类。当前已经完成基础增强版:导入二值/标签 mask 图片后,后端按非零像素值拆分类别,再按连通域生成高精度 polygon 标注;导入结果与普通 mask 共用拓扑统计、边缘平滑、编辑和保存链路,前端不显示或拖动 seed point。 + +已完成: + +1. 工作区左侧工具栏提供“导入 GT Mask”入口,位置在“重叠区域去除”之后。 +2. 前端调用 `POST /api/ai/import-gt-mask` multipart 接口。 +3. 后端按非零像素值拆分多类别 mask。 +4. 后端使用 OpenCV 高精度 contour 提取每个类别下的连通域,尽量保留边界细节,并用点数上限保护前端性能。 +5. 后端保留 distance transform `points` seed 供数据兼容。 +6. 导入结果写入 `annotations` 表并回显为工作区 mask。 +7. 前端不显示 seed point,也不提供 seed point 拖动;导入 mask 与普通 mask 保持一致的可选中、顶点编辑、拓扑统计、边缘平滑和保存体验。 + +剩余建议: + +1. 增加骨架提取和聚类增强。 +2. 为多类别像素值提供模板分类自动映射规则。 + +## 阶段 7:模板优先级融合(已完成导出侧裁决) + +当前导出 PNG Mask ZIP 时已经按 class/template z-index 做重叠裁决,从低到高覆盖,生成每帧 `semantic_frame_*.png`。 + +已完成: + +1. 标注保存时记录 template class id / name / maskid,并保留内部覆盖优先级。 +2. 导出 mask 时按内部优先级从低到高覆盖。 +3. 同类语义值在融合图中共享同一个 class value。 +4. 跨类重叠由更高内部优先级覆盖更低内部优先级;maskid 不作为排序规范。 + +剩余建议: + +1. 在前端预览重叠裁决结果。 +2. 对多帧多类导出增加颜色 palette PNG 或可视化 legend。 + +## 阶段 7.5:视频片段传播(已完成基础闭环) + +当前工作区传播功能会使用当前打开参考帧的全部 mask 作为 seed,按用户设置的传播起始帧和传播结束帧向前、向后或双向传播,并把结果写入后端标注表。前端只保留一个“自动传播”按钮,减少传播对象选择带来的歧义。 + +已完成: + +1. 前端 `propagateMasks()` 已接入 `POST /api/ai/propagate`。 +2. 工作区会把 seed mask 的 normalized polygon、bbox、label、color 和 class 元数据传给后端。 +3. SAM 2 路径使用官方 `SAM2VideoPredictor.add_new_mask()` 和 `propagate_in_video()`。 +4. SAM 3 video tracker 路径已从当前产品入口禁用,相关 helper 仅保留作后续恢复参考。 +5. 后端会跳过源帧,把传播结果保存到后续帧 `annotations`,并在完成后由前端刷新回显。 +6. 前端已经支持参考帧、起止帧范围和单按钮自动传播;多个 seed 或前后双向范围会拆成多次顺序调用单 seed 后端接口。 + +剩余建议: + +1. 把传播任务改为异步任务,接入 Dashboard 和 WebSocket 进度。 +2. 增加覆盖已有标注策略设置,例如跳过已有、覆盖同类、全部覆盖。 +3. 用真实长视频做 SAM 2 tracker smoke test 和质量评估;如果未来恢复 SAM 3,再单独补充 SAM 3 tracker 评估。 + +## 阶段 8:清理 UI 文案与 Mock + +建议统一这些文案和真实能力: + +- SAM/GPU 状态已改为 `GET /api/ai/models/status` 驱动。 +- 撤销/重做按钮已接全局 mask 历史栈。 +- “重新提取内侧中轴树骨架”接真实接口,否则标为未实现。 +- AI 独立页已移除固定 Unsplash 演示图;没有当前项目帧时显示空状态。后续如果要支持独立图片分析,应接正式上传入口和项目/帧关联。 diff --git a/doc/06-fastapi-docs-explained.md b/doc/06-fastapi-docs-explained.md new file mode 100644 index 0000000..033e1f9 --- /dev/null +++ b/doc/06-fastapi-docs-explained.md @@ -0,0 +1,103 @@ +# `/docs` 是什么 + +地址: + +- 本机:`http://localhost:8000/docs` +- 局域网:`http://192.168.3.11:8000/docs` + +这个页面不是文件列表,也不是项目文档目录。它是 FastAPI 自动生成的 Swagger UI,用来展示和调试后端 HTTP API。 + +## 为什么会自动出现 + +FastAPI 会根据代码里的路由和 Pydantic schema 自动生成 OpenAPI 描述,然后用 Swagger UI 展示出来。 + +相关代码在: + +- `backend/main.py` 创建 `FastAPI(...)` +- `backend/routers/*.py` 定义 `@router.get(...)`、`@router.post(...)` 等接口 +- `backend/schemas.py` 定义请求体和响应体 + +## 页面上 GET / POST / PATCH / DELETE 是什么 + +这些是 HTTP 方法,不是文件。 + +| 方法 | 含义 | 例子 | +|------|------|------| +| GET | 读取数据 | `GET /api/projects` 获取项目列表 | +| POST | 创建或触发动作 | `POST /api/media/upload` 上传文件 | +| PATCH | 局部更新 | `PATCH /api/templates/{template_id}` 更新模板 | +| DELETE | 删除 | `DELETE /api/projects/{project_id}` 删除项目 | + +你看到的每一行,都是后端暴露给前端调用的一个接口。 + +## `/docs` 能做什么 + +可以: + +- 查看后端目前有哪些接口。 +- 展开接口查看参数、请求体和响应格式。 +- 点击 `Try it out` 直接发请求测试后端。 +- 检查接口返回错误,比如 400、401、404、500。 + +不能: + +- 查看前端页面源码。 +- 直接代表某个功能已经完整可用。 +- 展示 WebSocket 的完整交互,因为 OpenAPI 主要描述 HTTP 接口。 + +## 和前端有什么关系 + +前端的 `src/lib/api.ts` 会调用这些接口。例如: + +- 登录页调用 `/api/auth/login` +- 项目库调用 `/api/projects` +- 上传视频调用 `/api/media/upload` +- 拆帧调用 `/api/media/parse` +- 模板库调用 `/api/templates` + +所以 `/docs` 是检查“后端提供了什么”的地方;前端是否真的用对了,还要对照 `src/lib/api.ts`。 + +## 目前通过 `/docs` 能看到的接口 + +当前后端接口包括: + +- Auth:登录 +- Projects:项目 CRUD、项目帧 CRUD +- Templates:模板 CRUD +- Media:上传视频/DICOM、触发拆帧 +- AI:当前启用 SAM 2 推理、模型状态、自动分割、保存标注;SAM 3 源码保留但产品入口禁用 +- Export:导出 COCO JSON、导出 PNG masks +- Health:健康检查 + +## 为什么看起来像“列举文件和请求” + +因为 Swagger UI 默认按接口分组,把每个 endpoint 展开成一行。它列举的是“后端可被调用的功能入口”,不是项目文件。 + +真正的项目文件在本地目录里,例如: + +- 前端:`src/components/*.tsx` +- 后端路由:`backend/routers/*.py` +- 后端模型:`backend/models.py` + +## 如何用 `/docs` 验证一个接口 + +以项目列表为例: + +1. 打开 `/docs`。 +2. 找到 `GET /api/projects`。 +3. 点开。 +4. 点击 `Try it out`。 +5. 点击 `Execute`。 +6. 查看 Response body。 + +如果这里能返回数据,但前端项目库加载失败,那问题多半在前端 API 地址、CORS、字段映射或浏览器网络请求。 + +## 另一个机器可读入口 + +OpenAPI JSON 在: + +```text +http://localhost:8000/openapi.json +``` + +这是给工具读取的接口描述,Swagger UI 就是基于它渲染出来的。 diff --git a/doc/07-current-requirements-freeze.md b/doc/07-current-requirements-freeze.md new file mode 100644 index 0000000..12bd687 --- /dev/null +++ b/doc/07-current-requirements-freeze.md @@ -0,0 +1,234 @@ +# 当前需求冻结文档 + +冻结日期:2026-05-01 + +本文档描述当前仓库已经实现或明确保留为占位的需求。测试用例以本文档为准,不把早期设想或 Word 文档中的远期能力当作当前版本必须实现的功能。 + +## R1 登录与会话 + +- 系统提供登录页。 +- 默认开发管理员为启动时种子化的 `admin / 123456`,密码以哈希形式存入 `users` 表。 +- 登录成功后前端保存签名 JWT,并进入主应用。 +- 页面刷新后前端会用已有 token 调用 `/api/auth/me` 恢复当前用户。 +- 登录失败时显示错误信息。 +- 业务接口必须校验 Bearer token;缺失或无效 token 返回 401。 +- 项目、帧、标注、任务、Dashboard 和导出使用全员共享项目库;所有登录用户读取同一项目库,`admin/annotator` 可创建、导入、解析、标注、AI 推理、导出、复制、重命名和删除项目。 +- 角色只包括唯一默认 `admin` 和 `annotator`;历史 `viewer` 或额外管理员会归一为标注员;用户管理、审计日志和演示环境出厂设置后台仅默认 `admin` 可用。 +- 管理员侧栏显示“用户管理”入口;管理员可以新增标注员、停用/启用、修改密码、删除用户。 +- 系统记录登录成功/失败和用户管理操作到 `audit_logs`,管理员后台可查看最近审计日志。 +- 管理员后台提供“恢复演示出厂设置”危险操作;前端必须二次确认,后端也必须校验 `confirmation=RESET_DEMO_FACTORY`,执行后只保留默认 admin 账号、系统模板、从 `demo/演视LC视频序列.mp4` 创建的已生成帧演示视频项目和从 `demo/演视DICOM序列/` 创建的已按文件名自然顺序生成帧的演示 DICOM 项目,清空其它用户、项目、帧、标注、任务、用户模板和旧审计记录,并写入本次重置审计。 +- 系统默认模板至少包含“腹腔镜胆囊切除术”和“头颈部CT分割”;头颈部 CT 默认分类名必须使用纯中文,不带括号英文翻译;恢复演示出厂设置不得删除系统默认模板,并必须重建缺失的默认模板、覆盖恢复被修改或删减的默认语义分类树。 + +## R2 项目管理 + +- 前端展示项目库,并从 `GET /api/projects` 获取项目列表。 +- 项目库不提供独立“新建项目”按钮;导入视频或 DICOM 时由前端/后端自动创建项目,后端仍保留 `POST /api/projects` 供导入流程和兼容接口使用。 +- 用户可以选择项目,进入工作区。 +- 用户可以导入视频文件,前端创建项目、上传文件并刷新项目列表;导入视频不自动拆帧。 +- 用户可以对已导入源视频的视频项目点击“生成帧”或“重新生成帧”,在弹窗中选择目标 FPS 后创建拆帧任务;如果项目已有帧,重新生成前必须清空该项目现有帧、标注和 mask,避免重复帧序列;项目名称编辑状态下不能显示/触发生成帧入口,DICOM 项目不能显示生成帧入口。 +- 用户可以导入 DICOM 序列,前端上传 DICOM、触发拆帧、刷新项目列表。 +- 用户可以在项目库项目卡片上修改项目名称,名称不能为空。 +- 用户可以在项目卡片删除按钮旁复制项目;复制时可选择“新项目重置”或“全内容复制”。新项目重置必须复制项目媒体字段和已生成帧序列,但不复制标注或 mask 元数据;全内容复制必须额外复制标注和关联 mask 元数据,并将复制标注重新指向新项目中的对应帧。任务运行历史不复制。 +- 用户可以在项目卡片上删除项目;前端调用 `DELETE /api/projects/{id}`,删除成功后从项目库移除,若删除当前项目则清空工作区当前项目、帧、mask 和选区。 +- 后端支持项目创建、列表、详情、局部更新、复制和删除。 +- 后端删除项目时通过 ORM 级联删除项目帧、标注、导出 mask 元数据和后台任务记录。 +- 后端支持项目帧创建、列表和单帧查询。 + +## R3 媒体上传与拆帧 + +- 后端允许上传视频、图片、DICOM 文件,其他扩展名返回 400。 +- 未提供项目 ID 上传时,后端自动创建项目。 +- 提供项目 ID 上传时,后端把上传对象关联到该项目。 +- 项目库导入视频和导入 DICOM 序列时,前端必须显示导入进度条;浏览器提供上传总字节数时显示百分比和已上传/总字节数,未提供总字节数时显示已上传字节的非确定进度。DICOM 导入还必须显示本次有效 `.dcm` 文件数量,并在上传完成后持续显示解析任务进度,直到成功、失败或取消。 +- 拆帧接口根据项目 `source_type` 处理视频或 DICOM。 +- 拆帧接口支持 `parse_fps`、`max_frames` 和 `target_width` 参数,用于生成可被 SAM 2 视频处理复用的标准帧序列。 +- DICOM 批量导入和解析必须按文件名自然顺序处理 `.dcm` 文件,避免数字文件名被字符串排序打乱。 +- 视频/DICOM 解析后都使用连续 `frame_%06d.jpg` 命名,默认从 `frame_000000.jpg` 开始;视频帧按 `target_width` 缩放。 +- 拆帧完成后写入 `frames` 记录,并把项目状态设为 `ready`。 +- 每条帧记录包含 `frame_index`、`image_url`、`width`、`height`、`timestamp_ms` 和 `source_frame_number`。 +- 任务完成结果包含 `frame_sequence` 元数据:`original_fps`、`parse_fps`、`frame_count`、`duration_ms`、`target_width`、帧宽高和对象存储前缀。 +- 拆帧接口会创建 `processing_tasks` 记录并投递 Celery worker。 +- 前端可通过 `GET /api/tasks/{task_id}` 查询任务状态。 +- 后端支持 `POST /api/tasks/{task_id}/cancel` 取消 queued/running 任务,写入 `cancelled` 状态并尝试 revoke Celery。 +- 后端支持 `POST /api/tasks/{task_id}/retry` 对 failed/cancelled 任务创建新的 queued 任务。 +- worker 会在关键阶段检查任务是否已取消,取消后停止继续写帧。 + +## R4 工作区与帧浏览 + +- 工作区根据当前项目加载帧列表。 +- 若项目有媒体但无帧,工作区只提示需要先在项目库生成帧,不再自动触发拆帧。 +- 工作区加载帧时必须获取项目完整帧序列,不能被接口默认分页截断到 1000 帧。 +- Canvas 显示当前帧图片。 +- Canvas 支持滚轮缩放、移动工具拖拽、鼠标坐标显示。 +- Canvas 未选中特定 mask 时,mask 显示顺序必须遵循右侧“语义分类树”拖拽得到的内部覆盖优先级:低优先级先渲染,高优先级后渲染并显示在上层;选中 mask 后可以为了编辑交互临时置顶。 +- 时间轴支持缩略图点击切帧、range 拖动切帧、视频处理进度条点击切帧、人工/AI 标注帧和自动传播帧标识点击切帧、键盘左右方向键切帧、播放/暂停顺序推进帧。 +- 顶栏旧“清空片段遮罩”入口已移除;当前清空/DEL 只在目标 mask 存在传播链结果时进入范围选择。用户选择按帧范围清空后,必须复用时间轴范围选择并最终确认;范围内只清空同一传播链自动传播结果,不能清空无关人工绘制或独立 AI 智能分割 mask。按范围清空或清空所有传播帧时,如果目标帧范围内包含人工绘制或独立 AI 智能分割 mask,必须二次询问是否删除人工/AI 标注帧;用户选择是时删除这些人工/AI 标注帧中的全部 mask,用户选择否时这些帧整帧保留,只清空其它自动传播帧。用户取消确认时不能删除本地 mask、后端标注或传播历史条。 +- 用户在某帧选中 mask 后,如果切换到同一自动传播结果覆盖的其他帧,工作区应自动识别并选中目标帧中对应的传播 mask;匹配依据为传播结果回显到 mask metadata 的 seed 来源和传播链字段,而不是仅凭标签或颜色。 +- 播放帧率使用项目 `parse_fps` 或 `original_fps`,限制在 1 到 30 FPS。 +- 时间轴显示当前帧时间和总时长,时间基准使用项目 `parse_fps` 或 `original_fps`,格式为 `mm:ss.cc`。 +- 时间轴顶部播放进度条只表达当前播放位置;其下方的视频处理进度条表达处理状态:当前帧位置用白色竖线贯穿播放进度条和视频处理进度条;人工绘制或 AI 智能分割生成的帧显示红色竖线,自动传播生成的帧显示蓝色区段,最近自动传播处理过的片段叠加同一蓝色系纯色条,按距最新传播的时间顺序逐次变暗,且第 5 次及更早统一为阈值旧记录色,帮助识别第一次、第二次、第 N 次传播;清空片段遮罩后,与清空范围重叠的最近传播历史条必须同步移除或裁剪,不应继续显示已经清空的传播范围;未处理背景使用中性灰以和标记保持明显区分。进入自动传播或清空遮罩范围选择时,起始帧和结束帧必须额外显示两条贯穿两条进度条的高对比边界线,颜色避开青色播放进度、红色标注、蓝色传播、amber 选区和深色背景。底部帧可视化栏中,人工/AI 标注帧缩略图边框为红色,自动传播/推理帧缩略图边框为蓝色,当前帧仍用青色外框高亮优先;如果同一帧既有人工/AI 标注又有自动传播结果,红色人工/AI 标注框优先保留,自动传播状态只作为蓝色内描边或次级提示;如果当前帧同时是人工/AI 标注帧,则显示青色外框加红色内描边,外层选中框和内层标注框顺序不能交换。 +- 自动传播提交前支持独立选择传播权重,范围限定为 SAM 2.1 tiny/small/base+/large 四个权重变体;该选择只影响传播任务,不提供 SAM2/SAM3 家族切换,也不改变 AI 智能分割页的单帧推理权重。 + +## R5 工具栏 + +- 工具栏可以切换当前 active tool。 +- 工作区左侧工具栏不展示正向点、反向点、框选工具;这些入口只属于 AI 智能分割页。 +- 侧栏“AI智能分割”和工作区工具栏 AI 跳转入口必须使用带明确 AI 语义的图标,而不是普通魔法棒等泛化工具图标。 +- 工作区 AI 智能分割入口切换到 AI 页面。 +- 多边形、矩形、圆、画笔、橡皮擦工具会在 Canvas 上生成或编辑可保存的 polygon mask;左侧工具栏不再提供创建点和创建线段入口。 +- 多边形通过点击取点并按 Enter 完成,也支持三点后点击首节点闭合;矩形、圆通过拖拽生成;点击创建多边形、创建矩形或创建圆工具时必须保留当前 mask 选区;如果当前有选中 mask,新建多边形/矩形/圆必须并入该选中 mask,即使两块区域不重叠也作为同一个多 polygon mask;如果当前没有选中 mask,才创建新 mask 并自动选中,在仍处于创建工具时显示该 mask 边界顶点作为只读选中提示;画笔和橡皮擦支持调整大小。 +- 画笔工具在语义分类树有选中类别或当前已有选中 mask 时可用,按住拖动时以圆形笔触采样,鼠标松开后一次性 union;如果当前有选中 mask,笔触必须并入该 mask,不论是否重叠;如果当前没有选中 mask,才创建新的当前类别 mask;笔触只在当前图像范围内采样,最终几何也必须裁剪到当前帧边界内;如果画笔闭合形成中空区域,必须保留外圈与内洞 ring 分组,并按中空 mask 规则渲染、编辑和保存。 +- 橡皮擦工具只在当前帧已选中 mask 时可用,按住拖动时以圆形笔触采样,鼠标松开后从选中 mask 中 difference 扣除;扣空时删除该 mask,已保存 mask 仍需同步后端删除;进入画笔或橡皮擦模式后,当前选中 mask 的顶点提示仍保持可见,但这些顶点在笔触模式下只读不可拖动。 +- 创建多边形、创建矩形、区域合并/去除、调整多边形等 Canvas 左上角上下文提示只作为短提示,切换工具或操作状态变化时显示,数秒后自动隐藏,避免长期遮挡待编辑图像;再次切换工具或操作状态变化会重新显示。 +- 绘制工具点击已有 mask 时应继续执行当前绘制动作,不应被 mask 选择逻辑吞掉;按 `Esc` 或点击左侧工具栏“取消选中”实体按钮,必须清空当前 mask 选区和正在绘制的临时点/笔触,使用户可以重新选择语义分类并用画笔创建一个新 mask。 +- 所有 polygon mask 都不显示黄色 seed point,也不提供 seed point 拖动;普通手工/AI/GT mask 在画布上应保持一致的区域渲染、选择、顶点编辑、拓扑统计、边缘平滑和保存体验。 +- 工具栏提供“调整多边形”工具,用户可以点击 mask 进入 polygon 顶点编辑态;按住顶点即可直接拖动并实时更新 mask 几何,不需要先单击选中顶点,已保存 mask 会标记为 dirty;顶点拖拽不能冒泡成画布拖拽,编辑结束后 Canvas 当前缩放和平移视口必须保持不变。 +- 工具栏按浅灰分隔线分组:拖拽/选择、取消选中到创建圆为基础绘制组,画笔/橡皮擦为局部笔触组,区域合并/重叠区域去除/DEL/清空遮罩为布尔与删除组,导入 GT Mask/AI 智能分割为外部动作组;`data-testid="tool-group-separator"` 位于清空遮罩下方并分隔外部动作组。工作区在拖拽/选择下方提供“取消选中”按钮,语义等同 `Esc`;在“清空遮罩”上方提供 `DEL` 按钮,语义等同键盘 Delete/Backspace;“导入 GT Mask”入口使用区别于普通编辑工具的紫色底色,不切换 activeTool。 +- 顶点编辑态显示边中点插入手柄;点击边中点会在该边中间新增顶点。 +- “调整多边形”工具下双击 polygon 边界时,会在最接近的线段上按双击位置新增顶点。 +- 顶点编辑态下选中顶点后可用 Delete/Backspace 删除顶点,但不会让 polygon 少于三点。 +- 中空 mask 必须保留外圈与内洞 ring 分组;进入“调整多边形”后,外圈和内洞都应显示可拖动顶点与边中点插入手柄,内洞顶点拖动、插点和保存后的回显都不能把 mask 变成实心。 +- 多 polygon 或分离区域组成的同一个 mask 进入“调整多边形”后,所有分离 polygon 都应显示可拖动顶点与边中点插入手柄,不能只显示第一个主区域。 +- 选中整个 mask 且未选中具体顶点时,Delete/Backspace 删除该 mask;左侧 `DEL` 按钮复用同一删除链路。删除已保存 mask 前,前端必须用当前后端标注列表预检 `annotationId`,只对仍存在的 id 发送 `DELETE /api/ai/annotations/{id}`,避免本地陈旧 id 导致浏览器控制台出现 404 红字;如果删除对象属于自动传播链或是传播 seed,应同步删除同一传播链上的自动传播 mask,但不能删除其他帧独立 AI 推理或人工标注 mask。 +- 撤销、重做绑定全局 `maskHistory/maskFuture`,工作区支持顶栏按钮和全局快捷键 `Ctrl/Cmd+Z`、`Ctrl/Cmd+Shift+Z`、`Ctrl/Cmd+Y`;快捷键监听应在 capture 阶段处理,并在 `event.key` 不可靠时兼容 `event.code=KeyZ/KeyY`,但输入框、文本域、下拉框和可编辑文本聚焦时不能拦截;AI 页支持自己的按钮;左侧工具栏不重复放置撤销/重做入口。 +- 区域合并工具支持多选当前帧 mask,并使用 polygon union 生成合并后的主 mask;若主区域和参与区域存在同一传播链上的对应 mask,合并前必须弹出范围选择,让用户选择只处理当前帧、处理所有传播帧或按帧范围选择;按帧范围选择进入和自动传播/清空一致的时间轴范围选择,点击确认后再弹出最终确认。选择所有传播帧或范围帧时,同一次合并必须同步应用到对应传播帧中的主区域和参与区域,只删除每个已同步帧里的参与合并 mask,不能把未参与本次同步或范围外的同链对象整链误删。 +- 区域去除工具支持多选当前帧 mask,并从第一个选中的主 mask 中扣除后续选中 mask;若主区域和参与区域存在同一传播链上的对应 mask,去除前必须弹出范围选择,让用户选择只处理当前帧、处理所有传播帧或按帧范围选择;按帧范围选择进入和自动传播/清空一致的时间轴范围选择,点击确认后再弹出最终确认。选择所有传播帧或范围帧时,同一次去除必须同步应用到对应传播帧中的主区域和参与区域,参与扣除的 mask 本身保留。 +- 区域合并/去除同步到传播帧时必须保留传播 mask 原有 `source`、`source_annotation_id`、`source_mask_id`、`propagation_seed_key`、`propagation_seed_signature` 等 lineage metadata;这些帧可以进入 dirty 待保存状态,但不能因为几何同步在时间轴上从自动传播帧变成人工/AI 标注帧。 +- 区域合并/去除模式显示已选数量,并隐藏 polygon 编辑手柄以避免手柄抢占多选点击;第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓。 +- 区域去除结果包含内洞时,前端保留 hole ring 并用 even-odd 规则渲染,保存时把外圈写入 `mask_data.polygons`、把每个外圈对应内洞写入 `mask_data.holes`,并用 `metadata.polygonRingCounts` 支撑前端 ring 回显。 + +## R6 AI 推理 + +- 当前 AI 页面支持选择 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus`、`sam2.1_hiera_large`;SAM 3 选择、文本输入和相关状态展示已隐藏。 +- 前端和工作区通过 `GET /api/ai/models/status` 展示 GPU 和四个 SAM 2.1 变体的真实运行状态;`selected_model=sam3` 会被后端拒绝。 +- 前端 `predictMask()` 调用 `POST /api/ai/predict`。 +- 前端发送后端契约:`image_id`、`prompt_type`、`prompt_data`、`model`。 +- 点提示传 `{ points, labels }`,正向点 label 为 1,反向点 label 为 0。 +- AI 页面在已有候选 mask 上点击正向/反向选点时,应继续添加提示点,不应被 mask 选择事件拦截。 +- AI 页面点击已有提示点应删除对应点;“删除最近锚点”只移除最近放置的提示点,不删除候选 mask 或工作区 mask。 +- AI 页面“删除选中候选”和 Delete/Backspace 只删除本页生成且已选中的 AI 候选 mask,不删除工作区已有 mask。 +- 工作区点击已有 SAM 提示点应优先删除该点并按剩余提示重新推理;该事件不得冒泡成新增提示点、mask 选择或其它画布点击行为。 +- 框选提示传归一化 `[x1, y1, x2, y2]`。 +- AI 页面边界框选应支持画布拖拽预览框;执行分割时只框选不加点发送 `box` prompt,框选后继续加点发送 `interactive` prompt。 +- 工作区 SAM 2.1 框选会建立一个候选 mask;后续正向点/反向点会携带原始框和累计点,以 `interactive` prompt 细化并替换同一个候选 mask。 +- 工作区 SAM 2.1 一旦包含反向点,会随请求启用 `auto_filter_background` 和 `min_score=0.05`;若后端判定反向点排除了当前候选区域并返回空结果,前端会移除旧候选 mask,避免继续显示已被否定的区域。 +- SAM 2.1 不支持文本语义提示;当前 AI 页面不提供文本语义输入,必须使用点/框提示。 +- SAM 2.1 点提示和 auto fallback 默认只采用一个最高分候选 mask,避免多个候选 mask 作为同一结果重叠显示。 +- AI 页面只渲染本页最新生成的候选 mask;重复执行高精度分割会替换上一次 AI 页候选,工作区已有手工、保存、传播或 GT 导入 mask 不会自动进入 AI 画布,也不会被替换。 +- AI 页面提供“AI 遮罩透明度”滑杆,并与右侧“遮罩透明度”共享 `maskPreviewOpacity`;调节任一入口都会改变 AI 候选 mask 预览透明度,不改变 mask 几何、分类或保存数据。 +- AI 页面参数开关展示文案使用“局部专注模式(自动裁剪无锚区域)”和“严格除杂模式(自动清理干涉点)”;这是 UI 可读性文案,不改变 `cropMode`、`autoDeleteBg` 或后端 `options` 字段。 +- AI 页面生成的 SAM 2.1 mask 会写入全局 `masks`,自动同步到当前项目帧,并写入全局 `selectedMaskIds`;右侧语义分类树可以直接给新生成 mask 换标签。 +- AI 页“清空全体锚点”只清空本页提示点和本页生成的候选 mask,不删除工作区已有 mask。 +- AI 页面“推送至工作区编辑”必须先校验待推送 AI 候选 mask 已有语义分类;没有 `classId` 或 `className` 时用右上角 error toast 明确提示并停留在 AI 页,不允许进入工作区,确保工作区内 mask 都有语义。 +- 如果用户不通过推送按钮而是直接离开 AI 页面,仍未选择语义分类的 AI 候选 mask 必须从全局 `masks` 和 `selectedMaskIds` 中清理,避免无语义候选通过侧栏切换进入工作区。 +- AI 页面“推送至工作区编辑”在语义校验通过后会切回工作区并把工具切到“调整多边形”,保留当前选中的 AI mask 和当前帧视角,以便继续编辑轮廓和归档保存;如果 AI 操作发生在非第一帧,回到工作区后不得强制跳回第一帧。 +- 工作区加载后端已保存标注时,必须保留当前项目帧里尚未保存的 AI/手工 draft mask,避免 AI 页推送到工作区的候选 mask 被异步回显流程覆盖。 +- 语义文本提示 `semantic` 当前被后端禁用并返回 400。 +- SAM 3 源码和历史测试保留,但不属于当前产品可用功能;前端不再展示 SAM 3 入口,后端 registry 不暴露 `sam3`。 +- 工作区传播功能以当前打开帧作为参考帧,并使用该帧全部 mask 作为 seed;用户不再选择“选中区域/当前帧全部”传播对象。 +- 工作区传播功能允许设置传播起始帧和传播结束帧;前端以当前参考帧为 seed,只向起止范围内位于参考帧之前和之后的帧传播,源帧不重复保存。 +- 工作区只保留一个“自动传播”按钮,点击后在指定范围内按前向/后向自动生成 mask。 +- 工作区进入自动传播范围选择时,顶栏必须显示本次传播权重以及按当前参考帧计算的向前/向后传播帧数,避免用户只看到起止帧而不清楚实际传播方向。 +- 当前参考帧没有 mask 时,点击“开始传播”必须提示“当前参考帧无遮罩”,且不得提交传播任务或保存其它帧标注。 +- 自动传播提交前,前端必须只保存当前参考帧中的 draft/dirty mask;参考帧 seed 优先使用后端 `annotation_id` 作为稳定来源,避免第一次用前端临时 id 传播、后续保存后无法替换旧传播结果;其它帧的脏标注不能在传播准备阶段触发无关后端更新。 +- 前端会把多个 seed 或双向范围拆成 `steps`,通过 `POST /api/ai/propagate/task` 创建 `propagate_masks` 后台任务,避免长 HTTP 请求卡在浏览器侧,同时避免并发抢占 GPU。 +- `POST /api/ai/propagate` 作为单 seed 同步兼容接口保留;`POST /api/ai/propagate/task` 是工作区自动传播使用的任务接口。两者当前支持四个 SAM 2.1 变体;兼容 `model=sam2` 并归一化为 tiny。SAM 2.1 使用官方 `SAM2VideoPredictor.add_new_mask()` 和 `propagate_in_video()`。 +- 自动传播任务写入 `processing_tasks`,前端轮询 `GET /api/tasks/{task_id}` 显示进度并刷新标注;Dashboard 也能看到该任务,任务可取消和重试。 +- 传播 seed 若包含中空结构,前端必须把内洞按外圈对齐传入 `holes`;后端栅格化 SAM 2 seed mask 时先填充外圈再扣除内洞,不能以实心 mask 注入 video predictor;seed 签名也必须包含 `holes`,避免中空编辑后被误判为未变化。 +- 传播结果会写入后续帧 `annotations`,`mask_data.source` 标记为 `_propagation`,并保留 label、color、class 元数据、seed 来源 id、seed 签名和传播方向;后端从传播二值 mask 提取轮廓时必须保留内洞,保存为与 `polygons` 对齐的 `mask_data.holes`,前端回显后仍能编辑内洞;如果历史或外部 seed 带 `geometry_smoothing` 平滑参数,worker 保存前仍必须对传播返回的 polygon 实际应用同一平滑几何,不能只更新拓扑锚点或 metadata。当前工作区平滑按钮应用后会直接改写实际 polygon 并清除平滑参数,后续传播以新几何本身参与签名。 +- 自动传播任务必须避免重复叠加:同一目标帧段内,同一参考 seed、同一权重、同一方向且所有目标帧已有未变化结果时,worker 直接跳过;同一参考 seed 已变化、目标帧段只部分覆盖或用户改用其他 SAM 2.1 权重时,worker 先删除本次目标帧段内对应旧自动传播标注,再保存新传播结果;对早期只记录前端临时 `source_mask_id` 的旧传播结果,worker 会按传播方向和语义信息做兼容清理。用户在自动传播链中间帧人工新增或修改同一物体 mask 后重新向前/向后传播时,即使新 seed 缺少旧传播链 source id,也要按语义信息和目标帧空间重叠清理旧传播结果后再写入新结果;写入前清理不受旧结果 `propagation_direction` 限制,因此当前帧向前传播时也会替换原先由更早帧向后传播出来的旧 mask,避免同一物体新旧 mask 堆叠。未编辑的自动传播结果再次作为参考 seed 时,会继承原始 `propagation_seed_signature` 以避免重复传播;被编辑后的传播结果只保留 lineage,不继承旧签名,以便触发删除旧结果并重新传播。历史带 `geometry_smoothing` 的 seed 在 forward/backward 两个方向都会用同一参数平滑保存结果。 +- AI 页面会对未放置点提示、后端错误和返回 0 个 mask 的情况显示明确反馈。 +- AI 参数支持 `crop_to_prompt`、`auto_filter_background` 和 `min_score`;点/框 prompt 可以裁剪局部区域推理并回映射结果,背景过滤会移除低分结果和包含负向点的 polygon。 +- 后端返回 `polygons` 和 `scores`。 +- 前端把后端 `polygons` 转成 Konva `pathData`、`segmentation`、`bbox`、`area`。 +- AI 推理结果先存放在前端 store 的 `masks` 中,顶栏保存状态按钮会按待保存数量显示“保存 X 个改动”或“已全部保存”;点击保存后持久化到后端标注表。 + +## R7 标注保存 + +- 后端提供 `POST /api/ai/annotate` 保存标注。 +- 保存时必须存在项目;如果传入 `frame_id`,帧也必须存在。 +- 后端提供 `GET /api/ai/annotations` 查询项目标注,可选按 `frame_id` 过滤。 +- 后端提供 `PATCH /api/ai/annotations/{annotation_id}` 更新已保存标注的 `mask_data`、`points`、`bbox` 和 `template_id`。 +- 后端提供 `DELETE /api/ai/annotations/{annotation_id}` 删除已保存标注。 +- 当前前端保存状态按钮会保存当前项目未保存 mask,并会更新已标记为 dirty 的已保存 mask。 +- 如果 dirty mask 携带的本地旧 `annotationId` 在后端已经不存在,前端保存链路必须先用当前后端标注列表做存在性预检,已知缺失的 id 直接用同一几何和 metadata 重新 `POST` 创建标注;如果预检后发生并发删除导致 `PATCH` 返回 404,也必须降级为重新创建,并重新拉取后端标注替换本地旧 id;点击“开始传播”前的参考帧保存也必须复用该容错逻辑,不能因陈旧 id 中断传播。 +- 保存成功后,前端会重新拉取后端标注,并用后端 saved annotation 替换本次提交的 draft mask;未提交的其他 draft mask 仍保留。 +- 工作区“清空遮罩”只从左侧工具栏触发;当前帧有选中 mask 时以选中 mask 为对象,没有选中时以当前帧全部 mask 为对象。若目标 mask 没有关联其它传播帧,则直接删除当前帧已保存标注并清空当前帧未保存 mask,不弹确认;若目标 mask 存在传播链上的其它帧结果,则弹出范围确认,用户可在同一行选择“取消”、“只清当前帧”、“按帧范围选择”或“清空所有传播帧”;按帧范围选择进入和自动传播/布尔操作一致的时间轴范围选择模式,并在顶栏“确认清空”后最终确认。清空所有传播帧或范围帧时若目标帧范围包含人工/AI 标注帧,会二次询问是否删除;选择是会删除这些人工/AI 标注帧中的全部 mask,选择否会保留这些人工/AI 标注帧整帧,只同步清空其它同传播链自动传播结果,不能删除其它帧独立 AI 推理或人工标注 mask。 +- 工作区加载项目帧后会查询已保存标注并回显。 +- 工作区支持导入 GT mask 图片,前端调用 `POST /api/ai/import-gt-mask`。 +- 导入 GT Mask 时,前端必须让用户选择未知 maskid 处理策略:舍弃未知类别,或导入为黑色 `maskid:0` 的“待分类”,并保留原始 `gt_label_value` 等待后续重新命名。 +- 后端导入 GT mask 时必须仅支持 8-bit 二值/灰度 `GT_label图`,以及 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图;0 是背景,X 是 1-255 的 maskid。灰度/RGB 等通道图按当前模板 `maskId` 匹配类别,超出现有类别时按用户选择的策略处理;16-bit/uint16 GT_label 和普通彩色 RGB 类别图不再视为合法 GT mask,必须返回图片不符合要求的明确错误。 +- 后端导入 GT mask 时必须把全背景 0 图视为非法 GT mask,返回“GT Mask 图片中没有非背景 maskid 区域。”,前端导入预览也必须保留同一提示并禁止继续导入。 +- 导入 GT mask 前端必须提供导入结果预览,显示检测到的 maskid、未知 maskid 和尺寸适配提示;如果 mask 图片尺寸与当前帧不同,后端导入前必须按当前帧长宽用最近邻插值拉伸,使 mask 可适配当前图片。 +- 后端导入 GT mask 时按非背景像素值或颜色拆分多类别区域,再按连通域生成高精度 polygon 标注;轮廓提取应尽量保留边界细节,同时对单轮廓点数设置上限避免严重影响前端渲染和编辑性能;可通过距离变换写入内部 `points` seed 供数据兼容。 +- 前端不回显导入标注的 seed point,也不提供 seed point 拖动;导入 mask 必须与普通 mask 共用拓扑锚点统计、边缘平滑、顶点编辑、分类和保存能力。 + +## R8 模板库 + +- 前端展示模板列表,调用 `GET /api/templates`。 +- 用户可以新建、编辑、删除模板,也可以在“生效中模板架构清单”中用鼠标复制现有模板为当前用户私有副本。 +- 模板分类存放在 `mapping_rules.classes`,规则存放在 `mapping_rules.rules`。 +- 所有新建、复制、批量导入和后端返回的模板必须包含 `maskid: 0`、颜色 `[0,0,0]`/`#000000`、名称为“待分类”的保留分类;该分类固定显示在语义分类树最后,不能删除,也不能通过拖拽上移。 +- 前端支持添加/删除分类、拖拽排序后更新内部覆盖优先级和 JSON 批量导入。JSON 批量导入必须支持 `[[colors], [names]]` 和 `{colors, names}` 两种格式,并兼容常见粘贴内容中的前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号。模板详情页分类区标题必须显示为“语义分类树(拖拽调层级)”,右上角按钮必须显示为带编辑图标的“编辑模板”;分类行右侧不得显示“未分类/批量导入/模板名”等描述标签,必须显示垃圾桶图标并可点击删除该 label。编辑模板弹窗点击分类后只允许编辑分类名称,不得显示或编辑旧 `category` 来源元信息。复制模板必须保留分类名称、颜色、`maskid`、内部层级顺序和规则,但要重建类别内部 id。界面不展示内部优先级数值,只展示每个类别稳定的 `maskid`。 +- 后端支持模板创建、列表、详情、局部更新和删除。 + +## R9 本体检查面板 + +- 工作区右侧可以选择模板。 +- 面板显示模板分类;新增自定义分类会写入当前激活模板的后端 `mapping_rules.classes`。 +- 右侧面板修改激活模板时,如果当前项目已有任意 mask,必须弹窗提示用户确认;确认后清空当前项目所有本地 mask 和已保存后端标注,再切换激活模板。当前项目没有任何 mask 时切换激活模板不需要提示。 +- 用户可以选择具体分类;新 AI mask 会记录 `classId`、`className`、`classZIndex`,并在保存时写入 `mask_data.class`。 +- 如果 Canvas 当前已经选中一个或多个 mask,点击语义分类树会把这些 mask 的 `label`、`color` 和 class 元数据改为该分类;如果 Canvas 当前没有选中任何 mask,点击语义分类树只设置后续新建 mask 使用的 active class,不能修改已有 mask;如果这些 mask 属于自动传播链,还必须通过 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 和 `propagation_seed_signature` 同步更新同一传播链前后帧的对应 mask;已保存 mask 会进入 `dirty` 状态,归档保存时更新后端。 +- 打开工作区回显项目标注时,如果已保存 mask 的 class 不再存在于其所属模板中,前端必须把该 mask 转为 `maskid: 0` 的“待分类”mask,保留几何,标记为 dirty,等待用户重新分类并保存。 +- 添加自定义分类需要先选择模板,保存时调用 `PATCH /api/templates/{id}` 并同步全局模板 store。 +- “特定目标实例属性追踪”下方显示当前选中 mask 的 `className/label`,不显示全局 active class 的旧值。 +- 当前实例属性面板不展示“当前选中区域”计数;当前 mask 交互以单选为主,计数长期为 1,不作为有效业务信息展示。 +- 选中 mask 后,拓扑锚点调用 `POST /api/ai/analyze-mask` 自动读取,不再显示固定占位值;后端 `topology_anchor_count` 必须表示 polygon 的真实顶点数量,不能用抽样后的展示点数代替;前端必须静默忽略 abort/cancel 或过期的分析请求,避免快速切换 mask、拖动平滑预览或卸载组件时误显示“后端属性读取失败”;前端不再展示“后端模型置信度”条目,也不再提供“重新提取拓扑锚点”调试按钮。 +- 选中 mask 后,右侧实例属性面板提供“边缘平滑强度”和“应用边缘平滑”;调整滑杆时必须立即更新数值,但后端预览请求需要做短防抖,用户停止拖动约 220ms 后再调用 `POST /api/ai/smooth-mask` 并用返回 polygon 临时预览当前 mask 边缘,避免连续拖动时请求过密造成卡顿;预览阶段不标 dirty;点击“应用边缘平滑”后确认当前预览结果,前端必须把平滑 polygon 作为新的实际 mask 几何写入当前 mask,并同步写入同一传播链前后对应 mask;整次平滑应用必须作为一个撤销/重做历史步骤,撤销/重做要同时作用于当前 mask 和传播链对应 mask;应用后相关 mask 标记为 dirty/draft,平滑强度重置为 0,用户仍可继续用 polygon 编辑工具编辑平滑后的新多边形,并通过顶栏保存状态按钮落库;保存 dirty 传播链 mask 时必须保留传播来源 metadata,不能让原本自动传播帧变为人工/AI 标注帧。后端平滑必须对 AI/SAM 密集轮廓执行去噪简化、Chaikin 平滑和二次简化,使结果 polygon 的密集边缘点实际减少;强度映射必须低段温和、高段继续递进,避免 20% 左右已经过度平滑且后续档位无明显变化。 + +## R10 Dashboard 与 WebSocket + +- Dashboard 显示基础统计、任务进度和活动日志。 +- Dashboard 初始数据来自 `GET /api/dashboard/overview`。 +- 后端聚合项目数、处理中任务数、标注数、帧数、模板数和主机 load average。 +- 任务进度由 `processing_tasks` 中的 queued/running/success/failed/cancelled 任务生成,避免刚完成任务从进度区立即消失;处理中任务数统计只计算 queued/running;活动日志由最近任务、项目、标注和模板记录生成。 +- Dashboard 对 queued/running 任务提供取消按钮,对 failed/cancelled 任务提供重试按钮。 +- Dashboard 任务详情会读取 `GET /api/tasks/{task_id}` 并展示失败 error、payload、result、Celery ID 和时间信息。 +- Dashboard 会连接 `/ws/progress`。 +- 收到 progress、complete、error、status 消息时,前端会更新队列或日志。 +- 收到 cancelled 消息时,前端会把对应任务标记为已取消。 +- Celery worker 每次更新 `processing_tasks` 后会发布 Redis `seg:progress` 事件,FastAPI 订阅并广播给 `/ws/progress` 客户端。 +- 后端 WebSocket 接收到客户端消息后返回 status heartbeat。 + +## R11 导出 + +- 后端支持 `GET /api/export/{project_id}/coco` 导出 COCO JSON。 +- 后端支持 `GET /api/export/{project_id}/masks` 导出 PNG mask ZIP。 +- 后端支持 `GET /api/export/{project_id}/results` 统一导出分割结果 ZIP,参数支持整体视频、特定范围帧和当前图片三种范围,并支持分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图;Mix_label 透明度默认 0.3。 +- 统一导出 ZIP 必须固定包含 `maskid_GT像素值_类别映射.json`,记录当前导出中每个类别的 `maskid`、GT_label 像素值、中文名、类别名、RGB 值、颜色和类别 key;GT_label 必须固定输出 8-bit uint8 PNG,背景值固定为 0,语义类别值使用类别真实 maskid,缺失 maskid 的旧标注才补下一个可用正整数,且同一类别跨图片保持一致;`maskid: 0` 的“待分类”必须在映射中保留为 0,GT_label 内与背景同为 0;正整数 maskid 超出 1-255 时必须拒绝导出。 +- 统一导出 ZIP 必须固定包含 `原始图片/` 文件夹,导出范围内每帧的原始图片命名为 `视频名称_时间戳_项目帧序号` 加原图片扩展名;视频名称来自项目视频文件名,时间戳来自帧 `timestamp_ms` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based `frame_index + 1`,不使用原视频帧号。 +- 选择“分开 Mask”时,统一导出 ZIP 必须包含 `分开Mask分割结果/`;每帧建立 `{视频名称_时间戳_项目帧序号}_分别导出` 子文件夹,同一帧同一类别的所有 annotation 合并为一张二值 PNG,文件名包含 `视频名称_时间戳_项目帧序号_{类别名称}_maskid{maskid}`。 +- 选择“GT_label 黑白图”时,统一导出 ZIP 必须包含 `GT_label图/`;每帧输出一张融合后的 GT_label PNG,文件名为 `视频名称_时间戳_项目帧序号`,重叠区域仍按内部拖拽排序从低到高覆盖;maskid 不构成排序规范。 +- 选择“Pro_label 彩色图”时,统一导出 ZIP 必须包含 `Pro_label彩色分割结果/`;每帧输出一张按类别 RGB 上色的 PNG,背景为 `[0,0,0]`,`maskid: 0` 的“待分类”也必须输出为黑色 `[0,0,0]`。 +- 选择“Mix_label 叠加图”时,统一导出 ZIP 必须包含 `Mix_label重叠覆盖彩色分割结果/`;每帧输出一张彩色 label 叠加原始图片的 PNG,透明度可选且默认为 0.3。 +- GT_label、Pro_label 和 Mix_label 的重叠区域覆盖顺序必须和右侧“语义分类树”的内部覆盖优先级一致,低优先级先写入,高优先级后写入。 +- 分割结果导出 ZIP 文件名必须使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;项目名来自项目库中的 `Project.name`,时间戳来自导出范围首尾帧 `timestamp_ms` 并格式化为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based `frame_index + 1`。 +- 当前前端 `exportCoco()` API 封装已对齐后端路径。 +- 当前前端 `exportMasks()` API 封装已对齐后端路径。 +- 当前前端 `exportSegmentationResults()` API 封装已对齐统一导出路径。 +- 工作区“分割结果导出”按钮已替代原 JSON/PNG 两个按钮;点击后在下拉栏内选择导出范围、勾选导出内容,并在选择 Mix_label 时调节遮罩透明度和查看当前/待导出第一帧预览;导出范围默认选中“当前图片”,导出前会先保存当前未归档 mask。选择“特定范围帧”时,用户既可以直接修改起止帧输入框,也可以像自动传播、清空遮罩一样在播放进度条或视频处理进度条上点击/拖拽选择导出范围。 +- PNG mask ZIP 包含单标注二值 mask、按 zIndex 融合后的每帧语义 mask 和 `semantic_classes.json`。 +- 统一导出的 GT_label 图必须是 8-bit uint8 PNG,背景值固定为 0,所有语义类别值优先保留模板类别真实 maskid,缺失 maskid 的旧标注才按下一个可用正整数补值;有效类别值范围为 0-255,其中 0 仅用于背景和系统保留的“待分类”。 + +## R12 配置 + +- 前端 API 地址由 `src/lib/config.ts` 统一推导。 +- `VITE_API_BASE_URL` 优先级高于自动推导。 +- `VITE_WS_PROGRESS_URL` 优先级高于从 API 地址推导 WebSocket 地址。 +- 未设置环境变量时,前端按当前浏览器 hostname 推导 `http://:8000`。 + +## R13 文档与测试 + +- `doc/` 目录保存当前实现审计、接口契约、需求冻结、设计冻结和测试计划。 +- 测试应覆盖当前冻结需求中的真实功能、半可用行为和明确占位行为。 +- 对外部服务依赖 PostgreSQL、MinIO、Redis、SAM 模型的测试应使用 mock 或测试替身,不依赖真实服务可用性。 diff --git a/doc/08-current-design-freeze.md b/doc/08-current-design-freeze.md new file mode 100644 index 0000000..2055e41 --- /dev/null +++ b/doc/08-current-design-freeze.md @@ -0,0 +1,304 @@ +# 当前设计冻结文档 + +冻结日期:2026-05-01 + +本文档描述当前代码结构、数据流、接口契约和测试边界。后续实现如果改变这些设计,应同步更新本文档和测试。 + +## 总体架构 + +当前系统由三层组成: + +- React + TypeScript 前端 SPA。 +- FastAPI 后端 API。 +- PostgreSQL、MinIO、Redis、SAM 2 等外部基础设施。SAM 3 相关源码保留,但当前产品入口禁用。 + +开发时前端通过 `server.ts` 启动 Express + Vite middleware;后端通过 `backend/main.py` 启动 FastAPI。前端业务接口访问 FastAPI,`server.ts` 不再保留旧版 `/api/*` mock。 + +## 前端模块 + +| 模块 | 文件 | 设计职责 | +|------|------|----------| +| 应用入口 | `src/App.tsx` | 根据登录状态和 `activeModule` 切换页面 | +| 全局状态 | `src/store/useStore.ts` | Zustand store,保存项目、帧、模板、mask、当前选中 mask ids、工具状态和 mask 撤销/重做历史栈 | +| API 封装 | `src/lib/api.ts` | Axios 客户端、字段映射、AI 响应转换 | +| 配置 | `src/lib/config.ts` | 推导 API 和 WebSocket 地址 | +| WebSocket | `src/lib/websocket.ts` | 进度流连接、订阅、连接状态通知、心跳和重连 | +| 模型状态 | `src/components/ModelStatusBadge.tsx` | 展示 GPU 与当前 SAM 模型真实可用状态;左侧 Sidebar 底部使用 compact 形态显示 GPU/CPU 状态,工作区顶栏不再重复显示,具体传播权重只在进入自动传播后由顶栏下拉负责 | +| 登录页 | `src/components/Login.tsx` | 使用 `public/logo.png` 和系统标题文案,调用登录 API,写入 store | +| Dashboard | `src/components/Dashboard.tsx` | 展示统计、任务控制、失败详情和 WebSocket 进度消息 | +| 项目库 | `src/components/ProjectLibrary.tsx` | 项目列表、重命名、删除、复制、导入视频/DICOM、显式生成帧/重新生成帧 | +| 工作区 | `src/components/VideoWorkspace.tsx` | 加载帧和模板,组织工具栏、Canvas、本体面板、时间轴 | +| Canvas | `src/components/CanvasArea.tsx` | 显示帧、缩放平移、点/框提示、渲染 mask | +| 工具栏 | `src/components/ToolsPalette.tsx` | 切换工作区编辑工具、提供等同 `Esc` 的“取消选中”实体按钮、在“重叠区域去除”后触发当前帧/传播链清空、GT Mask 导入和 AI 页面跳转;AI 跳转入口复用 Bot + Sparkles 组合图标以明确表达 AI 智能分割;不再放置 AI 正/反点和框选工具,也不重复放置撤销/重做;拖拽/选择/取消选中到创建圆、画笔/橡皮擦/区域合并/重叠区域去除、清空遮罩/导入 GT Mask/AI 智能分割三类工具之间用浅灰横线分隔;紧凑垂直布局,高度不足时自身滚动;外层宽 56px,按钮列固定 48px,滚动条使用右侧外扩空间和低对比 `seg-scrollbar` | +| 工作区顶栏 | `src/components/VideoWorkspace.tsx` | 保存状态按钮(“保存 X 个改动”/“已全部保存”)、导出/传播/按起止帧批量清空遮罩、显式撤销/重做按钮和工作区快捷键 | +| 时间轴 | `src/components/FrameTimeline.tsx` | 帧导航、播放进度、视频处理进度条、自动传播历史片段、自动传播/布尔操作/导出范围选择、左右方向键切帧、播放和当前/总时长显示 | +| 本体面板 | `src/components/OntologyInspector.tsx` | 模板选择、工作区 mask 透明度、分类树、后端自定义分类、mask 后端属性分析;内容过长时自身滚动,滚动条使用低对比 `seg-scrollbar` | +| AI 页面 | `src/components/AISegmentation.tsx` | 独立 AI 推理视图,使用当前项目帧 | +| 模板库 | `src/components/TemplateRegistry.tsx` | 模板 CRUD、分类编辑、导入、详情页和编辑弹窗拖拽排序 | +| 短提示浮层 | `src/components/TransientNotice.tsx` | 项目库和模板库的非阻塞成功/失败提示,自动消失 | + +## 后端模块 + +| 模块 | 文件 | 设计职责 | +|------|------|----------| +| 应用入口 | `backend/main.py` | FastAPI app、CORS、路由注册、健康检查、WebSocket | +| 配置 | `backend/config.py` | Pydantic settings | +| 数据库 | `backend/database.py` | SQLAlchemy engine、session、Base | +| 模型 | `backend/models.py` | User、Project、Frame、Template、Annotation、Mask、AuditLog、ProcessingTask | +| Schema | `backend/schemas.py` | Pydantic 请求/响应模型 | +| Auth | `backend/routers/auth.py` | 用户表、密码哈希、JWT 登录和 `/api/auth/me` | +| Admin | `backend/routers/admin.py` | 管理员用户 CRUD、角色/密码/启停用和审计日志 | +| Projects | `backend/routers/projects.py` | 项目与帧 CRUD | +| Templates | `backend/routers/templates.py` | 模板 CRUD 和 mapping_rules 打包/解包 | +| Media | `backend/routers/media.py` | 上传媒体和拆帧 | +| AI | `backend/routers/ai.py` | 当前启用 SAM 2 推理、视频传播、模型状态和标注保存 | +| 传播任务 | `backend/services/propagation_task_runner.py` | Celery 中执行自动传播 steps,写任务进度并保存传播标注 | +| Export | `backend/routers/export.py` | COCO 和 PNG mask 导出 | +| SAM 2 | `backend/services/sam2_engine.py` | SAM 2 懒加载、状态检测、点/框/自动推理和视频 mask 传播 | +| SAM 3 | `backend/services/sam3_engine.py`, `backend/services/sam3_external_worker.py`, `backend/setup_sam3_env.sh` | 历史保留的 SAM 3 桥接源码和脚本;当前未接入 registry | +| SAM Registry | `backend/services/sam_registry.py` | 当前暴露 SAM 2.1 四个变体、GPU 状态和推理分发 | + +## 状态模型 + +前端 store 的核心对象: + +- `Project`:项目基本信息、状态、帧数、fps、媒体路径。 +- `Frame`:帧 ID、项目 ID、索引、图片 URL、宽高、序列时间戳和原视频源帧号。 +- `Template` / `TemplateClass`:模板和分类定义。 +- `Mask`:前端渲染用 mask,包含 `pathData`、`segmentation`、`bbox`、`area`。 +- `selectedMaskIds`:Canvas 当前选中的 mask id 列表,供右侧本体面板对已选区域直接换标签。 +- `maskHistory` / `maskFuture`:mask 编辑历史栈,用于撤销和重做。 +- `activeModule`:当前页面。 +- `activeTool`:当前工具。 +- `aiModel`:当前启用的 AI 模型,取值为 `sam2.1_hiera_tiny`、`sam2.1_hiera_small`、`sam2.1_hiera_base_plus` 或 `sam2.1_hiera_large`,默认 `sam2.1_hiera_tiny`。 + +## 关键数据流 + +### 登录 + +1. `Login` 收集用户名和密码。 +2. `login()` 调用 `POST /api/auth/login`。 +3. 后端用 `users` 表中的密码哈希校验用户,成功后返回签名 JWT 和用户资料。 +4. 前端把 token 写入 `localStorage` 和 Zustand;刷新页面时 `useStore` 会从 `localStorage` 恢复 token。 +5. `App` 在已登录状态调用 `/api/auth/me` 恢复当前用户,再拉取当前用户项目列表。 + +### 用户隔离 + +1. `Project.owner_user_id` 指向 `users.id` 作为创建者/历史元数据保留;项目库访问不再按该字段隔离,所有登录用户共享同一项目库。 +2. 项目、帧、媒体上传/拆帧、AI 标注、传播任务、任务列表、Dashboard 和导出接口都通过当前 JWT 用户过滤项目资源。 +3. `Template.owner_user_id` 支持用户模板;`owner_user_id IS NULL` 的模板视为系统模板,可作为默认分类体系对用户可见。 +4. 角色只分为唯一默认 `admin` 和 `annotator`:`admin/annotator` 可调用写入类业务接口;`/api/admin/*` 仅允许默认 `admin`,用于用户管理、审计日志和演示环境出厂设置。 +5. `UserAdmin.tsx` 仅在当前用户角色为 `admin` 时从 `Sidebar` 展示,调用 `/api/admin/users` 完成标注员新增、停用/启用、密码修改和删除用户,调用 `/api/admin/audit-logs` 展示登录和管理操作审计;改密码、删除用户和危险区“恢复演示出厂设置”均使用站内弹窗确认,恢复出厂设置要求输入 `RESET_DEMO_FACTORY` 后调用 `/api/admin/demo-factory-reset`。 +6. `POST /api/admin/demo-factory-reset` 仅允许 `admin`,会重置默认 admin 密码/角色/启用状态,删除其它用户、项目、帧、标注、mask、任务、用户模板和旧审计,重新创建 `demo/演视LC视频序列.mp4` 指向且显示名为“演视LC视频序列”的已生成帧演示视频项目,以及 `demo/演视DICOM序列/` 指向且显示名为“演视DICOM序列”的演示 DICOM 项目;视频和 DICOM 都会上传源媒体并生成帧,DICOM 会按文件名自然顺序读取;系统模板保留以保证重置后仍可标注。 +7. 缺失、过期或伪造的 Bearer token 会在业务路由返回 401,权限不足返回 403,其他用户项目资源对当前用户表现为 404。 + +### 项目导入与生成帧 + +1. `ProjectLibrary` 创建项目。 +2. 导入视频时上传源视频到 `/api/media/upload` 并关联项目;该步骤不调用 `/api/media/parse`。上传期间项目库显示导入进度条、百分比和已上传字节,完成后短暂显示“视频导入完成”。 +3. 用户在视频项目卡片点击“生成帧”或“重新生成帧”,在弹窗中选择目标 FPS。已有帧的视频重新生成时会提示该操作会清空现有帧序列、标注和 mask;后端任务开始时删除旧帧和旧标注,再写入新的标准帧序列。任务入队后项目库会继续轮询任务进度,解析成功后自动重新拉取项目列表和当前项目对象,使后端生成的 `thumbnail_url` 立即显示为项目封面,无需刷新页面或重新进入项目库。 +4. 前端调用 `/api/media/parse` 创建异步拆帧任务;可通过 `parse_fps`、`max_frames` 和 `target_width` 指定标准帧序列参数。 +5. Celery worker 执行 FFmpeg/OpenCV/pydicom 拆帧;DICOM 在前端选择、后端上传、worker 下载和 pydicom 读取时都按文件名自然顺序排序;视频/DICOM 解析结果都按 `frame_%06d.jpg` 从 `frame_000000.jpg` 连续命名,视频帧按目标宽度缩放。 +6. worker 写入 `frames.timestamp_ms` 和 `frames.source_frame_number`,并在任务 `result.frame_sequence` 中记录 FPS、帧数、时长、尺寸和对象存储前缀。 +7. worker 持续更新 `processing_tasks`,并发布 Redis `seg:progress`。 +8. 刷新项目列表;项目卡片右上角 FPS 徽标显示生成关键帧序列时选择的 `parse_fps`,原始视频 FPS 仅作为底部“原 xx fps”辅助信息显示。 +9. 导入视频、生成帧、上传 DICOM 和失败反馈使用 `TransientNotice`,不再使用浏览器 `alert()` 阻塞操作;提示默认数秒后自动消失。视频和 DICOM 上传阶段额外显示项目库内的导入进度面板,DICOM 面板显示有效文件数量,并在上传完成后切换为解析任务进度;视频生成帧也会显示解析进度并轮询 `GET /api/tasks/{task_id}` 直到成功、失败或取消。 +10. DICOM 和视频帧序列写入同一 `frames` 表并共用工作区、时间轴、AI 传播、标注保存、GT 导入和导出链路,差异只存在于项目库导入入口和后端解析器。 + +### 任务控制 + +1. Dashboard 从 `GET /api/dashboard/overview` 读取 queued/running/success/failed/cancelled 任务;queued/running 代表当前进度,success/failed/cancelled 代表最近任务状态。 +2. 用户取消任务时,前端调用 `POST /api/tasks/{task_id}/cancel`;后端写入 `cancelled`、设置 `finished_at`,并尝试 `celery_app.control.revoke(..., terminate=True)`。 +3. worker 在下载、解析、上传、写帧等关键阶段刷新任务状态;如果发现 `cancelled`,停止后续写入并发布 cancelled 事件。 +4. 用户重试任务时,前端调用 `POST /api/tasks/{task_id}/retry`;后端基于原任务 `payload` 创建新任务,记录 `retry_of` 并重新投递 Celery。 +5. 用户打开详情时,前端调用 `GET /api/tasks/{task_id}`,弹窗展示 error、payload、result、Celery ID 和时间。 +6. Dashboard 通过 `/ws/progress` 接收 Redis `seg:progress` 转发事件;前端 WebSocket 客户端在 `onopen/onclose/onerror` 主动更新连接状态,并定时发送 `ping` 心跳,服务端返回 `status` 确认连接仍活跃。 + +### 工作区加载 + +1. `VideoWorkspace` 根据 `currentProject.id` 调用 `getProjectFrames()`。 +2. 若无帧但项目有 `video_path`,显示“尚未生成帧”的状态提示,不自动触发 `parseMedia()`;帧列表接口默认返回完整帧序列,工作区右下角总帧数以实际项目帧数为准。 +3. 帧数据映射为 store `Frame[]`,包含 `timestampMs` 和 `sourceFrameNumber`,供时间轴和后续视频传播使用。 +4. 工作区调用 `GET /api/ai/annotations` 回显已保存标注时,会替换当前项目帧中的已保存 mask,但保留没有 `annotationId` 的未保存 draft mask;这保证 AI 页推送到工作区的候选 mask 不会被异步回显覆盖,并会在合并完成后恢复仍然存在的已选 mask id。 +5. `VideoWorkspace` 加载项目帧时会优先按当前选中 mask 的 `frameId` 和当前打开帧 id 恢复 `currentFrameIndex`;只有没有可恢复帧时才回到第一帧,避免 AI 页在非第一帧推送回工作区时视角被重置。 +6. `CanvasArea` 会把全局 `selectedMaskIds` 中仍存在于当前帧的 id 同步回本地选区,避免帧初始化时的临时清空覆盖 AI 页推送过来的选中态;如果切换到另一帧时原 id 不存在,但目标帧存在同一自动传播链的结果,前端会用 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 和 `propagation_seed_signature` 匹配对应传播 mask 并自动选中。 +7. `CanvasArea` 根据容器和帧尺寸按 86% 适配比例计算初始 scale/position,使底图默认居中且尽量大,但保留画布边距;滚轮缩放和拖拽平移仍由用户后续控制。 +8. `CanvasArea` 未选中特定 mask 时,会按 `classZIndex` 从低到高渲染当前帧 mask;该值来自右侧“语义分类树”的拖拽排序,因此高优先级类别会后渲染并覆盖低优先级类别。有选中 mask 时,编辑态可保留选中区域置顶,方便拖点、换类和布尔操作。 +9. `FrameTimeline` 顶部播放进度条显示当前播放位置;其下方视频处理进度条根据 `Mask.metadata.source` / `propagated_from_frame_id` 计算自动传播帧并显示蓝色区段,对人工绘制或 AI 智能分割等非传播 mask 帧显示红色竖线。当前帧另用白色竖线贯穿播放进度条和视频处理进度条,和青色播放进度、红色标注、蓝色传播状态区分。普通状态下,视频处理进度条可点击跳转到对应帧,红色人工/AI 标注帧和蓝色自动传播帧标识本身也可点击跳转。处理条未处理背景使用中性灰,和红色/蓝色标记保持明显区分。`VideoWorkspace` 会记录当前会话最近 8 次成功处理过的自动传播范围,并通过 `propagationHistory` 传给 `FrameTimeline`;时间轴会把这些片段叠加为同一蓝色系的纯色条,按距最新传播的时间顺序逐次变暗,且第 5 次及更早统一为阈值旧记录色,不再在单个片段内部使用渐变。传播历史条只显示当前仍有自动传播 mask 的帧,`VideoWorkspace` 会在 mask 变化时按剩余传播 mask 裁剪本地传播历史;`FrameTimeline` 渲染时也会按当前传播 mask 再次拆分/过滤,避免单独删除传播 mask 后空帧仍显示红/蓝颜色。底部缩略图导航轴对非当前帧使用红色边框标识人工/AI 标注帧,使用蓝色边框标识自动传播/推理帧;如果同一帧同时存在人工/AI 标注和自动传播结果,红色人工/AI 标注边框优先保留,自动传播状态只作为蓝色内描边。当前帧使用青色外框高亮优先,若当前帧同时是人工/AI 标注帧,则以青色外框加红色内描边同时表达两个状态,外层当前帧框和内层人工/AI 框的顺序固定。工作区进入自动传播、布尔操作或特定范围帧导出选择模式时,播放进度条和视频处理进度条显示 amber 覆盖层,并额外用洋红色起始线和黄绿色结束线贯穿两条进度条,表达待处理或待导出范围边界,可点击/拖拽设置起止帧。 +10. 当前帧传入 `CanvasArea`。 +11. 工作区顶栏短状态文本会在空闲状态下自动消失;保存、导出、导入 GT 和传播任务运行中仍保留进度状态,无帧项目提示也会保留。 +12. 左侧工具栏和右侧本体/语义分类面板使用 `seg-scrollbar` 定制纵向滚动条;默认滚动条 thumb 低透明度融入深色背景,hover/focus 时增强为青色提示,避免系统默认滚动条在工具区中过于突兀。左侧工具栏额外保留右侧滚动条槽位,按钮列仍按原 48px 布局,避免滚动条和图标抢空间。 +12. 右侧面板不再显示“本体论与属性分类管理树”固定说明栏,直接展示实际可操作内容。 +13. 右侧“遮罩透明度”滑杆写入 Zustand `maskPreviewOpacity`,`CanvasArea` 和 `AISegmentation` 都用该值计算 mask group opacity;选中 mask 在基础透明度上加亮或按基础透明度显示,方便保留选中反馈。 +14. Canvas 点击 mask 后,全局 `selectedMaskIds` 会同步到 `OntologyInspector`;本体面板按选中 mask 的 `classId`、`className/label` 和颜色匹配模板分类,自动设置 active class,并把分类按钮滚动/聚焦到可见区域。 +15. 工作区顶栏只在进入自动传播、传播链布尔操作或传播链清空时显示对应范围控制;自动传播由左侧工具栏按钮进入范围选择,传播权重下拉旁显示当前权重和相对参考帧的向前/向后帧数,点击“开始传播”后提交后台任务。布尔操作范围选择时,顶栏按钮变为“确认区域合并”或“确认重叠区域去除”;清空范围选择时顶栏按钮变为“确认清空”;点击后均弹出最终确认,再只对范围内存在对应传播链的帧执行。顶栏不再提供重复的“清空片段遮罩”。 + +### AI 点/框推理 + +1. 用户在 Canvas 选择正向点、反向点或框选。 +2. `CanvasArea` 读取当前帧 ID 和宽高。 +3. SAM 2.1 框选会创建一个候选 mask,并记录原始框;后续正向点/反向点会累计到同一候选上。 +4. `predictMask()` 归一化坐标并携带当前 `model` 调用 `/api/ai/predict`;同时有框和点时发送 `interactive` prompt。 +5. SAM 2.1 请求中只要存在反向点,`CanvasArea` 会额外发送 `options.auto_filter_background=true` 和 `options.min_score=0.05`,让后端移除低分结果和包含负向点的 polygon。 +6. 后端加载帧图片并通过 SAM registry 分发到所选 SAM 2.1 变体;`model=sam2` 会兼容归一化为 tiny,`model=sam3` 会被拒绝。 +7. 前端把 `polygons` 转为 mask;交互式细化会替换同一个候选 mask,而不是新增多个 mask。 +8. 若带反向点的 SAM 2.1 细化返回空结果,前端会删除当前旧候选 mask 并提示反向点已排除该区域。 +9. AI 页面只按本页最新生成的候选 id 渲染 mask,不把工作区已有 mask 带入 AI 画布;每次 `runInference()` 都先过滤掉旧 `aiMaskIds` 对应候选,再写入本次最高分候选。 +10. AI 页面候选 mask 的 Path 点击事件会先判断当前工具;正向/反向选点工具下点击 mask 会继续追加提示点,其他工具下才选中 mask。 +11. 工作区 SAM 提示点由 `CanvasArea` 本地 `points` 状态维护;点击已渲染提示点会先 `cancelBubble`,再删除对应点并按剩余提示重新调用 `runInference()`,避免同一次点击继续触发 Stage 加点或 Path 选择。 +12. AI 页面边界框选由 `promptBox/boxStart/boxCurrent` 维护;拖拽时渲染蓝色虚线框,鼠标释放后固化 `promptBox` 并清空旧提示点,避免旧点误绑定到新框。 +13. AI 页面执行分割时,如果只有 `promptBox` 则发送 `box` prompt;如果 `promptBox` 和 `points` 同时存在,`predictMask()` 会发送 interactive prompt。 +14. AI 页面提示点由本地 `points` 状态维护;点击已渲染提示点会按 index 删除对应点,“删除最近锚点”会删除数组最后一个点,不改动候选 mask 列表。 +15. AI 页面候选 mask 删除只接受当前 `aiMaskIds` 范围内的已选 id;“删除选中候选”和 Delete/Backspace 都复用该范围过滤,避免删除工作区已有 mask。 +16. AI 页面参数开关文案只做展示增强:“局部专注模式(自动裁剪无锚区域)”仍控制 `cropMode/crop_to_prompt`,“严格除杂模式(自动清理干涉点)”仍控制 `autoDeleteBg/auto_filter_background/min_score`。 +17. AI 页面“AI 遮罩透明度”滑杆复用 Zustand `maskPreviewOpacity`,和右侧“遮罩透明度”联动,只调节候选 mask 的 Konva preview opacity,不写入 `Mask.segmentation`、分类元数据或后端 payload。 +18. AI 画布左上角根据正向点、反向点、边界框选和视口控制显示上下文提示,说明点击/拖拽、删除提示点和执行推理的操作方式。 +19. AI 画布根据容器和当前帧尺寸按 86% 适配比例计算初始 scale/position,使底图默认居中且尽量大,但保留画布边距。 +20. Canvas 按当前帧过滤并渲染 mask。 +21. 新 mask 会带上当前选择的模板分类元数据,包括 `classId`、`className`、`classZIndex`、`metadata.source=ai_segmentation` 和保存状态 `draft`。 +20. 顶栏保存状态按钮按当前项目待保存数量显示为“保存 X 个改动”或“已全部保存”;用户点击保存后,前端将像素 `segmentation` 转成 normalized `mask_data.polygons`;未保存 mask 调用 `POST /api/ai/annotate`,dirty mask 会先读取当前后端标注 id 列表,已知存在的 id 调用 `PATCH /api/ai/annotations/{annotation_id}`,已知缺失的本地旧 id 直接保留同一 `mask_data`、几何、分类和传播 lineage metadata 改用 `POST /api/ai/annotate` 重新创建;如果预检后发生并发删除导致 `PATCH` 返回 404,也会降级为重新创建,并在随后回显时排除本地旧 mask id;保存成功后本次提交的 draft mask id 会从本地保留列表中排除,并由后端 saved annotation 回显替换。 +21. 工作区加载项目帧后通过 `GET /api/ai/annotations` 取回已保存标注并转成前端 mask。 +22. 工作区“清空遮罩”只从左侧工具栏触发;如果当前帧存在选中 mask,则以当前帧选中 mask 为清空对象,否则以当前帧全部 mask 为清空对象。如果清空对象没有关联其它传播帧,直接删除当前帧已保存标注并清除当前帧本地 mask,不弹确认;如果存在传播链结果,`VideoWorkspace` 弹出范围选择,用户可在同一行选择取消、只清当前帧、按帧范围选择或清空当前帧及同传播链所有自动传播帧;按帧范围选择复用时间轴范围选择并在顶栏“确认清空”后最终确认。按范围清空或清空所有传播帧时,如果目标帧范围包含人工/AI 标注帧,会二次询问是否删除;其中清空所有传播帧会用传播链 seed 与传播结果跨越的完整帧段检查人工/AI 帧,避免从传播结果帧触发时漏掉中间独立 AI/人工帧;选择是会删除这些人工/AI 标注帧中的全部 mask,选择否时这些帧整帧保留,只清其它自动传播帧。左侧工具栏的 `DEL` 按钮和键盘 Delete/Backspace 删除整块 mask 时复用同一传播链范围确认,但 DEL/键盘删除在人工/AI 帧确认选择“是”时只删除本次选中或同传播链对应 mask,不会清掉同帧其它 mask;删除已保存标注前会通过 `GET /api/ai/annotations` 预检当前项目仍存在的 annotation id,只对存在的 id 发送 `DELETE`。 + +### 视频片段传播 + +1. 用户在工作区打开一帧作为参考帧;该帧全部 mask 都会作为传播 seed,不再提供传播对象下拉。 +2. 用户点击左侧工具栏橡皮擦下方的彩色 AI 大脑图标“AI自动推理”后,可以直接修改传播起始帧/结束帧数字框,并可通过工作区顶栏“传播权重”下拉独立选择本次传播使用的 SAM 2.1 tiny/small/base+/large 权重;该入口不提供 SAM2/SAM3 家族切换,默认跟随全局 AI 权重,用户手动选择后不再被 AI 页权重切换覆盖;未进入自动传播时顶栏不显示传播权重。 +3. `VideoWorkspace` 以当前参考帧为 seed,将起止帧拆成 `backward` 和/或 `forward` 两段;只包含当前帧时不传播。 +4. `VideoWorkspace` 在提交传播前会先调用现有归档保存链路保存当前项目中的 draft/dirty mask,并重新读取 store 中的回显结果;参考帧 seed 因此优先携带稳定的后端 `source_annotation_id`,避免用前端临时 mask id 生成传播结果后,二次传播无法找到旧结果。 +5. `VideoWorkspace` 用 `buildAnnotationPayload()` 把每个 seed mask 转成 normalized polygon、bbox、label、color、class 元数据、`instance_id`、`source_mask_id` 和可用时的 `source_annotation_id/source_instance_id`;中空 mask 会按 `metadata.polygonRingCounts` 将外圈写入 `mask_data.polygons`,把与外圈对齐的内洞写入 `mask_data.holes`,传播 seed 同步携带 `holes`;如果 seed mask 是未编辑的自动传播结果,会沿用其原始 `source_instance_id/source_annotation_id/source_mask_id/propagation_seed_signature`,让后端把它识别为原传播链的同一个 seed;如果该传播结果被编辑并保存,更新 payload 只保留 lineage,不保留旧签名,使后端按“已修改”路径清理旧结果并重传。`maskid` 仍是语义类别和导出像素值,不用于区分同类别实例。对历史或外部写入的 `geometry_smoothing` metadata,payload 仍可透传给后端兼容处理;当前前端平滑应用会直接改写 polygon 几何并移除该参数。 +6. 前端把传播权重 id、每个 seed、每个方向组装成 `steps`,一次调用 `POST /api/ai/propagate/task`,`include_source=false`、`save_annotations=true`;接口先规范化/校验 `model` 字段中的权重 id,再创建 `processing_tasks.task_type=propagate_masks` 并投递 Celery,避免长 HTTP 请求阻塞前端等待。 +7. `VideoWorkspace` 记录返回的 `task_id`,轮询 `GET /api/tasks/{task_id}` 显示任务 message、步骤进度、已处理帧次和已保存区域数;传播进度存在时,顶栏只在蓝色进度面板内显示任务 message,隐藏左侧灰色状态文字,避免同一提示重复出现;任务运行期间提供取消传播按钮,调用通用 `POST /api/tasks/{task_id}/cancel`。 +8. Celery worker 逐 step 顺序执行传播,避免多个视频 tracker 并发抢占 GPU;每个 step 开始/完成都会写入 `processing_tasks.progress/result/message` 并发布 Redis `seg:progress`,Dashboard 可同步显示。每个 step 开始前,worker 会在本次目标帧段内用 seed 来源 id、传播方向和 seed 签名查找旧传播标注:同权重、签名相同且目标帧都已有结果时跳过该 seed;签名不同、目标帧只部分覆盖或本次使用了其他 SAM 2.1 权重则先删除本次目标帧段内对应方向的旧自动传播标注,再执行新的 video predictor 传播;若历史 seed 签名中包含 `geometry_smoothing`,仍按完整签名参与兼容去重。对同一参考帧多个同类别 seed,worker 优先以 `source_instance_id/instance_id` 区分实例,再兼容 `source_annotation_id/source_mask_id/propagation_seed_key`,避免 label/color/class/maskid 相同的不同实例互相清理;旧版本缺少稳定来源 id 的传播标注才使用 label/color/class 兼容匹配,写入新结果前仍用目标帧 bbox 重叠做二次确认和清理,但已有稳定实例 id 且与当前 seed 不同的结果不会被这层空间兜底清理误删。写入前这层清理不限制旧结果方向,确保 backward 传播可覆盖早先 forward 传播留下的同物体旧 mask。 +9. 后端按项目帧序列截取片段,下载对应帧到临时目录,并写成 `000000.jpg` 这类纯数字文件名;这是 `SAM2VideoPredictor` 对视频帧排序的要求,和项目库中持久化的 `frame_%06d.jpg` 对象名无关。 +10. `model` 为任一 SAM 2.1 权重变体时,`sam2_engine` 使用对应 checkpoint/config 加载 `SAM2VideoPredictor.add_new_mask()` 注入 seed mask,再用 `propagate_in_video()` 传播;注入 seed 前会把外圈 polygon 栅格化为前景,再按 `holes` 扣除内洞,避免中空参考 mask 以实心形式传播;`model=sam2` 会在入队时规范化为 tiny,任务 payload/result 会保留规范化后的权重 id;单个 SAM2 video predictor 调用内部暂不提供逐帧流式进度。 +11. `model=sam3` 当前不支持;SAM 3 video tracker 代码保留但没有接入产品路径。 +12. 后端把传播返回的 normalized polygon 保存为后续帧 `Annotation`,跳过源帧;同一个 seed 在同一目标帧得到的多个不连通外轮廓会保存在同一个 annotation 的 `mask_data.polygons` 中,前端回显为一个含多个分离区域的 mask;传播 mask 轮廓提取使用层级信息保留内洞,外圈写入 `mask_data.polygons`,内洞按外圈对齐写入 `mask_data.holes`,并设置 `metadata.hasHoles` 供前端按中空 mask 回显和编辑;如果历史或外部 seed 带 `geometry_smoothing`,保存前仍会用同一平滑参数处理 forward/backward 两个方向的结果:强度先经过缓入曲线映射,低强度使用较小 Chaikin 切角比例和简化阈值,高强度再逐步增加迭代、切角和简化力度;随后按强度对 SAM 密集轮廓做 `approxPolyDP` 去噪简化,再做 Chaikin 平滑,最后二次简化并以平滑后的多 polygon 组合 bbox 后落库。当前工作区“应用边缘平滑”会在前端把同传播链对应 mask 直接改写为新的 polygon 并移除 `geometry_smoothing` 参数,因此后续传播通常按新几何本身参与 seed 签名。`mask_data.source` 记录权重传播来源,同时写入 `instance_id`、`source_instance_id`、`propagation_seed_key`、`propagation_seed_signature`、`propagation_direction`、`source_annotation_id` 和 `source_mask_id` 供后续幂等传播判断;历史 `geometry_smoothing` 仅在存在时保留用于兼容判断。 +13. 前端轮询到已创建区域后刷新 `GET /api/ai/annotations` 并回显新标注;任务结束后如果后端返回 0 个新区域,工作区会明确提示没有生成新的 mask,若是未改变 seed 被跳过则提示未改变 mask 已跳过。处理过帧次大于 0 的成功任务会追加一条本地传播历史片段,用于视频处理进度条显示最近传播范围;`annotationToMask()` 会保留传播来源 metadata,供时间轴视频处理进度条显示蓝色传播区段。 + +### 手工绘制与历史栈 + +1. 用户在 `ToolsPalette` 选择多边形、矩形、圆、画笔或橡皮擦工具;创建点和创建线段入口不在工作区左侧工具栏中提供。 +2. `CanvasArea` 将交互坐标转换成像素 polygon。 +3. 多边形工具逐次记录节点,三点后点击首节点或按 Enter 时生成闭合 polygon。 +4. Canvas 左上角根据当前工具和操作阶段显示上下文短提示;多边形提示会随已放置点数切换,明确 Enter 完成、Esc 取消和点击首节点闭合。`Esc` 和左侧工具栏“取消选中”按钮只取消当前 mask 选区、临时多边形点、矩形/圆拖拽状态、画笔/橡皮擦临时笔触和顶点选择,不删除已有 mask,也不清空右侧语义分类树的当前类别。提示会在工具或操作状态变化时出现,并在数秒后自动隐藏,避免长期遮挡底图。 +5. mask path 只在 `move`、`edit_polygon`、`area_merge` 和 `area_remove` 工具下拦截点击;绘制、画笔、橡皮擦和 AI prompt 工具点击已有 mask 时继续冒泡给 Stage。 +6. 画笔/橡皮擦尺寸保存在 Zustand 中;拖动期间只保留采样后的圆形笔触预览,鼠标松开后再用 `polygon-clipping` 计算一次几何结果,避免拖动中反复重算复杂 polygon。画笔有选中 mask 时会把本次采样笔触 union 进选中 mask,即使笔触和旧区域不重叠也形成同一个多 polygon mask;没有选中 mask 时才按当前语义分类创建新的独立 mask;如果画笔笔触闭合形成中空区域,`segmentation` 保留外圈和内洞 ring,`metadata.hasHoles/polygonRingCounts` 记录 ring 分组,并使用 even-odd 渲染;橡皮擦则对当前选中 mask 执行 difference 扣除。 +7. 多边形、矩形、圆和画笔完成时,如果当前帧有选中 mask,会把新几何 union 进该 mask,保留原 mask 的语义分类并将已保存 mask 标为 dirty;如果当前没有选中 mask,才创建新 mask,写入 `pathData`、像素 `segmentation`、`bbox`、`area` 和当前模板分类元数据,并自动写入 `selectedMaskIds` 成为当前选中 mask。若右侧没有选中具体分类,新建 mask 默认使用 `maskid: 0` 的“待分类”。创建工具仍处于激活状态时,刚创建/被并入的选中 mask 会显示只读边界顶点;切换到 `move` 或“调整多边形”后这些顶点可拖动编辑。 +8. `addMask()`、`setMasks()`、`updateMask()`、`clearMasks()` 会维护 `maskHistory/maskFuture`。 +9. 工作区撤销/重做只保留顶栏按钮和快捷键入口,AI 页保留自己的撤销/重做按钮;工作区由 `VideoWorkspace` 在 window capture 阶段统一处理 `Ctrl/Cmd+Z`、`Ctrl/Cmd+Shift+Z` 和 `Ctrl/Cmd+Y`,快捷键判断由 `src/lib/keyboardShortcuts.ts` 同时兼容 `event.key` 与物理键码 `event.code=KeyZ/KeyY`;输入框、下拉框和可编辑文本聚焦时跳过快捷键,避免影响帧范围输入。 + +### Polygon 逐点编辑 + +1. 用户选择“调整多边形”或“拖拽/选择”后点击 Canvas 上的 mask path,`CanvasArea` 记录 `selectedMaskId` 并显示该 mask 所有可编辑 polygon 的顶点控制点和边中点插入手柄;多 polygon 或分离区域组成的同一个 mask 不再只显示第一条 polygon。 +2. 顶点 `mousedown/dragstart` 会立即设置当前顶点选择;拖动过程中通过 `dragMove` 实时重算 `pathData`、像素 `segmentation`、`bbox`、`area`,不需要先单击顶点再拖动。 +3. Stage 的 `onDragEnd` 只处理 Stage 自身拖拽;polygon 顶点等子节点拖拽结束事件会被忽略,避免子节点坐标误写入 Canvas `position` 导致视口跳动。 +4. 点击边中点手柄会在该边中点插入新顶点;在“调整多边形”工具下双击 polygon path 会在最接近的线段上按双击位置插入新顶点。 +5. 如果 mask 已有 `annotationId`,编辑会把 `saveStatus` 标成 `dirty` 且 `saved=false`。 +6. 归档保存时复用现有 `PATCH /api/ai/annotations/{annotation_id}` 链路,把更新后的 normalized polygon 写回后端。 +7. 选中顶点后 Delete/Backspace 可删除顶点;前端保持 polygon 至少三点。 +8. 未选中具体顶点但选中了 mask 时,Delete/Backspace 从前端 store 删除该 mask;左侧工具栏 `DEL` 按钮调用同一删除逻辑。如果包含 `annotationId`,通过工作区回调先预检后端 annotation id 再调用删除接口;删除对象属于传播链或传播 seed 时,删除范围会扩展到同链自动传播 mask,但不移除其他帧独立 AI 推理/人工 mask。 +9. 普通 mask 和导入 mask 都不显示黄色 seed point,也不提供 seed point 拖动;保存 payload 仍可保留已有 `points` 数据兼容,但画布体验统一为区域选择和 polygon 顶点编辑。 + +### 区域合并与去除 + +1. 用户选择 `area_merge` 或 `area_remove` 后,点击多个当前帧 mask 组成选择集。 +2. 合并/去除模式隐藏 polygon 顶点和边中点编辑手柄,并在右下角显示已选数量;少于两个 mask 时操作按钮禁用。 +3. Canvas 左上角提示布尔选择顺序:第一个选中的是主区域,后续区域参与合并或扣除。 +4. 布尔选择态按选择顺序区分角色:第一个选中的主区域使用黄色实线轮廓,后续参与合并/扣除的区域使用红色虚线轮廓;所有已选区域填充透明度保持一致,避免被误解为阴影模式异常。 +5. `CanvasArea` 把 `Mask.segmentation` 转为 `polygon-clipping` 的 MultiPolygon。 +6. `area_merge` 使用 union,更新第一个选中的主 mask,并从前端 store 移除后续被合并 mask;如果被移除 mask 已保存,会调用工作区传入的删除回调删除后端标注。执行前会按 `source_instance_id/instance_id`、`source_annotation_id`、`source_mask_id` 和可靠的 `propagation_seed_key` 计算可同步的传播帧;若存在其它传播帧,先弹出范围选择,让用户选择只处理当前帧、处理所有传播帧或按帧范围选择。布尔同步使用严格实例匹配:优先可靠 lineage,旧传播结果缺少可靠 id 时只为每个已选 mask 选取空间最近的一个同语义传播结果,不使用宽泛同类别 legacy 分组批量合并,避免同类其它实例被一起卷入。按帧范围选择会把本次布尔操作交给 `VideoWorkspace`,复用底部时间轴范围选择和最终确认弹窗;确认后只在范围内且具备对应关系的帧上执行同一次 union,只删除该帧参与合并的次级 mask,避免把同链但未参与同步或范围外的区域整链误删。用户在顶栏范围确认前重新点击“合并选中”开始新的布尔选择时,旧的范围请求必须立即取消。 +7. `area_remove` 使用 difference,从第一个选中的主 mask 中扣除后续选中 mask,扣除对象本身保留;同样会在执行前计算可同步的传播帧并弹出当前帧/所有传播帧/按帧范围选择。按帧范围选择确认后,会在范围内其它传播帧中找到对应主区域和扣除区域并执行 difference,扣除区域本身继续保留;如果 difference 产生内洞,`segmentation` 保留外圈和 hole ring,`metadata.polygonRingCounts` 记录每个 polygon 的 ring 数,渲染时使用 even-odd fill。 +8. 结果会重算 `pathData`、`segmentation`、`bbox`、`area`,已保存主 mask 会进入 dirty 状态并复用归档 PATCH 链路;同步到传播帧时保留传播来源和 lineage metadata,避免自动传播帧在时间轴上变成人工/AI 标注帧;带洞结果的面积按外圈减内洞计算;进入调整多边形时,外圈和内洞 ring 都会显示顶点和边中点插入手柄,内洞拖动、插点、保存与回显继续保持中空结构。 + +### GT Mask 导入 + +1. 工作区左侧工具栏“导入 GT Mask”选择图片文件;入口位于“重叠区域去除”之后。 +2. 前端 `importGtMask()` 以 multipart form-data 调用 `POST /api/ai/import-gt-mask`,携带 `project_id` 和 `frame_id`。 +3. 后端验证项目、帧、模板后使用 OpenCV 读取灰度 mask。 +4. 后端按非零像素值拆分多类别标签。 +5. 后端对每个类别的前景做高精度 contour 提取,每个连通域保存为一个 `Annotation`;轮廓使用未压缩链提取并以较小 `approxPolyDP` epsilon 保留细节,超过点数上限时才逐步增加简化强度或抽样。 +6. `points` 字段可保存距离变换中心 seed point 供数据兼容,`mask_data.polygons` 保存 normalized polygon,`mask_data.holes` 保存与外圈对齐的内洞,`mask_data.gt_label_value` 保存原始像素类别值;导入后的 polygon 与普通 mask 走同一套拓扑锚点统计、边缘平滑、编辑和保存链路。 +7. 前端重新读取项目标注并回显。 +8. `annotationToMask()` 仍可把后端 `points` 转成像素坐标保存在 mask 数据中,但 Canvas 不显示 seed point,也不提供拖动;普通 polygon 若没有后端 seed point,保存逻辑可按 polygon 自动计算内部代表点写入,以保持数据兼容。 + +### 模板管理 + +1. `TemplateRegistry` 从后端读取模板。 +2. 编辑态在组件本地维护分类列表。 +3. 保存时调用 `createTemplate()` 或 `updateTemplate()`。 +4. 后端把 `classes`、`rules` 打包进 `mapping_rules`。 +5. 返回时再解包给前端。 +6. 模板详情页和编辑弹窗都支持拖拽调整语义类别层级顺序;拖拽后重算 `zIndex`,保存到后端模板并刷新当前详情页,`maskId` 保持不变。所有模板都会归一化包含黑色 `maskId: 0` 的“待分类”保留类,该类固定在语义分类树最后,不参与删除和拖拽上移。编辑弹窗点击分类后只编辑分类名称,不展示或编辑旧 `category` 来源元信息。编辑弹窗中的 JSON 批量导入支持 `[[colors], [names]]` 和 `{colors, names}` 两种格式,并兼容带前缀、代码块、未加引号 keys、单引号、中文逗号/冒号和尾随逗号的粘贴内容;导入前会先显示分类数量、maskid 分配起点和缺失颜色提示,语法或结构错误以内联错误展示,确认导入后进入编辑态,保存模板时落库。 +7. `CanvasArea` 把当前选中的 mask id 同步到全局 `selectedMaskIds`;切换到多边形、矩形、圆、画笔、橡皮擦、移动、调整多边形、区域合并或重叠区域去除时保留当前选区,创建工具会把新几何并入当前选中 mask;只有 `Esc`、左侧“取消选中”、删除 mask、AI prompt 等显式离开选区的动作会清空旧 mask 选区。切换帧时会优先沿传播链跟随同一 mask,找不到对应结果时才清空;卸载 Canvas 时清空选择。 +8. `AISegmentation` 生成 mask 后会写入全局 `masks` 并把生成的 mask id 写入 `selectedMaskIds`;点击 AI 页预览 mask 也会更新 `selectedMaskIds`。 +9. AI 页“推送至工作区编辑”会先检查待推送 AI 候选 mask 是否具备 `classId` 或 `className`;缺少语义分类时清空普通推理反馈,并通过 `TransientNotice` 右上角 error toast 提示用户先点右侧语义分类树,不切换模块、不修改工具状态。 +10. `AISegmentation` 卸载时会清理仍缺少 `classId/className` 的本页 AI 候选,并同步移除对应 `selectedMaskIds`,避免用户绕过推送按钮从侧栏切到工作区时带入无语义 mask。 +11. AI 页语义校验通过后会切换到工作区并把 `activeTool` 设为 `edit_polygon`;`CanvasArea` 初始读取全局 `selectedMaskIds`,让 AI 页选中的 mask 在工作区继续保持选中。 +12. 工作区帧/标注异步加载完成后,`hydrateSavedAnnotations()` 会合并本地未保存 draft mask 和后端已保存 mask,不会用后端回显结果直接覆盖整个 `masks` store。 +13. `OntologyInspector` 可以选择激活模板和具体分类;项目已有任意 mask 时,用户修改激活模板会先弹出确认框,确认后调用删除标注接口清空当前项目所有已保存标注并清空本地 mask,再切换模板;项目没有任何 mask 时直接切换。具体分类选择结果进入全局 store,供 `CanvasArea` 和 `AISegmentation` 新建/更新 mask 时使用。 +14. 如果 `selectedMaskIds` 中存在当前 store 的 mask,点击分类时会立即更新这些 mask 的 `templateId`、`classId`、`className`、`classZIndex`、`label` 和 `color`;如果当前没有选中任何 mask,点击分类只更新后续新建 mask 使用的 active class,不会改动已有 mask。 +15. 对属于自动传播链的 mask,分类更新会复用 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 和 `propagation_seed_signature` 查找同一目标实例在前后帧中的传播结果,并同步更新这些传播 mask 的分类元数据,避免同一物体跨帧语义不一致。 +16. 同一次点击会把这些已选 mask 移动到前端 `masks` 数组末尾;`CanvasArea` 按数组顺序渲染,后渲染的 Path 显示在最上层,方便用户继续编辑刚换标签的区域。该显示置顶不改变模板 `zIndex` 或后端导出语义覆盖规则。 +17. 已保存 mask 被重新分类后进入 `dirty` 且 `saved=false`,同传播链被同步更新的已保存 mask 也进入 `dirty`,继续复用工作区归档保存的 PATCH 链路。 +18. 模板保存、删除和 JSON 导入失败使用 `TransientNotice` 非阻塞提示,默认数秒后自动消失。 + +### 导出 + +1. 后端根据项目、帧、标注和模板生成 COCO JSON。 +2. PNG mask 导出会把 normalized polygon 渲染为单标注二值 mask。 +3. PNG mask 导出还会按 `mask_data.class.zIndex` 或模板 `z_index` 从低到高覆盖,生成每帧语义融合 mask。 +4. ZIP 内写入 `semantic_classes.json`,记录语义值到类别、颜色和 zIndex 的映射。 +5. 前端使用“分割结果导出”统一入口替代原 JSON/PNG 两个按钮;点击后在下拉栏选择整体视频、特定范围帧或当前图片,默认选中当前图片,并勾选分开二值 mask、GT_label 黑白图、Pro_label 彩色图和 Mix_label 原图叠加图。选择“特定范围帧”时,导出起止帧输入框和 `FrameTimeline` 的范围拖拽选择共用同一组导出范围状态;选择 Mix_label 时显示透明度滑杆,默认 0.3,并用当前/待导出第一帧做遮罩预览。提交前会保存待归档标注,然后下载统一 ZIP。下载文件名使用 `{项目库项目名}_seg_T_{起始时间戳}-{结束时间戳}_P_{起始项目帧序号}-{结束项目帧序号}.zip`;项目名来自 `currentProject.name`,起止帧按当前导出范围取首尾帧,时间戳格式为 `0h00m00s000ms`,帧号使用项目抽帧后的 1-based 顺序,项目名中的文件系统不安全字符会替换为 `_`。 +6. 统一导出 ZIP 固定包含 `annotations_coco.json`、`maskid_GT像素值_类别映射.json` 和 `原始图片/`;原始图片文件名使用 `视频名称_时间戳_项目帧序号`。导出会保留类别真实 maskid,GT_label 固定为 8-bit uint8 PNG,像素值与 maskid 相同并跨图一致;`maskId: 0` 的“待分类”保持 0,和背景同为黑色,Pro_label 中也输出为 `[0,0,0]`;缺失 maskid 的旧标注才补下一个可用正整数并写入映射 JSON;正整数 maskid 超出 1-255 会拒绝导出。选择分开 mask 时包含 `分开Mask分割结果/`,每帧建立 `{视频名称_时间戳_项目帧序号}_分别导出` 子文件夹,并按“同一帧同一类别合并一张图”的方式输出 `{视频名称_时间戳_项目帧序号}_{类别名称}_maskid{maskid}.png`。选择 GT_label 图时包含 `GT_label图/{视频名称_时间戳_项目帧序号}.png`;选择 Pro_label 图时包含 `Pro_label彩色分割结果/{视频名称_时间戳_项目帧序号}.png`;选择 Mix_label 图时包含 `Mix_label重叠覆盖彩色分割结果/{视频名称_时间戳_项目帧序号}.png`。GT_label、Pro_label 和 Mix_label 的重叠区域按内部拖拽排序从低到高覆盖,和未选中状态下的画布显示顺序一致;maskid 不参与排序。后端直接下载接口的 `Content-Disposition` 使用同一 ZIP 命名规则,并用 `filename*` 支持中文项目名。 +7. 右侧 `OntologyInspector` 的语义分类树支持拖拽调整内部覆盖顺序;拖拽后保存到模板并同步当前工作区同类 mask 的 `classZIndex`,但保留类别 maskid 不变。 + +## 接口契约 + +接口详情见 `doc/04-api-contracts.md`。测试中重点固定以下契约: + +- `updateProject()` 使用 `PATCH /api/projects/{id}`。 +- `exportCoco()` 使用 `GET /api/export/{projectId}/coco`。 +- `exportMasks()` 使用 `GET /api/export/{projectId}/masks`。 +- `exportSegmentationResults()` 使用 `GET /api/export/{projectId}/results`,通过 query 参数选择范围和 mask 类型。 +- `cancelTask()` 使用 `POST /api/tasks/{taskId}/cancel`。 +- `retryTask()` 使用 `POST /api/tasks/{taskId}/retry`。 +- `predictMask()` 使用 `POST /api/ai/predict`,请求体为 `image_id`、`prompt_type`、`prompt_data`、`model`。 +- `propagateMasks()` 使用 `POST /api/ai/propagate`,请求体为 `project_id`、`frame_id`、`model`、`seed`、`direction`、`max_frames`,作为单 seed 同步兼容接口保留。 +- `queuePropagationTask()` 使用 `POST /api/ai/propagate/task`,请求体为 `project_id`、`frame_id`、`model`、`steps`、`include_source`、`save_annotations`,返回 `ProcessingTask`。 +- `saveAnnotation()` 使用 `POST /api/ai/annotate`。 +- `importGtMask()` 使用 `POST /api/ai/import-gt-mask` multipart form-data,并传入 `unknown_color_policy=discard|undefined`。前端上传前弹出导入结果预览和未知 maskid 策略选择;后端使用 `cv2.IMREAD_UNCHANGED` 读取后校验 dtype。合法 GT mask 限定为 8-bit 灰度图或 8-bit RGB 三通道完全相同的 `[X,X,X]` maskid 图,0 为背景、X 为 1-255 的 maskid;灰度/RGB 等通道图按模板 `maskId` 匹配类别,16-bit/uint16 GT_label、全背景 0 图和普通彩色 RGB 类别图不再按颜色匹配并会返回格式错误;全背景图提示为“GT Mask 图片中没有非背景 maskid 区域。”;未知类别按策略舍弃或保存为黑色 `maskid:0` 的“待分类”,并保留 `gt_unknown_class` 和原始 `gt_label_value`。若 GT mask 尺寸和当前帧不同,后端用最近邻插值拉伸到当前帧尺寸后再生成高精度 polygon。 +- `getProjectAnnotations()` 使用 `GET /api/ai/annotations`。 +- `updateAnnotation()` 使用 `PATCH /api/ai/annotations/{annotationId}`。 +- `deleteAnnotation()` 使用 `DELETE /api/ai/annotations/{annotationId}`;工作区批量删除前会先用 `getProjectAnnotations()` 预检当前项目存在的 id,跳过本地陈旧 id,避免已被撤销/清空流程删除过的 annotation 再次发起 DELETE 产生 404。 +- `parseMedia()` 使用 `POST /api/media/parse?project_id=...`,可选 `parse_fps`、`max_frames`、`target_width`,用于生成标准帧序列。 +- `getProjectFrames()` 返回帧图像 URL、宽高、`timestamp_ms` 和 `source_frame_number`。 +- 后端 `/api/ai/predict` 当前支持 SAM 2.1 的 point、box、interactive;`semantic` 文本提示禁用并返回 400。 +- SAM 2.1 是点/框交互式分割模型,不做文本语义分割;AI 页面已经移除纯文本输入。 +- SAM 2.1 点提示和 auto fallback 只返回一个最高分候选,避免同一提示产生多个重叠候选 mask。 +- SAM 3 前端入口、后端 registry 入口和状态展示均已禁用;`model=sam3` 会返回不支持。 +- 后端 `/api/ai/predict` 支持可选 `options`:`crop_to_prompt` 会对 point/box/interactive prompt 做局部裁剪推理并回映射 polygon,`auto_filter_background` 会按 `min_score` 和负向点过滤结果。 +- 后端 `/api/ai/propagate/task` 当前支持所选 SAM 2.1 mask seed 视频传播后台任务;同步 `/api/ai/propagate` 仍保留为单 seed 兼容接口。 +- 后端 `/api/ai/models/status` 返回 GPU 和四个 SAM 2.1 变体的真实运行状态。 +- point prompt 支持旧数组形式和 `{ points, labels }` 对象形式。 + +## 外部依赖边界 + +测试不直接依赖以下真实服务: + +- PostgreSQL:后端测试使用内存 SQLite。 +- MinIO:上传、下载、预签名 URL 使用 monkeypatch。 +- Redis:单测使用 monkeypatch 验证进度事件发布,不依赖真实 Redis 服务。 +- SAM:AI 推理测试使用 fake registry。 +- 浏览器 Canvas/Konva 图片加载:前端测试 mock `react-konva` 和 `use-image`。 + +## 已知占位设计 + +以下能力属于当前冻结版本的占位或半可用功能: + +- Dashboard 初始快照来自 `GET /api/dashboard/overview`;任务进度区由 `processing_tasks` queued/running/success/failed/cancelled 任务生成,处理中统计只计算 queued/running。 +- 已保存标注支持通过右侧语义分类树换标签、polygon 顶点拖动/删除、边中点插入、多 polygon 子区域编辑、中空 mask 内洞 ring 编辑和区域合并/去除进入 dirty 状态并归档更新;多 polygon/分离区域选中后所有子区域都显示编辑手柄,同帧同传播链的分散 mask 会按 `source_annotation_id`、`source_mask_id`、`propagation_seed_key` 或 `propagation_seed_signature` 联动高亮;旧传播结果缺少稳定 lineage 时,会用传播来源、来源帧、方向、分类/标签/颜色构造兼容分组,保证同一传播 mask 拆出的不连通片段仍一起高亮;区域合并/去除同步传播帧时不复用这类宽泛高亮分组,而是优先可靠 lineage,缺少可靠 lineage 时为每个已选 mask 在同来源帧且同语义/颜色的候选传播结果中选取空间最近的单个对应实例,避免把同类别其它实例一起合并或扣除;区域合并支持跨语义链路,当前帧把 A mask 合并进 B mask 时,传播帧中的 A 对应结果会并入 B 对应结果;若某个传播帧没有 B 对应结果但有 A 对应结果,则把该 A 结果转换为 B 语义并标记为 dirty;Canvas 右下角不再提供旧的“应用分类”按钮,避免没选区时误改整帧;区域合并/去除会在存在传播帧时弹窗选择当前帧、所有传播帧或按帧范围选择,范围选择复用时间轴和确认弹窗,并保留传播帧来源 metadata;选中整块 mask 可用 Delete/Backspace 或左侧 `DEL` 删除,同步后端前会预检 id,同传播链自动传播结果会随传播 seed/传播结果删除而一并清理,独立 AI 推理/人工 mask 保留。 +- SAM 3 文本语义分割已从当前产品路径中禁用;相关源码保留,恢复时需要重新接入前端入口、registry、状态接口和测试。 +- 自定义分类通过 `PATCH /api/templates/{id}` 写入当前激活模板的 `mapping_rules.classes`。 +- 选中 mask 后,本体面板的“特定目标实例属性追踪”标题值来自当前 mask 的 `className/label`,不使用全局 active class;面板不再展示长期为 1 的“当前选中区域”计数;面板调用 `POST /api/ai/analyze-mask` 自动显示拓扑锚点数量等属性,`topology_anchor_count` 是真实 polygon 顶点数量,`topology_anchors` 只保留最多 64 个抽样点用于调试展示;`OntologyInspector` 会为分析请求维护递增序号,旧请求返回时不再回写状态,并静默忽略 Axios abort/cancel 错误,避免快速切换、平滑预览或组件卸载时把正常中止误报成失败;不再提供“重新提取拓扑锚点”调试按钮;“边缘平滑强度”滑杆会即时更新数值,但 `POST /api/ai/smooth-mask` 预览请求经过约 220ms 防抖后才发送,返回 polygon 作为临时预览写入当前 mask 显示,预览不改变保存状态;点击“应用边缘平滑”后,前端把平滑 polygon 作为新的实际几何写入当前 mask,并按传播 lineage 同步写入传播链前后对应 mask,相关 mask 标记为 dirty/draft,整次操作通过一次 `setMasks()` 进入撤销/重做历史;应用后不保留 `geometry_smoothing` 参数,平滑强度重置为 0。前端不再展示“后端模型置信度”。 +- GT mask 导入已完成多类别像素值拆分、contour 和 distance transform seed point 数据兼容;前端不显示或拖动 seed point,导入 mask 与普通 mask 共享拓扑统计、边缘平滑、顶点编辑、分类和保存体验;骨架提取、HDBSCAN 聚类和模板自动映射尚未实现。 diff --git a/doc/09-test-plan.md b/doc/09-test-plan.md new file mode 100644 index 0000000..d9f5735 --- /dev/null +++ b/doc/09-test-plan.md @@ -0,0 +1,100 @@ +# 当前测试计划 + +本文档把 `doc/07-current-requirements-freeze.md` 中的冻结需求映射到测试。测试目标是覆盖当前真实行为和明确占位行为。 + +## 测试分层 + +| 层级 | 工具 | 覆盖范围 | +|------|------|----------| +| 前端单元/组件 | Vitest + Testing Library | API 封装、store、组件交互、Mock/UI-only 状态 | +| 后端路由 | pytest + FastAPI TestClient | Auth、Projects、Templates、AI、Export、Media 的接口契约 | +| 静态契约 | TypeScript / py_compile | 类型和 Python 语法 | + +## 覆盖矩阵 + +| 需求 | 测试文件 | 覆盖点 | +|------|----------|--------| +| R1 登录与会话 | `src/components/Login.test.tsx`, `src/components/Sidebar.test.tsx`, `src/components/UserAdmin.test.tsx`, `src/store/useStore.test.ts`, `backend/tests/test_auth.py`, `backend/tests/test_admin.py` | 登录页 logo 和系统标题文案、成功登录、JWT/token 写入、当前用户写入、刷新恢复基础状态、失败提示、登录输入 autocomplete、后端 401、`/api/auth/me`、管理员入口用户图标、底部退出图标和非交互 tooltip、用户 CRUD、唯一 admin/标注员角色权限、审计日志、旧 viewer 归一为标注员、改密码/删除用户站内确认、演示出厂设置站内二次确认和重置结果 | +| R2 项目管理 | `src/lib/api.test.ts`, `src/components/ProjectLibrary.test.tsx`, `backend/tests/test_projects.py` | 前端字段映射、PATCH 更新、项目库不展示独立新建项目按钮、项目卡片复制/删除、修改项目名称时隐藏生成帧、DICOM 项目不显示生成帧、复制项目 reset/full 契约、DELETE 契约、后端 CRUD、删除级联、完整帧列表不默认截断到 1000、项目按当前 JWT 用户隔离 | +| R3 媒体上传与拆帧 | `src/components/ProjectLibrary.test.tsx`, `src/components/TransientNotice.test.tsx`, `backend/tests/test_media.py`, `backend/tests/test_tasks.py` | 视频导入不自动拆帧、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式生成帧/重新生成帧 FPS 选择、重新生成前清空旧帧旧标注旧 mask、视频生成帧入队后轮询解析任务并在成功后自动刷新项目封面、项目卡片显示目标 parse_fps 而非原视频 FPS、扩展名校验、自动建项目、关联项目、创建异步任务、非阻塞自动消失操作提示、标准帧序列参数、帧时间戳/源帧号、任务序列元数据、worker 注册帧、取消任务、重试任务、取消后 worker 停止 | +| R4 工作区与帧浏览 | `src/components/VideoWorkspace.test.tsx`, `src/components/FrameTimeline.test.tsx` | 加载帧、无帧项目不自动解析并提示生成帧、工作区短状态自动消失、工作区/AI 画布底图默认居中且保留边距、工作区 mask 透明度、回显已保存标注时保留本地未保存 draft mask、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏清空遮罩优先作用于当前帧选中 mask/无选中时作用于当前帧全部 mask、无传播链时直接执行、有传播链时可选取消/只清当前帧/按帧范围选择/清空所有传播帧且按范围清空需最终确认、按范围清空或清空所有传播帧遇到人工/AI 标注帧时二次询问并支持保留人工帧、顶栏不显示重复的清空片段遮罩、传播进度存在时任务 message 只显示在蓝色进度面板内且不重复出现在灰色状态文字里、传播链布尔操作按帧范围选择并二次确认、清空/删除前预检后端 annotation id 并跳过本地陈旧 id、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、自动传播范围选择时显示传播权重和向前/向后帧数、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧通过 source/lineage metadata 识别为蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进纯色显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播/布尔/清空范围边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条和视频处理进度条选择传播/布尔/清空范围、左右方向键切帧、播放、按项目 FPS 显示当前/总时长 | +| R5 工具栏 | `src/components/ToolsPalette.test.tsx`, `src/components/CanvasArea.test.tsx`, `src/components/VideoWorkspace.test.tsx`, `src/lib/keyboardShortcuts.test.ts`, `src/store/useStore.test.ts` | 工具切换、切换到多边形/矩形/圆会保留旧 mask 选区、有选中 mask 时多边形/矩形/圆/画笔新几何会并入选中 mask 且不要求重叠、无选中 mask 时手工新建 mask 后自动选中新 mask 并显示创建后边界点、Esc 和左侧“取消选中”按钮清空当前 mask 选区和临时绘制状态、工具栏紧凑垂直布局和高度不足时滚动、工具栏低对比滚动条、工具栏外扩滚动条槽位不挤占按钮列、调整多边形工具、AI 跳转、清空遮罩唯一左侧工具栏入口、清空遮罩上方 DEL 删除按钮、橡皮擦下方彩色 AI自动推理入口、Canvas 右下角不再重复显示清空遮罩或应用分类按钮、GT Mask 导入位于清空遮罩分隔线之后且使用紫色底色、工具栏分隔线位于创建圆后、AI自动推理后和清空遮罩后、GT Mask 未知类别导入策略选择、工作区工具栏不展示 AI 正/反点和框选、左侧工具栏不重复撤销/重做、左侧工具栏不展示创建点/创建线段、矩形/圆/多边形手工 mask 绘制且未选分类时默认待分类、普通/导入 polygon mask 不显示黄色 seed point、画笔/橡皮擦尺寸控制、画笔无选中时新建当前类别 mask、画笔/橡皮擦模式下保留当前选中 mask 顶点提示且只读、画笔从图外落笔不创建 mask、靠边画笔生成几何裁剪到当前帧边界内、橡皮擦从选中 mask 扣除、未选中 mask 时画布按语义分类树内部优先级渲染、多边形 Enter/首节点闭合、上下文提示提示 Enter/Esc/首节点闭合且数秒后自动隐藏、polygon 顶点直接拖动/删除、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、多 polygon/分离区域全部显示编辑顶点、中空 mask 与中空画笔 mask 内洞 ring 顶点和插点可编辑、整块 mask 删除、DEL 和 Delete/Backspace 删除共用传播链范围确认、同帧传播链分散 mask 点选联动高亮、传播链自动传播 mask 随 seed/传播结果删除、独立 AI 推理 mask 不被误删、区域合并/去除存在传播帧时弹窗选择当前帧/所有传播帧/按帧范围选择、范围确认前重新开始当前帧布尔操作会取消旧顶栏范围请求、区域合并/去除按帧范围同步到对应传播帧且保留传播 metadata、旧传播缺可靠 lineage 时布尔同步只选每个已选 mask 的空间最近对应实例而不批量处理同类其它实例、布尔选择主区域/扣除区域视觉区分和选择顺序提示、内含去除 hole 渲染和 ring 分组保存、合并模式隐藏编辑手柄、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z、Ctrl/Cmd+Y、物理键码 fallback 和输入框快捷键跳过、撤销/重做历史栈 | +| 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 保存时自动写入代表点、项目不存在、帧不存在 | +| 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 标注帧、平滑撤销/重做、平滑应用后强度归零 | +| 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 再导入保持类别一致 | +| R12 配置 | `src/lib/config.test.ts` | env 优先、hostname 推导、WS 推导 | +| R13 文档与测试 | `doc/09-test-plan.md`, `doc/11-frontend-interaction-state-machines.md` | 测试覆盖矩阵、前端交互状态机、键盘规则和确认弹窗流 | + +## 逐功能点追踪 + +| 需求 | 功能点 | 对应测试 | 当前状态 | +|------|--------|----------|----------| +| R1 | 登录页 logo 和系统标题文案、唯一默认管理员、JWT 写入、当前用户写入、刷新恢复基础状态、失败提示、后端 401、`/api/auth/me`、管理员用户管理入口图标、底部退出入口图标和 tooltip 命中范围、角色权限、审计日志、演示出厂设置二次确认、重置后只保留 admin、名为“演视LC视频序列”的已生成帧演示视频项目和名为“演视DICOM序列”的已生成帧自然排序演示 DICOM 项目 | `Login.test.tsx`, `Sidebar.test.tsx`, `UserAdmin.test.tsx`, `useStore.test.ts`, `test_auth.py`, `test_admin.py` | 已覆盖 | +| R2 | 项目列表/无独立新建项目按钮/选择/重命名/复制、重命名时不触发生成帧、DICOM 不显示生成帧、完整帧列表不默认截断到 1000、项目复制 reset/full、项目按用户隔离、视频导入、DICOM 导入、DICOM 前端选择自然排序、后端项目和帧 CRUD | `ProjectLibrary.test.tsx`, `api.test.ts`, `test_projects.py` | 已覆盖 | +| R3 | 文件类型校验、自动/指定项目上传、视频导入与生成帧分离、视频/DICOM 上传进度可视化、DICOM 导入显示有效文件数量并在上传后持续显示解析任务进度、显式 FPS 生成帧/重新生成帧、重新生成清理旧帧旧标注旧 mask、视频生成帧完成后自动刷新项目封面、项目卡片 FPS 徽标显示 `parse_fps`、视频/DICOM 拆帧任务、DICOM 上传/下载/读取自然排序、非阻塞自动消失操作提示、`parse_fps/max_frames/target_width`、标准帧序列 metadata、任务查询、取消、重试、worker 取消停止 | `ProjectLibrary.test.tsx`, `TransientNotice.test.tsx`, `api.test.ts`, `test_media.py`, `test_tasks.py` | 已覆盖 | +| R4 | 工作区加载帧、无帧项目不自动解析、工作区短状态自动消失、后端标注回显保留本地未保存 draft mask、Canvas/AI 底图居中适配且保留边距、工作区 mask 透明度、选中 mask 后跨帧自动跟随同一传播链结果、左侧工具栏当前帧清空优先作用于选中 mask、无传播链时直接执行、有传播链时可选当前帧/传播所有帧/取消、清空人工/AI 标注帧前二次确认、取消确认不删除、仅自动传播帧不确认、删除单个传播 mask 后空帧不保留传播历史颜色、传播权重下拉深色可读配色、缩略图/range/视频处理进度条、视频处理进度条点击跳帧、人工/AI 标注帧红色竖线和标识点击跳帧、自动传播帧蓝色区段和标识点击跳帧、最近自动传播历史片段同一蓝色系按新旧递进显示,旧记录第 5 次后统一阈值色、当前帧白色贯穿线、传播范围洋红/黄绿色边界贯穿线、缩略图红/蓝边框、人工/AI 标注帧叠加传播状态时红框优先保留并显示蓝色内描边、当前人工/AI 标注帧青色外框加红色内描边、普通状态不显示传播范围黄色选区、播放进度条/视频处理进度条拖拽选择传播范围、Canvas/AI 画布拖拽平移回写 position state、左右方向键切帧、播放、按 FPS 显示时间 | `VideoWorkspace.test.tsx`, `FrameTimeline.test.tsx`, `CanvasArea.test.tsx`, `AISegmentation.test.tsx` | 已覆盖 | +| R5 | 工具切换、工具栏紧凑滚动布局、低对比滚动条、外扩滚动条槽位、调整多边形入口、清空遮罩唯一左侧入口、Canvas 右下角旧清空/应用分类按钮移除、GT Mask 导入入口位置和紫色底色、工作区工具栏隐藏 AI 正/反点和框选、左侧工具栏不重复撤销/重做、AI 跳转、矩形/圆/线/点/多边形绘制、已有 mask 上继续绘制、多边形和布尔工具上下文提示、Canvas 上下文提示数秒后自动隐藏 | `ToolsPalette.test.tsx`, `CanvasArea.test.tsx` | 已覆盖 | +| R5 | 顶点直接拖动编辑、顶点拖拽结束不改变 Canvas 视口、边中点插点、双击边界按位置插点、中空 mask 与中空画笔 mask 内洞 ring 顶点和插点可编辑、顶点删除、整块删除、删除传播链自动传播 mask 且保留独立 AI 推理 mask、工作区顶栏撤销/重做按钮、顶栏撤销/重做图标强调色、撤销/重做快捷键 Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z、Ctrl/Cmd+Y 和 KeyZ/KeyY fallback、区域合并、区域去除、布尔选择主区域黄色实线/扣除区域红色虚线、布尔选择顺序提示、hole even-odd 渲染 | `CanvasArea.test.tsx`, `VideoWorkspace.test.tsx`, `keyboardShortcuts.test.ts`, `useStore.test.ts` | 已覆盖 | +| 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` | 已覆盖 | +| 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` | 已覆盖 | +| 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` | 已覆盖 | +| R12 | API/WS 地址 env 优先和 hostname 推导 | `config.test.ts` | 已覆盖 | +| R13 | 文档测试矩阵、前端交互状态机、键盘规则、工具/范围/确认弹窗流与对应测试追踪 | `doc/09-test-plan.md`, `doc/11-frontend-interaction-state-machines.md` | 已覆盖 | + +## 本轮补齐记录 + +- R5:补充 `CanvasArea.test.tsx` 中圆形、画笔新建、画笔有选中 mask 时并入选中 mask、无选中时新建和橡皮擦扣除测试,明确验证 metadata、segmentation、bbox/area、选中状态和草稿状态;补充 `ToolsPalette.test.tsx` 中画笔/橡皮擦尺寸控制测试,并验证创建点、创建线段入口不再显示。 +- R6:补充 `AISegmentation.test.tsx` 中 SAM 2.1 变体选择测试,验证前端不展示 SAM 3 入口、选择 small 后请求携带对应模型,且未放置点提示时不发起推理。 +- R6:补充 SAM 2 纯文本提示拦截、SAM 2 多候选只保留最高分、SAM 2 engine 单候选请求测试,避免多个重叠候选 mask 被同时叠加。 +- R6:补充 Canvas 工作区 SAM 2 反向点背景过滤测试,覆盖请求 options 和过滤为空时清除旧候选 mask。 +- R6:补充 `ModelStatusBadge.test.tsx` 中 SAM 3 不展示测试,避免禁用入口重新出现在前端。 +- R6:补充后端 `selected_model=sam3` 拒绝测试和 semantic 禁用测试,避免后端继续暴露 SAM 3 产品能力。 +- R6:补充 `POST /api/ai/propagate` 后端测试,验证 seed mask 传播结果会保存为后续帧标注并保留 class 元数据。 +- R6:补充 `propagateMasks()` 同步兼容接口和 `queuePropagationTask()` 任务接口测试,验证当前参考帧全部 mask 会按范围组装为后台传播 steps。 +- R6:补充 `VideoWorkspace` 自动传播进度测试,验证传播任务运行中显示进度,后端返回 0 个新区域时给出明确反馈。 +- R4/R6:补充时间轴传播范围选择测试,验证点击“AI自动推理”后可在播放进度条或视频处理进度条上拖拽回填起止帧,再提交后台传播任务。 +- R4/R6:补充视频处理进度条传播历史测试,验证多次自动传播后会按同一蓝色系显示最近处理范围,最新最亮、旧记录逐次变暗且第 5 次后统一阈值色,单个片段不使用渐变。 +- R6/R10:补充 `queuePropagationTask()`、`POST /api/ai/propagate/task`、传播 Celery runner 和传播任务重试测试,验证工作区自动传播不再依赖长 HTTP 请求,并验证传给 `SAM2VideoPredictor` 的临时帧文件名是纯数字序列。 +- R6:补充传播去重回归测试,验证前端传播前会先保存 draft seed mask 并用稳定 `source_annotation_id` 入队;后端在 seed 来源由前端临时 id 迁移到后端 annotation id、用户换用其他 SAM 2.1 权重、未编辑传播结果再次作为 seed、已编辑传播结果重新作为 seed、中间帧人工新增替代 seed 时,会分别跳过或清理旧传播标注再保存新结果。 +- R6/R7:补充传播实例 id 回归测试,验证保存标注会写入/保留 `instance_id`,自动传播 seed 携带 `source_instance_id`,同类别多个 mask 在传播、重传、布尔合并/去除和选择高亮时按实例链路同步,不因相同 label/color/maskid 互相合并或删除。 +- R5/R6/R7:补充中空 mask 回归测试,验证保存时拆分 `polygons`/`holes` 并回显为 ring 分组,调整多边形时内洞显示可编辑顶点,以及 SAM 2 seed mask 会扣除 holes、传播结果轮廓提取会保留 holes。 +- R7:补充 dirty 本地旧 annotationId 回归测试,验证后端标注 id 预检已缺失时会跳过失败 PATCH、直接 `POST /api/ai/annotate` 重新创建;同时验证预检后 `PATCH /api/ai/annotations/{id}` 返回 404 时,保存链路也会改用 `POST` 重新创建并用回显标注替换本地旧 mask。 +- R4/R5/R8/R9:补充模板切换、工具栏清空入口和传播链布尔操作回归测试,验证已有 mask 切换模板需确认清空,模板详情按钮改为“编辑模板”,当前帧清空会在传播链存在时同一行提供取消/只清当前帧/按帧范围选择/清空所有传播帧,且按范围/全部清空遇到人工/AI 标注帧时可选择保留人工帧,区域合并/去除会在存在传播帧时同一行选择取消/按帧范围选择/当前帧/所有传播帧并保留传播 metadata。 +- R6:`backend/tests/test_sam3_engine.py` 已标记跳过,仅作为历史保留实现的参考测试,不计入当前产品功能覆盖。 +- R3:补充 `parseMedia()` 查询参数和后端拆帧任务 payload 测试,验证 `parse_fps`、`max_frames`、`target_width` 会进入任务。 +- R3:补充 `ProjectLibrary.test.tsx` 和 `api.test.ts` 中上传进度测试,验证视频/DICOM 上传通过 Axios `onUploadProgress` 回调更新项目库导入进度条,并显示 DICOM 文件数量和解析任务轮询进度。 +- R3:补充 worker 注册标准帧序列测试,验证帧 `timestamp_ms`、`source_frame_number` 和 `result.frame_sequence` 元数据。 +- R8:补充 `TemplateRegistry.test.tsx` 中模板编辑、删除测试,验证前端调用真实 API 封装并更新全局 store。 +- R9:补充 Canvas 选中 mask id 全局同步、本体树点击分类给已选 mask 换标签并移到渲染最上层的测试,验证已保存 mask 会进入 dirty 状态。 +- R9:补充边缘平滑滑杆防抖测试,验证连续拖动只触发最后一次后端预览请求,降低拖动卡顿。 +- R9:补充边缘平滑应用到传播链并可撤销/重做的测试,验证平滑后成为新的实际 polygon、强度归零且不再只保存平滑参数。 +- R5/R13:补充 `CanvasArea.test.tsx` 中 `Esc` 交互测试,验证 `Esc` 只取消当前 mask 选中和临时多边形点,不删除已有 mask、不清空 `activeClass`;新增 `doc/11-frontend-interaction-state-machines.md` 记录工作区工具、语义分类树、范围选择、AI 页、模板确认和导入导出状态机。 +- R5/R13:完成文档一致性回查,修正 `doc/02-current-implementation-map.md` 和 `doc/08-current-design-freeze.md` 中手工绘制、画笔有选中时并入/无选中时新建、Esc、工具切换保留选区和无选区点击语义分类树的旧描述,使实现映射、设计冻结、状态机文档和测试计划保持一致。 +- R5/R13:补充左侧工具栏“取消选中”实体按钮测试和 Canvas `clearSelectionSignal` 测试,验证实体按钮与 `Esc` 共享取消选区/临时绘制状态语义。 +- R5:补充创建后边界点和中空画笔回归测试,验证多边形/矩形/圆创建完成后即使仍在创建工具下也显示已选 mask 边界点,并验证画笔闭合成中空区域时保留 `hasHoles/polygonRingCounts`、使用 even-odd 渲染且内外圈顶点可显示。 + +## 运行命令 + +```bash +npm run test +npm run test:run +npm run lint +npm run build + +pip install -r backend/requirements-dev.txt +pytest backend/tests +python -m py_compile backend/routers/ai.py backend/routers/templates.py backend/schemas.py +``` + +## 当前不做的测试 + +- 不启动真实 PostgreSQL、MinIO、Redis 或 SAM 模型。 +- 不做真实视频大文件拆帧性能测试。 +- 不用浏览器 E2E 验证视觉细节。 +- 不把当前明确 Mock/UI-only 的按钮当成真实业务成功路径测试。 diff --git a/doc/10-installation.md b/doc/10-installation.md new file mode 100644 index 0000000..75ba6c5 --- /dev/null +++ b/doc/10-installation.md @@ -0,0 +1,639 @@ +# Installation / 部署安装指南 + +本文件记录当前仓库的真实安装和部署方式。它面向一台新的 Linux 机器,目标是跑起完整系统: + +- React 前端:默认 `http://localhost:3000` +- FastAPI 后端:默认 `http://localhost:8000` +- PostgreSQL:项目、帧、模板、标注、任务元数据 +- Redis:Celery broker/result backend 与进度 pub/sub +- MinIO:视频、DICOM、拆帧图片等对象存储 +- Celery worker:执行视频/DICOM 拆帧等后台任务 +- SAM 2.1:当前产品启用 tiny/small/base+/large;SAM 3 源码保留但产品入口禁用,正常部署不需要安装 SAM 3 + +--- + +## 1. 前置条件 + +推荐环境: + +| 项 | 建议 | +|----|------| +| OS | Ubuntu 22.04 LTS 或相近 Linux | +| Python | 3.11 | +| Node.js | 22.x | +| 数据库 | PostgreSQL 14+ | +| 缓存/队列 | Redis 6+ | +| 对象存储 | MinIO | +| 视频处理 | FFmpeg | +| GPU | NVIDIA GPU + CUDA,用于 SAM 2.1 推理;无 GPU 时可 CPU 运行但会很慢 | + +Docker GPU 部署还需要宿主机安装 NVIDIA Container Toolkit,并确保以下命令可正常输出 GPU: + +```bash +docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi +``` + +如果这里报 `failed to discover GPU vendor from CDI`,说明 Docker 还没有拿到 GPU,即使宿主机 `nvidia-smi` 正常,容器内仍会显示 CPU。 + +安装系统依赖: + +```bash +sudo apt update +sudo apt install -y \ + postgresql postgresql-contrib \ + redis-server \ + ffmpeg \ + libpq-dev build-essential curl ca-certificates gnupg wget +``` + +安装 MinIO: + +```bash +cd /tmp +wget https://dl.min.io/server/minio/release/linux-amd64/minio +chmod +x minio +sudo mv minio /usr/local/bin/ +mkdir -p ~/minio_data +``` + +--- + +## 2. 获取代码 + +```bash +cd ~/Desktop +git clone Seg_Server +cd Seg_Server +``` + +如果已经有仓库,进入项目根目录即可: + +```bash +cd /home/wkmgc/Desktop/Seg_Server +``` + +后续命令默认在项目根目录执行,除非特别说明。 + +--- + +## 2.1 Docker 最小部署 + +当前仓库旁的 `/home/wkmgc/Desktop/Seg_Server_Docker` 是最小 Docker 部署目录,包含前端、FastAPI 后端、Celery worker、PostgreSQL、Redis、MinIO、演示视频/DICOM 数据和部署文档。 + +编辑 `.env`: + +```bash +cd /home/wkmgc/Desktop/Seg_Server_Docker +cp .env.example .env +``` + +关键配置: + +```ini +PUBLIC_HOST=192.168.3.11 +CORS_ORIGINS=["http://192.168.3.11:3000","http://localhost:3000","http://127.0.0.1:3000"] +SAM_MODELS_DIR=/home/wkmgc/Desktop/Seg_Server/models +``` + +`SAM_MODELS_DIR` 会挂载到容器内 `/app/models`。当前后端镜像安装 PyTorch/SAM2 后,只有这里存在 checkpoint 的 SAM 2.1 变体会显示可用;如果没有 NVIDIA Container Toolkit,模型可在 CPU 上可用,但 GPU 状态仍是 CPU。 + +启动普通容器: + +```bash +docker compose up -d --build +``` + +启动 GPU 容器: + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build +``` + +验证: + +```bash +docker compose ps +curl http://localhost:8000/health +``` + +--- + +## 3. 配置 PostgreSQL + +默认后端配置来自 `backend/config.py`: + +```text +postgresql://seguser:segpass123@localhost:5432/segserver +``` + +创建数据库和用户: + +```bash +sudo systemctl start postgresql + +sudo -u postgres psql -c "CREATE DATABASE segserver;" +sudo -u postgres psql -c "CREATE USER seguser WITH PASSWORD 'segpass123';" +sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE segserver TO seguser;" +sudo -u postgres psql -d segserver -c "GRANT ALL ON SCHEMA public TO seguser;" +sudo -u postgres psql -c "ALTER DATABASE segserver OWNER TO seguser;" +``` + +验收: + +```bash +pg_isready +psql "postgresql://seguser:segpass123@localhost:5432/segserver" -c "select 1;" +``` + +--- + +## 4. 启动 Redis 和 MinIO + +Redis: + +```bash +sudo systemctl start redis-server +redis-cli ping +``` + +MinIO: + +```bash +nohup minio server ~/minio_data --console-address :9001 > /tmp/minio.log 2>&1 & +curl http://localhost:9000/minio/health/live +``` + +默认 MinIO 账号密码是: + +```text +minioadmin / minioadmin +``` + +后端启动时会检查并创建 bucket: + +```text +seg-media +``` + +--- + +## 5. 安装后端 Python 环境 + +推荐使用 Conda: + +```bash +conda create -n seg_server python=3.11 -y +conda activate seg_server +``` + +安装 PyTorch。根据机器 CUDA 版本选择合适 wheel。示例: + +```bash +# CUDA 12.4 示例 +pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124 + +# 无 GPU / CPU 示例 +# pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu +``` + +安装后端依赖: + +```bash +cd backend +pip install -r requirements.txt +cd .. +``` + +### Docker 使用本机 GPU + +Docker 显示 GPU 的前提不是前端开关,而是宿主机、Docker runtime 和容器依赖都可用: + +1. 宿主机 `nvidia-smi` 必须能正常看到 NVIDIA GPU。 +2. 安装并配置 NVIDIA Container Toolkit。 +3. Docker compose 需要给 `backend` 和 `worker` 透传 GPU,例如在部署包中使用 `docker-compose.gpu.yml` 覆盖文件。 +4. 后端镜像内还必须安装 CUDA 版 PyTorch、`sam2` Python 包,并挂载对应 `models/sam2.1_*.pt` 权重;最小部署镜像为了体积默认不安装这些 AI 依赖,因此只加 GPU 透传仍会显示 CPU/模型不可用。 + +示例启动: + +```bash +docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build +docker compose exec backend python - <<'PY' +import torch +print(torch.cuda.is_available()) +PY +curl http://localhost:8000/api/ai/models/status +``` + +确认关键包: + +```bash +python - <<'PY' +import fastapi, sqlalchemy, redis, celery, minio, torch +print("torch:", torch.__version__, "cuda:", torch.cuda.is_available()) +PY +``` + +--- + +## 6. 配置后端环境变量 + +后端从 `backend/.env` 读取配置;该文件被 `.gitignore` 忽略,不要提交真实密码或本机路径。 + +创建 `backend/.env`: + +```bash +cat > backend/.env <<'EOF' +db_url=postgresql://seguser:segpass123@localhost:5432/segserver +redis_url=redis://localhost:6379/0 + +minio_endpoint=localhost:9000 +minio_access_key=minioadmin +minio_secret_key=minioadmin +minio_secure=false + +sam_default_model=sam2.1_hiera_tiny +sam_model_path=/home/wkmgc/Desktop/Seg_Server/models/sam2.1_hiera_tiny.pt +sam_model_config=configs/sam2.1/sam2.1_hiera_t.yaml +sam3_external_enabled=false + +app_env=development +cors_origins=["http://localhost:3000","http://127.0.0.1:3000"] +jwt_secret_key=change-this-to-a-long-random-production-secret +access_token_expire_minutes=1440 +default_admin_username=admin +default_admin_password=123456 +demo_video_path=/home/wkmgc/Desktop/Seg_Server/demo/演视LC视频序列.mp4 +demo_dicom_dir=/home/wkmgc/Desktop/Seg_Server/demo/演视DICOM序列 +EOF +``` + +演示视频和 DICOM 测试影像统一放在项目根目录 `demo/` 下;系统 seed 和“恢复演示出厂设置”会直接读取 `demo/演视LC视频序列.mp4` 和 `demo/演视DICOM序列/`。 + +如果前端通过局域网 IP 访问,例如 `http://192.168.3.11:3000`,需要把该地址加入 `cors_origins`,同时前端也要配置 API 地址。 + +--- + +## 7. 准备 SAM 2.1 权重 + +当前产品入口只暴露 SAM 2.1 变体: + +- `sam2.1_hiera_tiny` +- `sam2.1_hiera_small` +- `sam2.1_hiera_base_plus` +- `sam2.1_hiera_large` + +下载脚本: + +```bash +cd backend +python download_sam2.py +cd .. +``` + +脚本默认下载到: + +```text +/home/wkmgc/Desktop/Seg_Server/models/ +``` + +推荐文件名: + +```text +models/sam2.1_hiera_tiny.pt +models/sam2.1_hiera_small.pt +models/sam2.1_hiera_base_plus.pt +models/sam2.1_hiera_large.pt +``` + +可以只部署 tiny;前端会显示四个选项,但只有本地存在 checkpoint 的模型会显示可用。 + +注意:SAM 3 相关脚本和源码是历史保留。当前前端入口隐藏 SAM 3,后端 registry 不暴露 `sam3`,正常部署不需要下载 SAM 3 权重,也不要把 Hugging Face token 写进项目文件。 + +--- + +## 8. 安装前端依赖 + +```bash +npm install +``` + +如需指定前端访问的后端地址,在项目根目录创建 `.env`: + +```bash +cat > .env <<'EOF' +VITE_API_BASE_URL=http://localhost:8000 +VITE_WS_PROGRESS_URL=ws://localhost:8000/ws/progress +EOF +``` + +如果不设置,前端会按当前浏览器 hostname 推导: + +```text +http://:8000 +ws://:8000/ws/progress +``` + +--- + +## 9. 手动启动所有服务 + +开 4 个终端分别启动。 + +终端 A:FastAPI 后端 + +```bash +conda activate seg_server +cd /home/wkmgc/Desktop/Seg_Server/backend +uvicorn main:app --host 0.0.0.0 --port 8000 --reload +``` + +终端 B:Celery worker + +```bash +conda activate seg_server +cd /home/wkmgc/Desktop/Seg_Server/backend +celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1 +``` + +终端 C:前端开发服务 + +```bash +cd /home/wkmgc/Desktop/Seg_Server +npm run dev +``` + +终端 D:确认基础设施 + +```bash +pg_isready +redis-cli ping +curl http://localhost:9000/minio/health/live +``` + +访问: + +| 服务 | 地址 | +|------|------| +| 前端 | `http://localhost:3000` | +| FastAPI Docs | `http://localhost:8000/docs` | +| Health | `http://localhost:8000/health` | +| MinIO Console | `http://localhost:9001` | + +默认开发登录: + +```text +admin / 123456 +``` + +首次启动会自动创建默认管理员,密码以哈希形式写入 `users` 表;登录返回签名 JWT,业务接口会校验 `Authorization: Bearer `。生产环境必须修改 `jwt_secret_key` 和默认管理员密码。 + +默认管理员登录后会看到“用户管理”后台,可新增标注员、停用/启用用户、重置密码、删除用户并查看登录与用户管理审计日志。系统只支持唯一默认 `admin` 和 `annotator` 两类角色:标注员不能新增用户、查看审计日志或恢复演示出厂设置,但可以和管理员共享同一项目库并执行项目管理、标注、AI 推理、任务和导出等业务操作。演示部署可在该后台使用“恢复演示出厂设置”,二次确认后只保留默认 admin、名为“演视LC视频序列”的已生成帧演示视频项目和名为“演视DICOM序列”的已按文件名自然顺序生成帧的演示 DICOM 项目;视频来自 `demo_video_path`,DICOM 序列来自 `demo_dicom_dir`。 + +--- + +## 10. 一键启动脚本 + +项目根目录有 `start_services.sh`: + +```bash +chmod +x start_services.sh +./start_services.sh +``` + +脚本会检查/启动: + +```text +PostgreSQL -> Redis -> MinIO -> FastAPI -> Celery worker -> 前端 +``` + +使用前必须检查脚本里的本机路径和 sudo 逻辑: + +- `PROJECT_DIR="/home/wkmgc/Desktop/Seg_Server"` +- `CONDA_ENV="seg_server"` +- MinIO 数据目录 `/home/wkmgc/minio_data` +- 脚本里包含本机 sudo 密码写法,迁移机器时应移除或改成安全的 systemd/service 管理方式 + +### 10.1 开发重启速查 + +本地开发时不要靠猜。不同服务的热更新行为如下: + +| 改动类型 | 是否需要重启 | 原因 | +|----------|--------------|------| +| 前端 `src/`、`server.ts` | 通常不需要 | `npm run dev` 使用 Vite/tsx,前端会热更新 | +| 前端依赖、`.env`、`vite.config.ts` | 需要重启前端 | 依赖和环境变量只在进程启动时读取 | +| FastAPI 路由/普通后端代码 | 需要重启后端 | 开发重启脚本用独立后台进程运行后端;显式重启可以保证接口和运行态一致 | +| `backend/.env`、模型路径、依赖安装 | 需要重启后端 | 配置和依赖在进程启动时生效 | +| Celery 任务、拆帧、自动传播、SAM runner | 必须重启 Celery worker | worker 不是 `uvicorn --reload` 的子进程,不会自动加载代码改动 | + +推荐使用项目根目录的开发重启脚本: + +```bash +cd /home/wkmgc/Desktop/Seg_Server +./restart_dev_services.sh +``` + +该脚本会: + +```text +检查 PostgreSQL/Redis/MinIO -> 停止旧 FastAPI/Celery/前端 -> 用独立后台进程启动 FastAPI/Celery/前端 -> 检查 3000/8000 +``` + +脚本通过 `setsid` 启动应用层服务,脚本退出后服务会继续运行;pid 文件默认位于 `/tmp/seg_server_*.pid`,日志默认位于 `/tmp/seg_server_*.log`。 + +默认日志: + +```text +/tmp/seg_server_fastapi.log +/tmp/seg_server_celery.log +/tmp/seg_server_frontend.log +/tmp/seg_server_minio.log +``` + +如果只想手动重启应用层服务,可以使用: + +```bash +cd /home/wkmgc/Desktop/Seg_Server + +# 停止旧进程 +pkill -f "uvicorn main:app" || true +pkill -f "celery -A celery_app:celery_app worker" || true +pkill -f "/home/wkmgc/Desktop/Seg_Server/node_modules/.bin/tsx server.ts" || true +pkill -f "npm run dev" || true + +# 启动后端和 worker +cd /home/wkmgc/Desktop/Seg_Server/backend +setsid ~/miniconda3/bin/conda run -n seg_server uvicorn main:app --host 0.0.0.0 --port 8000 \ + > /tmp/seg_server_fastapi.log 2>&1 < /dev/null & +setsid ~/miniconda3/bin/conda run -n seg_server celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1 \ + > /tmp/seg_server_celery.log 2>&1 < /dev/null & + +# 启动前端 +cd /home/wkmgc/Desktop/Seg_Server +setsid npm run dev > /tmp/seg_server_frontend.log 2>&1 < /dev/null & +``` + +验收: + +```bash +curl http://localhost:8000/health +curl -I http://localhost:3000 +ps -ef | grep -E "(uvicorn main:app|celery -A celery_app:celery_app worker|tsx server.ts)" | grep -v grep +``` + +--- + +## 11. 生产构建方式 + +前端构建: + +```bash +npm run build +``` + +生产模式启动前端静态服务: + +```bash +NODE_ENV=production npm start +``` + +后端生产启动示例: + +```bash +cd backend +uvicorn main:app --host 0.0.0.0 --port 8000 +``` + +Celery worker 仍需要单独启动: + +```bash +cd backend +celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1 +``` + +实际生产建议用 systemd、supervisor 或容器编排托管 FastAPI、Celery、前端静态服务、MinIO、Redis、PostgreSQL。 + +--- + +## 12. 部署验收 Checklist + +基础服务: + +```bash +pg_isready +redis-cli ping +curl http://localhost:9000/minio/health/live +curl http://localhost:8000/health +``` + +后端模型状态: + +```bash +curl http://localhost:8000/api/ai/models/status +``` + +前端质量检查: + +```bash +npm run lint +npm run test:run +npm run build +``` + +后端测试: + +```bash +conda activate seg_server +python -m pytest backend/tests +``` + +手工业务验收: + +1. 打开 `http://localhost:3000`。 +2. 使用 `admin / 123456` 登录。 +3. 创建项目或上传视频。 +4. 在项目库点击“生成帧”,选择 FPS。 +5. Dashboard 中应看到任务进度;Celery 日志应显示拆帧任务。 +6. 进入分割工作区,能看到帧、时间轴和画布。 +7. 手工画一个多边形 mask,确认顶栏保存状态按钮显示“保存 1 个改动”,点击保存。 +8. 刷新工作区后,已保存标注应回显。 +9. AI 智能分割中选择可用 SAM 2.1 模型,放置点或框,执行分割。 +10. 导出 JSON 或 PNG Mask ZIP。 + +--- + +## 13. 常见问题 + +### 前端打不开或请求后端失败 + +检查: + +```bash +curl http://localhost:8000/health +cat .env +``` + +如果通过局域网 IP 访问前端,确保: + +- `.env` 中 `VITE_API_BASE_URL` 是浏览器可访问的后端地址。 +- `backend/.env` 中 `cors_origins` 包含前端地址。 + +### Dashboard WebSocket 经常断开 + +检查: + +```bash +redis-cli ping +curl http://localhost:8000/health +``` + +同时确认前端 `VITE_WS_PROGRESS_URL` 指向真实可访问的: + +```text +ws://:8000/ws/progress +``` + +### 生成帧没有进度 + +检查 Celery worker 是否启动: + +```bash +ps aux | grep celery +tail -f /tmp/celery.log +``` + +检查 Redis: + +```bash +redis-cli ping +``` + +### MinIO 上传失败 + +检查: + +```bash +curl http://localhost:9000/minio/health/live +tail -f /tmp/minio.log +``` + +如果磁盘空间不足,MinIO 可能拒绝写入。清理 `~/minio_data`、旧日志、旧模型权重或迁移数据目录。 + +### SAM 2 模型不可用 + +检查: + +```bash +ls -lh models/ +curl http://localhost:8000/api/ai/models/status +``` + +常见原因: + +- checkpoint 文件不存在。 +- `backend/.env` 中 `sam_model_path` 指向旧文件名。 +- `sam2` Python 包未正确安装。 +- PyTorch/CUDA 不匹配。 + +### 不需要 SAM 3 + +当前版本不用 SAM 3。不要为了正常部署执行 `backend/setup_sam3_env.sh`,也不要在项目里保存 Hugging Face token。 diff --git a/doc/11-frontend-interaction-state-machines.md b/doc/11-frontend-interaction-state-machines.md new file mode 100644 index 0000000..3fbf242 --- /dev/null +++ b/doc/11-frontend-interaction-state-machines.md @@ -0,0 +1,110 @@ +# 前端交互与状态机 + +本文档记录当前前端真实交互规则,重点覆盖那些不会直接体现在接口契约里的 UI 细节。测试以本文件、`doc/07-current-requirements-freeze.md` 和 `doc/09-test-plan.md` 为准。 + +## 全局状态 + +| 状态字段 | 所在文件 | 含义 | +|----------|----------|------| +| `activeModule` | `src/store/useStore.ts` | 当前页面模块;登录后默认 `dashboard`。 | +| `currentProject` / `frames` / `currentFrameIndex` | `src/store/useStore.ts` | 当前工作项目、帧序列和当前帧。 | +| `activeTool` | `src/store/useStore.ts` | 工作区当前工具。 | +| `selectedMaskIds` | `src/store/useStore.ts` | 当前选中的 mask id 列表;Canvas、本体面板和 AI 页共享。 | +| `activeTemplateId` / `activeClass` | `src/store/useStore.ts` | 当前模板和后续新建 mask 使用的语义类别。 | +| `maskHistory` / `maskFuture` | `src/store/useStore.ts` | 撤销/重做栈。 | + +## 工作区工具自动机 + +| 状态 | 进入事件 | 可用动作 | 退出事件 | 测试 | +|------|----------|----------|----------|------| +| `idle/no-selection` | 初始、`Esc`、左侧“取消选中”、删除 mask、切帧无对应传播结果 | 右侧语义树只设置后续新建类别;清空遮罩作用于当前帧全部 mask | 点击 mask、AI 推送、创建新 mask | `CanvasArea.test.tsx`、`OntologyInspector.test.tsx` | +| `mask-selected` | `move/edit_polygon` 下点击 mask、新建 mask 完成、AI 候选选中 | 右侧语义树给已选 mask 换类;Delete/Backspace/DEL 删除;橡皮擦可扣除;顶点可编辑;创建工具的新几何会并入当前 mask | `Esc`、左侧“取消选中”、删除 mask、切帧无对应传播结果 | `CanvasArea.test.tsx` | +| `polygon-drawing` | `create_polygon` 下点击画布 | 继续加点;三点后 Enter 或点击首点闭合 | Enter/首点在有选中 mask 时并入选中 mask,无选中时创建新 mask 并显示边界点;`Esc` 放弃临时点并清选区 | `CanvasArea.test.tsx` | +| `shape-dragging` | `create_rectangle/create_circle` 下按下鼠标 | 拖拽预览形状 | 鼠标释放时有选中 mask 则并入选中 mask,无选中时创建新 mask 并显示边界点;切工具取消临时状态 | `CanvasArea.test.tsx` | +| `brush-stroking` | `brush` 且已有 `activeClass` 或当前选中 mask 时按下鼠标 | 采样图像范围内圆形笔触 | 鼠标释放时有选中 mask 则并入选中 mask,无选中时创建新的当前类别 mask;闭合成中空区域时保留内洞 ring;图外落笔不创建;`Esc` 取消笔触和选区 | `CanvasArea.test.tsx` | +| `eraser-stroking` | `eraser` 且已有选中 mask 时按下鼠标 | 采样图像范围内圆形笔触 | 鼠标释放从选中 mask 扣除;扣空则删除该 mask;`Esc` 取消笔触和选区 | `CanvasArea.test.tsx` | +| `boolean-selecting` | `area_merge/area_remove` | 选择多个 mask;主区域黄色实线,参与区域红色虚线 | 当前帧执行、所有传播帧、按帧范围、取消、切换工具 | `CanvasArea.test.tsx`、`VideoWorkspace.test.tsx` | + +### 细节规则 + +- `Esc` 是取消当前交互状态,不是删除:清空 `selectedMaskIds`、临时多边形点、矩形/圆拖拽状态、画笔/橡皮擦笔触和顶点选择;保留已有 mask、当前 `activeClass` 和当前工具。 +- 切换到 `create_polygon`、`create_rectangle`、`create_circle` 会保留旧 mask 选区;用户若想新建独立 mask,需要先按 `Esc` 或点击“取消选中”。 +- 多边形、矩形、圆和画笔创建完成后,有选中 mask 时会并入选中 mask,无选中 mask 时会自动选中新创建的 mask。 +- 画笔和形状创建遵循同一规则:有选中 mask 时并入选中 mask,没有选中 mask 时才新建独立 mask。 +- 橡皮擦只作用于当前选中 mask,不会在无选区时启动。 +- 绘制类工具点击已有 mask 时继续绘制,不触发 mask 选择。 + +## 右侧语义分类树自动机 + +| 状态 | 点击分类结果 | 后续效果 | 测试 | +|------|--------------|----------|------| +| 无选中 mask | 仅更新 `activeClass` | 后续新建 mask 使用该类别;已有 mask 不变 | `OntologyInspector.test.tsx` | +| 有选中 mask | 更新已选 mask 的 class/label/color;同传播链对应 mask 同步更新 | 已保存 mask 标记为 dirty;已选 mask 移到前端渲染数组末尾 | `OntologyInspector.test.tsx` | +| 当前 mask 的类别被删除 | 工作区回显时降级为 `maskid:0` “待分类” | 保留几何并等待用户重新分类保存 | `VideoWorkspace.test.tsx` | + +## 键盘交互 + +| 按键 | 前置状态 | 行为 | 测试 | +|------|----------|------|------| +| `Esc` / 左侧“取消选中” | 任意 Canvas 工具 | 取消选中 mask 和临时绘制状态,不删除 mask,不清 active class | `CanvasArea.test.tsx`、`ToolsPalette.test.tsx` | +| `Enter` | 多边形已有至少 3 点 | 闭合并创建新 mask | `CanvasArea.test.tsx` | +| `Delete/Backspace` | 选中顶点 | 删除该顶点,保持 polygon 至少 3 点 | `CanvasArea.test.tsx` | +| `Delete/Backspace` | 选中整块 mask | 删除 mask;传播链 mask 走范围确认;人工/AI 帧按确认策略处理 | `CanvasArea.test.tsx`、`VideoWorkspace.test.tsx` | +| `Ctrl/Cmd+Z` | 工作区且非输入控件聚焦 | 撤销 mask 历史 | `VideoWorkspace.test.tsx`、`keyboardShortcuts.test.ts` | +| `Ctrl/Cmd+Shift+Z` / `Ctrl/Cmd+Y` | 工作区且非输入控件聚焦 | 重做 mask 历史 | `VideoWorkspace.test.tsx`、`keyboardShortcuts.test.ts` | +| 左/右方向键 | 工作区时间轴 | 切换上一帧/下一帧 | `VideoWorkspace.test.tsx` | + +## 工作区范围选择自动机 + +`VideoWorkspace` 用 `rangeSelectionMode` 区分四类范围选择:`propagation`、`export`、`boolean`、`clear`。 + +| 模式 | 进入事件 | 顶栏状态 | 时间轴行为 | 确认行为 | 测试 | +|------|----------|----------|------------|----------|------| +| `propagation` | 左侧“AI自动推理” | 显示传播权重、向前/向后帧数和“开始传播” | 拖拽/点击设置传播起止帧 | 先校验当前 SAM 2.1 权重状态;可用才保存参考帧 draft/dirty seed 并提交 Celery 传播任务 | `VideoWorkspace.test.tsx` | +| `export` | 打开导出菜单并选择“特定范围帧” | 导出菜单保持打开 | 拖拽/点击设置导出起止帧 | “开始导出”保存待归档 mask 后下载 ZIP | `VideoWorkspace.test.tsx` | +| `boolean` | 区域合并/去除选择“按帧范围选择” | 显示“确认区域合并/确认重叠区域去除” | 拖拽/点击设置布尔操作范围 | 弹最终确认,只同步范围内对应传播帧,保留传播 metadata | `CanvasArea.test.tsx`、`VideoWorkspace.test.tsx` | +| `clear` | 清空/DEL 选择“按帧范围选择” | 显示“确认清空” | 拖拽/点击设置清空范围 | 弹最终确认;如范围含人工/AI 帧,再询问是否删除这些帧 | `VideoWorkspace.test.tsx` | + +取消规则: + +- 关闭导出菜单会退出 `export` 范围选择。 +- 在布尔范围确认前重新点击“合并选中/从主区域去除”,会取消旧的顶栏范围请求。 +- 清空/删除传播链时选择“取消”不会删除任何 mask。 + +## AI 智能分割自动机 + +| 状态 | 进入事件 | 主要行为 | 退出事件 | 测试 | +|------|----------|----------|----------|------| +| `no-prompt` | 打开 AI 页或清空提示 | 等待正点/负点/框选 | 放置提示或框选 | `AISegmentation.test.tsx` | +| `box-prompt` | 框选完成 | 仅框选时发送 `box` prompt | 加正/负点后转 interactive | `AISegmentation.test.tsx` | +| `interactive-prompt` | 框选后加点或直接点选 | 发送累计正/负点;负点启用背景过滤 | 空结果移除旧候选 | `AISegmentation.test.tsx` | +| `candidate-selected` | 推理返回 mask 或点击候选 | 可通过语义树换标签;可删除候选 | 推送工作区、删除候选、重新推理 | `AISegmentation.test.tsx` | +| `send-blocked` | 候选缺少语义分类时点击推送 | 显示 error toast,不切模块、不改工具 | 选择语义分类 | `AISegmentation.test.tsx` | +| `model-unavailable` | `/api/ai/models/status` 返回所选 SAM 2.1 变体不可用 | 禁用不可用模型按钮和执行按钮;不调用 `/api/ai/predict` | 后端模型状态恢复可用或切换到可用变体 | `AISegmentation.test.tsx` | + +## 模板与项目确认流 + +| 交互 | 状态机 | 测试 | +|------|--------|------| +| 切换激活模板 | 无 mask 直接切换;有任意 mask 时弹确认;确认后删除项目所有本地/后端标注再切换;取消则保持原模板 | `OntologyInspector.test.tsx` | +| 删除模板 | 站内确认后删除;系统默认模板可由演示恢复出厂设置恢复 | `TemplateRegistry.test.tsx`、后端模板/管理员测试 | +| 复制模板 | 鼠标点击复制入口,生成当前用户私有副本并保留分类颜色、maskid 和层级 | `TemplateRegistry.test.tsx` | +| 项目复制 | 项目删除按钮旁复制入口;可选“新项目重置”或“全内容复制” | `ProjectLibrary.test.tsx` | +| 演示恢复出厂设置 | 管理员危险区二次确认并要求输入 `RESET_DEMO_FACTORY`;后端也校验 confirmation | `UserAdmin.test.tsx`、`backend/tests/test_admin.py` | + +## 文件与导入导出交互 + +| 交互 | 状态机 | 测试 | +|------|--------|------| +| 视频/DICOM 上传 | 选择文件后显示上传进度;DICOM 显示有效文件数量;上传后继续轮询解析任务进度 | `ProjectLibrary.test.tsx` | +| 显式生成帧/重新生成帧 | 只对有源视频的视频项目显示;项目名称编辑状态不显示;DICOM 项目不显示;已有帧时显示“重新生成帧”并提示会清空旧帧、标注和 mask;入队后轮询解析任务,成功后刷新项目列表并立即显示新封面 | `ProjectLibrary.test.tsx` | +| GT Mask 导入 | 选择文件后预览并选择未知 maskid 策略;非法格式返回错误;尺寸不一致最近邻拉伸;导入结果与普通 mask 同体验 | `VideoWorkspace.test.tsx`、后端 AI 测试 | +| 分割结果导出 | 默认当前帧;可选整体/范围;范围可用时间轴;导出前保存待归档 mask;按钮带导出图标和绿色强调背景 | `VideoWorkspace.test.tsx`、`api.test.ts`、后端导出测试 | + +## 维护要求 + +新增或修改前端交互时,应同步做三件事: + +1. 更新本文件中对应状态机或规则。 +2. 在 `doc/09-test-plan.md` 的覆盖矩阵中写明测试归属。 +3. 添加或更新组件测试,至少覆盖状态转移的进入条件、退出条件和副作用。 diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..19aeeba --- /dev/null +++ b/doc/README.md @@ -0,0 +1,34 @@ +# 项目文档索引 + +本目录用于记录当前代码库的真实状态、目标设计与实现差距。文档依据包括: + +- 根目录 Word 文档:`语义分割系统构建方案.docx` +- 前端源码:`src/App.tsx`、`src/components/*.tsx`、`src/lib/api.ts`、`src/store/useStore.ts` +- 后端源码:`backend/main.py`、`backend/routers/*.py`、`backend/schemas.py`、`backend/models.py` +- 运行时 OpenAPI:`http://localhost:8000/openapi.json` + +## 文档结构 + +| 文档 | 内容 | +|------|------| +| [01-purpose-and-word-summary.md](./01-purpose-and-word-summary.md) | 为什么要做这个系统,Word 方案中的目标,以及当前代码的落地程度 | +| [02-current-implementation-map.md](./02-current-implementation-map.md) | 当前系统怎么运行,前后端、存储、数据流具体怎么串起来 | +| [03-frontend-element-audit.md](./03-frontend-element-audit.md) | 前端逐页面/逐元素审计:真实可用、半可用、Mock/UI-only、接口不通 | +| [04-api-contracts.md](./04-api-contracts.md) | 前端 API 封装、后端 FastAPI 接口、已完成对齐项和剩余接口问题 | +| [05-implementation-plan.md](./05-implementation-plan.md) | 后续要把 Mock 变成真实功能的建议实施顺序 | +| [06-fastapi-docs-explained.md](./06-fastapi-docs-explained.md) | `http://192.168.3.11:8000/docs` 是什么,怎么看和怎么用 | +| [07-current-requirements-freeze.md](./07-current-requirements-freeze.md) | 当前版本需求冻结,测试以此为准 | +| [08-current-design-freeze.md](./08-current-design-freeze.md) | 当前版本设计冻结,记录模块、数据流和接口边界 | +| [09-test-plan.md](./09-test-plan.md) | 需求到测试文件的覆盖矩阵和运行命令 | +| [10-installation.md](./10-installation.md) | 系统安装部署指南,覆盖 PostgreSQL、Redis、MinIO、后端、Celery、前端和 SAM 2.1 权重 | +| [11-frontend-interaction-state-machines.md](./11-frontend-interaction-state-machines.md) | 前端 UI 交互细节、键盘规则、工具/范围/确认弹窗状态机和对应测试 | + +## 状态标记 + +| 标记 | 含义 | +|------|------| +| 真实可用 | 已接真实前端状态或后端 API,按当前代码能完成主要动作 | +| 部分可用 | 有真实数据或真实 UI,但存在关键缺口,例如只读、不能持久化、缺少错误处理 | +| Mock / UI-only | 只有展示或本地状态变化,没有真实业务效果 | +| 接口不通 | 前端调用和后端接口契约不一致,按当前代码大概率失败 | +| 目标设计 | Word 方案中提出,但当前代码尚未实现 | diff --git a/docker-compose.gpu.yml b/docker-compose.gpu.yml new file mode 100644 index 0000000..2f36836 --- /dev/null +++ b/docker-compose.gpu.yml @@ -0,0 +1,12 @@ +services: + backend: + gpus: all + environment: + NVIDIA_VISIBLE_DEVICES: all + NVIDIA_DRIVER_CAPABILITIES: compute,utility + + worker: + gpus: all + environment: + NVIDIA_VISIBLE_DEVICES: all + NVIDIA_DRIVER_CAPABILITIES: compute,utility diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f215641 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,131 @@ +services: + postgres: + image: postgres:16-alpine + container_name: seg-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-seguser} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-segpass123} + POSTGRES_DB: ${POSTGRES_DB:-segserver} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + container_name: seg-redis + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + + minio: + image: minio/minio:RELEASE.2025-04-22T22-12-26Z + container_name: seg-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin} + ports: + - "${MINIO_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - minio-data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 30 + + backend: + image: seg-server-backend:latest + build: + context: . + dockerfile: Dockerfile.backend + container_name: seg-backend + restart: unless-stopped + env_file: .env + environment: + DB_URL: postgresql://${POSTGRES_USER:-seguser}:${POSTGRES_PASSWORD:-segpass123}@postgres:5432/${POSTGRES_DB:-segserver} + REDIS_URL: redis://redis:6379/0 + MINIO_ENDPOINT: minio:9000 + MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:-localhost:9000} + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} + MINIO_SECURE: ${MINIO_SECURE:-false} + CORS_ORIGINS: ${CORS_ORIGINS:-["http://localhost:3000","http://127.0.0.1:3000"]} + DEMO_VIDEO_PATH: /app/demo/演视LC视频序列.mp4 + DEMO_DICOM_DIR: /app/demo/演视DICOM序列 + SAM_MODEL_PATH: /app/models/sam2.1_hiera_tiny.pt + SAM_MODEL_CONFIG: configs/sam2.1/sam2.1_hiera_t.yaml + SAM3_EXTERNAL_ENABLED: "false" + ports: + - "${BACKEND_PORT:-8000}:8000" + volumes: + - ${SAM_MODELS_DIR:-./models}:/app/models:ro + - ./demo:/app/demo:ro + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 30 + + worker: + image: seg-server-backend:latest + pull_policy: never + container_name: seg-worker + restart: unless-stopped + command: celery -A celery_app:celery_app worker --loglevel=info --pool=solo --concurrency=1 + env_file: .env + environment: + DB_URL: postgresql://${POSTGRES_USER:-seguser}:${POSTGRES_PASSWORD:-segpass123}@postgres:5432/${POSTGRES_DB:-segserver} + REDIS_URL: redis://redis:6379/0 + MINIO_ENDPOINT: minio:9000 + MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:-localhost:9000} + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} + MINIO_SECURE: ${MINIO_SECURE:-false} + DEMO_VIDEO_PATH: /app/demo/演视LC视频序列.mp4 + DEMO_DICOM_DIR: /app/demo/演视DICOM序列 + SAM_MODEL_PATH: /app/models/sam2.1_hiera_tiny.pt + SAM_MODEL_CONFIG: configs/sam2.1/sam2.1_hiera_t.yaml + SAM3_EXTERNAL_ENABLED: "false" + volumes: + - ${SAM_MODELS_DIR:-./models}:/app/models:ro + - ./demo:/app/demo:ro + depends_on: + backend: + condition: service_healthy + + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + args: + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-} + VITE_WS_PROGRESS_URL: ${VITE_WS_PROGRESS_URL:-} + container_name: seg-frontend + restart: unless-stopped + ports: + - "${FRONTEND_PORT:-3000}:80" + depends_on: + backend: + condition: service_healthy + +volumes: + postgres-data: + minio-data: diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..3c25a98 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,10 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..bdfbcf3 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + 语义分割系统 SegServer + + +
+ + + diff --git a/models/.gitkeep b/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f02a5ee --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5788 @@ +{ + "name": "react-example", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "react-example", + "version": "0.0.0", + "dependencies": { + "@google/genai": "^1.29.0", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "axios": "^1.15.2", + "clsx": "^2.1.1", + "dotenv": "^17.2.3", + "express": "^4.21.2", + "konva": "^10.2.5", + "lucide-react": "^0.546.0", + "motion": "^12.23.24", + "polygon-clipping": "^0.15.7", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-konva": "^19.2.3", + "tailwind-merge": "^3.5.0", + "use-image": "^1.1.4", + "vite": "^6.2.0", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/express": "^4.17.21", + "@types/node": "^22.14.0", + "autoprefixer": "^10.4.21", + "jsdom": "^29.1.1", + "tailwindcss": "^4.1.14", + "tsx": "^4.21.0", + "typescript": "~5.8.2", + "vite": "^6.2.0", + "vitest": "^4.1.5" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@google/genai": { + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.50.1.tgz", + "integrity": "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", + "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "license": "ISC" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/konva": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/konva/-/konva-10.2.5.tgz", + "integrity": "sha512-WwBoe/EBhFcv+seL1Wnp3OAOwOFjCY4nCCgpLRrzUzw1IX4lKf/lYhj2Z3qo9P9q2fA3h+OdGDlimSNqZJaY5A==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.546.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.546.0.tgz", + "integrity": "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.38.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/polygon-clipping": { + "version": "0.15.7", + "resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz", + "integrity": "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==", + "license": "MIT", + "dependencies": { + "robust-predicates": "^3.0.2", + "splaytree": "^3.1.0" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", + "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.1", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-konva": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.3.tgz", + "integrity": "sha512-VsO5CJZwUo12xFa33UEIDOQn6ZZBeE6jlkStGFvpR/3NiDA/9RPQTzw6Ri++C0Pnh3Arco1AehB8qJNv9YCRwg==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.33.0", + "its-fine": "^2.0.0", + "react-reconciler": "0.33.0", + "scheduler": "0.27.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/splaytree": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-3.2.3.tgz", + "integrity": "sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w==", + "license": "MIT", + "engines": { + "node": ">=18.20 || >=20" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.29", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.29.tgz", + "integrity": "sha512-JIXCerhudr/N6OWLwLF1HVsTTUo7ry6qHa5eWZEkiMuxsIiAACL55tGLfqfHfoH7QaMQUW8fngD7u7TxWexYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.29" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.29", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.29.tgz", + "integrity": "sha512-W99NuU7b1DcG3uJ3v9k9VztCH3WialNbBkBft5wCs8V8mexu0XQqaZEYb9l9RNNzK8+3EJ9PKWB0/RUtTQ/o+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-image": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/use-image/-/use-image-1.1.4.tgz", + "integrity": "sha512-P+swhszzHHgEb2X2yQ+vQNPCq/8Ks3hyfdXAVN133pvnvK7UK++bUaZUa5E+A3S02Mw8xOCBr9O6CLhk2fjrWA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1f5332e --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "react-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "tsx server.ts", + "build": "vite build", + "preview": "vite preview", + "start": "node server.ts", + "clean": "rm -rf dist", + "lint": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run" + }, + "dependencies": { + "@google/genai": "^1.29.0", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "axios": "^1.15.2", + "clsx": "^2.1.1", + "dotenv": "^17.2.3", + "express": "^4.21.2", + "konva": "^10.2.5", + "lucide-react": "^0.546.0", + "motion": "^12.23.24", + "polygon-clipping": "^0.15.7", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-konva": "^19.2.3", + "tailwind-merge": "^3.5.0", + "use-image": "^1.1.4", + "vite": "^6.2.0", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/express": "^4.17.21", + "@types/node": "^22.14.0", + "autoprefixer": "^10.4.21", + "jsdom": "^29.1.1", + "tailwindcss": "^4.1.14", + "tsx": "^4.21.0", + "typescript": "~5.8.2", + "vite": "^6.2.0", + "vitest": "^4.1.5" + } +} diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..6b9ee7e Binary files /dev/null and b/public/logo.png differ diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..ae0cf84 --- /dev/null +++ b/server.ts @@ -0,0 +1,30 @@ +import express from "express"; +import { createServer as createViteServer } from "vite"; +import path from "path"; + +async function startServer() { + const app = express(); + const PORT = 3000; + + app.use(express.json()); + // Vite middleware for development + if (process.env.NODE_ENV !== "production") { + const vite = await createViteServer({ + server: { middlewareMode: true }, + appType: "spa", + }); + app.use(vite.middlewares); + } else { + const distPath = path.join(process.cwd(), 'dist'); + app.use(express.static(distPath)); + app.get('*', (req, res) => { + res.sendFile(path.join(distPath, 'index.html')); + }); + } + + app.listen(PORT, "0.0.0.0", () => { + console.log(`Server running on http://localhost:${PORT}`); + }); +} + +startServer(); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..001e021 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,60 @@ +import React, { useEffect } from 'react'; +import { useStore } from './store/useStore'; +import { getCurrentUser, getProjects } from './lib/api'; +import { Sidebar } from './components/Sidebar'; +import { Dashboard } from './components/Dashboard'; +import { ProjectLibrary } from './components/ProjectLibrary'; +import { VideoWorkspace } from './components/VideoWorkspace'; +import { TemplateRegistry } from './components/TemplateRegistry'; +import { AISegmentation } from './components/AISegmentation'; +import { Login } from './components/Login'; +import { UserAdmin } from './components/UserAdmin'; + +export type ActiveModule = 'dashboard' | 'projects' | 'ai' | 'workspace' | 'templates' | 'admin'; + +export default function App() { + const isAuthenticated = useStore((state) => state.isAuthenticated); + const activeModule = useStore((state) => state.activeModule); + const setActiveModule = useStore((state) => state.setActiveModule); + const setProjects = useStore((state) => state.setProjects); + const setError = useStore((state) => state.setError); + const setCurrentUser = useStore((state) => state.setCurrentUser); + const logout = useStore((state) => state.logout); + const currentUser = useStore((state) => state.currentUser); + + useEffect(() => { + if (isAuthenticated) { + Promise.all([getCurrentUser(), getProjects()]) + .then(([user, projects]) => { + setCurrentUser(user); + setProjects(projects); + }) + .catch((err) => { + console.error('Failed to fetch projects:', err); + if (err?.response?.status === 401) { + logout(); + return; + } + setError('获取项目列表失败'); + }); + } + }, [isAuthenticated, logout, setCurrentUser, setProjects, setError]); + + if (!isAuthenticated) { + return ; + } + + return ( +
+ +
+ {activeModule === 'dashboard' && } + {activeModule === 'projects' && setActiveModule('workspace')} />} + {activeModule === 'ai' && setActiveModule('workspace')} />} + {activeModule === 'workspace' && setActiveModule('ai')} />} + {activeModule === 'templates' && } + {activeModule === 'admin' && currentUser?.role === 'admin' && } +
+
+ ); +} diff --git a/src/components/AISegmentation.test.tsx b/src/components/AISegmentation.test.tsx new file mode 100644 index 0000000..1ddf8a4 --- /dev/null +++ b/src/components/AISegmentation.test.tsx @@ -0,0 +1,734 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetStore } from '../test/storeTestUtils'; +import { useStore } from '../store/useStore'; +import { AISegmentation } from './AISegmentation'; + +const apiMock = vi.hoisted(() => ({ + getAiModelStatus: vi.fn(), + predictMask: vi.fn(), +})); + +vi.mock('../lib/api', () => ({ + getAiModelStatus: apiMock.getAiModelStatus, + predictMask: apiMock.predictMask, +})); + +describe('AISegmentation', () => { + beforeEach(() => { + resetStore(); + vi.clearAllMocks(); + useStore.setState({ + frames: [{ id: 'frame-1', projectId: 'project-1', index: 0, url: '/frame.jpg', width: 640, height: 360 }], + }); + apiMock.getAiModelStatus.mockResolvedValue({ + selected_model: 'sam2.1_hiera_tiny', + gpu: { available: true, device: 'cuda', name: 'RTX 4090', torch_available: true }, + models: [ + { id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2.1 Tiny ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false }, + { id: 'sam2.1_hiera_small', label: 'SAM 2.1 Small', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2.1 Small ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false }, + { id: 'sam2.1_hiera_base_plus', label: 'SAM 2.1 Base+', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2.1 Base+ ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false }, + { id: 'sam2.1_hiera_large', label: 'SAM 2.1 Large', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2.1 Large ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false }, + ], + }); + }); + + it('shows the SAM2.1 variant selector without exposing SAM3', async () => { + render(); + + expect(await screen.findByText('SAM 2.1 Tiny')).toBeInTheDocument(); + expect(screen.getByText('tiny')).toBeInTheDocument(); + expect(screen.getByText('small')).toBeInTheDocument(); + expect(screen.getByText('base+')).toBeInTheDocument(); + expect(screen.getByText('large')).toBeInTheDocument(); + expect(screen.queryByText('SAM3')).not.toBeInTheDocument(); + expect(apiMock.getAiModelStatus).toHaveBeenCalledWith('sam2.1_hiera_tiny'); + }); + + it('does not render the legacy upload-replace-background mock button', () => { + render(); + + expect(screen.queryByText('上传替换底图')).not.toBeInTheDocument(); + }); + + it('shows an empty state instead of a demo image when no project frame is selected', () => { + useStore.setState({ frames: [] }); + + render(); + + expect(screen.getByText('请先在项目库选择项目并生成帧')).toBeInTheDocument(); + }); + + it('shows contextual guidance for prompt tools', () => { + render(); + + fireEvent.click(screen.getByText('正向选点')); + expect(screen.getByText(/点击目标内部添加正向点/)).toBeInTheDocument(); + + fireEvent.click(screen.getByText('边界框选')); + expect(screen.getByText(/按住并拖拽建立框选区域/)).toBeInTheDocument(); + }); + + it('passes enabled inference parameters to the backend', async () => { + apiMock.predictMask.mockResolvedValueOnce({ masks: [] }); + render(); + + expect(screen.getByText('局部专注模式(自动裁剪无锚区域)')).toBeInTheDocument(); + expect(screen.getByText('严格除杂模式(自动清理干涉点)')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + + expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({ + imageId: 'frame-1', + imageWidth: 640, + imageHeight: 360, + model: 'sam2.1_hiera_tiny', + points: [{ x: 120, y: 80, type: 'pos' }], + options: { + crop_to_prompt: false, + auto_filter_background: true, + min_score: 0.05, + }, + })); + }); + + it('sends the selected SAM2.1 variant to prediction', async () => { + apiMock.predictMask.mockResolvedValueOnce({ masks: [] }); + render(); + + fireEvent.click(await screen.findByText('small')); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + + expect(apiMock.getAiModelStatus).toHaveBeenCalledWith('sam2.1_hiera_small'); + expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({ + model: 'sam2.1_hiera_small', + })); + }); + + it('does not render masks that were created in the workspace', async () => { + useStore.setState({ + masks: [ + { + id: 'workspace-mask', + frameId: 'frame-1', + pathData: 'M 0 0 L 10 0 L 10 10 Z', + label: 'Manual Mask', + color: '#ff0000', + segmentation: [[0, 0, 10, 0, 10, 10]], + metadata: { source: 'manual' }, + }, + ], + selectedMaskIds: ['workspace-mask'], + }); + + render(); + + expect(screen.queryAllByTestId('konva-path')).toHaveLength(0); + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([])); + }); + + it('requires point prompts before running SAM2 inference', async () => { + render(); + + fireEvent.click(await screen.findByText('执行高精度语义分割')); + + expect(apiMock.predictMask).not.toHaveBeenCalled(); + expect(await screen.findByText('请先放置正/反向提示点或框选区域。')).toBeInTheDocument(); + }); + + it('disables unavailable model variants and skips AI inference', async () => { + apiMock.getAiModelStatus.mockResolvedValue({ + selected_model: 'sam2.1_hiera_tiny', + gpu: { available: false, device: 'cpu', name: null, torch_available: false }, + models: [ + { id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', available: false, loaded: false, device: 'cpu', supports: [], message: 'missing PyTorch, sam2 package, checkpoint', package_available: false, checkpoint_exists: false, python_ok: false, torch_ok: false, cuda_required: false }, + { id: 'sam2.1_hiera_small', label: 'SAM 2.1 Small', available: false, loaded: false, device: 'cpu', supports: [], message: 'missing PyTorch, sam2 package, checkpoint', package_available: false, checkpoint_exists: false, python_ok: false, torch_ok: false, cuda_required: false }, + ], + }); + render(); + + expect(await screen.findByText('当前模型不可用')).toBeDisabled(); + expect(screen.getByRole('button', { name: /tiny/i })).toBeDisabled(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(screen.getByText('当前模型不可用')); + + expect(apiMock.predictMask).not.toHaveBeenCalled(); + }); + + it('uses a dragged box prompt for AI page inference without adding a point on click', async () => { + apiMock.predictMask.mockResolvedValueOnce({ masks: [] }); + render(); + + fireEvent.click(screen.getByText('边界框选')); + const stage = screen.getByTestId('konva-stage'); + fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 }); + fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 }); + fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 }); + + expect(screen.getByTestId('konva-rect')).toHaveAttribute('data-width', '140'); + expect(await screen.findByText('已框选区域,可执行分割,或继续添加正/反向点细化。')).toBeInTheDocument(); + expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0); + expect(apiMock.predictMask).not.toHaveBeenCalled(); + + fireEvent.click(await screen.findByText('执行高精度语义分割')); + + expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({ + imageId: 'frame-1', + imageWidth: 640, + imageHeight: 360, + model: 'sam2.1_hiera_tiny', + points: undefined, + box: { x1: 120, y1: 80, x2: 260, y2: 200 }, + options: { + crop_to_prompt: false, + auto_filter_background: true, + min_score: 0.05, + }, + })); + }); + + it('handles stage drag end for move-tool canvas panning', () => { + render(); + + expect(screen.getByTestId('konva-stage')).toHaveAttribute('data-has-drag-end', 'true'); + }); + + it('centers the active frame with a large default fit inside the AI canvas', async () => { + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 1000 }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 700 }); + + render(); + + await waitFor(() => { + const stage = screen.getByTestId('konva-stage'); + expect(Number(stage.getAttribute('data-scale-x'))).toBeCloseTo(1.34375, 4); + expect(Number(stage.getAttribute('data-x'))).toBeCloseTo(70, 0); + expect(Number(stage.getAttribute('data-y'))).toBeCloseTo(108, 0); + }); + }); + + it('combines the AI page box prompt with later positive and negative refinement points', async () => { + apiMock.predictMask.mockResolvedValueOnce({ masks: [] }); + render(); + + fireEvent.click(screen.getByText('边界框选')); + const stage = screen.getByTestId('konva-stage'); + fireEvent.mouseDown(stage, { clientX: 100, clientY: 60 }); + fireEvent.mouseMove(stage, { clientX: 300, clientY: 180 }); + fireEvent.mouseUp(stage, { clientX: 300, clientY: 180 }); + + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(stage, { clientX: 160, clientY: 100 }); + fireEvent.click(screen.getByText('反向选点')); + fireEvent.click(stage, { clientX: 260, clientY: 150 }); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + + expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({ + points: [ + { x: 160, y: 100, type: 'pos' }, + { x: 260, y: 150, type: 'neg' }, + ], + box: { x1: 100, y1: 60, x2: 300, y2: 180 }, + })); + }); + + it('replaces the previous AI page candidate when running the same box prompt again', async () => { + useStore.setState({ + masks: [ + { + id: 'workspace-mask', + frameId: 'frame-1', + pathData: 'M 0 0 L 10 0 L 10 10 Z', + label: 'Manual Mask', + color: '#ff0000', + segmentation: [[0, 0, 10, 0, 10, 10]], + metadata: { source: 'manual' }, + }, + ], + }); + apiMock.predictMask + .mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-first', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }) + .mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-second', + pathData: 'M 20 20 L 50 20 L 50 50 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[20, 20, 50, 20, 50, 50]], + bbox: [20, 20, 30, 30], + area: 900, + }, + ], + }); + + render(); + fireEvent.click(screen.getByText('边界框选')); + const stage = screen.getByTestId('konva-stage'); + fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 }); + fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 }); + fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 }); + + fireEvent.click(await screen.findByText('执行高精度语义分割')); + await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask', 'sam2-first'])); + expect(useStore.getState().selectedMaskIds).toEqual(['sam2-first']); + + fireEvent.click(screen.getByText('执行高精度语义分割')); + + await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask', 'sam2-second'])); + expect(useStore.getState().selectedMaskIds).toEqual(['sam2-second']); + expect(screen.getAllByTestId('konva-path')).toHaveLength(1); + }); + + it('deletes prompt points individually and can remove the latest point', async () => { + apiMock.predictMask.mockResolvedValueOnce({ masks: [] }); + render(); + + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage'), { clientX: 120, clientY: 80 }); + fireEvent.click(screen.getByText('反向选点')); + fireEvent.click(screen.getByTestId('konva-stage'), { clientX: 220, clientY: 140 }); + + await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(4)); + fireEvent.click(screen.getAllByTestId('konva-circle')[0]); + + await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(2)); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + + expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({ + points: [{ x: 220, y: 140, type: 'neg' }], + })); + + fireEvent.click(screen.getByLabelText('删除最近锚点')); + + await waitFor(() => expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0)); + }); + + it('keeps only the best SAM2 candidate when the backend returns overlapping alternatives', async () => { + apiMock.predictMask.mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-best', + pathData: 'M 0 0 L 10 0 L 10 10 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[0, 0, 10, 0, 10, 10]], + bbox: [0, 0, 10, 10], + area: 100, + }, + { + id: 'sam2-alt', + pathData: 'M 1 1 L 11 1 L 11 11 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[1, 1, 11, 1, 11, 11]], + bbox: [1, 1, 10, 10], + area: 100, + }, + ], + }); + + render(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + + await waitFor(() => expect(useStore.getState().masks).toHaveLength(1)); + expect(useStore.getState().masks[0].id).toBe('sam2-best'); + expect(useStore.getState().masks[0].metadata).toEqual(expect.objectContaining({ source: 'ai_segmentation' })); + expect(useStore.getState().selectedMaskIds).toEqual(['sam2-best']); + expect(await screen.findByText('SAM 2.1 Tiny 返回 2 个候选,已采用最高分区域。')).toBeInTheDocument(); + }); + + it('adjusts the AI mask preview opacity without changing mask data', async () => { + apiMock.predictMask.mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-mask', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }); + + render(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + await waitFor(() => expect(screen.getByTestId('konva-path')).toBeInTheDocument()); + + const maskGroup = () => screen.getAllByTestId('konva-group').find((group) => group.getAttribute('data-opacity')); + expect(maskGroup()).toHaveAttribute('data-opacity', '0.5'); + fireEvent.change(screen.getByLabelText('AI 遮罩透明度'), { target: { value: '35' } }); + + expect(maskGroup()).toHaveAttribute('data-opacity', '0.35'); + expect(useStore.getState().maskPreviewOpacity).toBe(35); + expect(useStore.getState().masks[0].segmentation).toEqual([[10, 10, 40, 10, 40, 40]]); + }); + + it('updates AI candidate opacity when the shared ontology opacity slider changes', async () => { + apiMock.predictMask.mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-mask', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }); + + render(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + await waitFor(() => expect(screen.getByTestId('konva-path')).toBeInTheDocument()); + + const maskGroup = () => screen.getAllByTestId('konva-group').find((group) => group.getAttribute('data-opacity')); + fireEvent.change(screen.getByLabelText('遮罩透明度'), { target: { value: '80' } }); + + expect(maskGroup()).toHaveAttribute('data-opacity', '0.8'); + }); + + it('lets positive and negative prompt points be added on top of an AI mask', async () => { + apiMock.predictMask + .mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-mask', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }) + .mockResolvedValueOnce({ masks: [] }); + + render(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage'), { clientX: 120, clientY: 80 }); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + await waitFor(() => expect(screen.getByTestId('konva-path')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('反向选点')); + fireEvent.click(screen.getByTestId('konva-path'), { clientX: 220, clientY: 140 }); + await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(4)); + fireEvent.click(screen.getByText('执行高精度语义分割')); + + expect(apiMock.predictMask).toHaveBeenLastCalledWith(expect.objectContaining({ + points: [ + { x: 120, y: 80, type: 'pos' }, + { x: 220, y: 140, type: 'neg' }, + ], + })); + expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']); + }); + + it('clears only AI page candidates and keeps workspace masks in the store', async () => { + useStore.setState({ + masks: [ + { + id: 'workspace-mask', + frameId: 'frame-1', + pathData: 'M 0 0 L 10 0 L 10 10 Z', + label: 'Manual Mask', + color: '#ff0000', + segmentation: [[0, 0, 10, 0, 10, 10]], + metadata: { source: 'manual' }, + }, + ], + }); + apiMock.predictMask.mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-mask', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }); + + render(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(2)); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask', 'sam2-mask'])); + + fireEvent.click(screen.getByText('清空全体锚点')); + + expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask']); + expect(useStore.getState().selectedMaskIds).toEqual([]); + }); + + it('deletes only the selected AI candidate and preserves workspace masks', async () => { + useStore.setState({ + masks: [ + { + id: 'workspace-mask', + frameId: 'frame-1', + pathData: 'M 0 0 L 10 0 L 10 10 Z', + label: 'Manual Mask', + color: '#ff0000', + segmentation: [[0, 0, 10, 0, 10, 10]], + metadata: { source: 'manual' }, + }, + ], + }); + apiMock.predictMask.mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-mask', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }); + + render(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask'])); + + fireEvent.click(screen.getByLabelText('删除选中候选')); + + await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask'])); + expect(useStore.getState().selectedMaskIds).toEqual([]); + }); + + it('lets Delete remove the selected AI candidate after a mask click selects it', async () => { + apiMock.predictMask.mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-mask', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }); + + render(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + await waitFor(() => expect(screen.getByTestId('konva-path')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('视口控制')); + fireEvent.click(screen.getByTestId('konva-path')); + fireEvent.keyDown(window, { key: 'Delete' }); + + await waitFor(() => expect(useStore.getState().masks).toEqual([])); + }); + + it('lets a SAM2 result be selected and relabeled from the ontology panel', async () => { + useStore.setState({ + templates: [ + { + id: 'template-1', + name: '腹腔镜模板', + classes: [ + { id: 'class-1', name: '胆囊', color: '#ff0000', zIndex: 30 }, + { id: 'class-2', name: '肝脏', color: '#00ff00', zIndex: 20 }, + ], + rules: [], + }, + ], + }); + apiMock.predictMask.mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-mask', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }); + + render(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask'])); + fireEvent.click(screen.getByText('肝脏')); + + expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({ + templateId: 'template-1', + classId: 'class-2', + className: '肝脏', + classZIndex: 20, + label: '肝脏', + color: '#00ff00', + saveStatus: 'draft', + })); + }); + + it('keeps the generated SAM2 mask selected when sending it to the workspace editor', async () => { + const onSendToWorkspace = vi.fn(); + useStore.setState({ + activeTemplateId: 'template-1', + activeClass: { id: 'class-1', name: '胆囊', color: '#ff0000', zIndex: 30 }, + activeClassId: 'class-1', + }); + apiMock.predictMask.mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-mask', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }); + + render(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask'])); + + fireEvent.click(screen.getByText('推送至工作区编辑')); + + expect(useStore.getState().activeTool).toBe('edit_polygon'); + expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']); + expect(onSendToWorkspace).toHaveBeenCalled(); + }); + + it('blocks sending an AI candidate to the workspace until a semantic class is selected', async () => { + const onSendToWorkspace = vi.fn(); + apiMock.predictMask.mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-mask', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }); + + render(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask'])); + + fireEvent.click(screen.getByText('推送至工作区编辑')); + + const toast = screen.getByRole('status'); + expect(toast).toHaveTextContent('请先在右侧语义分类树为 AI 候选区域选择语义分类,再推送至工作区。'); + expect(toast.className).toContain('bg-red-950'); + expect(useStore.getState().activeTool).toBe('point_pos'); + expect(onSendToWorkspace).not.toHaveBeenCalled(); + }); + + it('removes unclassified AI candidates when leaving the AI page', async () => { + apiMock.predictMask.mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-mask', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }); + + const { unmount } = render(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + await waitFor(() => expect(useStore.getState().masks).toHaveLength(1)); + + unmount(); + + expect(useStore.getState().masks).toEqual([]); + expect(useStore.getState().selectedMaskIds).toEqual([]); + }); + + it('keeps classified AI candidates when leaving the AI page', async () => { + useStore.setState({ + activeTemplateId: 'template-1', + activeClass: { id: 'class-1', name: '胆囊', color: '#ff0000', zIndex: 30 }, + activeClassId: 'class-1', + }); + apiMock.predictMask.mockResolvedValueOnce({ + masks: [ + { + id: 'sam2-mask', + pathData: 'M 10 10 L 40 10 L 40 40 Z', + label: 'AI Mask', + color: '#06b6d4', + segmentation: [[10, 10, 40, 10, 40, 40]], + bbox: [10, 10, 30, 30], + area: 900, + }, + ], + }); + + const { unmount } = render(); + fireEvent.click(screen.getByText('正向选点')); + fireEvent.click(screen.getByTestId('konva-stage')); + fireEvent.click(await screen.findByText('执行高精度语义分割')); + await waitFor(() => expect(useStore.getState().masks[0]?.classId).toBe('class-1')); + + unmount(); + + expect(useStore.getState().masks).toHaveLength(1); + expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']); + }); + +}); diff --git a/src/components/AISegmentation.tsx b/src/components/AISegmentation.tsx new file mode 100644 index 0000000..ab1877c --- /dev/null +++ b/src/components/AISegmentation.tsx @@ -0,0 +1,798 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Undo, Redo, Loader2, XCircle, Trash2 } from 'lucide-react'; +import { cn } from '../lib/utils'; +import { Stage, Layer, Image as KonvaImage, Circle, Path, Group, Rect } from 'react-konva'; +import useImage from 'use-image'; +import { OntologyInspector } from './OntologyInspector'; +import { TransientNotice, type NoticeState } from './TransientNotice'; +import { SAM2_MODEL_OPTIONS, useStore, type Mask } from '../store/useStore'; +import { getAiModelStatus, predictMask, type AiRuntimeStatus } from '../lib/api'; + +interface AISegmentationProps { + onSendToWorkspace: () => void; +} + +type PromptPoint = { x: number; y: number; type: 'pos' | 'neg' }; +type PromptBox = { x1: number; y1: number; x2: number; y2: number }; +type ToolHint = { title: string; body: string }; +const DEFAULT_IMAGE_FIT_RATIO = 0.86; + +export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) { + const canvasContainerRef = useRef(null); + const storeActiveTool = useStore((state) => state.activeTool); + const setActiveTool = useStore((state) => state.setActiveTool); + const masks = useStore((state) => state.masks); + const setMasks = useStore((state) => state.setMasks); + const selectedMaskIds = useStore((state) => state.selectedMaskIds); + const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds); + const maskHistory = useStore((state) => state.maskHistory); + const maskFuture = useStore((state) => state.maskFuture); + const undoMasks = useStore((state) => state.undoMasks); + const redoMasks = useStore((state) => state.redoMasks); + const frames = useStore((state) => state.frames); + const currentFrameIndex = useStore((state) => state.currentFrameIndex); + const aiModel = useStore((state) => state.aiModel); + const setAiModel = useStore((state) => state.setAiModel); + const activeTemplateId = useStore((state) => state.activeTemplateId); + const activeClass = useStore((state) => state.activeClass); + const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity); + const setMaskPreviewOpacity = useStore((state) => state.setMaskPreviewOpacity); + + const [modelStatus, setModelStatus] = useState(null); + const [autoDeleteBg, setAutoDeleteBg] = useState(true); + const [cropMode, setCropMode] = useState(false); + const [isInferencing, setIsInferencing] = useState(false); + const [inferenceMessage, setInferenceMessage] = useState(''); + const [notice, setNotice] = useState(null); + const [aiMaskIds, setAiMaskIds] = useState([]); + + // Canvas state + const [stageSize, setStageSize] = useState({ width: 800, height: 600 }); + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const lastAutoFitKeyRef = useRef(''); + const [points, setPoints] = useState([]); + const [promptBox, setPromptBox] = useState(null); + const [boxStart, setBoxStart] = useState<{ x: number; y: number } | null>(null); + const [boxCurrent, setBoxCurrent] = useState<{ x: number; y: number } | null>(null); + const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }); + const currentFrame = frames[currentFrameIndex] || null; + const [image] = useImage(currentFrame?.url || ''); + const aiMaskIdSet = new Set(aiMaskIds); + const frameMasks = currentFrame + ? masks.filter((mask) => mask.frameId === currentFrame.id && aiMaskIdSet.has(mask.id)) + : masks.filter((mask) => aiMaskIdSet.has(mask.id)); + const selectedAiMasks = frameMasks.filter((mask) => selectedMaskIds.includes(mask.id)); + const aiMasksToSend = selectedAiMasks.length > 0 ? selectedAiMasks : frameMasks; + const selectedModelStatus = modelStatus?.models.find((model) => model.id === aiModel); + const modelStatusLoaded = Boolean(modelStatus); + const modelCanInfer = modelStatusLoaded && Boolean(selectedModelStatus?.available); + + const effectiveTool = storeActiveTool; + + useEffect(() => { + return () => { + if (aiMaskIds.length === 0) return; + const state = useStore.getState(); + const aiIds = new Set(aiMaskIds); + const unclassifiedAiIds = new Set( + state.masks + .filter((mask) => aiIds.has(mask.id) && !mask.classId && !mask.className) + .map((mask) => mask.id), + ); + if (unclassifiedAiIds.size === 0) return; + + useStore.setState({ + masks: state.masks.filter((mask) => !unclassifiedAiIds.has(mask.id)), + selectedMaskIds: state.selectedMaskIds.filter((id) => !unclassifiedAiIds.has(id)), + }); + }; + }, [aiMaskIds]); + + useEffect(() => { + const handleResize = () => { + if (!canvasContainerRef.current) return; + setStageSize({ + width: canvasContainerRef.current.clientWidth, + height: canvasContainerRef.current.clientHeight, + }); + }; + + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + useEffect(() => { + if (!currentFrame?.id || stageSize.width <= 0 || stageSize.height <= 0) return; + const imageWidth = currentFrame.width || image?.naturalWidth || image?.width || 0; + const imageHeight = currentFrame.height || image?.naturalHeight || image?.height || 0; + if (imageWidth <= 0 || imageHeight <= 0) return; + + const fitKey = `${currentFrame.id}:${stageSize.width}x${stageSize.height}:${imageWidth}x${imageHeight}`; + if (lastAutoFitKeyRef.current === fitKey) return; + lastAutoFitKeyRef.current = fitKey; + + const nextScale = Math.max( + 0.05, + Math.min(stageSize.width / imageWidth, stageSize.height / imageHeight) * DEFAULT_IMAGE_FIT_RATIO, + ); + setScale(nextScale); + setPosition({ + x: (stageSize.width - imageWidth * nextScale) / 2, + y: (stageSize.height - imageHeight * nextScale) / 2, + }); + }, [currentFrame?.height, currentFrame?.id, currentFrame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, stageSize.height, stageSize.width]); + + const toolHint = React.useMemo(() => { + if (!currentFrame) return null; + if (effectiveTool === 'point_pos') { + return { + title: '正向选点', + body: '点击目标内部添加正向点;点击已有提示点可删除。完成提示后点击“执行高精度语义分割”。', + }; + } + if (effectiveTool === 'point_neg') { + return { + title: '反向选点', + body: '点击不应包含的区域添加反向点;可和框选/正向点一起使用来细化结果。', + }; + } + if (effectiveTool === 'box_select') { + return { + title: promptBox ? '边界框已建立' : '边界框选', + body: promptBox + ? '当前框会随推理一起发送;也可以继续添加正向/反向点细化。重新拖拽会替换框。' + : '按住并拖拽建立框选区域,松开后保留框,再点击“执行高精度语义分割”。', + }; + } + if (effectiveTool === 'move') { + return { title: '视口控制', body: '拖拽移动画布,滚轮缩放;切回正向/反向点或框选后继续放置提示。' }; + } + return null; + }, [currentFrame, effectiveTool, promptBox]); + + const boxRect = React.useMemo(() => { + const activeBox = boxStart && boxCurrent + ? { + x1: Math.min(boxStart.x, boxCurrent.x), + y1: Math.min(boxStart.y, boxCurrent.y), + x2: Math.max(boxStart.x, boxCurrent.x), + y2: Math.max(boxStart.y, boxCurrent.y), + } + : promptBox; + if (!activeBox) return null; + return { + x: activeBox.x1, + y: activeBox.y1, + width: activeBox.x2 - activeBox.x1, + height: activeBox.y2 - activeBox.y1, + }; + }, [boxCurrent, boxStart, promptBox]); + + useEffect(() => { + let cancelled = false; + getAiModelStatus(aiModel) + .then((status) => { + if (!cancelled) setModelStatus(status); + }) + .catch(() => { + if (!cancelled) setModelStatus(null); + }); + return () => { + cancelled = true; + }; + }, [aiModel]); + + useEffect(() => { + const visibleIds = new Set(frameMasks.map((mask) => mask.id)); + const nextSelectedMaskIds = selectedMaskIds.filter((id) => visibleIds.has(id)); + const changed = nextSelectedMaskIds.length !== selectedMaskIds.length + || nextSelectedMaskIds.some((id, index) => id !== selectedMaskIds[index]); + if (changed) { + setSelectedMaskIds(nextSelectedMaskIds); + } + }, [frameMasks, selectedMaskIds, setSelectedMaskIds]); + + const handleWheel = (e: any) => { + e.evt.preventDefault(); + const scaleBy = 1.1; + const stage = e.target.getStage(); + const oldScale = stage.scaleX(); + const mousePointTo = { + x: stage.getPointerPosition().x / oldScale - stage.x() / oldScale, + y: stage.getPointerPosition().y / oldScale - stage.y() / oldScale, + }; + const newScale = e.evt.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy; + setScale(newScale); + setPosition({ + x: -(mousePointTo.x - stage.getPointerPosition().x / newScale) * newScale, + y: -(mousePointTo.y - stage.getPointerPosition().y / newScale) * newScale, + }); + }; + + const handleStageDragEnd = (e: any) => { + const stage = e.target; + setPosition({ + x: stage.x(), + y: stage.y(), + }); + }; + + const handleMouseMove = (e: any) => { + const stage = e.target.getStage(); + if (!stage) return; + const pos = stage.getPointerPosition(); + if (pos) { + const imageX = (pos.x - position.x) / scale; + const imageY = (pos.y - position.y) / scale; + setCursorPos({ x: imageX, y: imageY }); + } + if (effectiveTool === 'box_select' && boxStart) { + const relPos = stage.getRelativePointerPosition?.(); + if (relPos) { + setBoxCurrent({ x: relPos.x, y: relPos.y }); + } + } + }; + + const runInference = useCallback(async () => { + if (points.length === 0 && !promptBox) { + setInferenceMessage('请先放置正/反向提示点或框选区域。'); + return; + } + if (!currentFrame?.id) { + console.warn('AI inference skipped: no project frame is selected'); + setInferenceMessage('请先在项目工作区选择一帧图像。'); + return; + } + if (!modelCanInfer) { + setInferenceMessage(selectedModelStatus?.message ? `当前模型不可用:${selectedModelStatus.message}` : '当前模型不可用,请检查 GPU、PyTorch/SAM2 和模型权重。'); + return; + } + + const imageWidth = currentFrame.width || image?.naturalWidth || image?.width || 0; + const imageHeight = currentFrame.height || image?.naturalHeight || image?.height || 0; + if (imageWidth <= 0 || imageHeight <= 0) { + console.warn('AI inference skipped: active frame dimensions are unavailable'); + setInferenceMessage('当前帧缺少宽高信息,无法推理。'); + return; + } + + setIsInferencing(true); + setInferenceMessage(''); + try { + const result = await predictMask({ + imageId: currentFrame.id, + imageWidth, + imageHeight, + model: aiModel, + points: points.length > 0 ? points.map((p) => ({ x: p.x, y: p.y, type: p.type })) : undefined, + box: promptBox || undefined, + options: { + crop_to_prompt: cropMode, + auto_filter_background: autoDeleteBg, + min_score: autoDeleteBg ? 0.05 : 0, + }, + }); + + const masksToApply = result.masks.slice(0, 1); + + if (masksToApply.length === 0) { + setInferenceMessage('模型没有返回可用区域,请调整提示点后重试。'); + } else { + setInferenceMessage(result.masks.length > 1 + ? `${selectedModelStatus?.label || 'SAM 2.1'} 返回 ${result.masks.length} 个候选,已采用最高分区域。` + : `已生成 ${masksToApply.length} 个候选区域。`); + } + const generatedMasks: Mask[] = masksToApply.map((m) => { + const label = activeClass?.name || m.label; + const color = activeClass?.color || m.color; + return { + id: m.id, + frameId: currentFrame.id, + templateId: activeTemplateId || undefined, + classId: activeClass?.id, + className: activeClass?.name, + classZIndex: activeClass?.zIndex, + classMaskId: activeClass?.maskId, + saveStatus: 'draft', + saved: false, + pathData: m.pathData, + label, + color, + segmentation: m.segmentation, + bbox: m.bbox, + area: m.area, + metadata: { + source: 'ai_segmentation', + promptBox: promptBox || null, + promptPointCount: points.length, + promptNegativePointCount: points.filter((point) => point.type === 'neg').length, + }, + }; + }); + + const previousAiMaskIds = new Set(aiMaskIds); + const generatedMaskIds = generatedMasks.map((mask) => mask.id); + setMasks([ + ...masks.filter((mask) => !previousAiMaskIds.has(mask.id)), + ...generatedMasks, + ]); + setAiMaskIds(generatedMaskIds); + if (generatedMaskIds.length > 0) { + setSelectedMaskIds(generatedMaskIds); + } else { + setSelectedMaskIds(selectedMaskIds.filter((id) => !previousAiMaskIds.has(id))); + } + } catch (err) { + console.error('AI inference failed:', err); + const detail = (err as any)?.response?.data?.detail; + setInferenceMessage(detail || 'AI 推理失败,请查看模型状态或后端日志。'); + } finally { + setIsInferencing(false); + } + }, [activeClass, activeTemplateId, aiMaskIds, aiModel, autoDeleteBg, cropMode, currentFrame?.height, currentFrame?.id, currentFrame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, masks, modelCanInfer, points, promptBox, selectedMaskIds, selectedModelStatus?.label, selectedModelStatus?.message, setMasks, setSelectedMaskIds]); + + const clearAiLayer = useCallback(() => { + setPoints([]); + setPromptBox(null); + setBoxStart(null); + setBoxCurrent(null); + if (aiMaskIds.length === 0) return; + const idsToRemove = new Set(aiMaskIds); + setMasks(masks.filter((mask) => !idsToRemove.has(mask.id))); + setSelectedMaskIds(selectedMaskIds.filter((id) => !idsToRemove.has(id))); + setAiMaskIds([]); + }, [aiMaskIds, masks, selectedMaskIds, setMasks, setSelectedMaskIds]); + + const deleteAiMasksById = useCallback((maskIds: string[]) => { + const aiIds = new Set(aiMaskIds); + const idsToRemove = new Set(maskIds.filter((id) => aiIds.has(id))); + if (idsToRemove.size === 0) return; + setMasks(masks.filter((mask) => !idsToRemove.has(mask.id))); + setAiMaskIds((currentIds) => currentIds.filter((id) => !idsToRemove.has(id))); + setSelectedMaskIds(selectedMaskIds.filter((id) => !idsToRemove.has(id))); + }, [aiMaskIds, masks, selectedMaskIds, setMasks, setSelectedMaskIds]); + + const deleteSelectedAiMasks = useCallback(() => { + deleteAiMasksById(selectedMaskIds); + }, [deleteAiMasksById, selectedMaskIds]); + + const handleSendToWorkspace = useCallback(() => { + if (aiMasksToSend.length === 0) { + setInferenceMessage('请先执行分割并选择一个 AI 候选区域。'); + return; + } + const hasMissingSemantic = aiMasksToSend.some((mask) => !mask.classId && !mask.className); + if (hasMissingSemantic) { + setInferenceMessage(''); + setNotice({ + id: Date.now(), + message: '请先在右侧语义分类树为 AI 候选区域选择语义分类,再推送至工作区。', + tone: 'error', + }); + return; + } + + setInferenceMessage(''); + setActiveTool('edit_polygon'); + onSendToWorkspace(); + }, [aiMasksToSend, onSendToWorkspace, setActiveTool]); + + const removePromptPoint = useCallback((pointIndex: number) => { + setPoints((currentPoints) => currentPoints.filter((_, index) => index !== pointIndex)); + }, []); + + const removeLastPromptPoint = useCallback(() => { + setPoints((currentPoints) => currentPoints.slice(0, -1)); + }, []); + + const addPromptPointFromEvent = useCallback((event: any) => { + if (effectiveTool !== 'point_pos' && effectiveTool !== 'point_neg') return false; + const stage = event.target?.getStage?.(); + const pos = stage?.getRelativePointerPosition?.(); + if (!pos) return false; + setPoints((currentPoints) => [ + ...currentPoints, + { x: pos.x, y: pos.y, type: effectiveTool === 'point_pos' ? 'pos' : 'neg' }, + ]); + return true; + }, [effectiveTool]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + const tagName = target?.tagName?.toLowerCase(); + if (tagName === 'input' || tagName === 'textarea' || target?.isContentEditable) return; + if (event.key !== 'Delete' && event.key !== 'Backspace') return; + const selectedAiIds = selectedMaskIds.filter((id) => aiMaskIds.includes(id)); + if (selectedAiIds.length === 0) return; + event.preventDefault(); + deleteAiMasksById(selectedAiIds); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [aiMaskIds, deleteAiMasksById, selectedMaskIds]); + + const handleStageMouseDown = useCallback((event: any) => { + if (effectiveTool !== 'box_select') return; + const stage = event.target?.getStage?.(); + const pos = stage?.getRelativePointerPosition?.(); + if (!pos) return; + setBoxStart({ x: pos.x, y: pos.y }); + setBoxCurrent({ x: pos.x, y: pos.y }); + setInferenceMessage(''); + }, [effectiveTool]); + + const handleStageMouseUp = useCallback(() => { + if (effectiveTool !== 'box_select' || !boxStart || !boxCurrent) return; + const x1 = Math.min(boxStart.x, boxCurrent.x); + const y1 = Math.min(boxStart.y, boxCurrent.y); + const x2 = Math.max(boxStart.x, boxCurrent.x); + const y2 = Math.max(boxStart.y, boxCurrent.y); + if (Math.abs(x2 - x1) > 5 && Math.abs(y2 - y1) > 5) { + setPromptBox({ x1, y1, x2, y2 }); + setPoints([]); + setInferenceMessage('已框选区域,可执行分割,或继续添加正/反向点细化。'); + } + setBoxStart(null); + setBoxCurrent(null); + }, [boxCurrent, boxStart, effectiveTool]); + + const handleStageClick = (e: any) => { + if (effectiveTool === 'move') return; + if (effectiveTool === 'box_select') return; + addPromptPointFromEvent(e); + }; + + return ( +
+ setNotice(null)} /> + {/* Left AI Controller Panel */} + + + {/* Right Canvas Area */} +
+
+
+

模型端推理侧可视化 (Visualizer)

+ SAM 2.1 动态推理渲染 +
+
+ + +
+ + + +
+
+ +
+
+ {!currentFrame && ( +
+ 请先在项目库选择项目并生成帧 +
+ )} + {toolHint && ( +
+
{toolHint.title}
+
{toolHint.body}
+
+ )} + + + {/* Background Image */} + {image && ( + + )} + + {boxRect && ( + + )} + + {/* AI Returned Masks */} + {frameMasks.map((mask) => { + const isSelected = selectedMaskIds.includes(mask.id); + const baseOpacity = Math.min(Math.max(maskPreviewOpacity / 100, 0.1), 1); + const previewOpacity = isSelected + ? baseOpacity + : Math.max(0.12, baseOpacity * 0.62); + return ( + + { + if (addPromptPointFromEvent(event)) { + event.cancelBubble = true; + return; + } + event.cancelBubble = true; + setSelectedMaskIds([mask.id]); + }} + onTap={(event: any) => { + if (addPromptPointFromEvent(event)) { + event.cancelBubble = true; + return; + } + event.cancelBubble = true; + setSelectedMaskIds([mask.id]); + }} + /> + + ); + })} + + {/* Points */} + {points.map((p, i) => ( + + { + event.cancelBubble = true; + removePromptPoint(i); + }} + onTap={(event: any) => { + event.cancelBubble = true; + removePromptPoint(i); + }} + /> + { + event.cancelBubble = true; + removePromptPoint(i); + }} + onTap={(event: any) => { + event.cancelBubble = true; + removePromptPoint(i); + }} + /> + + ))} + + +
+ 光标坐标: {cursorPos.x.toFixed(2)}, {cursorPos.y.toFixed(2)} + 缩放比率: {(scale * 100).toFixed(0)}% + 遮罩数: {frameMasks.length} +
+
+
+
+ + {/* Right Ontology / Label Assignment Panel */} + +
+ ); +} diff --git a/src/components/AiAutoInferenceIcon.tsx b/src/components/AiAutoInferenceIcon.tsx new file mode 100644 index 0000000..8c24323 --- /dev/null +++ b/src/components/AiAutoInferenceIcon.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { BrainCircuit, Sparkles } from 'lucide-react'; + +interface AiAutoInferenceIconProps { + size?: number; + strokeWidth?: number; +} + +export function AiAutoInferenceIcon({ size = 20, strokeWidth = 2 }: AiAutoInferenceIconProps) { + const sparkleSize = Math.max(8, Math.round(size * 0.42)); + return ( + + + + + ); +} diff --git a/src/components/AiSegmentationIcon.tsx b/src/components/AiSegmentationIcon.tsx new file mode 100644 index 0000000..5c4e190 --- /dev/null +++ b/src/components/AiSegmentationIcon.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Bot, Sparkles } from 'lucide-react'; + +interface AiSegmentationIconProps { + size?: number; + strokeWidth?: number; +} + +export function AiSegmentationIcon({ size = 20, strokeWidth = 2 }: AiSegmentationIconProps) { + const sparkleSize = Math.max(9, Math.round(size * 0.48)); + return ( + + + + + ); +} diff --git a/src/components/CanvasArea.tsx b/src/components/CanvasArea.tsx new file mode 100644 index 0000000..578111d --- /dev/null +++ b/src/components/CanvasArea.tsx @@ -0,0 +1,2191 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { Stage, Layer, Image as KonvaImage, Circle, Rect, Path, Group } from 'react-konva'; +import polygonClipping, { type MultiPolygon, type Pair } from 'polygon-clipping'; +import useImage from 'use-image'; +import { useStore } from '../store/useStore'; +import { predictMask } from '../lib/api'; +import type { Frame, Mask } from '../store/useStore'; +import { RESERVED_UNCLASSIFIED_CLASS } from '../lib/maskIds'; + +type BooleanOperationTool = 'area_merge' | 'area_remove'; + +export interface BooleanFrameRangeRequest { + operation: BooleanOperationTool; + currentFrameId: string; + candidateFrameIds: string[]; + selectedMaskIds: string[]; + execute: (targetFrameIds: Set) => Promise | void; +} + +interface CanvasAreaProps { + activeTool: string; + frame: Frame | null; + currentFrameNumber?: number; + totalFrames?: number; + clearSelectionSignal?: number; + onRequestDeleteMasks?: (maskIds: string[]) => void; + onRequestBooleanFrameRange?: (request: BooleanFrameRangeRequest) => void; + onBooleanOperationStart?: () => void; + onDeleteMaskAnnotations?: (annotationIds: string[]) => Promise | void; +} + +type CanvasPoint = { x: number; y: number }; +type PromptPoint = CanvasPoint & { type: 'pos' | 'neg' }; +type PromptBox = { x1: number; y1: number; x2: number; y2: number }; +type ToolHint = { title: string; body: string }; + +const DRAG_MANUAL_TOOLS = new Set(['create_rectangle', 'create_circle']); +const POLYGON_TOOL = 'create_polygon'; +const EDIT_POLYGON_TOOL = 'edit_polygon'; +const BRUSH_TOOL = 'brush'; +const ERASER_TOOL = 'eraser'; +const PAINT_TOOLS = new Set([BRUSH_TOOL, ERASER_TOOL]); +const BOOLEAN_TOOLS = new Set(['area_merge', 'area_remove']); +const POLYGON_CLOSE_RADIUS = 8; +const DEFAULT_IMAGE_FIT_RATIO = 0.86; +const TOOL_HINT_TTL_MS = 3600; +const PAINT_STAMP_SEGMENTS = 16; +const MAX_PAINT_STROKE_POINTS = 128; + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function metadataNumber(value: unknown): number | null { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function propagationSourceMaskTokens(value: unknown): string[] { + if (typeof value !== 'string' || value.length === 0) return []; + const tokens = [`mask:${value}`]; + const annotationMatch = value.match(/^annotation-(\d+)$/); + if (annotationMatch) { + tokens.push(`annotation:${annotationMatch[1]}`); + } + return tokens; +} + +function propagationInstanceTokens(value: unknown): string[] { + return typeof value === 'string' && value.length > 0 ? [`instance:${value}`] : []; +} + +function reliablePropagationLineageTokens(mask: Mask): Set { + const metadata = mask.metadata || {}; + const tokens = new Set([`mask:${mask.id}`]); + if (mask.annotationId) { + tokens.add(`annotation:${mask.annotationId}`); + } + propagationInstanceTokens(metadata.source_instance_id).forEach((token) => tokens.add(token)); + propagationInstanceTokens(metadata.instance_id).forEach((token) => tokens.add(token)); + const sourceAnnotationId = metadataNumber(metadata.source_annotation_id); + if (sourceAnnotationId !== null) { + tokens.add(`annotation:${sourceAnnotationId}`); + } + propagationSourceMaskTokens(metadata.source_mask_id).forEach((token) => tokens.add(token)); + if (typeof metadata.propagation_seed_key === 'string') { + const seedKey = metadata.propagation_seed_key.trim(); + if (/^(annotation|mask):/.test(seedKey)) { + tokens.add(`seed-key:${seedKey}`); + tokens.add(seedKey); + } + } + return tokens; +} + +function isPropagationMask(mask: Mask): boolean { + const metadata = mask.metadata || {}; + const source = typeof metadata.source === 'string' ? metadata.source.toLowerCase() : ''; + return source.includes('propagat') + || metadata.propagated_from_frame_id !== undefined + || metadata.propagation_seed_key !== undefined + || metadata.source_annotation_id !== undefined + || metadata.source_mask_id !== undefined + || metadata.propagation_seed_signature !== undefined; +} + +function propagationLineageTokens(mask: Mask): Set { + const metadata = mask.metadata || {}; + const tokens = new Set([`mask:${mask.id}`]); + if (mask.annotationId) { + tokens.add(`annotation:${mask.annotationId}`); + } + let hasStablePropagationToken = false; + const instanceTokens = [ + ...propagationInstanceTokens(metadata.source_instance_id), + ...propagationInstanceTokens(metadata.instance_id), + ]; + if (instanceTokens.length > 0) { + instanceTokens.forEach((token) => tokens.add(token)); + hasStablePropagationToken = true; + } + const sourceAnnotationId = metadataNumber(metadata.source_annotation_id); + if (sourceAnnotationId !== null) { + tokens.add(`annotation:${sourceAnnotationId}`); + hasStablePropagationToken = true; + } + const sourceMaskTokens = propagationSourceMaskTokens(metadata.source_mask_id); + if (sourceMaskTokens.length > 0) { + sourceMaskTokens.forEach((token) => tokens.add(token)); + hasStablePropagationToken = true; + } + if (typeof metadata.propagation_seed_key === 'string' && metadata.propagation_seed_key.length > 0) { + tokens.add(`seed-key:${metadata.propagation_seed_key}`); + hasStablePropagationToken = true; + } + if (typeof metadata.propagation_seed_signature === 'string' && metadata.propagation_seed_signature.length > 0) { + tokens.add(`seed-signature:${metadata.propagation_seed_signature}`); + hasStablePropagationToken = true; + } + if (isPropagationMask(mask) && !hasStablePropagationToken) { + const source = typeof metadata.source === 'string' ? metadata.source : ''; + const classKey = mask.classId || mask.className || ''; + tokens.add([ + 'legacy-propagation', + source, + metadata.propagated_from_frame_id ?? '', + metadata.propagated_from_frame_index ?? '', + metadata.propagation_direction ?? '', + classKey, + mask.label || '', + mask.color || '', + ].join(':')); + } + return tokens; +} + +function maskSemanticKey(mask: Mask): string { + return [ + mask.classMaskId ?? '', + mask.classId ?? '', + mask.className ?? '', + mask.label ?? '', + mask.color ?? '', + ].join(':').toLowerCase(); +} + +function maskBboxCenter(mask: Mask): CanvasPoint | null { + const bbox = mask.bbox || segmentationBbox(mask.segmentation); + if (!bbox) return null; + return { x: bbox[0] + bbox[2] / 2, y: bbox[1] + bbox[3] / 2 }; +} + +function propagatedFromFrame(mask: Mask, sourceFrameId: string): boolean { + const metadata = mask.metadata || {}; + return metadata.propagated_from_frame_id !== undefined + && String(metadata.propagated_from_frame_id) === String(sourceFrameId); +} + +function propagationFallbackCompatible(selectedMask: Mask, candidate: Mask): boolean { + if (!isPropagationMask(candidate) || maskSemanticKey(candidate) !== maskSemanticKey(selectedMask)) return false; + const selectedMetadata = selectedMask.metadata || {}; + const candidateMetadata = candidate.metadata || {}; + if (!isPropagationMask(selectedMask)) { + return propagatedFromFrame(candidate, String(selectedMask.frameId)); + } + const selectedOriginFrameId = selectedMetadata.propagated_from_frame_id; + const candidateOriginFrameId = candidateMetadata.propagated_from_frame_id; + if ( + selectedOriginFrameId !== undefined + && candidateOriginFrameId !== undefined + && String(selectedOriginFrameId) !== String(candidateOriginFrameId) + ) { + return false; + } + const selectedOriginFrameIndex = selectedMetadata.propagated_from_frame_index; + const candidateOriginFrameIndex = candidateMetadata.propagated_from_frame_index; + if ( + selectedOriginFrameIndex !== undefined + && candidateOriginFrameIndex !== undefined + && String(selectedOriginFrameIndex) !== String(candidateOriginFrameIndex) + ) { + return false; + } + const selectedSource = typeof selectedMetadata.source === 'string' ? selectedMetadata.source : ''; + const candidateSource = typeof candidateMetadata.source === 'string' ? candidateMetadata.source : ''; + if (selectedSource && candidateSource && selectedSource !== candidateSource) return false; + const selectedDirection = selectedMetadata.propagation_direction; + const candidateDirection = candidateMetadata.propagation_direction; + if ( + selectedDirection !== undefined + && candidateDirection !== undefined + && String(selectedDirection) !== String(candidateDirection) + ) { + return false; + } + return true; +} + +function findLinkedMasksOnFrame( + selectedIds: string[], + allMasks: Mask[], + targetFrameId?: string, + options: { strictInstanceMatch?: boolean } = {}, +): string[] { + if (!targetFrameId || selectedIds.length === 0) return []; + const selectedMasks = selectedIds + .map((id) => allMasks.find((mask) => mask.id === id)) + .filter((mask): mask is Mask => Boolean(mask)); + if (selectedMasks.length === 0) return []; + + if (!options.strictInstanceMatch) { + const selectedTokens = new Set(); + const selectedHasPropagation = selectedMasks.some(isPropagationMask); + selectedMasks.forEach((mask) => { + propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token)); + }); + + const linkedIds = allMasks + .filter((mask) => String(mask.frameId) === String(targetFrameId)) + .filter((mask) => { + const candidateHasPropagation = isPropagationMask(mask); + if (!selectedHasPropagation && !candidateHasPropagation) return false; + const candidateTokens = propagationLineageTokens(mask); + return [...candidateTokens].some((token) => selectedTokens.has(token)); + }) + .map((mask) => mask.id); + + const linkedIdSet = new Set(linkedIds); + selectedMasks.forEach((selectedMask) => { + if (isPropagationMask(selectedMask)) return; + const selectedCenter = maskBboxCenter(selectedMask); + const selectedSemanticKey = maskSemanticKey(selectedMask); + const candidates = allMasks + .filter((mask) => String(mask.frameId) === String(targetFrameId)) + .filter((mask) => !linkedIdSet.has(mask.id)) + .filter((mask) => isPropagationMask(mask)) + .filter((mask) => propagatedFromFrame(mask, String(selectedMask.frameId))) + .filter((mask) => maskSemanticKey(mask) === selectedSemanticKey); + if (candidates.length === 0) return; + const best = candidates.reduce<{ mask: Mask; distance: number } | null>((currentBest, candidate) => { + const candidateCenter = maskBboxCenter(candidate); + const distance = selectedCenter && candidateCenter ? pointDistance(selectedCenter, candidateCenter) : 0; + if (!currentBest || distance < currentBest.distance) return { mask: candidate, distance }; + return currentBest; + }, null); + if (best) { + linkedIds.push(best.mask.id); + linkedIdSet.add(best.mask.id); + } + }); + + return linkedIds; + } + + const targetMasks = allMasks.filter((mask) => String(mask.frameId) === String(targetFrameId)); + const linkedIds: string[] = []; + const linkedIdSet = new Set(); + + selectedMasks.forEach((selectedMask) => { + const selectedTokens = reliablePropagationLineageTokens(selectedMask); + const exactMatches = targetMasks.filter((mask) => { + if (!isPropagationMask(selectedMask) && !isPropagationMask(mask)) return false; + const candidateTokens = reliablePropagationLineageTokens(mask); + return [...candidateTokens].some((token) => selectedTokens.has(token)); + }); + exactMatches.forEach((mask) => { + if (!linkedIdSet.has(mask.id)) { + linkedIds.push(mask.id); + linkedIdSet.add(mask.id); + } + }); + if (exactMatches.length > 0) return; + + const selectedCenter = maskBboxCenter(selectedMask); + const candidates = targetMasks + .filter((mask) => !linkedIdSet.has(mask.id)) + .filter((mask) => propagationFallbackCompatible(selectedMask, mask)); + if (candidates.length === 0) return; + const best = candidates.reduce<{ mask: Mask; distance: number } | null>((currentBest, candidate) => { + const candidateCenter = maskBboxCenter(candidate); + const distance = selectedCenter && candidateCenter ? pointDistance(selectedCenter, candidateCenter) : 0; + if (!currentBest || distance < currentBest.distance) return { mask: candidate, distance }; + return currentBest; + }, null); + if (best) { + linkedIds.push(best.mask.id); + linkedIdSet.add(best.mask.id); + } + }); + + return linkedIds; +} + +function findPropagationChainMaskIds(selectedIds: string[], allMasks: Mask[]): Set { + const selectedMasks = selectedIds + .map((id) => allMasks.find((mask) => mask.id === id)) + .filter((mask): mask is Mask => Boolean(mask)); + const selectedTokens = new Set(); + selectedMasks.forEach((mask) => { + propagationLineageTokens(mask).forEach((token) => selectedTokens.add(token)); + }); + if (selectedTokens.size === 0) return new Set(selectedIds); + + return new Set( + allMasks + .filter((mask) => { + const candidateTokens = propagationLineageTokens(mask); + return [...candidateTokens].some((token) => selectedTokens.has(token)); + }) + .map((mask) => mask.id), + ); +} + +function expandedPropagationDeletionMaskIds(selectedIds: string[], allMasks: Mask[]): Set { + const selectedIdSet = new Set(selectedIds); + const chainIds = findPropagationChainMaskIds(selectedIds, allMasks); + return new Set( + allMasks + .filter((mask) => selectedIdSet.has(mask.id) || (chainIds.has(mask.id) && isPropagationMask(mask))) + .map((mask) => mask.id), + ); +} + +function maskLayerPriority(mask: Mask): number { + const parsed = Number(mask.classZIndex ?? mask.metadata?.classZIndex ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +} + +function polygonPath(points: CanvasPoint[]): string { + if (points.length === 0) return ''; + return points + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) + .join(' ') + .concat(' Z'); +} + +function segmentationPath(segmentation?: number[][]): string { + return (segmentation || []) + .map((polygon) => polygonPath(flatPolygonToPoints(polygon))) + .filter(Boolean) + .join(' '); +} + +function segmentationPolygonPath(segmentation: number[][] | undefined, polygonIndex: number): string { + const polygon = segmentation?.[polygonIndex]; + return polygon ? polygonPath(flatPolygonToPoints(polygon)) : ''; +} + +function polygonSegmentation(points: CanvasPoint[]): number[][] { + return [points.flatMap((point) => [point.x, point.y])]; +} + +function segmentationToPoints(segmentation?: number[][], polygonIndex = 0): CanvasPoint[] { + const polygon = segmentation?.[polygonIndex] || []; + const points: CanvasPoint[] = []; + for (let index = 0; index < polygon.length - 1; index += 2) { + points.push({ x: polygon[index], y: polygon[index + 1] }); + } + return points; +} + +function flatPolygonToPoints(polygon: number[]): CanvasPoint[] { + const points: CanvasPoint[] = []; + for (let index = 0; index < polygon.length - 1; index += 2) { + points.push({ x: polygon[index], y: polygon[index + 1] }); + } + return points; +} + +function segmentationAllPoints(segmentation?: number[][]): CanvasPoint[] { + return (segmentation || []).flatMap((polygon) => flatPolygonToPoints(polygon)); +} + +function polygonBbox(points: CanvasPoint[]): [number, number, number, number] { + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + const maxX = Math.max(...xs); + const maxY = Math.max(...ys); + return [minX, minY, maxX - minX, maxY - minY]; +} + +function polygonArea(points: CanvasPoint[]): number { + if (points.length < 3) return 0; + const sum = points.reduce((acc, point, index) => { + const next = points[(index + 1) % points.length]; + return acc + point.x * next.y - next.x * point.y; + }, 0); + return Math.abs(sum) / 2; +} + +function pointDistance(a: CanvasPoint, b: CanvasPoint): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +function extendStrokePoints( + current: CanvasPoint[], + nextPoint: CanvasPoint, + spacing: number, + maxPoints = MAX_PAINT_STROKE_POINTS, +): CanvasPoint[] { + const previous = current[current.length - 1]; + if (!previous) return [nextPoint]; + const distance = pointDistance(previous, nextPoint); + if (distance < spacing) return current; + const steps = Math.max(1, Math.floor(distance / spacing)); + const additions: CanvasPoint[] = []; + for (let step = 1; step <= steps; step += 1) { + if (current.length + additions.length >= maxPoints) break; + const ratio = step / steps; + additions.push({ + x: previous.x + (nextPoint.x - previous.x) * ratio, + y: previous.y + (nextPoint.y - previous.y) * ratio, + }); + } + return [...current, ...additions]; +} + +function distanceToSegmentSquared(point: CanvasPoint, start: CanvasPoint, end: CanvasPoint): number { + const dx = end.x - start.x; + const dy = end.y - start.y; + const lengthSquared = dx * dx + dy * dy; + if (lengthSquared === 0) { + return (point.x - start.x) ** 2 + (point.y - start.y) ** 2; + } + const t = clamp(((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared, 0, 1); + const projected = { x: start.x + t * dx, y: start.y + t * dy }; + return (point.x - projected.x) ** 2 + (point.y - projected.y) ** 2; +} + +function nearestPolygonEdgeIndex(points: CanvasPoint[], point: CanvasPoint): number { + return points.reduce((bestIndex, start, index) => { + const end = points[(index + 1) % points.length]; + if (!end) return bestIndex; + const bestStart = points[bestIndex]; + const bestEnd = points[(bestIndex + 1) % points.length]; + const currentDistance = distanceToSegmentSquared(point, start, end); + const bestDistance = bestStart && bestEnd + ? distanceToSegmentSquared(point, bestStart, bestEnd) + : Number.POSITIVE_INFINITY; + return currentDistance < bestDistance ? index : bestIndex; + }, 0); +} + +function segmentationArea(segmentation?: number[][]): number { + return (segmentation || []).reduce((sum, polygon) => sum + polygonArea(flatPolygonToPoints(polygon)), 0); +} + +function segmentationBbox(segmentation?: number[][]): [number, number, number, number] | undefined { + const points = segmentationAllPoints(segmentation); + return points.length > 0 ? polygonBbox(points) : undefined; +} + +function closeRing(points: CanvasPoint[]): Pair[] { + const ring = points.map((point) => [point.x, point.y] as Pair); + const first = ring[0]; + const last = ring[ring.length - 1]; + if (first && last && (first[0] !== last[0] || first[1] !== last[1])) { + ring.push([first[0], first[1]]); + } + return ring; +} + +function segmentationRings(segmentation?: number[][]): Pair[][] { + return (segmentation || []) + .map((polygon) => closeRing(flatPolygonToPoints(polygon))) + .filter((ring) => ring.length >= 4); +} + +function maskPolygonRingCounts(mask: Mask, ringCount: number): number[] | null { + const rawCounts = mask.metadata?.polygonRingCounts; + if (!Array.isArray(rawCounts)) return null; + const counts = rawCounts + .map((count) => Number(count)) + .filter((count) => Number.isInteger(count) && count > 0); + const total = counts.reduce((sum, count) => sum + count, 0); + return total === ringCount ? counts : null; +} + +function maskToMultiPolygon(mask: Mask): MultiPolygon | null { + const rings = segmentationRings(mask.segmentation); + if (rings.length === 0) return null; + const counts = maskPolygonRingCounts(mask, rings.length); + if (counts) { + let offset = 0; + return counts.map((count) => { + const polygon = rings.slice(offset, offset + count); + offset += count; + return polygon; + }).filter((polygon) => polygon.length > 0); + } + if (mask.metadata?.hasHoles && rings.length > 1) { + return [rings]; + } + return rings.map((ring) => [ring]); +} + +function polygonsToMultiPolygon(polygons: CanvasPoint[][]): MultiPolygon | null { + const geometry = polygons + .filter((points) => points.length >= 3) + .map((points) => [closeRing(points)]); + return geometry.length > 0 ? geometry : null; +} + +function openRingPoints(ring: Pair[]): CanvasPoint[] { + const openRing = ring.length > 1 + && ring[0][0] === ring[ring.length - 1][0] + && ring[0][1] === ring[ring.length - 1][1] + ? ring.slice(0, -1) + : ring; + return openRing.map(([x, y]) => ({ x, y })); +} + +function multiPolygonToSegmentation(geometry: MultiPolygon): number[][] { + return geometry + .flatMap((polygon) => polygon) + .map((ring) => openRingPoints(ring).flatMap(({ x, y }) => [x, y])) + .filter((polygon) => polygon.length >= 6); +} + +function multiPolygonRingCounts(geometry: MultiPolygon): number[] { + return geometry + .map((polygon) => polygon.length) + .filter((count) => count > 0); +} + +function multiPolygonArea(geometry: MultiPolygon): number { + return geometry.reduce((sum, polygon) => { + const [outerRing, ...holeRings] = polygon; + const outerArea = outerRing ? polygonArea(openRingPoints(outerRing)) : 0; + const holesArea = holeRings.reduce((holeSum, ring) => holeSum + polygonArea(openRingPoints(ring)), 0); + return sum + Math.max(outerArea - holesArea, 0); + }, 0); +} + +function multiPolygonHasHoles(geometry: MultiPolygon): boolean { + return geometry.some((polygon) => polygon.length > 1); +} + +function maskWithSegmentation( + mask: Mask, + segmentation: number[][], + options: { area?: number; hasHoles?: boolean; polygonRingCounts?: number[] } = {}, +): Mask { + const bbox = segmentationBbox(segmentation); + const metadata = { ...(mask.metadata || {}) }; + if (options.hasHoles === true) metadata.hasHoles = true; + if (options.hasHoles === false) delete metadata.hasHoles; + if (options.polygonRingCounts && options.polygonRingCounts.length > 0) metadata.polygonRingCounts = options.polygonRingCounts; + if (options.hasHoles === false) delete metadata.polygonRingCounts; + return { + ...mask, + pathData: segmentationPath(segmentation), + segmentation, + bbox, + area: options.area ?? segmentationArea(segmentation), + metadata, + saveStatus: mask.annotationId ? 'dirty' : 'draft', + saved: mask.annotationId ? false : mask.saved, + }; +} + +function rectanglePoints(start: CanvasPoint, end: CanvasPoint): CanvasPoint[] { + const x1 = Math.min(start.x, end.x); + const y1 = Math.min(start.y, end.y); + const x2 = Math.max(start.x, end.x); + const y2 = Math.max(start.y, end.y); + return [ + { x: x1, y: y1 }, + { x: x2, y: y1 }, + { x: x2, y: y2 }, + { x: x1, y: y2 }, + ]; +} + +function circlePoints(start: CanvasPoint, end: CanvasPoint): CanvasPoint[] { + const cx = (start.x + end.x) / 2; + const cy = (start.y + end.y) / 2; + const rx = Math.abs(end.x - start.x) / 2; + const ry = Math.abs(end.y - start.y) / 2; + return Array.from({ length: 32 }, (_, index) => { + const angle = (Math.PI * 2 * index) / 32; + return { x: cx + Math.cos(angle) * rx, y: cy + Math.sin(angle) * ry }; + }); +} + +function circleStampPoints(center: CanvasPoint, radius: number, segments = PAINT_STAMP_SEGMENTS): CanvasPoint[] { + return Array.from({ length: segments }, (_, index) => { + const angle = (Math.PI * 2 * index) / segments; + return { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius }; + }); +} + +function paintStrokeToGeometry(strokePoints: CanvasPoint[], radius: number): MultiPolygon | null { + const geometries = strokePoints + .map((point) => polygonsToMultiPolygon([circleStampPoints(point, radius)])) + .filter((geometry): geometry is MultiPolygon => Boolean(geometry)); + if (geometries.length === 0) return null; + const [firstGeometry, ...restGeometries] = geometries; + return restGeometries.length === 0 + ? firstGeometry + : polygonClipping.union(firstGeometry, ...restGeometries); +} + +function imageBoundsGeometry(width: number, height: number): MultiPolygon | null { + if (width <= 0 || height <= 0) return null; + return polygonsToMultiPolygon([[ + { x: 0, y: 0 }, + { x: width, y: 0 }, + { x: width, y: height }, + { x: 0, y: height }, + ]]); +} + +export function CanvasArea({ + activeTool, + frame, + currentFrameNumber, + totalFrames, + clearSelectionSignal, + onRequestDeleteMasks, + onRequestBooleanFrameRange, + onBooleanOperationStart, + onDeleteMaskAnnotations, +}: CanvasAreaProps) { + const containerRef = useRef(null); + const [stageSize, setStageSize] = useState({ width: 800, height: 600 }); + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [points, setPoints] = useState([]); + const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }); + const [boxStart, setBoxStart] = useState<{ x: number, y: number } | null>(null); + const [boxCurrent, setBoxCurrent] = useState<{ x: number, y: number } | null>(null); + const [samPromptBox, setSamPromptBox] = useState(null); + const [samCandidateMaskId, setSamCandidateMaskId] = useState(null); + const [manualStart, setManualStart] = useState(null); + const [manualCurrent, setManualCurrent] = useState(null); + const [paintStrokePoints, setPaintStrokePointsState] = useState([]); + const [polygonPoints, setPolygonPoints] = useState([]); + const [selectedMaskId, setSelectedMaskId] = useState(() => useStore.getState().selectedMaskIds[0] || null); + const [selectedMaskIds, setSelectedMaskIds] = useState(() => useStore.getState().selectedMaskIds); + const [selectedPolygonIndex, setSelectedPolygonIndex] = useState(0); + const [selectedVertexIndex, setSelectedVertexIndex] = useState(null); + const [pendingBooleanFrameIds, setPendingBooleanFrameIds] = useState(null); + const previousFrameIdRef = useRef(frame?.id); + const [isInferencing, setIsInferencing] = useState(false); + const [inferenceMessage, setInferenceMessage] = useState(''); + const [isToolHintVisible, setIsToolHintVisible] = useState(false); + const lastAutoFitKeyRef = useRef(''); + const paintStrokeRef = useRef([]); + const paintToolRef = useRef(null); + const lastPaintPointRef = useRef(null); + const lastClearSelectionSignalRef = useRef(clearSelectionSignal); + const clearSelectionInProgressRef = useRef(false); + + const masks = useStore((state) => state.masks); + const addMask = useStore((state) => state.addMask); + const updateMask = useStore((state) => state.updateMask); + const setMasks = useStore((state) => state.setMasks); + const setGlobalSelectedMaskIds = useStore((state) => state.setSelectedMaskIds); + const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity); + const brushSize = useStore((state) => state.brushSize); + const eraserSize = useStore((state) => state.eraserSize); + const storeActiveTool = useStore((state) => state.activeTool); + const aiModel = useStore((state) => state.aiModel); + const activeTemplateId = useStore((state) => state.activeTemplateId); + const activeClass = useStore((state) => state.activeClass); + + const effectiveTool = activeTool || storeActiveTool; + + // Load the actual frame image + const [image] = useImage(frame?.url || ''); + const frameMasks = masks.filter((mask) => mask.frameId === frame?.id); + const displayFrameMasks = React.useMemo(() => { + if (selectedMaskIds.length > 0) return frameMasks; + return frameMasks + .map((mask, index) => ({ mask, index })) + .sort((a, b) => { + const priorityDiff = maskLayerPriority(a.mask) - maskLayerPriority(b.mask); + return priorityDiff === 0 ? a.index - b.index : priorityDiff; + }) + .map((item) => item.mask); + }, [frameMasks, selectedMaskIds.length]); + const selectedMask = React.useMemo( + () => frameMasks.find((mask) => mask.id === selectedMaskId) || null, + [frameMasks, selectedMaskId], + ); + const booleanSelectedMasks = React.useMemo( + () => selectedMaskIds + .map((id) => frameMasks.find((mask) => mask.id === id)) + .filter((mask): mask is Mask => Boolean(mask)), + [frameMasks, selectedMaskIds], + ); + const selectedMaskPoints = React.useMemo( + () => segmentationToPoints(selectedMask?.segmentation, selectedPolygonIndex), + [selectedMask?.segmentation, selectedPolygonIndex], + ); + const savedMaskCount = frameMasks.filter((mask) => mask.saveStatus === 'saved' || mask.saved).length; + const draftMaskCount = frameMasks.filter((mask) => !mask.annotationId).length; + const dirtyMaskCount = frameMasks.filter((mask) => mask.saveStatus === 'dirty').length; + const isBooleanTool = BOOLEAN_TOOLS.has(effectiveTool); + const isPaintTool = PAINT_TOOLS.has(effectiveTool); + const isPolygonEditTool = effectiveTool === 'move' || effectiveTool === EDIT_POLYGON_TOOL; + const isManualCreateTool = effectiveTool === POLYGON_TOOL || DRAG_MANUAL_TOOLS.has(effectiveTool); + const canKeepMaskSelection = isPolygonEditTool || isBooleanTool || isPaintTool || isManualCreateTool; + const showSelectedMaskVertices = Boolean(selectedMask && (isPolygonEditTool || isPaintTool || isManualCreateTool)); + const activePaintSize = effectiveTool === ERASER_TOOL ? eraserSize : brushSize; + const activePaintRadius = Math.max(2, activePaintSize / 2); + const setPaintStrokePoints = useCallback((nextPoints: CanvasPoint[]) => { + paintStrokeRef.current = nextPoints; + setPaintStrokePointsState(nextPoints); + }, []); + const clearSelectionState = useCallback(() => { + clearSelectionInProgressRef.current = true; + setPolygonPoints([]); + setManualStart(null); + setManualCurrent(null); + setPaintStrokePoints([]); + paintToolRef.current = null; + lastPaintPointRef.current = null; + setSelectedMaskId(null); + setSelectedMaskIds([]); + setGlobalSelectedMaskIds([]); + setSelectedPolygonIndex(0); + setSelectedVertexIndex(null); + }, [setGlobalSelectedMaskIds, setPaintStrokePoints]); + const currentLayerLabel = selectedMask + ? `${selectedMask.className || selectedMask.label}${selectedMask.annotationId ? ` #${selectedMask.annotationId}` : ' (未保存)'}` + : '未选择'; + const toolHint = React.useMemo(() => { + if (!frame) return null; + if (effectiveTool === POLYGON_TOOL) { + if (polygonPoints.length === 0) { + return { + title: '创建多边形', + body: '点击画布添加顶点;至少 3 个点后,点击首节点或按 Enter 完成,按 Esc 取消。', + }; + } + if (polygonPoints.length < 3) { + return { + title: `创建多边形 · 已放置 ${polygonPoints.length} 点`, + body: '继续点击添加顶点;满 3 个点后才能闭合,按 Esc 可取消当前多边形。', + }; + } + return { + title: `创建多边形 · 已放置 ${polygonPoints.length} 点`, + body: '点击黄色首节点或按 Enter 闭合完成;按 Esc 放弃当前多边形。', + }; + } + if (effectiveTool === 'create_rectangle') { + return { title: '创建矩形', body: '按住并拖拽框出区域,松开鼠标后生成 mask;切换工具可放弃当前操作。' }; + } + if (effectiveTool === 'create_circle') { + return { title: '创建圆形', body: '按住并拖拽确定外接范围,松开鼠标后生成椭圆 mask。' }; + } + if (effectiveTool === BRUSH_TOOL) { + return { + title: '画笔', + body: activeClass + ? '按住并拖动画出连续区域;已有选中 mask 时会并入选中区域,未选中时生成新 mask。' + : selectedMask + ? '按住并拖动画出连续区域,松开后并入当前选中 mask。' + : '先在右侧语义分类树选择类别,然后按住并拖动画出连续区域。', + }; + } + if (effectiveTool === ERASER_TOOL) { + return { + title: '橡皮擦', + body: selectedMask + ? '按住并拖动,从当前选中 mask 中扣除经过的区域。' + : '先选择一个 mask,然后按住并拖动擦除区域。', + }; + } + if (effectiveTool === 'box_select') { + return { + title: samPromptBox ? '边界框已建立' : '边界框选', + body: samPromptBox + ? '继续添加正向/反向点可细化同一个候选区域;重新拖拽会替换当前框。' + : '按住并拖拽建立框选区域,松开后会触发 SAM 推理。', + }; + } + if (effectiveTool === 'point_pos') { + return { title: '正向选点', body: '点击目标内部添加正向点并触发细化;点击已有提示点可删除并重新推理。' }; + } + if (effectiveTool === 'point_neg') { + return { title: '反向选点', body: '点击不应包含的区域添加反向点;点击已有提示点可删除并重新推理。' }; + } + if (effectiveTool === 'area_merge') { + return { + title: '区域合并', + body: booleanSelectedMasks.length > 0 + ? `已选 ${booleanSelectedMasks.length} 个区域;第一个选中的是主区域,点击“合并选中”完成。` + : '依次点击多个 mask;第一个选中的区域会作为合并后的主区域。', + }; + } + if (effectiveTool === 'area_remove') { + return { + title: '重叠区域去除', + body: booleanSelectedMasks.length > 0 + ? `已选 ${booleanSelectedMasks.length} 个区域;第一个是保留主区域,后续区域会被扣除。` + : '先点击要保留的主区域,再点击要扣除的干涉区域。', + }; + } + if (effectiveTool === EDIT_POLYGON_TOOL || (effectiveTool === 'move' && selectedMask)) { + return { + title: selectedMask ? '调整多边形' : '调整多边形', + body: selectedMask + ? '可直接拖动白色顶点;点击青色边中点或双击边线新增顶点;选中顶点/区域后按 Delete 删除。' + : '点击一个 mask 后,可拖动顶点、点击边中点新增顶点,或按 Delete 删除选中区域。', + }; + } + return null; + }, [activeClass, booleanSelectedMasks.length, effectiveTool, frame, polygonPoints.length, samPromptBox, selectedMask]); + + useEffect(() => { + if (!toolHint) { + setIsToolHintVisible(false); + return; + } + setIsToolHintVisible(true); + const timer = window.setTimeout(() => { + setIsToolHintVisible(false); + }, TOOL_HINT_TTL_MS); + return () => window.clearTimeout(timer); + }, [toolHint?.body, toolHint?.title]); + + useEffect(() => { + const handleResize = () => { + if (containerRef.current) { + setStageSize({ + width: containerRef.current.clientWidth, + height: containerRef.current.clientHeight, + }); + } + }; + + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + useEffect(() => { + if (!frame?.id || stageSize.width <= 0 || stageSize.height <= 0) return; + const imageWidth = frame.width || image?.naturalWidth || image?.width || 0; + const imageHeight = frame.height || image?.naturalHeight || image?.height || 0; + if (imageWidth <= 0 || imageHeight <= 0) return; + + const fitKey = `${frame.id}:${stageSize.width}x${stageSize.height}:${imageWidth}x${imageHeight}`; + if (lastAutoFitKeyRef.current === fitKey) return; + lastAutoFitKeyRef.current = fitKey; + + const nextScale = Math.max( + 0.05, + Math.min(stageSize.width / imageWidth, stageSize.height / imageHeight) * DEFAULT_IMAGE_FIT_RATIO, + ); + setScale(nextScale); + setPosition({ + x: (stageSize.width - imageWidth * nextScale) / 2, + y: (stageSize.height - imageHeight * nextScale) / 2, + }); + }, [frame?.height, frame?.id, frame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, stageSize.height, stageSize.width]); + + useEffect(() => { + if (clearSelectionSignal === undefined) return; + if (lastClearSelectionSignalRef.current === clearSelectionSignal) return; + lastClearSelectionSignalRef.current = clearSelectionSignal; + clearSelectionState(); + }, [clearSelectionSignal, clearSelectionState]); + + useEffect(() => { + setManualStart(null); + setManualCurrent(null); + setPaintStrokePoints([]); + paintToolRef.current = null; + lastPaintPointRef.current = null; + setPolygonPoints([]); + setSelectedVertexIndex(null); + if (!canKeepMaskSelection) { + setSelectedMaskId(null); + setSelectedMaskIds([]); + setGlobalSelectedMaskIds([]); + setSelectedPolygonIndex(0); + } + if (!isBooleanTool) setPendingBooleanFrameIds(null); + }, [canKeepMaskSelection, effectiveTool, isBooleanTool, setGlobalSelectedMaskIds, setPaintStrokePoints]); + + useEffect(() => { + if (previousFrameIdRef.current === frame?.id) return; + previousFrameIdRef.current = frame?.id; + const linkedMaskIds = findLinkedMasksOnFrame(useStore.getState().selectedMaskIds, masks, frame?.id); + if (linkedMaskIds.length > 0) { + setSelectedMaskId(linkedMaskIds[0]); + setSelectedMaskIds(linkedMaskIds); + setGlobalSelectedMaskIds(linkedMaskIds); + } else { + setSelectedMaskId(null); + setSelectedMaskIds([]); + setGlobalSelectedMaskIds([]); + } + setSelectedPolygonIndex(0); + setSelectedVertexIndex(null); + }, [frame?.id, masks, setGlobalSelectedMaskIds]); + + useEffect(() => { + setPoints([]); + setSamPromptBox(null); + setSamCandidateMaskId(null); + }, [frame?.id]); + + useEffect(() => { + const currentGlobalSelectedIds = useStore.getState().selectedMaskIds; + if (!canKeepMaskSelection) { + return; + } + const validLocalSelectedIds = selectedMaskIds.filter((id) => ( + frameMasks.some((mask) => mask.id === id) + )); + if (clearSelectionInProgressRef.current) { + if (selectedMaskIds.length === 0) { + clearSelectionInProgressRef.current = false; + return; + } + setSelectedMaskId(null); + setSelectedMaskIds([]); + setSelectedPolygonIndex(0); + setSelectedVertexIndex(null); + return; + } + if (selectedMaskIds.length > 0 && validLocalSelectedIds.length === 0) { + return; + } + if (selectedMaskIds.length === 0) { + const validGlobalSelectedIds = currentGlobalSelectedIds.filter((id) => ( + frameMasks.some((mask) => mask.id === id) + )); + if (validGlobalSelectedIds.length > 0) return; + } + const nextSelectedMaskIds = selectedMaskIds.length > 0 ? validLocalSelectedIds : selectedMaskIds; + const isSameSelection = currentGlobalSelectedIds.length === nextSelectedMaskIds.length + && currentGlobalSelectedIds.every((id, index) => id === nextSelectedMaskIds[index]); + if (!isSameSelection) { + setGlobalSelectedMaskIds(nextSelectedMaskIds); + } + }, [canKeepMaskSelection, frameMasks, selectedMaskIds, setGlobalSelectedMaskIds]); + + useEffect(() => () => setGlobalSelectedMaskIds([]), [setGlobalSelectedMaskIds]); + + useEffect(() => { + if (!canKeepMaskSelection) { + return; + } + if (!selectedMaskId) { + const validGlobalSelectedIds = useStore.getState().selectedMaskIds.filter((id) => ( + frameMasks.some((mask) => mask.id === id) + )); + if (validGlobalSelectedIds.length > 0) { + setSelectedMaskId(validGlobalSelectedIds[0]); + setSelectedMaskIds(validGlobalSelectedIds); + setSelectedPolygonIndex(0); + setSelectedVertexIndex(null); + return; + } + } + if (selectedMaskId && !frameMasks.some((mask) => mask.id === selectedMaskId)) { + const linkedMaskIds = findLinkedMasksOnFrame([selectedMaskId, ...selectedMaskIds], masks, frame?.id); + if (linkedMaskIds.length > 0) { + setSelectedMaskId(linkedMaskIds[0]); + setSelectedMaskIds(linkedMaskIds); + setGlobalSelectedMaskIds(linkedMaskIds); + } else { + setSelectedMaskId(null); + setSelectedMaskIds([]); + setGlobalSelectedMaskIds([]); + } + setSelectedPolygonIndex(0); + setSelectedVertexIndex(null); + } + }, [canKeepMaskSelection, frame?.id, frameMasks, masks, selectedMaskId, selectedMaskIds, setGlobalSelectedMaskIds]); + + const handleWheel = (e: any) => { + e.evt.preventDefault(); + const scaleBy = 1.1; + const stage = e.target.getStage(); + const oldScale = stage.scaleX(); + + const mousePointTo = { + x: stage.getPointerPosition().x / oldScale - stage.x() / oldScale, + y: stage.getPointerPosition().y / oldScale - stage.y() / oldScale, + }; + + const newScale = e.evt.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy; + setScale(newScale); + setPosition({ + x: -(mousePointTo.x - stage.getPointerPosition().x / newScale) * newScale, + y: -(mousePointTo.y - stage.getPointerPosition().y / newScale) * newScale, + }); + }; + + const handleStageDragEnd = (e: any) => { + const stage = e.target?.getStage?.(); + if (!stage || e.target !== stage) return; + setPosition({ + x: stage.x(), + y: stage.y(), + }); + }; + + const stagePoint = (e: any, options: { clampToImage?: boolean } = {}): CanvasPoint | null => { + const stage = e.target.getStage(); + const relPos = stage?.getRelativePointerPosition(); + if (!relPos) return null; + const imageWidth = frame?.width || image?.naturalWidth || image?.width || stageSize.width; + const imageHeight = frame?.height || image?.naturalHeight || image?.height || stageSize.height; + const shouldClamp = options.clampToImage ?? true; + if (!shouldClamp && (relPos.x < 0 || relPos.y < 0 || relPos.x > imageWidth || relPos.y > imageHeight)) { + return null; + } + return { + x: shouldClamp ? clamp(relPos.x, 0, imageWidth) : relPos.x, + y: shouldClamp ? clamp(relPos.y, 0, imageHeight) : relPos.y, + }; + }; + + const mergeGeometryIntoSelectedMask = useCallback((shape: string, geometry: MultiPolygon): Mask | null => { + if (!selectedMask) return null; + const currentSelectedMask = masks.find((mask) => mask.id === selectedMask.id) || selectedMask; + const targetGeometry = maskToMultiPolygon(currentSelectedMask); + if (!targetGeometry) return null; + const resultGeometry = polygonClipping.union(targetGeometry, geometry); + const resultSegmentation = multiPolygonToSegmentation(resultGeometry); + if (resultSegmentation.length === 0) return null; + const metadata = { + ...(currentSelectedMask.metadata || {}), + manualMergeShapes: [ + ...( + Array.isArray(currentSelectedMask.metadata?.manualMergeShapes) + ? currentSelectedMask.metadata.manualMergeShapes.filter((item): item is string => typeof item === 'string') + : [] + ), + shape, + ].slice(-12), + }; + const nextMask = maskWithSegmentation({ + ...currentSelectedMask, + metadata, + }, resultSegmentation, { + area: multiPolygonArea(resultGeometry), + hasHoles: multiPolygonHasHoles(resultGeometry), + polygonRingCounts: multiPolygonRingCounts(resultGeometry), + }); + setMasks(masks.map((mask) => (mask.id === currentSelectedMask.id ? nextMask : mask))); + setSelectedMaskId(nextMask.id); + setSelectedMaskIds([nextMask.id]); + setGlobalSelectedMaskIds([nextMask.id]); + setSelectedPolygonIndex(0); + setSelectedVertexIndex(null); + return nextMask; + }, [masks, selectedMask, setGlobalSelectedMaskIds, setMasks]); + + const createManualMask = useCallback((shape: string, polygon: CanvasPoint[]) => { + if (!frame?.id || polygon.length < 3) return; + const area = polygonArea(polygon); + if (area <= 1) return; + const geometry = polygonsToMultiPolygon([polygon]); + if (geometry && mergeGeometryIntoSelectedMask(shape, geometry)) return; + const templateClass = activeClass || RESERVED_UNCLASSIFIED_CLASS; + const mask: Mask = { + id: `manual-${frame.id}-${shape}-${Date.now()}`, + frameId: frame.id, + templateId: activeTemplateId || undefined, + classId: templateClass.id, + className: templateClass.name, + classZIndex: templateClass.zIndex, + classMaskId: templateClass.maskId, + saveStatus: 'draft', + saved: false, + pathData: polygonPath(polygon), + label: templateClass.name, + color: templateClass.color, + segmentation: polygonSegmentation(polygon), + bbox: polygonBbox(polygon), + area, + metadata: { source: 'manual', shape }, + }; + addMask(mask); + setSelectedMaskId(mask.id); + setSelectedMaskIds([mask.id]); + setGlobalSelectedMaskIds([mask.id]); + setSelectedPolygonIndex(0); + setSelectedVertexIndex(null); + }, [activeClass, activeTemplateId, addMask, frame?.id, mergeGeometryIntoSelectedMask, setGlobalSelectedMaskIds]); + + const createManualMaskFromGeometry = useCallback((shape: string, geometry: MultiPolygon): Mask | null => { + if (!frame?.id) return null; + const mergedMask = mergeGeometryIntoSelectedMask(shape, geometry); + if (mergedMask) return mergedMask; + if (!activeClass) return null; + const segmentation = multiPolygonToSegmentation(geometry); + const polygonRingCounts = multiPolygonRingCounts(geometry); + if (segmentation.length === 0) return null; + const area = multiPolygonArea(geometry); + if (area <= 1) return null; + const mask: Mask = { + id: `manual-${frame.id}-${shape}-${Date.now()}`, + frameId: frame.id, + templateId: activeTemplateId || undefined, + classId: activeClass.id, + className: activeClass.name, + classZIndex: activeClass.zIndex, + classMaskId: activeClass.maskId, + saveStatus: 'draft', + saved: false, + pathData: segmentationPath(segmentation), + label: activeClass.name, + color: activeClass.color, + segmentation, + bbox: segmentationBbox(segmentation), + area, + metadata: { + source: 'manual', + shape, + ...(multiPolygonHasHoles(geometry) ? { hasHoles: true } : {}), + ...(multiPolygonHasHoles(geometry) ? { polygonRingCounts } : {}), + }, + }; + addMask(mask); + return mask; + }, [activeClass, activeTemplateId, addMask, frame?.id, mergeGeometryIntoSelectedMask]); + + const finishPolygon = useCallback(() => { + if (polygonPoints.length < 3) return; + createManualMask('多边形', polygonPoints); + setPolygonPoints([]); + }, [createManualMask, polygonPoints]); + + const handleMouseMove = (e: any) => { + const stage = e.target.getStage(); + if (!stage) return; + const pos = stage.getPointerPosition(); + if (pos) { + const imageX = (pos.x - position.x) / scale; + const imageY = (pos.y - position.y) / scale; + setCursorPos({ x: imageX, y: imageY }); + } + + if (boxStart && effectiveTool === 'box_select') { + const relPos = stage.getRelativePointerPosition(); + if (relPos) { + setBoxCurrent({ x: relPos.x, y: relPos.y }); + } + } + + if (manualStart && DRAG_MANUAL_TOOLS.has(effectiveTool)) { + const pos = stage.getRelativePointerPosition(); + if (pos) { + setManualCurrent({ x: pos.x, y: pos.y }); + } + } + + if (paintToolRef.current && PAINT_TOOLS.has(effectiveTool)) { + const pos = stagePoint(e, { clampToImage: false }); + const currentStroke = paintStrokeRef.current; + if (!pos) { + lastPaintPointRef.current = null; + return; + } + const previous = lastPaintPointRef.current; + const radius = Math.max(2, (paintToolRef.current === ERASER_TOOL ? eraserSize : brushSize) / 2); + const minDistance = Math.max(3, radius * 0.55); + if (!previous) { + if (currentStroke.length >= MAX_PAINT_STROKE_POINTS) return; + const nextStroke = [...currentStroke, pos].slice(0, MAX_PAINT_STROKE_POINTS); + lastPaintPointRef.current = pos; + setPaintStrokePoints(nextStroke); + return; + } + if (pointDistance(previous, pos) < minDistance) return; + if (currentStroke.length >= MAX_PAINT_STROKE_POINTS) return; + const nextStroke = extendStrokePoints(currentStroke, pos, minDistance); + lastPaintPointRef.current = nextStroke[nextStroke.length - 1] || pos; + setPaintStrokePoints(nextStroke); + } + }; + + const runInference = useCallback(async ( + promptPoints?: PromptPoint[], + promptBox?: PromptBox, + options: { resetCandidate?: boolean } = {}, + ) => { + if (!frame?.id) { + console.warn('Inference skipped: no active frame'); + setInferenceMessage('请先选择一帧图像。'); + return; + } + const imageWidth = frame.width || image?.naturalWidth || image?.width || 0; + const imageHeight = frame.height || image?.naturalHeight || image?.height || 0; + if (imageWidth <= 0 || imageHeight <= 0) { + console.warn('Inference skipped: active frame dimensions are unavailable'); + setInferenceMessage('当前帧缺少宽高信息,无法推理。'); + return; + } + + setIsInferencing(true); + setInferenceMessage(''); + try { + const hasNegativePrompt = Boolean(promptPoints?.some((point) => point.type === 'neg')); + const existingCandidate = !options.resetCandidate && samCandidateMaskId + ? masks.find((mask) => mask.id === samCandidateMaskId) + : null; + const result = await predictMask({ + imageId: frame.id, + imageWidth, + imageHeight, + model: aiModel, + points: promptPoints && promptPoints.length > 0 + ? promptPoints.map((p) => ({ x: p.x, y: p.y, type: p.type })) + : undefined, + box: promptBox, + ...(hasNegativePrompt ? { options: { auto_filter_background: true, min_score: 0.05 } } : {}), + }); + + const [m] = result.masks; + if (m) { + const label = activeClass?.name || existingCandidate?.label || m.label; + const color = activeClass?.color || existingCandidate?.color || m.color; + const metadata = { + ...(existingCandidate?.metadata || {}), + source: 'sam2_interactive', + promptBox: promptBox || null, + promptPointCount: promptPoints?.length || 0, + promptNegativePointCount: promptPoints?.filter((point) => point.type === 'neg').length || 0, + }; + const nextMask = { + frameId: frame.id, + templateId: activeTemplateId || existingCandidate?.templateId || undefined, + classId: activeClass?.id || existingCandidate?.classId, + className: activeClass?.name || existingCandidate?.className, + classZIndex: activeClass?.zIndex ?? existingCandidate?.classZIndex, + classMaskId: activeClass?.maskId ?? existingCandidate?.classMaskId, + saveStatus: existingCandidate?.annotationId ? 'dirty' as const : 'draft' as const, + saved: false, + pathData: m.pathData, + label, + color, + segmentation: m.segmentation, + points: promptPoints?.filter((p) => p.type === 'pos').map((p) => [p.x, p.y]), + bbox: m.bbox, + area: m.area, + metadata, + }; + if (existingCandidate) { + updateMask(existingCandidate.id, nextMask); + setSelectedMaskId(existingCandidate.id); + setSelectedMaskIds([existingCandidate.id]); + } else { + const id = m.id; + setSamCandidateMaskId(id); + setSelectedMaskId(id); + setSelectedMaskIds([id]); + addMask({ + id, + ...nextMask, + }); + } + } else { + if (existingCandidate && hasNegativePrompt) { + setMasks(masks.filter((mask) => mask.id !== existingCandidate.id)); + setSamCandidateMaskId(null); + setSelectedMaskId(null); + setSelectedMaskIds([]); + setInferenceMessage('反向点已排除当前候选区域,请重新框选或添加新的正向点。'); + } else { + setInferenceMessage('模型没有返回可用区域,请调整点/框提示后重试。'); + } + } + } catch (err) { + console.error('Inference failed:', err); + const detail = (err as any)?.response?.data?.detail; + setInferenceMessage(detail || 'AI 推理失败,请查看模型状态或后端日志。'); + } finally { + setIsInferencing(false); + } + }, [activeClass, activeTemplateId, addMask, aiModel, frame?.height, frame?.id, frame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, masks, samCandidateMaskId, setMasks, updateMask]); + + const deleteMasksById = useCallback((maskIds: string[]) => { + if (maskIds.length === 0) return; + const idSet = expandedPropagationDeletionMaskIds(maskIds, masks); + const deletingMasks = masks.filter((mask) => idSet.has(mask.id)); + if (deletingMasks.length === 0) return; + setMasks(masks.filter((mask) => !idSet.has(mask.id))); + const annotationIds = deletingMasks + .map((mask) => mask.annotationId) + .filter((annotationId): annotationId is string => Boolean(annotationId)); + if (annotationIds.length > 0) { + void onDeleteMaskAnnotations?.(annotationIds); + } + if (samCandidateMaskId && idSet.has(samCandidateMaskId)) { + setSamCandidateMaskId(null); + setSamPromptBox(null); + setPoints([]); + } + setSelectedMaskId(null); + setSelectedMaskIds([]); + setGlobalSelectedMaskIds([]); + setSelectedPolygonIndex(0); + setSelectedVertexIndex(null); + }, [masks, onDeleteMaskAnnotations, samCandidateMaskId, setGlobalSelectedMaskIds, setMasks]); + + const applyPaintStroke = useCallback((tool: string | null, strokePoints: CanvasPoint[]) => { + if (!frame?.id || strokePoints.length === 0) return; + const radius = Math.max(2, (tool === ERASER_TOOL ? eraserSize : brushSize) / 2); + const rawStrokeGeometry = paintStrokeToGeometry(strokePoints, radius); + if (!rawStrokeGeometry) return; + const imageWidth = frame.width || image?.naturalWidth || image?.width || stageSize.width; + const imageHeight = frame.height || image?.naturalHeight || image?.height || stageSize.height; + const imageBounds = imageBoundsGeometry(imageWidth, imageHeight); + const strokeGeometry = imageBounds + ? polygonClipping.intersection(rawStrokeGeometry, imageBounds) + : rawStrokeGeometry; + if (!strokeGeometry || strokeGeometry.length === 0) return; + + if (tool === BRUSH_TOOL) { + if (!activeClass && !selectedMask) { + setInferenceMessage('请先在右侧语义分类树选择类别,再使用画笔。'); + return; + } + + const nextMask = createManualMaskFromGeometry('画笔', strokeGeometry); + if (nextMask) { + setSelectedMaskId(nextMask.id); + setSelectedMaskIds([nextMask.id]); + setGlobalSelectedMaskIds([nextMask.id]); + setSelectedPolygonIndex(0); + setSelectedVertexIndex(null); + } + return; + } + + if (tool === ERASER_TOOL) { + if (!selectedMask) { + setInferenceMessage('请先选择一个 mask,再使用橡皮擦。'); + return; + } + const targetGeometry = maskToMultiPolygon(selectedMask); + if (!targetGeometry) return; + const resultGeometry = polygonClipping.difference(targetGeometry, strokeGeometry); + const resultSegmentation = multiPolygonToSegmentation(resultGeometry); + if (resultSegmentation.length === 0) { + deleteMasksById([selectedMask.id]); + return; + } + const nextMask = maskWithSegmentation(selectedMask, resultSegmentation, { + area: multiPolygonArea(resultGeometry), + hasHoles: multiPolygonHasHoles(resultGeometry), + polygonRingCounts: multiPolygonRingCounts(resultGeometry), + }); + setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask))); + setSelectedMaskId(selectedMask.id); + setSelectedMaskIds([selectedMask.id]); + setGlobalSelectedMaskIds([selectedMask.id]); + setSelectedVertexIndex(null); + } + }, [ + activeClass, + activeTemplateId, + brushSize, + createManualMaskFromGeometry, + deleteMasksById, + eraserSize, + frame?.id, + frame?.height, + frame?.width, + image?.height, + image?.naturalHeight, + image?.naturalWidth, + image?.width, + masks, + selectedMask, + setGlobalSelectedMaskIds, + setMasks, + stageSize.height, + stageSize.width, + ]); + + const handleStageMouseDown = (e: any) => { + if (PAINT_TOOLS.has(effectiveTool)) { + const canStart = effectiveTool === BRUSH_TOOL ? Boolean(activeClass || selectedMask) : Boolean(selectedMask); + if (!canStart) return; + const pos = stagePoint(e, { clampToImage: false }); + if (pos) { + paintToolRef.current = effectiveTool; + lastPaintPointRef.current = pos; + setPaintStrokePoints([pos]); + } + return; + } + + if (DRAG_MANUAL_TOOLS.has(effectiveTool)) { + const pos = stagePoint(e); + if (pos) { + setManualStart(pos); + setManualCurrent(pos); + } + return; + } + + if (effectiveTool === 'box_select') { + const stage = e.target.getStage(); + const pos = stage.getRelativePointerPosition(); + if (pos) { + setBoxStart({ x: pos.x, y: pos.y }); + setBoxCurrent({ x: pos.x, y: pos.y }); + } + } + }; + + const handleStageMouseUp = (e: any) => { + if (paintToolRef.current && PAINT_TOOLS.has(effectiveTool)) { + const finalPoint = stagePoint(e, { clampToImage: false }); + const currentStroke = paintStrokeRef.current; + const spacing = Math.max(3, activePaintRadius * 0.55); + const nextStroke = finalPoint + && currentStroke.length > 0 + && pointDistance(currentStroke[currentStroke.length - 1], finalPoint) >= spacing + && currentStroke.length < MAX_PAINT_STROKE_POINTS + ? extendStrokePoints(currentStroke, finalPoint, spacing) + : currentStroke; + const tool = paintToolRef.current; + setPaintStrokePoints([]); + paintToolRef.current = null; + lastPaintPointRef.current = null; + applyPaintStroke(tool, nextStroke); + return; + } + + if (DRAG_MANUAL_TOOLS.has(effectiveTool) && manualStart) { + const end = stagePoint(e) || manualCurrent || manualStart; + const width = Math.abs(end.x - manualStart.x); + const height = Math.abs(end.y - manualStart.y); + + if (effectiveTool === 'create_rectangle' && width > 4 && height > 4) { + createManualMask('矩形', rectanglePoints(manualStart, end)); + } + if (effectiveTool === 'create_circle' && width > 4 && height > 4) { + createManualMask('圆形', circlePoints(manualStart, end)); + } + + setManualStart(null); + setManualCurrent(null); + return; + } + + if (effectiveTool === 'box_select' && boxStart && boxCurrent) { + const x1 = Math.min(boxStart.x, boxCurrent.x); + const y1 = Math.min(boxStart.y, boxCurrent.y); + const x2 = Math.max(boxStart.x, boxCurrent.x); + const y2 = Math.max(boxStart.y, boxCurrent.y); + + if (Math.abs(x2 - x1) > 5 && Math.abs(y2 - y1) > 5) { + const nextBox = { x1, y1, x2, y2 }; + setPoints([]); + setSamPromptBox(nextBox); + setSamCandidateMaskId(null); + runInference([], nextBox, { resetCandidate: true }); + } + + setBoxStart(null); + setBoxCurrent(null); + } + }; + + const handleStageClick = (e: any) => { + if (isPolygonEditTool) return; + if (effectiveTool === 'box_select') return; // handled by mouseup + if (DRAG_MANUAL_TOOLS.has(effectiveTool)) return; + if (PAINT_TOOLS.has(effectiveTool)) return; + + if (effectiveTool === POLYGON_TOOL) { + const pos = stagePoint(e); + if (pos) { + const closeRadius = POLYGON_CLOSE_RADIUS / Math.max(scale, 0.1); + if (polygonPoints.length >= 3 && pointDistance(pos, polygonPoints[0]) <= closeRadius) { + finishPolygon(); + return; + } + setPolygonPoints((current) => [...current, pos]); + } + return; + } + + if (effectiveTool === 'point_pos' || effectiveTool === 'point_neg') { + const stage = e.target.getStage(); + const pos = stage.getRelativePointerPosition(); + if (pos) { + const newPoints = [ + ...points, + { x: pos.x, y: pos.y, type: (effectiveTool === 'point_pos' ? 'pos' : 'neg') as 'pos' | 'neg' }, + ]; + setPoints(newPoints); + runInference(newPoints, samPromptBox || undefined); + } + } + }; + + const removePromptPoint = useCallback((pointIndex: number, event?: any) => { + if (event) event.cancelBubble = true; + const nextPoints = points.filter((_, index) => index !== pointIndex); + setPoints(nextPoints); + + if (nextPoints.length > 0 || samPromptBox) { + runInference(nextPoints, samPromptBox || undefined); + return; + } + + if (samCandidateMaskId) { + setMasks(masks.filter((mask) => mask.id !== samCandidateMaskId)); + setSamCandidateMaskId(null); + setSelectedMaskId(null); + setSelectedMaskIds([]); + setInferenceMessage('已移除最后一个提示点和对应候选区域。'); + } + }, [masks, points, runInference, samCandidateMaskId, samPromptBox, setMasks]); + + const updatePolygonMask = useCallback((mask: Mask, nextPoints: CanvasPoint[], polygonIndex = 0) => { + if (nextPoints.length < 3) return; + const nextSegmentation = [...(mask.segmentation || [])]; + nextSegmentation[polygonIndex] = nextPoints.flatMap((point) => [point.x, point.y]); + const bbox = segmentationBbox(nextSegmentation) || polygonBbox(nextPoints); + const nextGeometry = maskToMultiPolygon({ ...mask, segmentation: nextSegmentation }); + const hasHoles = nextGeometry ? multiPolygonHasHoles(nextGeometry) : Boolean(mask.metadata?.hasHoles); + const metadata = { ...(mask.metadata || {}) }; + if (hasHoles && nextGeometry) { + metadata.hasHoles = true; + metadata.polygonRingCounts = multiPolygonRingCounts(nextGeometry); + } else { + delete metadata.hasHoles; + delete metadata.polygonRingCounts; + } + updateMask(mask.id, { + pathData: segmentationPath(nextSegmentation), + segmentation: nextSegmentation, + bbox, + area: nextGeometry ? multiPolygonArea(nextGeometry) : segmentationArea(nextSegmentation), + metadata, + saveStatus: mask.annotationId ? 'dirty' : 'draft', + saved: mask.annotationId ? false : mask.saved, + }); + }, [updateMask]); + + const updateMaskFromSegmentation = useCallback(( + mask: Mask, + segmentation: number[][], + options: { area?: number; hasHoles?: boolean; polygonRingCounts?: number[] } = {}, + ): Mask => { + return maskWithSegmentation(mask, segmentation, options); + }, []); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + clearSelectionState(); + return; + } + if ((event.key === 'Delete' || event.key === 'Backspace') && selectedMask && selectedVertexIndex !== null) { + const currentPoints = segmentationToPoints(selectedMask.segmentation, selectedPolygonIndex); + if (currentPoints.length > 3) { + event.preventDefault(); + const nextPoints = currentPoints.filter((_, index) => index !== selectedVertexIndex); + updatePolygonMask(selectedMask, nextPoints, selectedPolygonIndex); + setSelectedVertexIndex(null); + } + return; + } + if ((event.key === 'Delete' || event.key === 'Backspace') && selectedMask) { + event.preventDefault(); + const ids = selectedMaskIds.length > 0 ? selectedMaskIds : [selectedMask.id]; + if (onRequestDeleteMasks) { + onRequestDeleteMasks(ids); + return; + } + deleteMasksById(ids); + return; + } + if (effectiveTool !== POLYGON_TOOL) return; + if (event.key === 'Enter' && polygonPoints.length >= 3) { + event.preventDefault(); + finishPolygon(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [clearSelectionState, deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, onRequestDeleteMasks, polygonPoints, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, updatePolygonMask]); + + const boxRect = React.useMemo(() => { + if (!boxStart || !boxCurrent) return null; + const x = Math.min(boxStart.x, boxCurrent.x); + const y = Math.min(boxStart.y, boxCurrent.y); + const width = Math.abs(boxCurrent.x - boxStart.x); + const height = Math.abs(boxCurrent.y - boxStart.y); + return { x, y, width, height }; + }, [boxStart, boxCurrent]); + + const manualPreviewPath = React.useMemo(() => { + if (manualStart && manualCurrent) { + if (effectiveTool === 'create_rectangle') return polygonPath(rectanglePoints(manualStart, manualCurrent)); + if (effectiveTool === 'create_circle') return polygonPath(circlePoints(manualStart, manualCurrent)); + } + if (effectiveTool === POLYGON_TOOL && polygonPoints.length > 0) { + const previewPoints = [...polygonPoints, cursorPos]; + return polygonPath(previewPoints); + } + return null; + }, [cursorPos, effectiveTool, manualCurrent, manualStart, polygonPoints]); + + const selectedMaskEditableRings = React.useMemo(() => { + if (!selectedMask?.segmentation) return []; + const hasHoles = Boolean(selectedMask.metadata?.hasHoles); + if (!hasHoles && selectedMask.segmentation.length <= 1) { + return [{ polygonIndex: selectedPolygonIndex, points: selectedMaskPoints }]; + } + return selectedMask.segmentation + .map((_, polygonIndex) => ({ polygonIndex, points: segmentationToPoints(selectedMask.segmentation, polygonIndex) })) + .filter((ring) => ring.points.length >= 3); + }, [selectedMask, selectedMaskPoints, selectedPolygonIndex]); + + const handleMaskSelect = (mask: Mask, event: any, polygonIndex = 0) => { + if (!isPolygonEditTool && !isBooleanTool) return; + event.cancelBubble = true; + if (isBooleanTool) { + setSelectedMaskIds((current) => ( + current.includes(mask.id) + ? current.filter((id) => id !== mask.id) + : [...current, mask.id] + )); + setSelectedMaskId(mask.id); + setSelectedPolygonIndex(polygonIndex); + setSelectedVertexIndex(null); + return; + } + setSelectedMaskId(mask.id); + const linkedMaskIds = findLinkedMasksOnFrame([mask.id], masks, frame?.id); + setSelectedMaskIds(linkedMaskIds.length > 0 ? linkedMaskIds : [mask.id]); + setSelectedPolygonIndex(polygonIndex); + setSelectedVertexIndex(null); + }; + + const handleVertexDragStart = (mask: Mask, vertexIndex: number, polygonIndex = selectedPolygonIndex, event?: any) => { + if (event) event.cancelBubble = true; + setSelectedMaskId(mask.id); + setSelectedMaskIds([mask.id]); + setSelectedPolygonIndex(polygonIndex); + setSelectedVertexIndex(vertexIndex); + }; + + const handleVertexDrag = (mask: Mask, vertexIndex: number, event: any, polygonIndex = selectedPolygonIndex) => { + const imageWidth = frame?.width || image?.naturalWidth || image?.width || stageSize.width; + const imageHeight = frame?.height || image?.naturalHeight || image?.height || stageSize.height; + const currentPoints = segmentationToPoints(mask.segmentation, polygonIndex); + if (!currentPoints[vertexIndex]) return; + const nextPoints = currentPoints.map((point, index) => ( + index === vertexIndex + ? { + x: clamp(event.target.x(), 0, imageWidth), + y: clamp(event.target.y(), 0, imageHeight), + } + : point + )); + setSelectedMaskId(mask.id); + setSelectedMaskIds([mask.id]); + setSelectedPolygonIndex(polygonIndex); + setSelectedVertexIndex(vertexIndex); + updatePolygonMask(mask, nextPoints, polygonIndex); + }; + + const handleEdgeInsert = (mask: Mask, edgeIndex: number, event: any, polygonIndex = selectedPolygonIndex) => { + event.cancelBubble = true; + const currentPoints = segmentationToPoints(mask.segmentation, polygonIndex); + const start = currentPoints[edgeIndex]; + const end = currentPoints[(edgeIndex + 1) % currentPoints.length]; + if (!start || !end) return; + const inserted = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; + const nextPoints = [ + ...currentPoints.slice(0, edgeIndex + 1), + inserted, + ...currentPoints.slice(edgeIndex + 1), + ]; + setSelectedMaskId(mask.id); + setSelectedPolygonIndex(polygonIndex); + setSelectedVertexIndex(edgeIndex + 1); + updatePolygonMask(mask, nextPoints, polygonIndex); + }; + + const handlePathDoubleClick = (mask: Mask, event: any, polygonIndex = 0) => { + if (effectiveTool !== EDIT_POLYGON_TOOL) return; + event.cancelBubble = true; + const point = stagePoint(event); + const currentPoints = segmentationToPoints(mask.segmentation, polygonIndex); + if (!point || currentPoints.length < 3) return; + const edgeIndex = nearestPolygonEdgeIndex(currentPoints, point); + const nextPoints = [ + ...currentPoints.slice(0, edgeIndex + 1), + point, + ...currentPoints.slice(edgeIndex + 1), + ]; + setSelectedMaskId(mask.id); + setSelectedMaskIds([mask.id]); + setSelectedPolygonIndex(polygonIndex); + setSelectedVertexIndex(edgeIndex + 1); + updatePolygonMask(mask, nextPoints, polygonIndex); + }; + + const collectBooleanOperationFrameIds = useCallback(() => { + if (!frame || booleanSelectedMasks.length < 2) return; + const primary = booleanSelectedMasks[0]; + const secondaryMasks = booleanSelectedMasks.slice(1); + const currentFrameId = String(frame.id); + const targetFrameIds = new Set([currentFrameId]); + + masks.forEach((mask) => { + const targetFrameId = String(mask.frameId); + if (targetFrameId === currentFrameId) return; + const hasPrimary = findLinkedMasksOnFrame([primary.id], masks, targetFrameId, { strictInstanceMatch: true }).length > 0; + const hasSecondary = secondaryMasks.some((secondary) => ( + findLinkedMasksOnFrame([secondary.id], masks, targetFrameId, { strictInstanceMatch: true }).length > 0 + )); + if (hasSecondary && (hasPrimary || effectiveTool === 'area_merge')) targetFrameIds.add(targetFrameId); + }); + return { currentFrameId, targetFrameIds }; + }, [booleanSelectedMasks, effectiveTool, frame, masks]); + + const runBooleanOperation = useCallback(async (targetFrameIds: Set) => { + if (!frame || booleanSelectedMasks.length < 2) return; + const primary = booleanSelectedMasks[0]; + const secondaryMasks = booleanSelectedMasks.slice(1); + const currentFrameId = String(frame.id); + const updatedMasks = new Map(); + const deletedMaskIds = new Set(); + + const applyOperationForFrame = (targetFrameId: string) => { + const linkedPrimaryTargetId = targetFrameId === currentFrameId + ? primary.id + : findLinkedMasksOnFrame([primary.id], masks, targetFrameId, { strictInstanceMatch: true })[0]; + + const secondaryTargetIds = Array.from(new Set( + secondaryMasks.flatMap((secondary) => ( + targetFrameId === currentFrameId + ? [secondary.id] + : findLinkedMasksOnFrame([secondary.id], masks, targetFrameId, { strictInstanceMatch: true }) + )), + )).filter((maskId) => maskId !== linkedPrimaryTargetId && !deletedMaskIds.has(maskId)); + const secondaryTargets = secondaryTargetIds + .map((maskId) => masks.find((mask) => mask.id === maskId)) + .filter((mask): mask is Mask => Boolean(mask)); + const fallbackPrimaryTarget = effectiveTool === 'area_merge' ? secondaryTargets[0] : undefined; + const rawPrimaryTarget = masks.find((mask) => mask.id === linkedPrimaryTargetId) || fallbackPrimaryTarget; + if (!rawPrimaryTarget || deletedMaskIds.has(rawPrimaryTarget.id)) return; + const usingSecondaryAsPrimary = !linkedPrimaryTargetId && rawPrimaryTarget.id === fallbackPrimaryTarget?.id; + const primaryTarget = usingSecondaryAsPrimary + ? { + ...rawPrimaryTarget, + templateId: primary.templateId ?? rawPrimaryTarget.templateId, + classId: primary.classId, + className: primary.className, + classZIndex: primary.classZIndex, + classMaskId: primary.classMaskId, + label: primary.label, + color: primary.color, + } + : rawPrimaryTarget; + const primaryGeometry = maskToMultiPolygon(primaryTarget); + if (!primaryGeometry) return; + const clipGeometries = secondaryTargets + .filter((mask) => mask.id !== primaryTarget.id) + .map(maskToMultiPolygon) + .filter((geometry): geometry is MultiPolygon => Boolean(geometry)); + if (clipGeometries.length === 0 && !usingSecondaryAsPrimary) return; + + const resultGeometry = effectiveTool === 'area_merge' + ? polygonClipping.union(primaryGeometry, ...clipGeometries) + : polygonClipping.difference(primaryGeometry, ...clipGeometries); + const resultSegmentation = multiPolygonToSegmentation(resultGeometry); + + if (resultSegmentation.length === 0) { + deletedMaskIds.add(primaryTarget.id); + } else { + updatedMasks.set(primaryTarget.id, updateMaskFromSegmentation(primaryTarget, resultSegmentation, { + area: multiPolygonArea(resultGeometry), + hasHoles: multiPolygonHasHoles(resultGeometry), + polygonRingCounts: multiPolygonRingCounts(resultGeometry), + })); + } + + if (effectiveTool === 'area_merge') { + secondaryTargets.forEach((mask) => { + if (mask.id !== primaryTarget.id) deletedMaskIds.add(mask.id); + }); + } + }; + + targetFrameIds.forEach(applyOperationForFrame); + + const deletedAnnotationIds = Array.from(new Set( + masks + .filter((mask) => deletedMaskIds.has(mask.id)) + .map((mask) => mask.annotationId) + .filter((annotationId): annotationId is string => Boolean(annotationId)), + )); + + setMasks(masks + .filter((mask) => !deletedMaskIds.has(mask.id)) + .map((mask) => updatedMasks.get(mask.id) || mask)); + if (deletedAnnotationIds.length > 0) await onDeleteMaskAnnotations?.(deletedAnnotationIds); + if (deletedMaskIds.has(primary.id)) { + setSelectedMaskId(null); + setSelectedMaskIds([]); + } else { + setSelectedMaskId(primary.id); + setSelectedMaskIds([primary.id]); + } + setSelectedVertexIndex(null); + setPendingBooleanFrameIds(null); + }, [booleanSelectedMasks, effectiveTool, frame, masks, onDeleteMaskAnnotations, setMasks]); + + const handleBooleanOperation = async () => { + const frameSelection = collectBooleanOperationFrameIds(); + if (!frameSelection) return; + onBooleanOperationStart?.(); + const { currentFrameId, targetFrameIds } = frameSelection; + if (targetFrameIds.size > 1) { + setPendingBooleanFrameIds(Array.from(targetFrameIds)); + return; + } + await runBooleanOperation(new Set([currentFrameId])); + }; + + return ( +
+ {isInferencing && ( +
+
+ AI 推理中... +
+ )} + {!isInferencing && inferenceMessage && ( +
+ {inferenceMessage} +
+ )} + {toolHint && isToolHintVisible && ( +
+
{toolHint.title}
+
{toolHint.body}
+
+ )} + + + + {/* Background Image Layer */} + {image && ( + + )} + + {/* AI Returned Masks */} + {displayFrameMasks.map((mask) => { + const selectedIndex = selectedMaskIds.indexOf(mask.id); + const isMaskSelected = selectedIndex >= 0; + const isBooleanPrimary = isBooleanTool && selectedIndex === 0; + const isBooleanSecondary = isBooleanTool && selectedIndex > 0; + const strokeColor = isBooleanPrimary + ? '#facc15' + : isBooleanSecondary + ? '#fb7185' + : mask.color; + const strokeDash = isBooleanSecondary ? [6 / scale, 4 / scale] : undefined; + const hasHoles = Boolean(mask.metadata?.hasHoles); + const paths = hasHoles + ? [{ data: segmentationPath(mask.segmentation), polygonIndex: 0, fillRule: 'evenodd' }] + : (mask.segmentation && mask.segmentation.length > 0 ? mask.segmentation : [undefined]).map((_, polygonIndex) => ({ + data: mask.segmentation ? segmentationPolygonPath(mask.segmentation, polygonIndex) : mask.pathData, + polygonIndex, + fillRule: undefined, + })); + return ( + + {paths.map(({ data, polygonIndex, fillRule }) => ( + handleMaskSelect(mask, event, polygonIndex)} + onTap={(event: any) => handleMaskSelect(mask, event, polygonIndex)} + onDblClick={(event: any) => handlePathDoubleClick(mask, event, polygonIndex)} + onDblTap={(event: any) => handlePathDoubleClick(mask, event, polygonIndex)} + /> + ))} + + ); + })} + + {/* Box selection preview */} + {boxRect && effectiveTool === 'box_select' && ( + + )} + + {/* Manual shape preview */} + {manualPreviewPath && ( + + )} + + {paintStrokePoints.length > 0 && ( + + {paintStrokePoints.map((point, index) => ( + + ))} + + )} + + {isPaintTool && (effectiveTool === BRUSH_TOOL ? activeClass : selectedMask) && paintStrokePoints.length === 0 && ( + + )} + + {polygonPoints.map((point, index) => ( + = 3 ? 6 : 4) / scale} + fill={index === 0 && polygonPoints.length >= 3 ? '#facc15' : '#22d3ee'} + stroke={index === 0 && polygonPoints.length >= 3 ? '#fef3c7' : '#ffffff'} + strokeWidth={1 / scale} + onClick={(event: any) => { + if (index !== 0 || polygonPoints.length < 3) return; + event.cancelBubble = true; + finishPolygon(); + }} + onTap={(event: any) => { + if (index !== 0 || polygonPoints.length < 3) return; + event.cancelBubble = true; + finishPolygon(); + }} + /> + ))} + + {/* Polygon edge insertion handles */} + {isPolygonEditTool && selectedMask && selectedMaskEditableRings.flatMap(({ polygonIndex, points: ringPoints }) => ( + ringPoints.map((point, index) => { + const next = ringPoints[(index + 1) % ringPoints.length]; + if (!next) return null; + return ( + handleEdgeInsert(selectedMask, index, event, polygonIndex)} + onTap={(event: any) => handleEdgeInsert(selectedMask, index, event, polygonIndex)} + /> + ); + }) + ))} + + {/* Polygon vertex editor */} + {showSelectedMaskVertices && selectedMask && selectedMaskEditableRings.flatMap(({ polygonIndex, points: ringPoints }) => ( + ringPoints.map((point, index) => { + const isActiveVertex = selectedPolygonIndex === polygonIndex && selectedVertexIndex === index; + return ( + handleVertexDragStart(selectedMask, index, polygonIndex, event)) : undefined} + onTouchStart={isPolygonEditTool ? ((event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)) : undefined} + onDragStart={isPolygonEditTool ? ((event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)) : undefined} + onClick={(event: any) => { + event.cancelBubble = true; + if (!isPolygonEditTool) return; + setSelectedPolygonIndex(polygonIndex); + setSelectedVertexIndex(index); + }} + onTap={(event: any) => { + event.cancelBubble = true; + if (!isPolygonEditTool) return; + setSelectedPolygonIndex(polygonIndex); + setSelectedVertexIndex(index); + }} + onDragMove={isPolygonEditTool ? ((event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)) : undefined} + onDragEnd={isPolygonEditTool ? ((event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)) : undefined} + /> + ); + }) + ))} + + {/* AI Prompts Point Regions */} + {points.map((p, i) => ( + + removePromptPoint(i, event)} + onTap={(event: any) => removePromptPoint(i, event)} + /> + removePromptPoint(i, event)} + onTap={(event: any) => removePromptPoint(i, event)} + /> + + ))} + + + +
+ 光标: {cursorPos.x.toFixed(2)}, {cursorPos.y.toFixed(2)} + 当前图层: {currentLayerLabel} + 缩放比: {(scale * 100).toFixed(0)}% + 遮罩数: {frameMasks.length} + 已保存: {savedMaskCount} + 未保存: {draftMaskCount} + 待更新: {dirtyMaskCount} +
+ + {currentFrameNumber !== undefined && totalFrames !== undefined && ( +
+ 当前帧:{currentFrameNumber}/{totalFrames} +
+ )} + + {frameMasks.length > 0 && isBooleanTool && ( +
+
+ + 已选 {booleanSelectedMasks.length} + + +
+
+ )} + + {pendingBooleanFrameIds && frame && ( +
+
+

选择操作范围

+

+ 当前选中的区域存在自动传播帧。请选择只处理当前帧,还是同步处理同一传播链上的所有帧。 +

+
+ 将影响 {pendingBooleanFrameIds.length} 帧的对应区域。 +
+
+ + {onRequestBooleanFrameRange && (effectiveTool === 'area_merge' || effectiveTool === 'area_remove') && ( + + )} + + +
+
+
+ )} +
+ ); +} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx new file mode 100644 index 0000000..7acaf6e --- /dev/null +++ b/src/components/Dashboard.tsx @@ -0,0 +1,444 @@ +import React, { useState, useEffect } from 'react'; +import { Activity, AlertTriangle, Clock, Folders, CheckCircle2, Info, Loader2, RotateCcw, XCircle } from 'lucide-react'; +import { progressWS, type ConnectionStatus, type ProgressMessage } from '../lib/websocket'; +import { cn } from '../lib/utils'; +import { + cancelTask, + getDashboardOverview, + getTask, + retryTask, + type DashboardActivity, + type DashboardOverview, + type DashboardTask, + type ProcessingTask, +} from '../lib/api'; + +const emptySummary: DashboardOverview['summary'] = { + project_count: 0, + parsing_task_count: 0, + annotation_count: 0, + frame_count: 0, + template_count: 0, + system_load_percent: 0, +}; + +export function Dashboard() { + const [summary, setSummary] = useState(emptySummary); + const [tasks, setTasks] = useState([]); + const [isConnected, setIsConnected] = useState(false); + const [activityLog, setActivityLog] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + const [selectedTask, setSelectedTask] = useState(null); + const [taskActionMessage, setTaskActionMessage] = useState(''); + const [busyTaskId, setBusyTaskId] = useState(null); + + const taskFromProcessingTask = (task: ProcessingTask, name = `任务 ${task.id}`): DashboardTask => ({ + id: `task-${task.id}`, + task_id: task.id, + project_id: task.project_id ?? 0, + name, + progress: task.progress, + status: task.message || task.status, + raw_status: task.status, + error: task.error, + frame_count: Number(task.result?.frames_extracted || task.result?.processed_frame_count || 0), + updated_at: task.updated_at, + }); + + const prependActivity = (message: string, project = '系统') => { + setActivityLog((prev) => [ + { id: `task-action-${Date.now()}`, kind: 'task', time: new Date().toISOString(), message, project }, + ...prev.slice(0, 9), + ]); + }; + + useEffect(() => { + let cancelled = false; + + const loadOverview = () => { + getDashboardOverview() + .then((overview) => { + if (cancelled) return; + setSummary(overview.summary); + setTasks((prev) => { + if (prev.length === 0) return overview.tasks; + const overviewIds = new Set(overview.tasks.map((task) => task.id)); + const wsOnly = prev.filter((task) => !task.id.startsWith('task-') && !overviewIds.has(task.id) && task.progress < 100); + return [...overview.tasks, ...wsOnly]; + }); + setActivityLog((prev) => { + if (prev.length === 0) return overview.activity; + const byId = new Map(prev.map((item) => [item.id, item])); + overview.activity.forEach((item) => byId.set(item.id, item)); + return Array.from(byId.values()).slice(0, 10); + }); + setLoadError(''); + }) + .catch((err) => { + console.error('Failed to load dashboard overview:', err); + if (!cancelled) setLoadError('Dashboard 数据加载失败'); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + }; + + loadOverview(); + const overviewInterval = setInterval(loadOverview, 5000); + + return () => { + cancelled = true; + clearInterval(overviewInterval); + }; + }, []); + + useEffect(() => { + let mounted = true; + const taskTitle = (data: ProgressMessage) => data.filename || data.projectName || data.taskId || '后台任务'; + const timer = setTimeout(() => { + if (mounted) progressWS.connect(); + }, 500); + + const unsubscribe = progressWS.onProgress((data: ProgressMessage) => { + if (!mounted) return; + setIsConnected(progressWS.isConnected()); + + if (data.type === 'progress' && data.taskId) { + setTasks((prev) => { + const exists = prev.find((t) => t.id === data.taskId); + if (exists) { + return prev.map((t) => + t.id === data.taskId + ? { ...t, progress: data.progress ?? t.progress, status: data.status ?? t.status } + : t + ); + } + return [ + ...prev, + { + id: data.taskId!, + project_id: data.project_id ?? Number(data.task_id || 0), + name: taskTitle(data), + progress: data.progress ?? 0, + status: data.status ?? '处理中', + raw_status: 'running', + error: data.error, + frame_count: 0, + updated_at: new Date().toISOString(), + }, + ]; + }); + } + + if (data.type === 'complete' && data.taskId) { + setTasks((prev) => + prev.map((t) => + t.id === data.taskId ? { ...t, progress: 100, status: '已完成', raw_status: 'success' } : t + ) + ); + setActivityLog((prev) => [ + { id: `ws-complete-${Date.now()}`, kind: 'websocket', time: new Date().toISOString(), message: data.message || `解析完成: ${taskTitle(data)}`, project: data.projectName || '系统' }, + ...prev.slice(0, 9), + ]); + } + + if (data.type === 'cancelled' && data.taskId) { + setTasks((prev) => + prev.map((t) => + t.id === data.taskId + ? { ...t, progress: 100, status: data.message || '任务已取消', raw_status: 'cancelled', error: data.error } + : t + ) + ); + setActivityLog((prev) => [ + { id: `ws-cancelled-${Date.now()}`, kind: 'websocket', time: new Date().toISOString(), message: data.message || `任务已取消: ${taskTitle(data)}`, project: data.projectName || '系统' }, + ...prev.slice(0, 9), + ]); + } + + if (data.type === 'error' && data.taskId) { + setTasks((prev) => + prev.map((t) => + t.id === data.taskId + ? { ...t, progress: data.progress ?? t.progress, status: `错误: ${data.error || data.message || '未知错误'}`, raw_status: 'failed', error: data.error } + : t + ) + ); + setActivityLog((prev) => [ + { id: `ws-error-${Date.now()}`, kind: 'websocket', time: new Date().toISOString(), message: data.message || `解析失败: ${taskTitle(data)}`, project: data.projectName || '系统' }, + ...prev.slice(0, 9), + ]); + } + + if (data.type === 'status') { + setActivityLog((prev) => [ + { id: `ws-status-${Date.now()}`, kind: 'websocket', time: new Date().toISOString(), message: data.message || '状态更新', project: '系统' }, + ...prev.slice(0, 9), + ]); + } + }); + const unsubscribeStatus = progressWS.onStatus((status: ConnectionStatus) => { + if (mounted) setIsConnected(status === 'connected'); + }); + + const checkConnection = setInterval(() => { + if (mounted) setIsConnected(progressWS.isConnected()); + }, 5000); + + return () => { + mounted = false; + clearTimeout(timer); + unsubscribe(); + unsubscribeStatus(); + clearInterval(checkConnection); + progressWS.disconnect(); + }; + }, []); + + const stats = [ + { label: '项目总数', value: summary.project_count.toString(), icon: Folders, color: 'text-blue-400', bg: 'bg-blue-400/10' }, + { label: '处理任务', value: summary.parsing_task_count.toString(), icon: Clock, color: 'text-orange-400', bg: 'bg-orange-400/10' }, + { label: '已存标注', value: summary.annotation_count.toString(), icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-400/10' }, + { label: '系统负载', value: `${summary.system_load_percent}%`, icon: Activity, color: 'text-cyan-400', bg: 'bg-cyan-400/10' }, + ]; + + function formatActivityTime(value: string | null): string { + if (!value) return '未知时间'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + } + + const taskRawStatus = (task: DashboardTask): string => task.raw_status || ( + task.status.includes('取消') ? 'cancelled' + : task.status.includes('失败') || task.status.includes('错误') ? 'failed' + : task.progress >= 100 ? 'success' + : 'running' + ); + + const canCancel = (task: DashboardTask): boolean => ['queued', 'running'].includes(taskRawStatus(task)) && Boolean(task.task_id); + const canRetry = (task: DashboardTask): boolean => ['failed', 'cancelled'].includes(taskRawStatus(task)) && Boolean(task.task_id); + + const handleCancelTask = async (task: DashboardTask) => { + if (!task.task_id) return; + setBusyTaskId(task.id); + setTaskActionMessage(''); + try { + const updated = await cancelTask(task.task_id); + setTasks((prev) => prev.map((item) => ( + item.id === task.id ? taskFromProcessingTask(updated, task.name) : item + ))); + prependActivity(`任务已取消 #${updated.id}`, task.name); + } catch (err) { + console.error('Cancel task failed:', err); + setTaskActionMessage('任务取消失败,请检查后端服务'); + } finally { + setBusyTaskId(null); + } + }; + + const handleRetryTask = async (task: DashboardTask) => { + if (!task.task_id) return; + setBusyTaskId(task.id); + setTaskActionMessage(''); + try { + const retried = await retryTask(task.task_id); + const dashboardTask = taskFromProcessingTask(retried, task.name); + setTasks((prev) => [dashboardTask, ...prev.filter((item) => item.id !== dashboardTask.id)]); + prependActivity(`重试任务已入队 #${retried.id}`, task.name); + } catch (err) { + console.error('Retry task failed:', err); + setTaskActionMessage('任务重试失败,请检查后端服务'); + } finally { + setBusyTaskId(null); + } + }; + + const handleOpenTaskDetail = async (task: DashboardTask) => { + if (!task.task_id) return; + setBusyTaskId(task.id); + setTaskActionMessage(''); + try { + setSelectedTask(await getTask(task.task_id)); + } catch (err) { + console.error('Load task detail failed:', err); + setTaskActionMessage('失败详情加载失败'); + } finally { + setBusyTaskId(null); + } + }; + + return ( +
+
+
+

系统整体概况

+
+
+ {isConnected ? 'WebSocket 已连接' : 'WebSocket 断开'} +
+
+

系统全局数据监控

+ {loadError &&

{loadError}

} + {taskActionMessage &&

{taskActionMessage}

} +
+ +
+ {stats.map((stat, i) => { + const Icon = stat.icon; + return ( +
+
+
+ +
+
{stat.value}
+
+
{stat.label}
+
+ ); + })} +
+ +
+
+

任务进度 (当前 / 最近)

+
+ {isLoading && ( +
正在读取后端 Dashboard 数据...
+ )} + {tasks.map((task) => ( +
+
+ {task.name} + {task.progress}% +
+
+
+
+
+ {taskRawStatus(task) === 'success' || task.status === '已完成' ? ( + + ) : taskRawStatus(task) === 'failed' ? ( + + ) : taskRawStatus(task) === 'cancelled' ? ( + + ) : ( + + )} + {task.status} + 帧: {task.frame_count} +
+
+ {canCancel(task) && ( + + )} + {canRetry(task) && ( + + )} + {task.task_id && ( + + )} +
+
+ ))} + {!isLoading && tasks.length === 0 && ( +
当前无处理任务;生成帧或传播任务开始后会在这里显示进度。
+ )} +
+
+ +
+

近期实时流转记录

+
+ {isLoading && ( +
正在读取近期流转记录...
+ )} + {activityLog.map((log) => ( +
+
+
+
{formatActivityTime(log.time)}
+
{log.message}
+
归属项目: {log.project}
+
+
+ ))} + {!isLoading && activityLog.length === 0 && ( +
暂无近期流转记录
+ )} +
+
+
+ + {selectedTask && ( +
+
+
+
+

任务详情 #{selectedTask.id}

+

{selectedTask.message || selectedTask.status}

+
+ +
+
+
状态: {selectedTask.status}
+
进度: {selectedTask.progress}%
+
项目 ID: {selectedTask.project_id ?? '-'}
+
Celery ID: {selectedTask.celery_task_id || '-'}
+
创建: {selectedTask.created_at}
+
结束: {selectedTask.finished_at || '-'}
+
+ {selectedTask.error && ( +
+ {selectedTask.error} +
+ )} +
+
+                {JSON.stringify(selectedTask.payload || {}, null, 2)}
+              
+
+                {JSON.stringify(selectedTask.result || {}, null, 2)}
+              
+
+
+
+ )} +
+ ); +} diff --git a/src/components/FrameTimeline.tsx b/src/components/FrameTimeline.tsx new file mode 100644 index 0000000..90491c8 --- /dev/null +++ b/src/components/FrameTimeline.tsx @@ -0,0 +1,541 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Play, Pause } from 'lucide-react'; +import { cn } from '../lib/utils'; +import { useStore } from '../store/useStore'; + +interface FrameTimelineProps { + propagationRange?: { + startFrame: number; + endFrame: number; + }; + propagationHistory?: Array<{ + id: string; + startFrame: number; + endFrame: number; + colorIndex: number; + label?: string; + }>; + propagationRangeSelectionActive?: boolean; + propagationRangeDisabled?: boolean; + onPropagationRangeChange?: (startFrame: number, endFrame: number) => void; +} + +export function FrameTimeline({ + propagationRange, + propagationHistory = [], + propagationRangeSelectionActive = false, + propagationRangeDisabled = false, + onPropagationRangeChange, +}: FrameTimelineProps = {}) { + const frames = useStore((state) => state.frames); + const currentProject = useStore((state) => state.currentProject); + const currentFrameIndex = useStore((state) => state.currentFrameIndex); + const masks = useStore((state) => state.masks); + const setCurrentFrame = useStore((state) => state.setCurrentFrame); + const [isPlaying, setIsPlaying] = useState(false); + const [rangeDragAnchorFrame, setRangeDragAnchorFrame] = useState(null); + + const totalFrames = frames.length; + const currentFrame = totalFrames > 0 ? currentFrameIndex + 1 : 0; + const playbackFps = useMemo(() => { + const fps = currentProject?.parse_fps || currentProject?.original_fps || 12; + return Math.min(Math.max(fps, 1), 30); + }, [currentProject?.original_fps, currentProject?.parse_fps]); + const timeBaseFps = useMemo(() => { + const fps = currentProject?.parse_fps || currentProject?.original_fps || 12; + return Math.max(fps, 1); + }, [currentProject?.original_fps, currentProject?.parse_fps]); + const currentSeconds = totalFrames > 0 ? currentFrameIndex / timeBaseFps : 0; + const totalSeconds = totalFrames > 0 ? Math.max(totalFrames - 1, 0) / timeBaseFps : 0; + const isPropagatedMask = (mask: (typeof masks)[number]) => { + const source = typeof mask.metadata?.source === 'string' ? mask.metadata.source.toLowerCase() : ''; + return source.includes('propagat') + || mask.metadata?.propagated_from_frame_id !== undefined + || mask.metadata?.source_annotation_id !== undefined + || mask.metadata?.source_mask_id !== undefined + || mask.metadata?.propagation_seed_key !== undefined + || mask.metadata?.propagation_seed_signature !== undefined; + }; + const propagatedFrameMarkers = useMemo(() => { + const frameIds = new Set(frames.map((frame) => frame.id)); + const propagatedIds = new Set( + masks + .filter((mask) => frameIds.has(mask.frameId)) + .filter(isPropagatedMask) + .map((mask) => mask.frameId), + ); + return frames + .map((frame, index) => ({ frame, index })) + .filter(({ frame }) => propagatedIds.has(frame.id)); + }, [frames, masks]); + const propagatedFrameIds = useMemo( + () => new Set(propagatedFrameMarkers.map(({ frame }) => frame.id)), + [propagatedFrameMarkers], + ); + const propagatedFrameNumbers = useMemo( + () => new Set(propagatedFrameMarkers.map(({ index }) => index + 1)), + [propagatedFrameMarkers], + ); + const annotatedFrameMarkers = useMemo(() => { + const frameIds = new Set(frames.map((frame) => frame.id)); + const annotatedIds = new Set( + masks + .filter((mask) => frameIds.has(mask.frameId)) + .filter((mask) => !isPropagatedMask(mask)) + .map((mask) => mask.frameId), + ); + return frames + .map((frame, index) => ({ frame, index })) + .filter(({ frame }) => annotatedIds.has(frame.id)); + }, [frames, masks]); + const annotatedFrameIds = useMemo( + () => new Set(annotatedFrameMarkers.map(({ frame }) => frame.id)), + [annotatedFrameMarkers], + ); + + const formatTime = (seconds: number) => { + const safeSeconds = Math.max(0, seconds); + const minutes = Math.floor(safeSeconds / 60); + const wholeSeconds = Math.floor(safeSeconds % 60); + const centiseconds = Math.floor((safeSeconds % 1) * 100); + return `${minutes.toString().padStart(2, '0')}:${wholeSeconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; + }; + + const clampFrame = (frame: number) => Math.min(Math.max(frame, 1), Math.max(totalFrames, 1)); + const normalizeRange = (startFrame: number, endFrame: number) => ({ + startFrame: Math.min(clampFrame(startFrame), clampFrame(endFrame)), + endFrame: Math.max(clampFrame(startFrame), clampFrame(endFrame)), + }); + const selectedRange = propagationRange + ? normalizeRange(propagationRange.startFrame, propagationRange.endFrame) + : null; + const visibleSelectedRange = propagationRangeSelectionActive ? selectedRange : null; + const rangeLeft = visibleSelectedRange && totalFrames > 0 ? ((visibleSelectedRange.startFrame - 1) / totalFrames) * 100 : 0; + const rangeWidth = visibleSelectedRange && totalFrames > 0 + ? ((visibleSelectedRange.endFrame - visibleSelectedRange.startFrame + 1) / totalFrames) * 100 + : 0; + const frameLineLeft = (frame: number) => { + if (totalFrames <= 1) return 0; + return ((clampFrame(frame) - 1) / (totalFrames - 1)) * 100; + }; + const currentFrameLineLeft = totalFrames > 0 ? frameLineLeft(currentFrame) : 0; + const rangeStartLineLeft = visibleSelectedRange ? frameLineLeft(visibleSelectedRange.startFrame) : 0; + const rangeEndLineLeft = visibleSelectedRange ? frameLineLeft(visibleSelectedRange.endFrame) : 0; + const propagationHistoryColor = (ageFromNewest: number) => { + const step = Math.min(Math.max(ageFromNewest, 0), 4); + const lightness = 58 - step * 7; + const alpha = 0.88 - step * 0.085; + return { + fill: `hsla(212, 88%, ${lightness}%, ${Math.max(alpha, 0.52)})`, + glow: `hsla(212, 88%, ${Math.min(lightness + 10, 76)}%, ${0.38 - step * 0.045})`, + border: `hsla(212, 90%, ${Math.min(lightness + 18, 84)}%, ${0.72 - step * 0.045})`, + }; + }; + const visiblePropagationHistory = useMemo(() => ( + propagationHistory + .flatMap((segment, order) => { + const range = normalizeRange(segment.startFrame, segment.endFrame); + const ageFromNewest = Math.min(Math.max(propagationHistory.length - 1 - order, 0), 4); + const chunks: Array = []; + let chunkStart: number | null = null; + for (let frameNumber = range.startFrame; frameNumber <= range.endFrame; frameNumber += 1) { + if (propagatedFrameNumbers.has(frameNumber)) { + chunkStart ??= frameNumber; + continue; + } + if (chunkStart !== null) { + chunks.push({ + ...segment, + id: chunkStart === range.startFrame && frameNumber - 1 === range.endFrame + ? segment.id + : `${segment.id}-${chunkStart}-${frameNumber - 1}`, + startFrame: chunkStart, + endFrame: frameNumber - 1, + order, + ageFromNewest, + }); + chunkStart = null; + } + } + if (chunkStart !== null) { + chunks.push({ + ...segment, + id: chunkStart === range.startFrame ? segment.id : `${segment.id}-${chunkStart}-${range.endFrame}`, + startFrame: chunkStart, + endFrame: range.endFrame, + order, + ageFromNewest, + }); + } + return chunks; + }) + .filter((segment) => totalFrames > 0 && segment.endFrame >= 1 && segment.startFrame <= totalFrames) + ), [propagatedFrameNumbers, propagationHistory, totalFrames]); + + const frameFromPointerEvent = (event: React.PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + const ratio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0; + return clampFrame(Math.round(Math.min(Math.max(ratio, 0), 1) * Math.max(totalFrames - 1, 0)) + 1); + }; + + const jumpToFrame = (frame: number) => { + if (totalFrames === 0) return; + setIsPlaying(false); + setCurrentFrame(clampFrame(frame) - 1); + }; + + const updatePropagationRangeFromPointer = ( + event: React.PointerEvent, + anchorFrame = rangeDragAnchorFrame, + ) => { + if (!propagationRangeSelectionActive || propagationRangeDisabled || totalFrames === 0 || !onPropagationRangeChange) return; + const frame = frameFromPointerEvent(event); + const startFrame = anchorFrame ?? frame; + const nextRange = normalizeRange(startFrame, frame); + onPropagationRangeChange(nextRange.startFrame, nextRange.endFrame); + }; + + const handleRangePointerDown = (event: React.PointerEvent) => { + if (!propagationRangeSelectionActive || propagationRangeDisabled || totalFrames === 0 || !onPropagationRangeChange) return; + event.preventDefault(); + setIsPlaying(false); + const frame = frameFromPointerEvent(event); + setRangeDragAnchorFrame(frame); + event.currentTarget.setPointerCapture?.(event.pointerId); + onPropagationRangeChange(frame, frame); + }; + + const handleProcessingBarPointerDown = (event: React.PointerEvent) => { + if (propagationRangeSelectionActive) { + handleRangePointerDown(event); + return; + } + if (totalFrames === 0) return; + event.preventDefault(); + jumpToFrame(frameFromPointerEvent(event)); + }; + + const handleFrameMarkerClick = (event: React.MouseEvent, frame: number) => { + event.stopPropagation(); + if (propagationRangeSelectionActive) return; + jumpToFrame(frame); + }; + + const handleRangePointerMove = (event: React.PointerEvent) => { + if (rangeDragAnchorFrame === null) return; + updatePropagationRangeFromPointer(event, rangeDragAnchorFrame); + }; + + const handleRangePointerUp = (event: React.PointerEvent) => { + if (rangeDragAnchorFrame === null) return; + updatePropagationRangeFromPointer(event, rangeDragAnchorFrame); + setRangeDragAnchorFrame(null); + }; + + useEffect(() => { + if (!isPlaying || totalFrames <= 1) return; + + const timer = window.setTimeout(() => { + if (currentFrameIndex >= totalFrames - 1) { + setIsPlaying(false); + return; + } + + setCurrentFrame(currentFrameIndex + 1); + }, 1000 / playbackFps); + + return () => window.clearTimeout(timer); + }, [currentFrameIndex, isPlaying, playbackFps, setCurrentFrame, totalFrames]); + + useEffect(() => { + if (totalFrames === 0) { + setIsPlaying(false); + } + }, [totalFrames]); + + useEffect(() => { + const isEditableTarget = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false; + const tagName = target.tagName.toLowerCase(); + return target.isContentEditable || ['input', 'textarea', 'select'].includes(tagName); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (isEditableTarget(event.target) || totalFrames <= 1) return; + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + + event.preventDefault(); + setIsPlaying(false); + const direction = event.key === 'ArrowRight' ? 1 : -1; + const nextIndex = Math.min(Math.max(currentFrameIndex + direction, 0), totalFrames - 1); + if (nextIndex !== currentFrameIndex) { + setCurrentFrame(nextIndex); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [currentFrameIndex, setCurrentFrame, totalFrames]); + + // show frames around current frame + const frameWindow = 20; + const displayIndices = totalFrames > 0 + ? Array.from({ length: 41 }, (_, i) => currentFrameIndex - frameWindow + i) + : []; + + return ( +
+
+
+ {formatTime(currentSeconds)} +
+
+ {formatTime(totalSeconds)} +
+ setCurrentFrame(parseInt(e.target.value) - 1)} + className={cn( + "w-full absolute left-0 right-0 top-0 h-7 opacity-0 cursor-ew-resize z-20", + propagationRangeSelectionActive && "pointer-events-none", + )} + disabled={totalFrames === 0} + /> +
setRangeDragAnchorFrame(null)} + > + {visibleSelectedRange && ( +
+ )} +
0 ? (currentFrame / totalFrames) * 100 : 0}%` }} + /> +
0 ? (currentFrame / totalFrames) * 100 : 0}%` }} + > + {formatTime(currentSeconds)} +
+
+ {totalFrames > 0 && ( +