2026-05-04-05-20-16 优化DICOM切片下载和3D预览

This commit is contained in:
2026-05-04 05:32:34 +08:00
parent 4ef3be69f4
commit 4922c2d991
8 changed files with 584 additions and 37 deletions

View File

@@ -3,10 +3,14 @@ import {
Plus,
Search,
Eye,
FileArchive,
RotateCw,
RotateCcw,
Box,
Image as ImageIcon,
ChevronRight,
ChevronUp,
ChevronDown,
Edit2,
FolderRoot,
Download,
@@ -17,7 +21,7 @@ import {
} from 'lucide-react';
import * as THREE from 'three';
import { DicomPreview, Project } from '../types';
import { api, downloadMask } from '../lib/api';
import { api, downloadDicomArchive, downloadMask } from '../lib/api';
type Plane = 'axial' | 'sagittal' | 'coronal';
type DisplayMode = DicomPreview['mode'];
@@ -42,8 +46,9 @@ function drawFallbackModelPreview(
previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>,
) {
const rect = canvas.getBoundingClientRect();
const width = Math.max(Math.floor(rect.width), 1);
const height = Math.max(Math.floor(rect.height), 1);
const parentRect = canvas.parentElement?.getBoundingClientRect();
const width = Math.max(Math.floor(rect.width || parentRect?.width || 720), 1);
const height = Math.max(Math.floor(rect.height || parentRect?.height || 460), 1);
canvas.width = width * window.devicePixelRatio;
canvas.height = height * window.devicePixelRatio;
canvas.style.width = `${width}px`;
@@ -100,7 +105,56 @@ function drawFallbackModelPreview(
context.globalAlpha = 1;
}
function DicomCanvas({ preview }: { preview: DicomPreview }) {
function drawDicomPreviewToCanvas(canvas: HTMLCanvasElement, preview: DicomPreview, rotation: number) {
const normalizedRotation = ((rotation % 360) + 360) % 360;
const sourceCanvas = document.createElement('canvas');
sourceCanvas.width = preview.width;
sourceCanvas.height = preview.height;
const sourceContext = sourceCanvas.getContext('2d');
const targetContext = canvas.getContext('2d');
if (!sourceContext || !targetContext) {
return;
}
const binary = atob(preview.pixels);
const imageData = sourceContext.createImageData(preview.width, preview.height);
for (let i = 0; i < binary.length; i += 1) {
const value = binary.charCodeAt(i);
const offset = i * 4;
imageData.data[offset] = value;
imageData.data[offset + 1] = value;
imageData.data[offset + 2] = value;
imageData.data[offset + 3] = 255;
}
sourceContext.putImageData(imageData, 0, 0);
const isQuarterTurn = normalizedRotation === 90 || normalizedRotation === 270;
canvas.width = isQuarterTurn ? preview.height : preview.width;
canvas.height = isQuarterTurn ? preview.width : preview.height;
targetContext.clearRect(0, 0, canvas.width, canvas.height);
targetContext.save();
targetContext.imageSmoothingEnabled = true;
if (normalizedRotation === 90) {
targetContext.translate(canvas.width, 0);
targetContext.rotate(Math.PI / 2);
} else if (normalizedRotation === 180) {
targetContext.translate(canvas.width, canvas.height);
targetContext.rotate(Math.PI);
} else if (normalizedRotation === 270) {
targetContext.translate(0, canvas.height);
targetContext.rotate(-Math.PI / 2);
}
targetContext.drawImage(sourceCanvas, 0, 0);
targetContext.restore();
}
function safeFilePart(value: string) {
return value.trim().replace(/[^\u4e00-\u9fa5a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'dicom';
}
function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
@@ -108,30 +162,13 @@ function DicomCanvas({ preview }: { preview: DicomPreview }) {
if (!canvas) {
return;
}
const context = canvas.getContext('2d');
if (!context) {
return;
}
const binary = atob(preview.pixels);
const imageData = context.createImageData(preview.width, preview.height);
for (let i = 0; i < binary.length; i += 1) {
const value = binary.charCodeAt(i);
const offset = i * 4;
imageData.data[offset] = value;
imageData.data[offset + 1] = value;
imageData.data[offset + 2] = value;
imageData.data[offset + 3] = 255;
}
context.putImageData(imageData, 0, 0);
}, [preview]);
drawDicomPreviewToCanvas(canvas, preview, rotation);
}, [preview, rotation]);
return (
<canvas
ref={canvasRef}
width={preview.width}
height={preview.height}
className="max-h-full max-w-full object-contain rounded-xl shadow-2xl"
className="max-h-full max-w-full object-contain rounded-xl bg-black shadow-2xl ring-1 ring-white/25"
/>
);
}
@@ -167,6 +204,8 @@ function NativeStlViewer({
const scene = new THREE.Scene();
scene.background = new THREE.Color('#f8fafc');
const camera = new THREE.PerspectiveCamera(45, Math.max(container.clientWidth, 1) / Math.max(container.clientHeight, 1), 0.1, 1000);
camera.position.set(4.5, 3.5, 5);
camera.lookAt(0, 0, 0);
let renderer: THREE.WebGLRenderer | null = null;
try {
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
@@ -265,10 +304,16 @@ function NativeStlViewer({
const box = new THREE.Box3().setFromObject(group);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
group.position.sub(center);
const maxSize = Math.max(size.x, size.y, size.z) || 1;
group.scale.setScalar(4 / maxSize);
camera.position.set(4.5, 3.5, 5);
group.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.translate(-center.x, -center.y, -center.z);
object.geometry.computeBoundingSphere();
object.geometry.computeVertexNormals();
}
});
group.position.set(0, 0, 0);
group.scale.setScalar(4.2 / maxSize);
camera.lookAt(0, 0, 0);
setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成');
}
@@ -350,6 +395,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
const [sliceIndex, setSliceIndex] = useState(0);
const [plane, setPlane] = useState<Plane>('axial');
const [displayMode, setDisplayMode] = useState<DisplayMode>('default');
const [rotation, setRotation] = useState(0);
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
const [dicomError, setDicomError] = useState('');
@@ -359,6 +405,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
const [editingProjectId, setEditingProjectId] = useState('');
const [editingName, setEditingName] = useState('');
const [actionMessage, setActionMessage] = useState('');
const sliceRepeatRef = useRef<number | null>(null);
const refreshProjects = () => {
setLoading(true);
@@ -400,6 +447,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
{ id: 'contrast', label: '高对比' },
];
const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false);
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0;
useEffect(() => {
const next: Record<string, ModuleStyle> = {};
@@ -439,6 +487,19 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
cancelled = true;
};
}, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, displayMode, viewMode]);
useEffect(() => () => {
if (sliceRepeatRef.current !== null) {
window.clearInterval(sliceRepeatRef.current);
}
}, []);
useEffect(() => {
const max = Math.max(sliceTotal - 1, 0);
if (sliceIndex > max) {
setSliceIndex(max);
}
}, [sliceIndex, sliceTotal]);
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
setModuleStyles(prev => ({
@@ -468,6 +529,49 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
});
};
const stepSlice = (delta: number) => {
setSliceIndex((current) => {
const max = Math.max((dicomPreview?.total ?? selectedProject?.dicomCount ?? 1) - 1, 0);
return Math.max(0, Math.min(max, current + delta));
});
};
const stopSliceStep = () => {
if (sliceRepeatRef.current !== null) {
window.clearInterval(sliceRepeatRef.current);
sliceRepeatRef.current = null;
}
};
const startSliceStep = (delta: number) => {
stopSliceStep();
stepSlice(delta);
sliceRepeatRef.current = window.setInterval(() => stepSlice(delta), 110);
};
const rotateDicom = (delta: number) => {
setRotation((current) => ((current + delta) % 360 + 360) % 360);
};
const downloadCurrentDicomPng = () => {
if (!dicomPreview || !selectedProject) {
setActionMessage('当前没有可下载的 DICOM 图片');
return;
}
const canvas = document.createElement('canvas');
drawDicomPreviewToCanvas(canvas, dicomPreview, rotation);
const link = document.createElement('a');
const planeLabel = planeOptions.find((option) => option.id === plane)?.label ?? plane;
const modeLabel = displayModes.find((mode) => mode.id === displayMode)?.label ?? displayMode;
link.href = canvas.toDataURL('image/png');
link.download = `${safeFilePart(selectedProject.name)}_${planeLabel}_slice-${dicomPreview.slice + 1}-of-${dicomPreview.total}_${modeLabel}_rot-${rotation}.png`;
document.body.appendChild(link);
link.click();
link.remove();
setActionMessage('已生成当前 DICOM 图片 PNG');
};
const handleCreateProject = async () => {
const name = newProjectName.trim();
if (!name) {
@@ -712,6 +816,22 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
</button>
))}
</div>
<div className="absolute top-28 right-4 z-10 flex rounded-lg bg-white/5 p-1 backdrop-blur-sm border border-white/10">
<button
onClick={() => rotateDicom(-90)}
className="px-3 py-1.5 rounded-md text-[10px] font-bold text-white/60 hover:bg-white/10 hover:text-white transition-all flex items-center gap-1"
title="左旋转 90°"
>
<RotateCcw size={12} />
</button>
<button
onClick={() => rotateDicom(90)}
className="px-3 py-1.5 rounded-md text-[10px] font-bold text-white/60 hover:bg-white/10 hover:text-white transition-all flex items-center gap-1"
title="右旋转 90°"
>
<RotateCw size={12} />
</button>
</div>
<div className="absolute top-4 left-4 text-white/40 font-mono text-[10px] space-y-1">
<p>PATIENT ID: {selectedProject.id}_XYZ</p>
<p>SCAN DATE: {selectedProject.createTime}</p>
@@ -719,7 +839,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
</div>
<div className="relative w-full h-full flex items-center justify-center">
{dicomPreview ? (
<DicomCanvas preview={dicomPreview} />
<DicomCanvas preview={dicomPreview} rotation={rotation} />
) : (
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">{dicomError || '正在解析 DICOM 像素...'}</p>
)}
@@ -733,18 +853,62 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
<div className="w-24 h-full flex flex-col items-center py-4 bg-slate-50 rounded-2xl">
<span className="text-[10px] text-slate-400 font-bold mb-3"></span>
<span className="text-[10px] text-slate-500 font-bold mb-4 whitespace-nowrap">
{sliceIndex + 1} / {dicomPreview?.total ?? selectedProject.dicomCount}
{sliceIndex + 1} / {sliceTotal || selectedProject.dicomCount}
</span>
<button
onMouseDown={() => startSliceStep(1)}
onMouseUp={stopSliceStep}
onMouseLeave={stopSliceStep}
onTouchStart={(event) => {
event.preventDefault();
startSliceStep(1);
}}
onTouchEnd={stopSliceStep}
className="mb-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
title="长按向上移动切片"
>
<ChevronUp size={16} />
</button>
<input
type="range"
min="0"
max={Math.max((dicomPreview?.total ?? selectedProject.dicomCount) - 1, 0)}
max={Math.max((sliceTotal || selectedProject.dicomCount) - 1, 0)}
value={sliceIndex}
onChange={(e) => setSliceIndex(Number(e.target.value))}
className="flex-1 w-6 accent-blue-600 cursor-pointer"
style={{ writingMode: 'vertical-lr', direction: 'rtl' }}
/>
<button
onMouseDown={() => startSliceStep(-1)}
onMouseUp={stopSliceStep}
onMouseLeave={stopSliceStep}
onTouchStart={(event) => {
event.preventDefault();
startSliceStep(-1);
}}
onTouchEnd={stopSliceStep}
className="mt-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
title="长按向下移动切片"
>
<ChevronDown size={16} />
</button>
<span className="text-[10px] text-blue-600 font-bold mt-4">#{sliceIndex + 1}</span>
<div className="mt-5 flex w-full flex-col gap-2 px-2">
<button
onClick={downloadCurrentDicomPng}
className="h-8 rounded-lg bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center gap-1 hover:bg-blue-700"
title="下载当前图片 PNG"
>
<Download size={12} /> PNG
</button>
<button
onClick={() => selectedProject && downloadDicomArchive(selectedProject.id)}
className="h-8 rounded-lg bg-white text-slate-600 text-[10px] font-bold flex items-center justify-center gap-1 border border-slate-200 hover:bg-slate-100"
title="下载 DICOM 影像压缩包"
>
<FileArchive size={12} /> DCM
</button>
</div>
</div>
</div>
)}