Files
DataMate/frontend/src/pages/DataAnnotation/Annotate/components/ImageAnnotation.tsx
chenghh-9609 e8e2c1a96b refactor: 修复标签管理功能、优化数据选择项显示、屏蔽开发中功能 (#12)
* refactor: clean up tag management and dataset handling, update API endpoints

* feat: add showTime prop to DevelopmentInProgress component across multiple pages

* refactor: update component styles and improve layout with new utility classes
2025-10-22 16:09:03 +08:00

618 lines
19 KiB
TypeScript

import type React from "react";
import { useState, useRef, useEffect } from "react";
import { Button, Badge, Checkbox, message } from "antd";
import {
Square,
Circle,
MousePointer,
ZoomIn,
ZoomOut,
RotateCcw,
ArrowLeft,
ArrowRight,
MoreHorizontal,
} from "lucide-react";
interface Annotation {
id: string;
type: "rectangle" | "circle" | "polygon";
label: string;
color: string;
coordinates: number[];
visible: boolean;
}
interface ImageAnnotationWorkspaceProps {
task: any;
currentFileIndex: number;
onSaveAndNext: () => void;
onSkipAndNext: () => void;
}
// 模拟医学图像数据
const mockMedicalImages = [
{
id: "1",
name: "2024-123456",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide1",
url: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/img_v3_02oi_e6dd5540-9ca4-4277-ad2b-4debaa1c8ddg.jpg-oibLbUmFpZMkLTmwZB7lT1UWKFlOLA.jpeg",
},
{
id: "2",
name: "2024-234567",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide2",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 2",
},
{
id: "3",
name: "2025-345678",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide3",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 3",
},
{
id: "4",
name: "1234-123456",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide4",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 4",
},
{
id: "5",
name: "2025-456789",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide5",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 5",
},
{
id: "6",
name: "2025-567890",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide6",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 6",
},
{
id: "7",
name: "2025-678901",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide7",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 7",
},
{
id: "8",
name: "2025-789012",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide8",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 8",
},
{
id: "9",
name: "2025-890123",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide9",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 9",
},
{
id: "10",
name: "2025-901234",
thumbnail: "/placeholder.svg?height=60&width=60&text=Slide10",
url: "/placeholder.svg?height=600&width=800&text=Medical Image 10",
},
];
// 医学标注选项
const medicalAnnotationOptions = [
{
id: "tumor_present",
label: "是否有肿瘤",
type: "radio",
options: ["是", "否"],
},
{
id: "tumor_type",
label: "肿瘤形成",
type: "checkbox",
options: ["腺管形成"],
},
{ id: "grade_1", label: "1级", type: "checkbox", options: ["1[x]"] },
{ id: "grade_2", label: "2级", type: "checkbox", options: ["2[x]"] },
{ id: "remarks", label: "备注", type: "textarea" },
{
id: "nuclear_polymorphism",
label: "核多形性",
type: "checkbox",
options: ["核分裂象"],
},
{
id: "histological_type",
label: "组织学类型",
type: "checkbox",
options: ["1[b]", "2[y]", "3[t]"],
},
{
id: "small_time_lesion",
label: "小时病位置[3]",
type: "checkbox",
options: ["1[b]", "2[y]", "3[t]"],
},
{
id: "ductal_position",
label: "导管原位置[4]",
type: "checkbox",
options: ["1[o]", "2[p]", "3[t]"],
},
{
id: "ductal_position_large",
label: "导管原位置件大于腺分",
type: "checkbox",
options: ["腺分裂象"],
},
{
id: "mitosis",
label: "化[5]",
type: "checkbox",
options: ["1[o]", "2[p]", "3[t]"],
},
{
id: "original_position",
label: "原位实性乳头状[6]",
type: "checkbox",
options: ["1[o]", "2[p]", "3[t]"],
},
{
id: "infiltrating_lesion",
label: "浸润性病(非特殊型)[7]",
type: "checkbox",
options: ["1[o]", "2[p]", "3[t]"],
},
{
id: "infiltrating_small",
label: "浸润性小叶癌[8]",
type: "checkbox",
options: ["脉管侵犯"],
},
{
id: "infiltrating_real",
label: "浸润实性乳头状癌[9]",
type: "checkbox",
options: ["1[o]", "2[p]", "3[t]"],
},
{
id: "other_lesion",
label: "其他病[0]",
type: "checkbox",
options: ["+[k]"],
},
];
export default function ImageAnnotationWorkspace({
currentFileIndex,
}: ImageAnnotationWorkspaceProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [selectedImageIndex, setSelectedImageIndex] = useState(
currentFileIndex || 0
);
const [currentImage, setCurrentImage] = useState(
mockMedicalImages[selectedImageIndex]
);
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [selectedTool, setSelectedTool] = useState<
"select" | "rectangle" | "circle"
>("select");
const [isDrawing, setIsDrawing] = useState(false);
const [startPoint, setStartPoint] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [selectedAnnotation, setSelectedAnnotation] = useState<string | null>(
null
);
const [annotationValues, setAnnotationValues] = useState<Record<string, any>>(
{}
);
useEffect(() => {
setCurrentImage(mockMedicalImages[selectedImageIndex]);
drawCanvas();
}, [selectedImageIndex, annotations, zoom, pan]);
const drawCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
ctx.save();
ctx.scale(zoom, zoom);
ctx.translate(pan.x, pan.y);
ctx.drawImage(img, 0, 0, canvas.width / zoom, canvas.height / zoom);
// 绘制标注
annotations.forEach((annotation) => {
if (!annotation.visible) return;
ctx.strokeStyle = annotation.color;
ctx.fillStyle = annotation.color + "20";
ctx.lineWidth = 2;
if (annotation.type === "rectangle") {
const [x, y, width, height] = annotation.coordinates;
ctx.strokeRect(x, y, width, height);
ctx.fillRect(x, y, width, height);
} else if (annotation.type === "circle") {
const [centerX, centerY, radius] = annotation.coordinates;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
ctx.stroke();
ctx.fill();
}
if (selectedAnnotation === annotation.id) {
ctx.strokeStyle = "#FF0000";
ctx.lineWidth = 3;
ctx.setLineDash([5, 5]);
if (annotation.type === "rectangle") {
const [x, y, width, height] = annotation.coordinates;
ctx.strokeRect(x - 2, y - 2, width + 4, height + 4);
} else if (annotation.type === "circle") {
const [centerX, centerY, radius] = annotation.coordinates;
ctx.beginPath();
ctx.arc(centerX, centerY, radius + 2, 0, 2 * Math.PI);
ctx.stroke();
}
ctx.setLineDash([]);
}
});
ctx.restore();
};
img.src = currentImage.url;
};
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - pan.x) / zoom;
const y = (e.clientY - rect.top - pan.y) / zoom;
if (selectedTool === "rectangle" || selectedTool === "circle") {
setIsDrawing(true);
setStartPoint({ x, y });
} else if (selectedTool === "select") {
const clickedAnnotation = annotations.find((annotation) => {
if (annotation.type === "rectangle") {
const [ax, ay, width, height] = annotation.coordinates;
return x >= ax && x <= ax + width && y >= ay && y <= ay + height;
} else if (annotation.type === "circle") {
const [centerX, centerY, radius] = annotation.coordinates;
const distance = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
return distance <= radius;
}
return false;
});
setSelectedAnnotation(clickedAnnotation?.id || null);
}
};
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - pan.x) / zoom;
const y = (e.clientY - rect.top - pan.y) / zoom;
drawCanvas();
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.save();
ctx.scale(zoom, zoom);
ctx.translate(pan.x, pan.y);
ctx.strokeStyle = "#3B82F6";
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
if (selectedTool === "rectangle") {
const width = x - startPoint.x;
const height = y - startPoint.y;
ctx.strokeRect(startPoint.x, startPoint.y, width, height);
} else if (selectedTool === "circle") {
const radius = Math.sqrt(
(x - startPoint.x) ** 2 + (y - startPoint.y) ** 2
);
ctx.beginPath();
ctx.arc(startPoint.x, startPoint.y, radius, 0, 2 * Math.PI);
ctx.stroke();
}
ctx.restore();
};
const handleCanvasMouseUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left - pan.x) / zoom;
const y = (e.clientY - rect.top - pan.y) / zoom;
let coordinates: number[] = [];
if (selectedTool === "rectangle") {
const width = x - startPoint.x;
const height = y - startPoint.y;
coordinates = [startPoint.x, startPoint.y, width, height];
} else if (selectedTool === "circle") {
const radius = Math.sqrt(
(x - startPoint.x) ** 2 + (y - startPoint.y) ** 2
);
coordinates = [startPoint.x, startPoint.y, radius];
}
if (coordinates.length > 0) {
const newAnnotation: Annotation = {
id: Date.now().toString(),
type: selectedTool as "rectangle" | "circle",
label: "标注",
color: "#3B82F6",
coordinates,
visible: true,
};
setAnnotations([...annotations, newAnnotation]);
}
setIsDrawing(false);
};
const handleAnnotationValueChange = (optionId: string, value: any) => {
setAnnotationValues((prev) => ({
...prev,
[optionId]: value,
}));
};
const handleUpdate = () => {
message({
title: "标注已更新",
description: "医学标注信息已保存",
});
};
return (
<div className="h-screen flex">
{/* Left Sidebar - Image List */}
<div className="w-80 border-r bg-white">
{/* Header */}
<div className="p-4 border-b bg-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Badge variant="outline">image</Badge>
<Badge className="bg-blue-100 text-blue-800">img</Badge>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">case_id</span>
<span className="text-sm font-mono">#13754</span>
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
</div>
</div>
<div className="flex items-center justify-between mt-2">
<div className="flex items-center space-x-2">
<div className="w-6 h-6 bg-purple-500 rounded-full flex items-center justify-center text-white text-xs">
DE
</div>
<span className="text-sm text-gray-600">de #14803</span>
</div>
<span className="text-xs text-gray-500">11 days ago</span>
</div>
</div>
{/* Image List */}
<div className="p-2">
{mockMedicalImages.map((image, index) => (
<div
key={image.id}
className={`flex items-center p-3 mb-2 rounded-lg cursor-pointer transition-colors ${
selectedImageIndex === index
? "bg-blue-50 border border-blue-200"
: "hover:bg-gray-50"
}`}
onClick={() => setSelectedImageIndex(index)}
>
<div className="w-8 h-8 bg-gray-200 rounded flex-center text-sm font-medium mr-3">
{index + 1}
</div>
<img
src={image.thumbnail || "/placeholder.svg"}
alt={`Slide ${index + 1}`}
className="w-12 h-12 rounded border mr-3"
/>
<div className="flex-1">
<div className="font-medium text-sm">{image.name}</div>
</div>
</div>
))}
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 flex flex-col">
{/* Main Image Display */}
<div className="flex-1 p-4">
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">WSI图像预览</h2>
<div className="flex items-center space-x-2">
<div className="text-sm text-gray-600">
: <span className="font-mono">1234-123456</span>
</div>
<div className="text-sm text-gray-600">
: <span></span>
</div>
</div>
</div>
<div className="flex-1 border-card overflow-hidden bg-gray-100 relative">
<canvas
ref={canvasRef}
width={800}
height={600}
className="w-full h-full object-contain cursor-crosshair"
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
/>
{/* Zoom Controls */}
<div className="absolute bottom-4 left-4 flex items-center space-x-2 bg-white rounded-lg shadow-lg p-2">
<Button onClick={() => setZoom(Math.max(zoom / 1.2, 0.1))}>
<ZoomOut className="w-4 h-4" />
</Button>
<span className="text-sm px-2">{Math.round(zoom * 100)}%</span>
<Button onClick={() => setZoom(Math.min(zoom * 1.2, 5))}>
<ZoomIn className="w-4 h-4" />
</Button>
<Button
onClick={() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}}
>
<RotateCcw className="w-4 h-4" />
</Button>
</div>
{/* Tool Selection */}
<div className="absolute top-4 left-4 flex items-center space-x-2 bg-white rounded-lg shadow-lg p-2">
<Button
variant={selectedTool === "select" ? "default" : "outline"}
size="sm"
onClick={() => setSelectedTool("select")}
>
<MousePointer className="w-4 h-4" />
</Button>
<Button
variant={selectedTool === "rectangle" ? "default" : "outline"}
size="sm"
onClick={() => setSelectedTool("rectangle")}
>
<Square className="w-4 h-4" />
</Button>
<Button
variant={selectedTool === "circle" ? "default" : "outline"}
size="sm"
onClick={() => setSelectedTool("circle")}
>
<Circle className="w-4 h-4" />
</Button>
</div>
</div>
{/* Navigation Controls */}
<div className="flex items-center justify-center mt-4 space-x-2">
<Button>
<ArrowLeft className="w-4 h-4" />
</Button>
<Button>
<ArrowRight className="w-4 h-4" />
</Button>
<span className="text-sm text-gray-600 mx-4"></span>
</div>
</div>
</div>
</div>
{/* Right Sidebar - Annotation Panel */}
<div className="w-80 border-l bg-gray-50 p-4">
<div className="space-y-4">
<div>
<h3 className="font-semibold text-lg mb-4"></h3>
<div className="space-y-4">
{medicalAnnotationOptions.map((option) => (
<div key={option.id} className="space-y-2">
<span className="text-sm font-medium">{option.label}</span>
{option.type === "radio" && (
<div className="space-y-1">
{option.options?.map((opt) => (
<div key={opt} className="flex items-center space-x-2">
<input
type="radio"
name={option.id}
value={opt}
onChange={(e) =>
handleAnnotationValueChange(
option.id,
e.target.value
)
}
className="w-4 h-4"
/>
<span className="text-sm">{opt}</span>
</div>
))}
</div>
)}
{option.type === "checkbox" && (
<div className="space-y-1">
{option.options?.map((opt) => (
<div key={opt} className="flex items-center space-x-2">
<Checkbox
checked={
annotationValues[`${option.id}_${opt}`] || false
}
onChange={(checked) =>
handleAnnotationValueChange(
`${option.id}_${opt}`,
checked
)
}
/>
<span className="text-sm">{opt}</span>
</div>
))}
</div>
)}
{option.type === "textarea" && (
<textarea
className="w-full p-2 border rounded-md text-sm resize-none"
rows={3}
placeholder={`请输入${option.label}`}
value={annotationValues[option.id] || ""}
onChange={(e) =>
handleAnnotationValueChange(option.id, e.target.value)
}
/>
)}
</div>
))}
</div>
</div>
<div className="pt-4 border-t">
<Button
onClick={handleUpdate}
className="w-full bg-amber-600 hover:bg-amber-700"
>
Update
</Button>
</div>
</div>
</div>
</div>
);
}