From 4d65c37c73fdea761553598c2e05bc367a3bc2ea Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Thu, 30 Apr 2026 22:42:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(template):=20=E4=BF=AE=E5=A4=8D=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=BA=93=E4=BF=9D=E5=AD=98/=E9=A2=9C=E8=89=B2/?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E6=8E=92=E5=BA=8F=EF=BC=8C=E8=81=94=E5=8A=A8?= =?UTF-8?q?OntologyInspector=EF=BC=8C=E7=A7=8D=E5=AD=90=E8=85=B9=E8=85=94?= =?UTF-8?q?=E9=95=9C35=E5=88=86=E7=B1=BB=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/schemas.py: TemplateUpdate 添加 classes/rules 字段 - backend/models.py: Template 添加 description 列 - backend/routers/templates.py: create/update 打包/解包 mapping_rules.classes (已有) - backend/main.py: seed 腹腔镜胆囊切除术35分类模板 - src/lib/api.ts: updateTemplate 改 PATCH,补齐 color/z_index,_mapTemplate 对齐 TS 接口 - src/store/useStore.ts: 新增 activeTemplateId/setActiveTemplateId - src/components/TemplateRegistry.tsx: 随机颜色(HSL轮盘)、HTML5拖拽排序、批量JSON导入、一键载入腹腔镜模板、handleSave 补齐必填字段 - src/components/OntologyInspector.tsx: 完全重写,从store读取模板,支持模板切换和自定义分类 - src/components/VideoWorkspace.tsx: 进入时自动加载模板列表 - src/components/ProjectLibrary.tsx: 修复状态字符串 TS 严格类型报错 - 工程分析/: 更新实现方案与经验记录 Timestamp: 20260430_222830 --- backend/main.py | 59 +++++++ backend/models.py | 1 + backend/routers/templates.py | 38 ++++- backend/schemas.py | 4 + src/components/OntologyInspector.tsx | 125 ++++++++++++--- src/components/ProjectLibrary.tsx | 10 +- src/components/TemplateRegistry.tsx | 231 +++++++++++++++++++++++---- src/components/VideoWorkspace.tsx | 11 +- src/lib/api.ts | 27 +++- src/store/useStore.ts | 4 + 工程分析/实现方案-20260430_222830.md | 73 +++++++++ 工程分析/测试方案-20260430_222830.md | 25 +++ 工程分析/经验记录.md | 37 +++++ 工程分析/需求分析-20260430_222830.md | 21 +++ 14 files changed, 598 insertions(+), 68 deletions(-) create mode 100644 工程分析/实现方案-20260430_222830.md create mode 100644 工程分析/测试方案-20260430_222830.md create mode 100644 工程分析/需求分析-20260430_222830.md diff --git a/backend/main.py b/backend/main.py index 62ca759..ba88450 100644 --- a/backend/main.py +++ b/backend/main.py @@ -109,6 +109,59 @@ def _seed_default_project_sync() -> None: db.close() +def _seed_default_templates_sync() -> None: + """Seed default ontology templates on first startup.""" + from models import Template + + db = SessionLocal() + try: + if db.query(Template).first() is not None: + return + + # Laparoscopic cholecystectomy template (35 classes) + colors = [ + (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), + ] + names = [ + '针', '线', '肿瘤', '血管阻断夹', '棉球', '双极电凝', + '肝脏', '胆囊', '分离钳', '脂肪', '止血海绵', '肝总管', + '吸引器', '剪刀', '超声刀', '止血纱布', '胆总管', '生物夹', + '无损伤钳', '钳夹', '喷洒', '胆囊管', '动脉', '电凝', + '静脉', '标本袋', '引流管', '纱布', '金属钛夹', '韧带', + '肝蒂', '推结器', '乳胶管-血管阻断', '吻合器', '术中超声', + ] + 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"cls-lap-{idx}", + "name": name, + "color": color_hex, + "zIndex": (len(names) - idx) * 10, + "category": "腹腔镜胆囊切除术", + }) + + template = Template( + name="腹腔镜胆囊切除术", + description="腹腔镜胆囊切除术(LC)手术器械与解剖结构语义分割模板,共35个分类", + color="#06b6d4", + z_index=0, + mapping_rules={"classes": classes, "rules": []}, + ) + db.add(template) + db.commit() + logger.info("Seeded default template '腹腔镜胆囊切除术' with %d classes", len(classes)) + except Exception as exc: + logger.error("Failed to seed default templates: %s", exc) + finally: + db.close() + + @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan: startup and shutdown hooks.""" @@ -134,6 +187,12 @@ async def lifespan(app: FastAPI): else: logger.warning("Redis connection failed.") + # 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)) diff --git a/backend/models.py b/backend/models.py index 28e293f..8b778a8 100644 --- a/backend/models.py +++ b/backend/models.py @@ -67,6 +67,7 @@ class Template(Base): 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) diff --git a/backend/routers/templates.py b/backend/routers/templates.py index d26fb2c..99aab13 100644 --- a/backend/routers/templates.py +++ b/backend/routers/templates.py @@ -14,6 +14,26 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/templates", tags=["Templates"]) +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"] = data["classes"] + if "rules" in data and data["rules"] is not None: + mapping["rules"] = data["rules"] + 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 = mapping.get("classes", []) + template.rules = mapping.get("rules", []) + return template + + @router.post( "", response_model=TemplateOut, @@ -22,10 +42,13 @@ router = APIRouter(prefix="/api/templates", tags=["Templates"]) ) def create_template(payload: TemplateCreate, db: Session = Depends(get_db)) -> Template: """Create a new ontology template / segmentation class.""" - template = Template(**payload.model_dump()) + data = payload.model_dump() + data = _pack_mapping_rules(data) + template = Template(**data) 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 @@ -41,7 +64,10 @@ def list_templates( db: Session = Depends(get_db), ) -> List[Template]: """Retrieve all ontology templates.""" - return db.query(Template).offset(skip).limit(limit).all() + templates = db.query(Template).offset(skip).limit(limit).all() + for t in templates: + _unpack_template(t) + return templates @router.get( @@ -54,6 +80,7 @@ def get_template(template_id: int, db: Session = Depends(get_db)) -> Template: template = db.query(Template).filter(Template.id == template_id).first() if not template: raise HTTPException(status_code=404, detail="Template not found") + _unpack_template(template) return template @@ -72,11 +99,16 @@ def update_template( if not template: raise HTTPException(status_code=404, detail="Template not found") - for key, value in payload.model_dump(exclude_unset=True).items(): + 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 diff --git a/backend/schemas.py b/backend/schemas.py index 5e13bf9..1e1bebb 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -73,6 +73,8 @@ class TemplateBase(BaseModel): 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): @@ -84,6 +86,8 @@ class TemplateUpdate(BaseModel): 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): diff --git a/src/components/OntologyInspector.tsx b/src/components/OntologyInspector.tsx index 9b643d5..d68a0d2 100644 --- a/src/components/OntologyInspector.tsx +++ b/src/components/OntologyInspector.tsx @@ -1,13 +1,37 @@ -import React from 'react'; -import { Layers, ChevronDown, Tag, Eye } from 'lucide-react'; +import React, { useState } from 'react'; +import { Layers, ChevronDown, Tag, Eye, Plus, X } from 'lucide-react'; +import { useStore } from '../store/useStore'; +import type { TemplateClass } from '../store/useStore'; export function OntologyInspector() { - const ontology = [ - { id: '1', label: 'vehicle_four_wheels', color: 'bg-cyan-500', count: 4, zIndex: 60 }, - { id: '2', label: 'pedestrian', color: 'bg-purple-500', count: 2, zIndex: 70 }, - { id: '3', label: 'road_surface', color: 'bg-gray-500', count: 1, zIndex: 10 }, - { id: '4', label: 'traffic_sign', color: 'bg-green-500', count: 3, zIndex: 50 }, - ]; + const templates = useStore((state) => state.templates); + const activeTemplateId = useStore((state) => state.activeTemplateId); + const setActiveTemplateId = useStore((state) => state.setActiveTemplateId); + + // Project-level custom classes (in addition to template classes) + const [customClasses, setCustomClasses] = useState([]); + const [showAddForm, setShowAddForm] = useState(false); + const [newClassName, setNewClassName] = useState(''); + const [newClassColor, setNewClassColor] = useState('#06b6d4'); + + const activeTemplate = templates.find((t) => t.id === activeTemplateId) || templates[0] || null; + const templateClasses = activeTemplate?.classes || []; + const allClasses = [...templateClasses, ...customClasses].sort((a, b) => b.zIndex - a.zIndex); + + const handleAddCustom = () => { + if (!newClassName.trim()) return; + const maxZ = allClasses.length > 0 ? Math.max(...allClasses.map((c) => c.zIndex)) : 0; + const newClass: TemplateClass = { + id: `custom-${Date.now()}`, + name: newClassName.trim(), + color: newClassColor, + zIndex: maxZ + 10, + category: '自定义', + }; + setCustomClasses([...customClasses, newClass]); + setNewClassName(''); + setShowAddForm(false); + }; return (
@@ -17,35 +41,42 @@ export function OntologyInspector() {
- {/* Frame Metadata */} + {/* Template Selector */}
-

局部帧元数据

-
-
- 物理分辨率: 1920x1080 -
-
- 绝对时间码: 00:01:24.16 -
-
- 涵盖实体: 10 个已实例化 -
+

当前激活模板

+
+ +
+ {activeTemplate && ( +
+ {activeTemplate.classes?.length ?? 0} 个分类来自模板 + {customClasses.length > 0 && ` + ${customClasses.length} 个自定义`} +
+ )}
- {/* Global Priority Classes */} + {/* Semantic Classification Tree */}

语义分类树 (高度/Z-Index) -

- {ontology.sort((a,b) => b.zIndex - a.zIndex).map(cls => ( + {allClasses.map(cls => (
- - {cls.label} + + {cls.name}
z:{cls.zIndex} @@ -54,16 +85,58 @@ export function OntologyInspector() {
))} + {allClasses.length === 0 && ( +
请先选择一个模板
+ )}
+ {/* Add Custom Class */} +
+
+

自定义分类

+ +
+ {showAddForm && ( +
+
+ setNewClassColor(e.target.value)} + className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer" + /> + setNewClassName(e.target.value)} + placeholder="分类名称" + className="flex-1 bg-[#111] border border-white/10 rounded px-2 py-1 text-xs text-white" + onKeyDown={(e) => e.key === 'Enter' && handleAddCustom()} + /> + + +
+
+ )} +
+ {/* Current Active Object Properties */}

特定目标实例属性追踪

- vehicle_four_wheels + {activeTemplate?.name || '未选择'}
diff --git a/src/components/ProjectLibrary.tsx b/src/components/ProjectLibrary.tsx index 867b015..0dd23c5 100644 --- a/src/components/ProjectLibrary.tsx +++ b/src/components/ProjectLibrary.tsx @@ -212,14 +212,14 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) { {proj.source_type === 'dicom' ? 'DICOM' : (proj.fps || '30FPS')} - {proj.status === 'Ready' || proj.status === 'ready' ? ( + {proj.status === 'Ready' ? ( <>
已就绪 - ) : proj.status === 'Parsing' || proj.status === 'parsing' ? ( + ) : proj.status === 'Parsing' ? ( <>
解析拆帧中 - ) : proj.status === 'pending' || proj.status === 'Pending' ? ( - <>
待处理 - ) : ( + ) : proj.status === 'Error' ? ( <>
异常 + ) : ( + <>
待处理 )}
diff --git a/src/components/TemplateRegistry.tsx b/src/components/TemplateRegistry.tsx index f972ff6..61e31ab 100644 --- a/src/components/TemplateRegistry.tsx +++ b/src/components/TemplateRegistry.tsx @@ -1,10 +1,45 @@ import React, { useState, useEffect } from 'react'; -import { Settings, Database, Trash2, Edit3, Plus, Loader2, X } from 'lucide-react'; +import { Settings, Database, Trash2, Edit3, Plus, Loader2, X, GripVertical, Import } from 'lucide-react'; import { cn } from '../lib/utils'; import { useStore } from '../store/useStore'; import { getTemplates, createTemplate, updateTemplate, deleteTemplate } from '../lib/api'; import type { Template, TemplateClass } from '../store/useStore'; +// HSL to Hex color generator +function hslToHex(h: number, s: number, l: number): string { + l /= 100; + const a = s * Math.min(l, 1 - l) / 100; + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color).toString(16).padStart(2, '0'); + }; + return `#${f(0)}${f(8)}${f(4)}`; +} + +function generateColor(index: number, total: number): string { + const hue = (index * 360 / Math.max(total, 1)) % 360; + return hslToHex(hue, 75, 55); +} + +const LAPAROSCOPIC_COLORS = [ + [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], +]; + +const LAPAROSCOPIC_NAMES = [ + '针', '线', '肿瘤', '血管阻断夹', '棉球', '双极电凝', + '肝脏', '胆囊', '分离钳', '脂肪', '止血海绵', '肝总管', + '吸引器', '剪刀', '超声刀', '止血纱布', '胆总管', '生物夹', + '无损伤钳', '钳夹', '喷洒', '胆囊管', '动脉', '电凝', + '静脉', '标本袋', '引流管', '纱布', '金属钛夹', '韧带', + '肝蒂', '推结器', '乳胶管-血管阻断', '吻合器', '术中超声', +]; + export function TemplateRegistry() { const templates = useStore((state) => state.templates); const setTemplates = useStore((state) => state.setTemplates); @@ -16,11 +51,14 @@ export function TemplateRegistry() { const [selectedTemplate, setSelectedTemplate] = useState