实现图像注释的保存和重现功能,用户可以使用各种注释工具(包括测量工具、文本注释等)在图像上添加注释,后端提供注释相关的API,客户端需要实现保存注释、获取注释并重现注释的逻辑。
保存注释
获取注释
重现注释
注释数据结构
注释工具集成:
UI交互组件:
API层:
注释数据结构:
工具集成:
sequenceDiagram
participant User as 用户
participant UI as UI组件
participant Cornerstone as Cornerstone工具
participant State as 状态管理
participant API as API服务
participant Backend as 后端
User->>UI: 选择注释工具
UI->>Cornerstone: 激活工具
User->>Cornerstone: 在图像上创建注释
Cornerstone->>Cornerstone: 生成注释数据
Cornerstone->>State: 保存到本地状态
State->>API: 触发保存API
API->>Backend: POST /auth/image/{id}/annotation
Backend-->>API: 保存成功响应
User->>Viewer: 打开图像
Viewer->>API: 获取注释API
API->>Backend: GET /auth/image/{id}/annotation
Backend-->>API: 返回注释数据
API->>State: 加载注释到状态
State->>Cornerstone: 重现注释到图像
Cornerstone-->>User: 显示注释在图像上
注释创建流: 用户操作 → UI选择工具 → Cornerstone创建注释 → 本地状态 → API调用 → 后端保存
注释重现流: 图像打开 → API获取数据 → 状态更新 → Cornerstone反序列化 → UI显示
interface AnnotationData {
// 基础信息
id: string;
toolName: string; // 工具类型,如 'LengthTool', 'AngleTool', 'LabelTool'
sopInstanceUid: string; // 图像SOP Instance UID
// 几何数据
handles: {
points: Point3[]; // 关键点坐标
activeHandleIndex?: number; // 激活的手柄索引
textBox?: Point2; // 文本框位置
};
// 元数据
metadata: {
viewPlaneNormal: Point3;
viewUp: Point3;
FrameOfReferenceUID: string;
referencedImageId: string;
};
// 计算结果(针对测量工具)
cachedStats?: {
[targetId: string]: {
// 测量结果,如长度、角度等
length?: number;
angle?: number;
area?: number;
// 其他统计数据
};
};
// 显示属性
label?: string; // 显示标签
highlighted?: boolean; // 是否高亮
isSelected?: boolean; // 是否选中
// 时间戳
createdAt: string;
updatedAt: string;
userId?: string;
}
// 获取注释
interface GetAnnotationResponse {
code: string;
description: string;
data: any; // JSON格式的注释数据
}
// 保存注释
interface SaveAnnotationRequest {
// 请求体为任意JSON数据
[key: string]: any;
}
interface SaveAnnotationResponse {
code: string;
description: string;
data: {};
}
基于Cornerstone Tools的事件系统,实现对注释变化的实时监控:
import { annotation, eventTarget } from '@cornerstonejs/tools';
// 监听注释完成事件
eventTarget.addEventListener('CORNERSTONE_TOOLS_ANNOTATION_COMPLETED', (evt) => {
const { annotation, toolName } = evt.detail;
handleAnnotationCreated(annotation, toolName);
});
// 监听注释修改事件
eventTarget.addEventListener('CORNERSTONE_TOOLS_ANNOTATION_MODIFIED', (evt) => {
const { annotation, toolName } = evt.detail;
handleAnnotationModified(annotation, toolName);
});
// 监听注释删除事件
eventTarget.addEventListener('CORNERSTONE_TOOLS_ANNOTATION_REMOVED', (evt) => {
const { annotationUID } = evt.detail;
handleAnnotationRemoved(annotationUID);
});
创建事件 (ANNOTATION_COMPLETED)
修改事件 (ANNOTATION_MODIFIED)
删除事件 (ANNOTATION_REMOVED)
为了避免频繁的API调用,实现防抖保存:
class AnnotationAutoSave {
private saveTimeout: NodeJS.Timeout | null = null;
private pendingChanges: Map<string, any> = new Map();
// 延迟保存(防抖)
scheduleSave(annotationId: string, data: any, delay: number = 1000) {
this.pendingChanges.set(annotationId, data);
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(() => {
this.performBulkSave();
}, delay);
}
// 立即保存(重要操作)
async saveImmediately(annotationId: string, data: any) {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
try {
await this.saveAnnotation(annotationId, data);
this.pendingChanges.delete(annotationId);
} catch (error) {
console.error('立即保存失败:', error);
}
}
// 批量保存待处理的更改
private async performBulkSave() {
const changes = Array.from(this.pendingChanges.entries());
this.pendingChanges.clear();
try {
await this.saveMultipleAnnotations(changes);
} catch (error) {
console.error('批量保存失败:', error);
// 失败时重新加入待保存队列
changes.forEach(([id, data]) => {
this.pendingChanges.set(id, data);
});
}
}
}
维护注释的状态变化:
interface AnnotationState {
id: string;
lastModified: number;
isDirty: boolean; // 是否有未保存的更改
isSaving: boolean; // 是否正在保存
lastSavedData: any; // 最后保存的数据
retryCount: number; // 保存失败重试次数
}
class AnnotationStateManager {
private states: Map<string, AnnotationState> = new Map();
markAsDirty(annotationId: string, data: any) {
const state = this.getOrCreateState(annotationId);
state.isDirty = true;
state.lastModified = Date.now();
}
markAsSaving(annotationId: string) {
const state = this.getOrCreateState(annotationId);
state.isSaving = true;
}
markAsSaved(annotationId: string, data: any) {
const state = this.getOrCreateState(annotationId);
state.isDirty = false;
state.isSaving = false;
state.lastSavedData = data;
state.retryCount = 0;
}
markAsFailed(annotationId: string) {
const state = this.getOrCreateState(annotationId);
state.isSaving = false;
state.retryCount++;
}
shouldRetry(annotationId: string): boolean {
const state = this.states.get(annotationId);
return state ? state.retryCount < 3 : false;
}
}
处理多用户并发编辑:
class AnnotationConflictResolver {
// 乐观锁版本控制
private versions: Map<string, number> = new Map();
async resolveConflict(annotationId: string, localData: any, serverData: any): Promise<any> {
const localVersion = this.getVersion(annotationId);
const serverVersion = serverData.version || 0;
if (serverVersion > localVersion) {
// 服务器版本更新,提示用户
return await this.promptUserResolution(localData, serverData);
}
// 本地版本更新,覆盖服务器版本
return localData;
}
private async promptUserResolution(localData: any, serverData: any): Promise<any> {
// 显示冲突解决对话框
return new Promise((resolve) => {
// 实现用户选择逻辑
// 选择保留本地更改、接受服务器更改、或手动合并
});
}
}
由于注释的添加/编辑监控、加载和重现都是针对同一个图像的,这些功能应该由一个统一的ImageAnnotationManager来管理:
class ImageAnnotationManager {
private annotationMonitor: AnnotationMonitor;
private annotationLoader: AnnotationLoader;
private annotationRenderer: AnnotationRenderer;
private imageEventListener: ImageEventListener;
private activeImageId: string | null = null;
constructor(
private cornerstoneIntegration: CornerstoneIntegration,
private annotationAPI: AnnotationAPI,
private stateManager: AnnotationStateManager
) {
this.annotationMonitor = new AnnotationMonitor(this);
this.annotationLoader = new AnnotationLoader(this);
this.annotationRenderer = new AnnotationRenderer(this);
this.imageEventListener = new ImageEventListener(this);
}
// 绑定到特定图像
async bindToImage(imageId: string, viewportId: string): Promise<void> {
if (this.activeImageId === imageId) return;
// 解绑之前的图像
if (this.activeImageId) {
await this.unbindFromImage();
}
this.activeImageId = imageId;
// 绑定新图像的事件监听
this.imageEventListener.bindToImage(imageId, viewportId);
// 加载注释数据
await this.annotationLoader.loadAnnotations(imageId);
// 启动监控
this.annotationMonitor.startMonitoring(viewportId);
}
// 解绑图像
async unbindFromImage(): Promise<void> {
if (!this.activeImageId) return;
// 停止监控
this.annotationMonitor.stopMonitoring();
// 取消事件监听
this.imageEventListener.unbindFromImage();
// 保存所有待保存的更改
await this.annotationMonitor.flushPendingChanges();
this.activeImageId = null;
}
// 处理注释创建
async handleAnnotationCreated(annotation: any, toolName: string): Promise<void> {
const serializedData = this.serializeAnnotation(annotation, toolName);
await this.annotationMonitor.handleAnnotationCreated(this.activeImageId!, serializedData);
}
// 处理注释修改
async handleAnnotationModified(annotation: any, toolName: string): Promise<void> {
const serializedData = this.serializeAnnotation(annotation, toolName);
await this.annotationMonitor.handleAnnotationModified(this.activeImageId!, serializedData);
}
// 处理注释删除
async handleAnnotationRemoved(annotationUID: string): Promise<void> {
await this.annotationMonitor.handleAnnotationRemoved(this.activeImageId!, annotationUID);
}
// 序列化注释数据
private serializeAnnotation(annotation: any, toolName: string): any {
return this.cornerstoneIntegration.serializeAnnotation(annotation, toolName);
}
// 反序列化注释数据
private deserializeAnnotation(data: any): any {
return this.cornerstoneIntegration.deserializeAnnotation(data);
}
}
扩展事件监听,包含图像相关的生命周期事件:
class ImageEventListener {
constructor(private manager: ImageAnnotationManager) {}
bindToImage(imageId: string, viewportId: string): void {
const { eventTarget } = cornerstoneTools;
// 图像加载完成事件
eventTarget.addEventListener(
cornerstone.EVENTS.IMAGE_LOAD_COMPLETED,
this.handleImageLoadCompleted
);
// 图像渲染完成事件
eventTarget.addEventListener(
cornerstone.EVENTS.IMAGE_RENDERED,
this.handleImageRendered
);
// 视口大小改变事件
eventTarget.addEventListener(
cornerstone.EVENTS.VIEWPORT_NEW_IMAGE_SET,
this.handleViewportChanged
);
// 工具模式改变事件
eventTarget.addEventListener(
cornerstoneTools.Enums.Events.TOOL_MODE_CHANGED,
this.handleToolModeChanged
);
}
unbindFromImage(): void {
const { eventTarget } = cornerstoneTools;
eventTarget.removeEventListener(
cornerstone.EVENTS.IMAGE_LOAD_COMPLETED,
this.handleImageLoadCompleted
);
eventTarget.removeEventListener(
cornerstone.EVENTS.IMAGE_RENDERED,
this.handleImageRendered
);
eventTarget.removeEventListener(
cornerstone.EVENTS.VIEWPORT_NEW_IMAGE_SET,
this.handleViewportChanged
);
eventTarget.removeEventListener(
cornerstoneTools.Enums.Events.TOOL_MODE_CHANGED,
this.handleToolModeChanged
);
}
private handleImageLoadCompleted = (evt: any) => {
const { imageId } = evt.detail;
if (imageId === this.manager.getActiveImageId()) {
console.log(`图像 ${imageId} 加载完成,开始加载注释`);
// 图像加载完成后,延迟一下再加载注释,确保Cornerstone完全就绪
setTimeout(() => {
this.manager.loadAnnotationsForImage(imageId);
}, 100);
}
};
private handleImageRendered = (evt: any) => {
// 图像渲染完成后,确保注释正确显示
const { viewportId } = evt.detail;
this.manager.ensureAnnotationsVisible(viewportId);
};
private handleViewportChanged = (evt: any) => {
// 视口改变时,可能需要重新计算注释位置
const { viewportId } = evt.detail;
this.manager.handleViewportResize(viewportId);
};
private handleToolModeChanged = (evt: any) => {
// 工具模式改变时,调整注释的交互状态
const { toolName, toolMode } = evt.detail;
this.manager.handleToolModeChange(toolName, toolMode);
};
}
专门负责注释数据的加载:
class AnnotationLoader {
private loadedAnnotations: Map<string, any> = new Map();
private isLoading: boolean = false;
async loadAnnotations(imageId: string): Promise<void> {
if (this.loadedAnnotations.has(imageId)) {
// 已有缓存,直接重现
await this.renderCachedAnnotations(imageId);
return;
}
if (this.isLoading) return; // 防止重复加载
this.isLoading = true;
try {
const annotationsData = await this.annotationAPI.getAnnotation(imageId);
if (annotationsData && annotationsData.length > 0) {
this.loadedAnnotations.set(imageId, annotationsData);
await this.renderAnnotations(imageId, annotationsData);
}
} catch (error) {
console.error(`加载注释失败 ${imageId}:`, error);
// 可以在这里实现重试逻辑或降级处理
} finally {
this.isLoading = false;
}
}
private async renderCachedAnnotations(imageId: string): Promise<void> {
const cachedData = this.loadedAnnotations.get(imageId);
if (cachedData) {
await this.renderAnnotations(imageId, cachedData);
}
}
private async renderAnnotations(imageId: string, annotationsData: any[]): Promise<void> {
for (const annotationData of annotationsData) {
try {
const deserializedAnnotation = this.manager.deserializeAnnotation(annotationData);
await this.annotationRenderer.renderAnnotation(deserializedAnnotation);
} catch (error) {
console.error(`反序列化注释失败:`, error);
// 继续处理其他注释
}
}
}
clearCache(imageId?: string): void {
if (imageId) {
this.loadedAnnotations.delete(imageId);
} else {
this.loadedAnnotations.clear();
}
}
}
专门负责注释的重现:
class AnnotationRenderer {
private renderedAnnotations: Map<string, any> = new Map();
async renderAnnotation(annotation: any): Promise<void> {
const annotationId = annotation.annotationUID || annotation.id;
try {
// 检查是否已渲染
if (this.renderedAnnotations.has(annotationId)) {
return; // 已渲染,跳过
}
// 应用到Cornerstone
await this.cornerstoneIntegration.applyAnnotation(annotation);
// 标记为已渲染
this.renderedAnnotations.set(annotationId, annotation);
} catch (error) {
console.error(`渲染注释失败 ${annotationId}:`, error);
throw error;
}
}
removeAnnotation(annotationId: string): void {
const annotation = this.renderedAnnotations.get(annotationId);
if (annotation) {
this.cornerstoneIntegration.removeAnnotation(annotation);
this.renderedAnnotations.delete(annotationId);
}
}
updateAnnotation(annotationId: string, newAnnotation: any): void {
this.removeAnnotation(annotationId);
this.renderAnnotation(newAnnotation);
}
clearAll(): void {
for (const [annotationId] of this.renderedAnnotations) {
this.removeAnnotation(annotationId);
}
}
}
在React组件中的使用方式:
const ImageViewerComponent: React.FC = () => {
const annotationManagerRef = useRef<ImageAnnotationManager | null>(null);
useEffect(() => {
// 初始化注释管理器
annotationManagerRef.current = new ImageAnnotationManager(
cornerstoneIntegration,
annotationAPI,
stateManager
);
return () => {
// 清理
annotationManagerRef.current?.unbindFromImage();
};
}, []);
const handleImageLoad = useCallback(async (imageId: string, viewportId: string) => {
if (annotationManagerRef.current) {
await annotationManagerRef.current.bindToImage(imageId, viewportId);
}
}, []);
const handleImageUnload = useCallback(async () => {
if (annotationManagerRef.current) {
await annotationManagerRef.current.unbindFromImage();
}
}, []);
return (
<div>
{/* 图像显示组件 */}
<StackViewerWithErrorBoundary
imageUrls={imageUrls}
viewportId={viewportId}
renderingEngineId={renderingEngineId}
onImageLoad={handleImageLoad}
onImageUnload={handleImageUnload}
/>
</div>
);
};
除了注释相关事件,还需要监听以下图像相关事件:
图像加载事件
IMAGE_LOAD_START - 图像开始加载IMAGE_LOAD_COMPLETED - 图像加载完成IMAGE_LOAD_FAILED - 图像加载失败图像渲染事件
IMAGE_RENDERED - 图像渲染完成IMAGE_BEFORE_RENDER - 图像渲染前视口事件
VIEWPORT_NEW_IMAGE_SET - 视口设置新图像VIEWPORT_CAMERA_MODIFIED - 相机参数修改VIEWPORT_CAMERA_RESET - 相机重置工具事件
TOOL_MODE_CHANGED - 工具模式改变TOOL_ACTIVATED - 工具激活TOOL_DEACTIVATED - 工具停用注释相关补充事件
ANNOTATION_SELECTION_CHANGE - 注释选择改变ANNOTATION_VISIBILITY_CHANGE - 注释可见性改变ANNOTATION_LOCK_CHANGE - 注释锁定状态改变class AnnotationMemoryManager {
private maxCacheSize: number = 50; // 最大缓存图像数量
private cacheSize: number = 0;
// LRU缓存策略
private accessOrder: string[] = [];
addToCache(imageId: string): void {
// 更新访问顺序
const index = this.accessOrder.indexOf(imageId);
if (index > -1) {
this.accessOrder.splice(index, 1);
}
this.accessOrder.push(imageId);
// 检查缓存大小限制
if (this.cacheSize > this.maxCacheSize) {
this.evictOldest();
}
}
private evictOldest(): void {
const oldestImageId = this.accessOrder.shift();
if (oldestImageId) {
// 清理最老的图像注释数据
this.annotationLoader.clearCache(oldestImageId);
this.annotationRenderer.clearForImage(oldestImageId);
this.cacheSize--;
}
}
cleanup(): void {
this.annotationLoader.clearCache();
this.annotationRenderer.clearAll();
this.accessOrder = [];
this.cacheSize = 0;
}
}
注释数据的获取主要在以下时机触发:
图像首次加载完成时
// 监听图像加载完成事件
eventTarget.addEventListener(
cornerstone.EVENTS.IMAGE_LOAD_COMPLETED,
(evt) => {
const { imageId } = evt.detail;
// 图像加载完成后立即获取注释
this.loadAnnotationsForImage(imageId);
}
);
typescript
// 当用户切换到新的图像时
async bindToImage(newImageId: string, viewportId: string) {
// 解绑旧图像
await this.unbindFromImage();
// 绑定新图像并加载注释
await this.loadAnnotationsForImage(newImageId);
}
手动刷新时
// 用户点击刷新按钮或需要重新同步
async refreshAnnotations(imageId: string) {
this.clearAnnotationCache(imageId);
await this.loadAnnotationsForImage(imageId);
}
// 缓存不存在,从服务器获取 try { const serverData = await this.annotationAPI.getAnnotation(imageId); this.cacheAnnotations(imageId, serverData); await this.renderAnnotations(imageId, serverData); } catch (error) { console.error('获取注释失败:', error); // 可以显示错误提示或降级处理 } } ```
增量更新策略
// 只获取上次同步时间之后的新注释
async loadIncrementalAnnotations(imageId: string, since: Date) {
const newAnnotations = await this.annotationAPI.getAnnotationsSince(imageId, since);
// 合并到现有注释中
this.mergeAnnotations(imageId, newAnnotations);
}
注释数据从JSON格式转换为Cornerstone Tools可识别的对象:
class AnnotationDeserializer {
// 反序列化单个注释
deserializeAnnotation(jsonData: any): CornerstoneAnnotation {
const { toolName, handles, metadata, cachedStats, ...otherProps } = jsonData;
// 1. 验证数据完整性
this.validateAnnotationData(jsonData);
// 2. 转换坐标系(如果需要)
const transformedHandles = this.transformCoordinates(handles, metadata);
// 3. 重建注释对象
const annotation = {
annotationUID: jsonData.id || this.generateUID(),
metadata: {
...metadata,
toolName,
referencedImageId: metadata.referencedImageId,
FrameOfReferenceUID: metadata.FrameOfReferenceUID,
},
data: {
handles: transformedHandles,
cachedStats: cachedStats || {},
...otherProps,
},
isSelected: false,
highlighted: false,
};
// 4. 工具特定的后处理
return this.postProcessByTool(annotation, toolName);
}
// 坐标系转换(世界坐标 ↔ 画布坐标)
private transformCoordinates(handles: any, metadata: any) {
// 如果存储的是世界坐标,需要转换
if (this.isWorldCoordinate(handles)) {
return handles; // 直接使用世界坐标
}
// 如果是相对坐标,需要转换为世界坐标
return this.convertToWorldCoordinates(handles, metadata);
}
// 工具特定的后处理
private postProcessByTool(annotation: any, toolName: string) {
switch (toolName) {
case 'LengthTool':
return this.processLengthTool(annotation);
case 'AngleTool':
return this.processAngleTool(annotation);
case 'LabelTool':
return this.processLabelTool(annotation);
case 'HipNHAAngleMeasurementTool':
return this.processHipNHATool(annotation);
default:
return annotation;
}
}
// 处理长度测量工具
private processLengthTool(annotation: any) {
// 确保有两个端点
const points = annotation.data.handles.points;
if (points.length !== 2) {
console.warn('LengthTool需要正好2个点');
}
// 重新计算长度(如果cachedStats不存在或无效)
if (!annotation.data.cachedStats.length) {
const length = this.calculateDistance(points[0], points[1]);
annotation.data.cachedStats = { length };
}
return annotation;
}
// 处理角度测量工具
private processAngleTool(annotation: any) {
const points = annotation.data.handles.points;
if (points.length !== 3) {
console.warn('AngleTool需要正好3个点');
}
// 重新计算角度
if (!annotation.data.cachedStats.angle) {
const angle = this.calculateAngle(points[0], points[1], points[2]);
annotation.data.cachedStats = { angle };
}
return annotation;
}
}
class AnnotationValidator {
validateAnnotationData(data: any): void {
// 必需字段检查
this.checkRequiredFields(data, ['toolName', 'handles', 'metadata']);
// 工具特定验证
this.validateToolSpecificData(data.toolName, data);
// 坐标数据验证
this.validateCoordinates(data.handles.points);
// 元数据验证
this.validateMetadata(data.metadata);
}
private checkRequiredFields(data: any, requiredFields: string[]): void {
const missing = requiredFields.filter(field => !data[field]);
if (missing.length > 0) {
throw new Error(`缺少必需字段: ${missing.join(', ')}`);
}
}
private validateCoordinates(points: any[]): void {
if (!Array.isArray(points) || points.length < 2) {
throw new Error('坐标点数据无效');
}
points.forEach((point, index) => {
if (!this.isValidPoint(point)) {
throw new Error(`坐标点 ${index} 无效: ${JSON.stringify(point)}`);
}
});
}
private isValidPoint(point: any): boolean {
return point &&
typeof point.x === 'number' && !isNaN(point.x) &&
typeof point.y === 'number' && !isNaN(point.y) &&
(point.z === undefined || (typeof point.z === 'number' && !isNaN(point.z)));
}
}
注释渲染通过Cornerstone Tools的渲染管道实现:
class AnnotationRenderer {
async renderAnnotation(annotation: CornerstoneAnnotation): Promise<void> {
const { toolName } = annotation.metadata;
const toolInstance = this.getToolInstance(toolName);
// 1. 注册注释到Cornerstone
await this.registerAnnotation(annotation);
// 2. 触发渲染更新
this.triggerViewportRender(annotation.metadata.referencedImageId);
// 3. 设置交互状态
this.setupInteraction(annotation);
}
private async registerAnnotation(annotation: CornerstoneAnnotation): Promise<void> {
// 将注释添加到Cornerstone的annotation state中
cornerstoneTools.annotation.state.addAnnotation(annotation);
// 如果是测量工具,确保统计数据正确
if (this.isMeasurementTool(annotation.metadata.toolName)) {
await this.ensureStatsCalculated(annotation);
}
}
private triggerViewportRender(imageId: string): void {
// 找到对应的视口
const viewports = cornerstone.getEnabledElementByViewportId(imageId);
if (viewports) {
viewports.viewport.render();
}
}
private setupInteraction(annotation: CornerstoneAnnotation): void {
// 设置注释的交互属性
annotation.isSelected = false;
annotation.highlighted = false;
// 添加事件监听器
this.addAnnotationEventListeners(annotation);
}
}
Cornerstone Tools使用SVG进行注释渲染:
// 工具的renderAnnotation方法示例
renderAnnotation = (
enabledElement: CoreTypes.IEnabledElement,
svgDrawingHelper: SVGDrawingHelper
): boolean => {
const { viewport } = enabledElement;
const { element } = viewport;
// 获取该工具的所有注释
const annotations = annotation.state.getAnnotations(this.getToolName(), element);
if (!annotations?.length) {
return false;
}
// 为每个注释创建SVG元素
for (const ann of annotations) {
const { annotationUID, data, highlighted } = ann;
// 转换坐标
const canvasPoints = this.worldToCanvas(data.handles.points, viewport);
// 确定颜色和样式
const color = highlighted ? '#00AAFF' : '#FFFFFF';
// 绘制几何形状
this.drawGeometry(svgDrawingHelper, annotationUID, canvasPoints, color);
// 绘制统计文本
if (data.cachedStats) {
this.drawStatsText(svgDrawingHelper, annotationUID, data.cachedStats, canvasPoints[0]);
}
// 绘制交互手柄
if (highlighted || ann.isSelected) {
this.drawHandles(svgDrawingHelper, annotationUID, canvasPoints);
}
}
return true;
};
class RenderOptimizer {
private renderQueue: Map<string, CornerstoneAnnotation[]> = new Map();
private isRendering: boolean = false;
// 批量渲染优化
async batchRender(annotations: CornerstoneAnnotation[], viewportId: string): Promise<void> {
// 添加到渲染队列
if (!this.renderQueue.has(viewportId)) {
this.renderQueue.set(viewportId, []);
}
this.renderQueue.get(viewportId)!.push(...annotations);
// 如果正在渲染,等待完成
if (this.isRendering) {
return this.waitForCurrentRender(viewportId);
}
// 开始批量渲染
return this.performBatchRender(viewportId);
}
private async performBatchRender(viewportId: string): Promise<void> {
this.isRendering = true;
try {
const annotations = this.renderQueue.get(viewportId) || [];
this.renderQueue.delete(viewportId);
// 批量注册注释
for (const annotation of annotations) {
await this.registerAnnotation(annotation);
}
// 单次渲染更新
this.triggerViewportRender(viewportId);
} finally {
this.isRendering = false;
}
}
// 视口裁剪优化
isAnnotationVisible(annotation: CornerstoneAnnotation, viewport: any): boolean {
const bounds = this.calculateAnnotationBounds(annotation);
const viewportBounds = this.getViewportBounds(viewport);
return this.intersects(bounds, viewportBounds);
}
// 细节级别优化(LOD)
shouldRenderDetailed(annotation: CornerstoneAnnotation, zoomLevel: number): boolean {
// 在低缩放级别下简化渲染
if (zoomLevel < 0.5) {
return annotation.metadata.toolName === 'LabelTool'; // 只显示文本标签
}
return true;
}
}
flowchart TD
A[图像加载完成] --> B[触发注释加载]
B --> C{检查本地缓存}
C -->|有缓存| D[从缓存加载]
C -->|无缓存| E[调用API获取注释]
E --> F{API调用成功?}
F -->|是| G[接收JSON数据]
F -->|否| H[显示错误信息]
G --> I[数据验证]
I --> J{数据有效?}
J -->|否| K[记录错误,继续下一个]
J -->|是| L[反序列化数据]
L --> M[坐标系转换]
M --> N[工具特定处理]
N --> O[重建注释对象]
O --> P[注册到Cornerstone]
P --> Q[计算统计数据]
Q --> R[添加到渲染队列]
R --> S[触发视口渲染]
S --> T[SVG元素生成]
T --> U[绘制几何形状]
U --> V[绘制文本标签]
V --> W[绘制交互手柄]
W --> X[设置交互状态]
X --> Y[添加事件监听]
Y --> Z[注释渲染完成]
D --> O
K --> G
// Cornerstone的渲染架构
<div class="cornerstone-viewport">
<!-- 图像Canvas -->
<canvas class="cornerstone-canvas"></canvas>
<!-- 注释SVG图层 -->
<svg class="cornerstone-svg-layer">
<!-- 几何形状 -->
<line class="annotation-line" x1="100" y1="100" x2="200" y2="200"/>
<circle class="annotation-handle" cx="100" cy="100" r="5"/>
<!-- 文本标签 -->
<text class="annotation-text" x="150" y="90">Length: 14.2mm</text>
<!-- 角度弧线 -->
<path class="annotation-arc" d="M 150 100 A 30 30 0 0 1 180 130"/>
</svg>
</div>
class CoordinateMapper {
// 世界坐标 → Canvas坐标
worldToCanvas(worldPoint: Point3, viewport: CornerstoneViewport): Point2 {
return viewport.worldToCanvas(worldPoint);
}
// Canvas坐标 → 世界坐标
canvasToWorld(canvasPoint: Point2, viewport: CornerstoneViewport): Point3 {
return viewport.canvasToWorld(canvasPoint);
}
// 处理视口变换
transformWithViewport(point: Point3, viewport: CornerstoneViewport): Point2 {
// 考虑缩放、平移、旋转等变换
const transformed = this.applyViewportTransform(point, viewport.getCamera());
return this.worldToCanvas(transformed, viewport);
}
private applyViewportTransform(point: Point3, camera: Camera): Point3 {
// 应用相机变换矩阵
// 处理缩放、平移、旋转
return this.applyMatrixTransform(point, camera.viewMatrix);
}
}
class InteractionManager {
setupAnnotationInteraction(annotation: CornerstoneAnnotation, svgElement: SVGElement): void {
// 鼠标悬停
svgElement.addEventListener('mouseenter', () => {
annotation.highlighted = true;
this.triggerRender();
});
svgElement.addEventListener('mouseleave', () => {
annotation.highlighted = false;
this.triggerRender();
});
// 点击选择
svgElement.addEventListener('click', () => {
this.selectAnnotation(annotation);
});
// 拖拽手柄
const handles = svgElement.querySelectorAll('.annotation-handle');
handles.forEach(handle => {
this.makeDraggable(handle, annotation);
});
}
private makeDraggable(handleElement: Element, annotation: CornerstoneAnnotation): void {
let isDragging = false;
let startPos: Point2;
handleElement.addEventListener('mousedown', (e) => {
isDragging = true;
startPos = { x: e.clientX, y: e.clientY };
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const delta = {
x: e.clientX - startPos.x,
y: e.clientY - startPos.y
};
this.updateHandlePosition(annotation, handleElement, delta);
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
this.finalizeHandlePosition(annotation);
}
});
}
}
这个完整的流程确保了注释数据能够正确地从服务器获取、解析、验证,然后准确地渲染在图像上,并支持丰富的用户交互。渲染机制充分利用了SVG的矢量图形特性,保证了在不同缩放级别下的清晰显示和精确的坐标映射。
sequenceDiagram
participant User as 用户
participant Panel as 操作面板
participant Tool as Cornerstone工具
participant State as Redux状态
participant API as 注释API
participant DB as 后端存储
User->>Panel: 选择注释工具
Panel->>Tool: 激活工具
User->>Tool: 创建注释
Tool->>Tool: 生成注释数据
Tool->>State: 更新本地状态
State->>API: 自动保存
API->>DB: POST /auth/image/{id}/annotation
DB-->>API: 保存确认
User->>Viewer: 打开图像
Viewer->>API: 获取注释
API->>DB: GET /auth/image/{id}/annotation
DB-->>API: 返回注释数据
API-->>State: 加载到状态
State-->>Tool: 反序列化注释
Tool-->>User: 显示注释
classDiagram
class AnnotationManager {
+saveAnnotation(sopId: string, data: any): Promise
+loadAnnotation(sopId: string): Promise
+serializeAnnotation(toolName: string, annotation: any): any
+deserializeAnnotation(data: any): any
}
class CornerstoneIntegration {
+registerAnnotationTools(): void
+activateTool(toolName: string): void
+getAnnotationData(toolName: string): any
+applyAnnotationData(data: any): void
}
class AnnotationAPI {
+getAnnotation(sopId: string): Promise<any>
+saveAnnotation(sopId: string, data: any): Promise
+handleErrors(response: any): void
}
class AnnotationState {
+annotations: Map<string, any>
+addAnnotation(sopId: string, data: any): void
+removeAnnotation(sopId: string): void
+getAnnotations(sopId: string): any[]
}
AnnotationManager --> AnnotationAPI
AnnotationManager --> CornerstoneIntegration
AnnotationManager --> AnnotationState
flowchart TD
A[用户选择注释工具] --> B[激活Cornerstone工具]
B --> C[用户在图像上操作]
C --> D[工具生成注释数据]
D --> E[序列化注释数据]
E --> F[调用保存API]
F --> G{保存成功?}
G -->|是| H[更新本地状态]
G -->|否| I[显示保存失败]
J[用户打开图像] --> K[检查本地缓存]
K --> L{有缓存?}
L -->|是| M[从缓存加载]
L -->|否| N[调用获取API]
N --> O{获取成功?}
O -->|是| P[反序列化注释]
O -->|否| Q[显示错误信息]
P --> R[应用到Cornerstone]
R --> S[显示注释在图像上]
测量工具注释
文本注释
复杂注释
注释管理
完整工作流测试
错误场景测试
边界条件测试
预期结果:所有注释都能正确保存、重现,错误情况有适当的用户反馈。
基于全局事件监听方案,注释管理器通过修改 src/app.tsx 即可集成到应用中,无需修改其他现有代码:
// src/app.tsx
import { initializeAnnotationManager, cleanupAnnotationManager } from '@/features/imageAnnotation';
function AppContent({ children }: { children: ReactNode }): JSX.Element {
// ... 现有代码保持不变 ...
useEffect(() => {
// 浏览器环境不需要服务器连接检查,直接初始化应用
if (platform.isBrowser) {
console.log('浏览器环境,跳过服务器连接检查,直接初始化应用');
initializeApp();
return;
}
// Electron/Cordova 环境:检查服务器连接
dispatch(checkServerConnection())
.unwrap()
.then((result) => {
if (result.needsConfig) {
// 需要配置,显示对话框
console.log('检测到需要服务器配置,显示配置对话框');
setShowConfigModal(true);
} else {
// 连接正常,继续正常初始化
console.log('服务器连接正常,开始应用初始化');
return initializeApp();
}
})
.catch((error) => {
console.error('连接检查失败:', error);
setConnectionChecked(true);
setShowConfigModal(true);
});
}, [dispatch]);
// 应用正常初始化函数
const initializeApp = async () => {
try {
const productState = await dispatch(initializeProductState()).unwrap();
console.log(`初始化,拉取到产品信息:${JSON.stringify(productState)}`);
const languageCode = productState.language;
await dispatch(loadI18nMessages(languageCode)).unwrap();
// ✅ 新增:注释管理器初始化(在Cornerstone初始化之后)
await initializeAnnotationManager();
setIsI18nReady(true);
} catch (error) {
console.error('应用初始化失败:', error);
// 显示配置对话框,让用户重新配置
setShowConfigModal(true);
}
};
// ... 现有代码保持不变 ...
console.log('当前语言:', currentLocale);
console.log('messages', messages);
// children 是将要会渲染的页面
return (
<ConfigProvider theme={themeWithAlgorithm}>
<IntlProvider
locale={currentLocale ? currentLocale.split('_')[0] : 'en'}
messages={(messages as Record<string, string>) || {}}
>
<style>
{`:root {
--color-primary: ${currentTheme.token.colorPrimary};
--color-bg-layout: ${currentTheme.token.colorBgLayout};
--color-text: ${currentTheme.token.colorText};
--button-bg-hover: ${currentTheme.token.buttonBgHover};
}`}
</style>
{/* 加载状态覆盖层 */}
{(loading || (!isI18nReady && !connectionChecked)) && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9999,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: currentTheme.token.colorBgLayout,
color: currentTheme.token.colorText,
}}
>
<div>加载多语言资源中...</div>
</div>
)}
{/* 服务器配置对话框 */}
<ServerConfigModal
open={showConfigModal}
onSave={handleConfigSaved}
onCancel={() => setShowConfigModal(false)}
/>
{/* 错误状态覆盖层 */}
{error && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9999,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
backgroundColor: currentTheme.token.colorBgLayout,
color: currentTheme.token.colorText,
}}
>
<div>多语言资源加载失败: {error}</div>
<button
onClick={() => window.location.reload()}
style={{ marginTop: '16px', padding: '8px 16px' }}
>
重新加载
</button>
</div>
)}
{/* children 始终被渲染,满足 Taro 框架要求 */}
<div
style={{
backgroundColor: currentTheme.token.colorBgLayout,
color: currentTheme.token.colorText,
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
>
<AcquisitionTracer />
<QuotaAlertModal />
<FeatureNotAvailableFeedback
open={isFeatureNotAvailableOpen}
onClose={() => dispatch(setFeatureNotAvailableOpen(false))}
onContinue={() => {
dispatch(setFeatureNotAvailableOpen(false));
dispatch(setBusinessFlow('continueAfterFeatureNotAvailable'));
}}
/>
{children}
{process.env.NODE_ENV === 'development' && <ProductSelector />}
<ThemeSwitcher />
</div>
</IntlProvider>
</ConfigProvider>
);
}
function App({ children }: { children: ReactNode }): JSX.Element {
// 只在 Cypress 测试环境下暴露 store 到 window 对象
if (
typeof window !== 'undefined' &&
(window as unknown as { Cypress: unknown }).Cypress
) {
(window as unknown as { store: typeof store }).store = store;
}
return (
<Provider store={store}>
<AppContent>{children}</AppContent>
</Provider>
);
}
export default App;
导入注释管理器:
import { initializeAnnotationManager, cleanupAnnotationManager } from '@/features/imageAnnotation';
typescript
// 在产品状态和国际化初始化完成后启动注释管理器
await initializeAnnotationManager();
应用退出时清理:
useEffect(() => {
// 初始化逻辑...
return () => {
// 应用退出时清理注释管理器
cleanupAnnotationManager().catch(console.error);
};
}, []);
基于项目的feature-based架构,新功能应该放置在独立的feature文件夹中:
```
src/features/ ├── imageAnnotation/ # 图像注释功能模块 │ ├── index.ts # 模块导出 │ ├── components/ # UI组件 │ │ ├── AnnotationManager.tsx # 注释管理组件 │ │ └── AnnotationStatus.tsx # 注释状态显示组件 │ ├── services/ # 业务逻辑服务 │ │ ├── AnnotationAPI.ts # API调用封装 │ │ ├── AnnotationSerializer.ts # 注释序列化/反序列化 │ │ └── AnnotationValidator.ts # 数据验证 │ ├── state/ # Redux状态管理 │ │ ├── annotationSlice.ts # 注释状态管理 │ │ └── annotationThunks.ts # 异步操作 │ ├── types/ # TypeScript类型定义 │ │ ├── annotation.ts # 注释数据类型 │ │ └── api.ts # API接口类型 │ └── utils/ # 工具函数 │ ├── coordinateMapper.ts # 坐标系转换 │ ├── eventHandlers.ts # 事件处理 │ └── storage.ts # 本地存储 └── serverConfig/ # 现有功能
### 🔄 与现有代码的集成方案
#### 现有代码分析
**当前注释相关实现:**
- `MarkPanel.tsx` - 纯UI组件,只管理标记文本,没有后端交互
- `markPanelSlice.ts` - Redux状态管理,只处理UI状态
- Cornerstone Tools - 已实现各种注释工具,但没有持久化
**集成影响评估:**
1. **✅ 低影响集成点**
- 扩展`markPanelSlice.ts`添加注释持久化状态
- 在`ImageOperationPanel.tsx`和`MeasurementPanel.tsx`中添加保存触发逻辑
- 添加新的API调用函数
2. **⚠️ 中等影响点**
- 修改MarkPanel组件集成注释管理器
- 在图像加载流程中添加注释加载逻辑
3. **❌ 最小化核心修改**
- 不修改现有Cornerstone Tools实现
- 不破坏现有UI交互逻辑
- 保持向后兼容性
#### 集成策略
**全局事件监听方案(推荐):**
基于我们的讨论,最终采用**全局事件监听**策略,只需要修改`src/app.tsx`即可集成注释管理器,无需修改其他现有代码:
```typescript
// src/app.tsx - 唯一需要修改的文件
import { initializeAnnotationManager, cleanupAnnotationManager } from '@/features/imageAnnotation';
// 在initializeApp函数中添加注释管理器初始化
const initializeApp = async () => {
try {
const productState = await dispatch(initializeProductState()).unwrap();
const languageCode = productState.language;
await dispatch(loadI18nMessages(languageCode)).unwrap();
// ✅ 初始化注释管理器(在产品状态和国际化之后)
await initializeAnnotationManager();
setIsI18nReady(true);
} catch (error) {
console.error('应用初始化失败:', error);
setShowConfigModal(true);
}
};
// 在useEffect cleanup中添加注释管理器清理
useEffect(() => {
// ... 现有初始化逻辑 ...
// 应用退出时清理注释管理器
return () => {
cleanupAnnotationManager().catch(console.error);
};
}, [dispatch]);
为什么不修改markPanelSlice?
经过分析,markPanelSlice目前只管理UI状态(自定义标记列表、输入框状态等),注释数据应该:
为什么ImageViewerComponent不存在?
经过代码分析,实际的图像查看组件是ViewerContainer.tsx,它负责:
全局事件监听方案的优势:
新增API文件:
// src/API/annotation.ts (新增)
import { request } from './interceptor';
export const annotationAPI = {
// 获取图像注释
getAnnotations: (imageId: string) =>
request.get(`/dr/api/v1/auth/image/${imageId}/annotation`),
// 保存图像注释
saveAnnotations: (imageId: string, data: any) =>
request.post(`/dr/api/v1/auth/image/${imageId}/annotation`, data),
};
无
最小化侵入式集成:
基于全局事件监听方案,最终实现零侵入集成:
| 模块 | 修改类型 | 影响程度 | 向后兼容性 | 备注 |
|---|---|---|---|---|
app.tsx |
新增2行导入 + 2行代码 | 极低 | ✅ 完全兼容 | 唯一修改点 |
MarkPanel.tsx |
无需修改 | 无 | ✅ 完全兼容 | 保持现有UI功能 |
markPanelSlice.ts |
无需修改 | 无 | ✅ 完全兼容 | 保持现有状态管理 |
ImageOperationPanel.tsx |
无需修改 | 无 | ✅ 完全兼容 | 保持现有操作逻辑 |
MeasurementPanel.tsx |
无需修改 | 无 | ✅ 完全兼容 | 保持现有测量功能 |
ViewerContainer.tsx |
无需修改 | 无 | ✅ 完全兼容 | 保持现有显示逻辑 |
Cornerstone Tools |
无需修改 | 无 | ✅ 完全兼容 | 保持现有实现 |
API层 |
新增文件 | 无 | ✅ 不影响现有 | 新增注释API |
类型定义 |
扩展 | 低 | ✅ 完全兼容 | 添加注释类型 |
src/features/imageAnnotation文件夹结构这个方案确保了新功能能够平滑集成到现有系统中,同时将影响降到最低。