2026-05-04-05-20-16 优化DICOM切片下载和3D预览
This commit is contained in:
@@ -87,7 +87,11 @@ function listFiles(dir: string, extension: string) {
|
|||||||
.readdirSync(dir, { withFileTypes: true })
|
.readdirSync(dir, { withFileTypes: true })
|
||||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(extension))
|
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(extension))
|
||||||
.map((entry) => entry.name)
|
.map((entry) => entry.name)
|
||||||
.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
|
.sort(naturalFileCompare);
|
||||||
|
}
|
||||||
|
|
||||||
|
function naturalFileCompare(a: string, b: string) {
|
||||||
|
return a.localeCompare(b, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicUser(user: UserRecord) {
|
function publicUser(user: UserRecord) {
|
||||||
@@ -279,7 +283,7 @@ function getProjectDicomFiles(project: ProjectRecord) {
|
|||||||
if (project.id !== 'head-ct-demo') {
|
if (project.id !== 'head-ct-demo') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return listFiles(dicomDir, '.dcm').sort((a, b) => Number.parseInt(a) - Number.parseInt(b));
|
return listFiles(dicomDir, '.dcm');
|
||||||
}
|
}
|
||||||
|
|
||||||
function readAsciiValue(buffer: Buffer, start: number, length: number) {
|
function readAsciiValue(buffer: Buffer, start: number, length: number) {
|
||||||
@@ -375,10 +379,12 @@ function parseDicomPreview(filePath: string, mode: DicomDisplayMode = 'default')
|
|||||||
pixels[i] = normalized;
|
pixels[i] = normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enhancedPixels = enhanceDicomEdges(pixels, columns, rows);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: columns,
|
width: columns,
|
||||||
height: rows,
|
height: rows,
|
||||||
pixels: pixels.toString('base64'),
|
pixels: enhancedPixels.toString('base64'),
|
||||||
windowCenter,
|
windowCenter,
|
||||||
windowWidth,
|
windowWidth,
|
||||||
mode,
|
mode,
|
||||||
@@ -440,10 +446,13 @@ function createReformattedPreview(files: string[], plane: Exclude<DicomPlane, 'a
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cropped = cropDicomContent(pixels, outputWidth, outputHeight);
|
||||||
|
const enhancedPixels = enhanceDicomEdges(cropped.pixels, cropped.width, cropped.height);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: outputWidth,
|
width: cropped.width,
|
||||||
height: outputHeight,
|
height: cropped.height,
|
||||||
pixels: pixels.toString('base64'),
|
pixels: enhancedPixels.toString('base64'),
|
||||||
windowCenter: volume.windowCenter,
|
windowCenter: volume.windowCenter,
|
||||||
windowWidth: volume.windowWidth,
|
windowWidth: volume.windowWidth,
|
||||||
slice: clampedSlice,
|
slice: clampedSlice,
|
||||||
@@ -453,6 +462,71 @@ function createReformattedPreview(files: string[], plane: Exclude<DicomPlane, 'a
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enhanceDicomEdges(pixels: Buffer, width: number, height: number) {
|
||||||
|
if (width < 3 || height < 3) {
|
||||||
|
return pixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = Buffer.from(pixels);
|
||||||
|
for (let y = 1; y < height - 1; y += 1) {
|
||||||
|
for (let x = 1; x < width - 1; x += 1) {
|
||||||
|
const index = y * width + x;
|
||||||
|
const center = pixels[index];
|
||||||
|
const neighborAverage = (
|
||||||
|
pixels[index - 1] +
|
||||||
|
pixels[index + 1] +
|
||||||
|
pixels[index - width] +
|
||||||
|
pixels[index + width]
|
||||||
|
) / 4;
|
||||||
|
const sharpened = Math.round(center * 1.08 + (center - neighborAverage) * 0.55);
|
||||||
|
output[index] = Math.max(0, Math.min(255, sharpened));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cropDicomContent(pixels: Buffer, width: number, height: number) {
|
||||||
|
const threshold = 12;
|
||||||
|
const columnHits = Array.from({ length: width }, () => 0);
|
||||||
|
const rowHits = Array.from({ length: height }, () => 0);
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
if (pixels[y * width + x] > threshold) {
|
||||||
|
columnHits[x] += 1;
|
||||||
|
rowHits[y] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minColumnHits = Math.max(4, Math.floor(height * 0.012));
|
||||||
|
const minRowHits = Math.max(4, Math.floor(width * 0.012));
|
||||||
|
let minX = columnHits.findIndex((hits) => hits >= minColumnHits);
|
||||||
|
let maxX = width - 1 - [...columnHits].reverse().findIndex((hits) => hits >= minColumnHits);
|
||||||
|
let minY = rowHits.findIndex((hits) => hits >= minRowHits);
|
||||||
|
let maxY = height - 1 - [...rowHits].reverse().findIndex((hits) => hits >= minRowHits);
|
||||||
|
|
||||||
|
if (maxX < minX || maxY < minY) {
|
||||||
|
return { pixels, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
const padding = 18;
|
||||||
|
minX = Math.max(0, minX - padding);
|
||||||
|
minY = Math.max(0, minY - padding);
|
||||||
|
maxX = Math.min(width - 1, maxX + padding);
|
||||||
|
maxY = Math.min(height - 1, maxY + padding);
|
||||||
|
|
||||||
|
const croppedWidth = maxX - minX + 1;
|
||||||
|
const croppedHeight = maxY - minY + 1;
|
||||||
|
const croppedPixels = Buffer.alloc(croppedWidth * croppedHeight);
|
||||||
|
for (let row = 0; row < croppedHeight; row += 1) {
|
||||||
|
const sourceStart = (minY + row) * width + minX;
|
||||||
|
pixels.copy(croppedPixels, row * croppedWidth, sourceStart, sourceStart + croppedWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pixels: croppedPixels, width: croppedWidth, height: croppedHeight };
|
||||||
|
}
|
||||||
|
|
||||||
function createStlPreview(filePath: string, fileName: string, limit: number) {
|
function createStlPreview(filePath: string, fileName: string, limit: number) {
|
||||||
const cacheKey = `${fileName}:${limit}`;
|
const cacheKey = `${fileName}:${limit}`;
|
||||||
const cached = modelPreviewCache.get(cacheKey);
|
const cached = modelPreviewCache.get(cacheKey);
|
||||||
@@ -503,6 +577,52 @@ function createStlPreview(filePath: string, fileName: string, limit: number) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeOctal(buffer: Buffer, value: number, offset: number, length: number) {
|
||||||
|
const text = value.toString(8).padStart(length - 1, '0').slice(-(length - 1));
|
||||||
|
buffer.write(`${text}\0`, offset, length, 'ascii');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTarEntryHeader(name: string, size: number, mtime: number) {
|
||||||
|
const header = Buffer.alloc(512);
|
||||||
|
const safeName = name.slice(0, 100);
|
||||||
|
header.write(safeName, 0, 100, 'utf8');
|
||||||
|
writeOctal(header, 0o644, 100, 8);
|
||||||
|
writeOctal(header, 0, 108, 8);
|
||||||
|
writeOctal(header, 0, 116, 8);
|
||||||
|
writeOctal(header, size, 124, 12);
|
||||||
|
writeOctal(header, Math.floor(mtime), 136, 12);
|
||||||
|
header.fill(' ', 148, 156);
|
||||||
|
header.write('0', 156, 1, 'ascii');
|
||||||
|
header.write('ustar', 257, 6, 'ascii');
|
||||||
|
header.write('00', 263, 2, 'ascii');
|
||||||
|
|
||||||
|
let checksum = 0;
|
||||||
|
for (const byte of header) {
|
||||||
|
checksum += byte;
|
||||||
|
}
|
||||||
|
writeOctal(header, checksum, 148, 8);
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDicomTarGz(files: string[]) {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
files.forEach((fileName) => {
|
||||||
|
const filePath = path.join(dicomDir, fileName);
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
const data = fs.readFileSync(filePath);
|
||||||
|
chunks.push(createTarEntryHeader(`Head_CT_DICOM/${fileName}`, data.length, stat.mtimeMs / 1000));
|
||||||
|
chunks.push(data);
|
||||||
|
const remainder = data.length % 512;
|
||||||
|
if (remainder > 0) {
|
||||||
|
chunks.push(Buffer.alloc(512 - remainder));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chunks.push(Buffer.alloc(1024));
|
||||||
|
return zlib.gzipSync(Buffer.concat(chunks));
|
||||||
|
}
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
const app = express();
|
const app = express();
|
||||||
const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0';
|
const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0';
|
||||||
@@ -655,6 +775,30 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:projectId/dicom-archive', (req, res) => {
|
||||||
|
const project = findProject(readState(), req.params.projectId);
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ message: '项目不存在' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = getProjectDicomFiles(project);
|
||||||
|
if (!files.length) {
|
||||||
|
res.status(404).json({ message: '当前项目没有可下载的 DICOM 文件' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const archive = createDicomTarGz(files);
|
||||||
|
const filename = `${project.id}-${project.dicomPath || 'DICOM'}-${files.length}-files.tar.gz`;
|
||||||
|
res.setHeader('Content-Type', 'application/gzip');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(archive);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: error instanceof Error ? error.message : 'DICOM 压缩包生成失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/projects/:projectId/models/:fileName', (req, res) => {
|
app.get('/api/projects/:projectId/models/:fileName', (req, res) => {
|
||||||
const project = findProject(readState(), req.params.projectId);
|
const project = findProject(readState(), req.params.projectId);
|
||||||
const fileName = path.basename(req.params.fileName);
|
const fileName = path.basename(req.params.fileName);
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Eye,
|
Eye,
|
||||||
|
FileArchive,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
|
RotateCcw,
|
||||||
Box,
|
Box,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
Edit2,
|
Edit2,
|
||||||
FolderRoot,
|
FolderRoot,
|
||||||
Download,
|
Download,
|
||||||
@@ -17,7 +21,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { DicomPreview, Project } from '../types';
|
import { DicomPreview, Project } from '../types';
|
||||||
import { api, downloadMask } from '../lib/api';
|
import { api, downloadDicomArchive, downloadMask } from '../lib/api';
|
||||||
|
|
||||||
type Plane = 'axial' | 'sagittal' | 'coronal';
|
type Plane = 'axial' | 'sagittal' | 'coronal';
|
||||||
type DisplayMode = DicomPreview['mode'];
|
type DisplayMode = DicomPreview['mode'];
|
||||||
@@ -42,8 +46,9 @@ function drawFallbackModelPreview(
|
|||||||
previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>,
|
previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>,
|
||||||
) {
|
) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const width = Math.max(Math.floor(rect.width), 1);
|
const parentRect = canvas.parentElement?.getBoundingClientRect();
|
||||||
const height = Math.max(Math.floor(rect.height), 1);
|
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.width = width * window.devicePixelRatio;
|
||||||
canvas.height = height * window.devicePixelRatio;
|
canvas.height = height * window.devicePixelRatio;
|
||||||
canvas.style.width = `${width}px`;
|
canvas.style.width = `${width}px`;
|
||||||
@@ -100,7 +105,56 @@ function drawFallbackModelPreview(
|
|||||||
context.globalAlpha = 1;
|
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);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -108,30 +162,13 @@ function DicomCanvas({ preview }: { preview: DicomPreview }) {
|
|||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const context = canvas.getContext('2d');
|
drawDicomPreviewToCanvas(canvas, preview, rotation);
|
||||||
if (!context) {
|
}, [preview, rotation]);
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={preview.width}
|
className="max-h-full max-w-full object-contain rounded-xl bg-black shadow-2xl ring-1 ring-white/25"
|
||||||
height={preview.height}
|
|
||||||
className="max-h-full max-w-full object-contain rounded-xl shadow-2xl"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -167,6 +204,8 @@ function NativeStlViewer({
|
|||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
scene.background = new THREE.Color('#f8fafc');
|
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);
|
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;
|
let renderer: THREE.WebGLRenderer | null = null;
|
||||||
try {
|
try {
|
||||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
@@ -265,10 +304,16 @@ function NativeStlViewer({
|
|||||||
const box = new THREE.Box3().setFromObject(group);
|
const box = new THREE.Box3().setFromObject(group);
|
||||||
const center = box.getCenter(new THREE.Vector3());
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
const size = box.getSize(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;
|
const maxSize = Math.max(size.x, size.y, size.z) || 1;
|
||||||
group.scale.setScalar(4 / maxSize);
|
group.traverse((object) => {
|
||||||
camera.position.set(4.5, 3.5, 5);
|
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);
|
camera.lookAt(0, 0, 0);
|
||||||
setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成');
|
setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成');
|
||||||
}
|
}
|
||||||
@@ -350,6 +395,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
const [sliceIndex, setSliceIndex] = useState(0);
|
const [sliceIndex, setSliceIndex] = useState(0);
|
||||||
const [plane, setPlane] = useState<Plane>('axial');
|
const [plane, setPlane] = useState<Plane>('axial');
|
||||||
const [displayMode, setDisplayMode] = useState<DisplayMode>('default');
|
const [displayMode, setDisplayMode] = useState<DisplayMode>('default');
|
||||||
|
const [rotation, setRotation] = useState(0);
|
||||||
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
|
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
|
||||||
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
|
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
|
||||||
const [dicomError, setDicomError] = useState('');
|
const [dicomError, setDicomError] = useState('');
|
||||||
@@ -359,6 +405,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
const [editingProjectId, setEditingProjectId] = useState('');
|
const [editingProjectId, setEditingProjectId] = useState('');
|
||||||
const [editingName, setEditingName] = useState('');
|
const [editingName, setEditingName] = useState('');
|
||||||
const [actionMessage, setActionMessage] = useState('');
|
const [actionMessage, setActionMessage] = useState('');
|
||||||
|
const sliceRepeatRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const refreshProjects = () => {
|
const refreshProjects = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -400,6 +447,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
{ id: 'contrast', label: '高对比' },
|
{ id: 'contrast', label: '高对比' },
|
||||||
];
|
];
|
||||||
const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false);
|
const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false);
|
||||||
|
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const next: Record<string, ModuleStyle> = {};
|
const next: Record<string, ModuleStyle> = {};
|
||||||
@@ -439,6 +487,19 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, displayMode, viewMode]);
|
}, [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>) => {
|
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
|
||||||
setModuleStyles(prev => ({
|
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 handleCreateProject = async () => {
|
||||||
const name = newProjectName.trim();
|
const name = newProjectName.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -712,6 +816,22 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<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>PATIENT ID: {selectedProject.id}_XYZ</p>
|
||||||
<p>SCAN DATE: {selectedProject.createTime}</p>
|
<p>SCAN DATE: {selectedProject.createTime}</p>
|
||||||
@@ -719,7 +839,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative w-full h-full flex items-center justify-center">
|
<div className="relative w-full h-full flex items-center justify-center">
|
||||||
{dicomPreview ? (
|
{dicomPreview ? (
|
||||||
<DicomCanvas preview={dicomPreview} />
|
<DicomCanvas preview={dicomPreview} rotation={rotation} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">{dicomError || '正在解析 DICOM 像素...'}</p>
|
<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">
|
<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-400 font-bold mb-3">切片</span>
|
||||||
<span className="text-[10px] text-slate-500 font-bold mb-4 whitespace-nowrap">
|
<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>
|
</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
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max={Math.max((dicomPreview?.total ?? selectedProject.dicomCount) - 1, 0)}
|
max={Math.max((sliceTotal || selectedProject.dicomCount) - 1, 0)}
|
||||||
value={sliceIndex}
|
value={sliceIndex}
|
||||||
onChange={(e) => setSliceIndex(Number(e.target.value))}
|
onChange={(e) => setSliceIndex(Number(e.target.value))}
|
||||||
className="flex-1 w-6 accent-blue-600 cursor-pointer"
|
className="flex-1 w-6 accent-blue-600 cursor-pointer"
|
||||||
style={{ writingMode: 'vertical-lr', direction: 'rtl' }}
|
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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-slate-800">逆向工作区</h2>
|
<h2 className="text-2xl font-bold text-slate-800">逆向工作区</h2>
|
||||||
<p className="text-slate-500 mt-1">
|
<p className="text-slate-500 mt-1">
|
||||||
{project ? `${project.name} · ${project.dicomPath} ↔ ${project.modelPath}` : '配准 DICOM 影像与三维模型,生成像素映射关系'}
|
{project ? `当前项目:${project.name}` : '配准 DICOM 影像与三维模型,生成像素映射关系'}
|
||||||
</p>
|
</p>
|
||||||
{project && (
|
{project && (
|
||||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-bold">
|
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-bold">
|
||||||
|
|||||||
@@ -81,3 +81,24 @@ export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' =
|
|||||||
link.remove();
|
link.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadDicomArchive(projectId: string) {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/dicom-archive`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`DICOM 压缩包下载失败:${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const disposition = response.headers.get('Content-Disposition') ?? '';
|
||||||
|
const match = disposition.match(/filename="([^"]+)"/);
|
||||||
|
const filename = match?.[1] ?? `${projectId}-dicom-series.tar.gz`;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|||||||
61
工程分析/实现方案-2026-05-04-05-20-16.md
Normal file
61
工程分析/实现方案-2026-05-04-05-20-16.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# 实现方案 - 2026-05-04-05-20-16
|
||||||
|
|
||||||
|
## 修改目标
|
||||||
|
|
||||||
|
围绕项目库 DICOM/3D 浏览体验和逆向工作区标题信息进行收敛修复,保证 DICOM 切片控制可连续移动、三平面可旋转且显示完整清晰,补充下载能力,并修复 3D 模型加载完成后仍不可见的问题。
|
||||||
|
|
||||||
|
## 涉及路径
|
||||||
|
|
||||||
|
- `WebSite/server.ts`
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `WebSite/src/lib/api.ts`
|
||||||
|
- `WebSite/src/types.ts`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 技术路线
|
||||||
|
|
||||||
|
1. DICOM 文件排序
|
||||||
|
- 将 DICOM 文件列表改为基于文件名的自然排序,确保 `1.dcm`、`2.dcm`、`10.dcm` 顺序正确。
|
||||||
|
|
||||||
|
2. DICOM 重建与清晰度
|
||||||
|
- 后端对矢状面/冠状面重建结果进行非黑边界裁切并保留安全边距,减少“只显示一半”的视觉问题。
|
||||||
|
- 对预览灰度图做轻量边缘增强,提高 DCM 影像边界辨识度。
|
||||||
|
|
||||||
|
3. 切片控制与旋转
|
||||||
|
- 前端新增上下箭头按钮,长按时用定时器连续推进或回退切片。
|
||||||
|
- DICOM 三平面统一支持左/右 90 度旋转,旋转状态参与画布绘制和 PNG 导出。
|
||||||
|
|
||||||
|
4. 下载能力
|
||||||
|
- 后端新增 DICOM 序列压缩包接口,使用 `tar.gz` 生成压缩包,不引入额外依赖。
|
||||||
|
- 前端增加下载按钮,支持当前图片 PNG 和 DICOM 压缩包。
|
||||||
|
|
||||||
|
5. 3D 模型显示
|
||||||
|
- 继续使用后端 STL 抽样预览,前端原生 Three.js 渲染。
|
||||||
|
- 修正模型居中和相机适配逻辑,增加坐标轴容错、投影兜底和加载进度。
|
||||||
|
|
||||||
|
6. 逆向工作区
|
||||||
|
- 去掉 `Head_CT_DICOM ↔ Head_CT_ReConstruct` 路径说明,仅保留当前项目、DICOM 数量、STL 数量等上下文。
|
||||||
|
|
||||||
|
## 数据流与交互流程
|
||||||
|
|
||||||
|
- 项目库加载项目 -> 选择 DICOM 面/模式/切片 -> 后端返回灰度预览 -> 前端 canvas 绘制并按旋转角度显示。
|
||||||
|
- 用户点击 PNG 下载 -> 使用当前 canvas 像素、平面、切片、模式、旋转角度生成命名完整的 PNG。
|
||||||
|
- 用户点击 DICOM 压缩包下载 -> 后端按文件名自然排序打包 `Head_CT_DICOM`。
|
||||||
|
- 用户进入 3D 模型页 -> 前端请求 STL 抽样预览 -> Three.js 居中缩放显示全部可见构件。
|
||||||
|
|
||||||
|
## 兼容性与回滚方案
|
||||||
|
|
||||||
|
- 若 DICOM 压缩包下载异常,不影响 DICOM 预览和 PNG 下载。
|
||||||
|
- 若 WebGL 不可用,仍保留二维 canvas 投影兜底预览。
|
||||||
|
- 回滚时可恢复本次提交前的 `server.ts`、项目库和逆向工作区组件。
|
||||||
|
|
||||||
|
## 预计文件变更
|
||||||
|
|
||||||
|
- 前后端代码 4-5 个文件。
|
||||||
|
- 本次流程文档 3 个文件。
|
||||||
|
- 经验记录追加 2-3 条。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
用户已在需求中明确:“本次的 需求分析、实现方案、测试方案、执行修改 都不用我人工二次确认了”。因此本方案记录后直接执行。
|
||||||
62
工程分析/测试方案-2026-05-04-05-20-16.md
Normal file
62
工程分析/测试方案-2026-05-04-05-20-16.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# 测试方案 - 2026-05-04-05-20-16
|
||||||
|
|
||||||
|
## 静态检查
|
||||||
|
|
||||||
|
- 执行 `npm run lint`,确认 TypeScript 类型检查通过。
|
||||||
|
- 执行 `npm run build`,确认生产构建通过。
|
||||||
|
|
||||||
|
## 集成验证
|
||||||
|
|
||||||
|
- 调用 `/api/projects/head-ct-demo/dicom-preview` 验证:
|
||||||
|
- `plane=axial`
|
||||||
|
- `plane=sagittal`
|
||||||
|
- `plane=coronal`
|
||||||
|
- `mode=bone`
|
||||||
|
- 调用 `/api/projects/head-ct-demo/dicom-archive` 验证压缩包响应头和文件名。
|
||||||
|
- 调用 `/api/projects/head-ct-demo/models/:fileName/preview` 验证 STL 预览顶点数据非空。
|
||||||
|
|
||||||
|
## 关键业务场景验证
|
||||||
|
|
||||||
|
- 项目库 DICOM 视图:
|
||||||
|
- 长按上下箭头能连续改变切片。
|
||||||
|
- 三个面切换后图像显示完整。
|
||||||
|
- 左/右旋转 90 度后画布方向变化。
|
||||||
|
- 下载当前 PNG 命名包含项目、平面、切片、模式、旋转角度。
|
||||||
|
- 下载 DICOM 压缩包可触发。
|
||||||
|
|
||||||
|
- 项目库 3D 模型视图:
|
||||||
|
- 模型加载进度可见。
|
||||||
|
- 加载完成后模型可见,而不是空白画布。
|
||||||
|
- 颜色、透明度、显示/隐藏状态仍可影响渲染。
|
||||||
|
|
||||||
|
- 逆向工作区:
|
||||||
|
- 顶部不再显示 `Head_CT_DICOM ↔ Head_CT_ReConstruct`。
|
||||||
|
- 当前项目上下文仍可见。
|
||||||
|
|
||||||
|
## 医学影像数据相关边界验证
|
||||||
|
|
||||||
|
- DICOM 序列按文件名自然排序。
|
||||||
|
- 矢状面/冠状面重建裁切保留边距,避免把真实人体区域裁掉。
|
||||||
|
- 显示模式与三平面组合时返回的 `total`、`slice`、`width`、`height` 合理。
|
||||||
|
|
||||||
|
## 回归风险
|
||||||
|
|
||||||
|
- DICOM 预览缓存键应包含平面、模式和切片,避免错图。
|
||||||
|
- PNG 下载应和当前旋转状态一致。
|
||||||
|
- 3D 模型抽样数量不能过高导致页面阻塞。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
用户已明确本次测试方案无需二次确认,记录后直接执行。
|
||||||
|
|
||||||
|
## 执行结果
|
||||||
|
|
||||||
|
- `npm run lint`:通过。
|
||||||
|
- `npm run build`:通过;Vite 提示 bundle 超过 500 kB,为 Three.js/Recharts 等现有依赖带来的体积警告,不阻断构建。
|
||||||
|
- `GET /api/projects/head-ct-demo/dicom-preview?plane=axial&slice=150&mode=default`:通过,返回 `512x512`,`total=300`,文件名 `151.dcm`。
|
||||||
|
- `GET /api/projects/head-ct-demo/dicom-preview?plane=sagittal&slice=256&mode=bone`:通过,返回 `300x421`,`total=512`。
|
||||||
|
- `GET /api/projects/head-ct-demo/dicom-preview?plane=coronal&slice=256&mode=soft`:通过,返回 `300x512`,`total=512`。
|
||||||
|
- `HEAD /api/projects/head-ct-demo/dicom-archive`:通过,返回 `Content-Type: application/gzip`,文件名 `head-ct-demo-Head_CT_DICOM-300-files.tar.gz`。
|
||||||
|
- `GET /api/projects/head-ct-demo/models/会厌.stl/preview?limit=1200`:通过,返回 `1159` 个抽样三角面、`10431` 个顶点数值。
|
||||||
|
- 无头 Chrome 前端验证:登录后进入项目库 3D 模型页,在 WebGL 不可用场景自动生成二维兜底预览,canvas 尺寸 `1236x567`,像素采样 `nonBackground=67`,确认不是空白画布。
|
||||||
|
- 已重启部署到 `http://192.168.3.11:4000/`,tmux 会话:`revoxelseg-dicom`。
|
||||||
54
工程分析/经验记录.md
54
工程分析/经验记录.md
@@ -397,3 +397,57 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
跨页面工作流必须在目标页面重新显示当前操作对象;医学影像配准类视图至少应具备影像层、模型层、对齐标识和当前切片控制。
|
跨页面工作流必须在目标页面重新显示当前操作对象;医学影像配准类视图至少应具备影像层、模型层、对齐标识和当前切片控制。
|
||||||
|
|
||||||
|
## 2026-05-04-05-20-16 DICOM 三平面预览与下载
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
DICOM 切片长按不能连续移动,三平面不能旋转,矢状面/冠状面存在大量黑边导致主体像只显示一半,同时缺少当前 PNG 和整套 DICOM 压缩包下载。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
前端切片控制只有原生 range 输入,缺少连续步进逻辑;canvas 绘制没有旋转参数;后端重建平面直接输出完整矩阵,没有对非黑内容边界做裁切;下载链路只覆盖了分割 Mask。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
前端新增上下箭头长按定时步进、左/右 90 度旋转和与显示一致的 PNG 导出;后端 DICOM 文件按自然文件名排序,对重建平面做内容裁切并做轻量边缘增强;新增 `dicom-archive` 接口生成 `tar.gz` 压缩包。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
医学影像预览的显示状态应统一由平面、切片、窗宽窗位、旋转角度组成;下载当前视图时复用同一套 canvas 绘制逻辑,避免屏幕显示和导出结果不一致。
|
||||||
|
|
||||||
|
## 2026-05-04-05-20-16 3D 模型加载完成但画布空白
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
项目库 3D 模型页提示加载完成,但用户界面仍可能看不到模型。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
STL 抽样顶点仍保留原始模型坐标,先给 group 位置减中心再缩放时,模型可能被缩放后的坐标体系推离相机视野;WebGL 不可用时二维兜底 canvas 尺寸也依赖容器测量,可能拿到过小尺寸。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
模型加载完成后先计算整体包围盒,将中心偏移直接平移到每个 mesh 的 geometry 顶点,再统一缩放 group 和设置相机;二维兜底 canvas 在容器尺寸不可用时使用父容器尺寸或默认尺寸,保证兜底预览可见。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
三维预览应在模型坐标归一化后再设置相机;验证时要覆盖 WebGL 正常路径和 WebGL 不可用兜底路径,并做 canvas 尺寸和非背景像素检查。
|
||||||
|
|
||||||
|
## 2026-05-04-05-20-16 逆向工作区路径信息噪声
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
逆向工作区标题中显示 `Head_CT_DICOM ↔ Head_CT_ReConstruct`,对用户当前操作帮助不大。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
早期为了证明项目数据源接入,将数据目录路径直接放进了页面副标题。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
副标题改为只显示当前项目名称;DICOM 数量和 STL 数量保留在上下文标签中。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
工作区首屏应优先显示“当前正在处理哪个项目”和“下一步操作”,底层路径只在诊断、详情或设置区域出现。
|
||||||
|
|||||||
41
工程分析/需求分析-2026-05-04-05-20-16.md
Normal file
41
工程分析/需求分析-2026-05-04-05-20-16.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# 需求分析 - 2026-05-04-05-20-16
|
||||||
|
|
||||||
|
## 原始需求摘要
|
||||||
|
|
||||||
|
用户要求严格使用代码编纂工作流,但本次需求分析、实现方案、测试方案、执行修改均不需要人工二次确认。当前需要继续完善项目库与逆向工作区:
|
||||||
|
|
||||||
|
- 切片控制支持长按上下箭头连续移动进度条。
|
||||||
|
- DICOM 影像序列导入后必须按文件名顺序排列。
|
||||||
|
- DICOM 三个面都支持左/右 90 度旋转。
|
||||||
|
- 修复矢状面、冠状面显示疑似只显示一半的问题。
|
||||||
|
- DCM 影像边界更清晰。
|
||||||
|
- DICOM 右侧增加下载按钮,可下载 DCM 影像压缩包或当前图片 PNG,命名信息更完整。
|
||||||
|
- 修复 3D 模型提示加载完成但画布仍为空白的问题。
|
||||||
|
- 逆向工作区去掉 `Head_CT_DICOM ↔ Head_CT_ReConstruct` 这类路径说明。
|
||||||
|
|
||||||
|
## 业务目标
|
||||||
|
|
||||||
|
项目库应能稳定浏览真实 DICOM 切片和 STL 模型,用户可快速调整切片、平面、显示模式、旋转方向,并导出当前影像或整套 DICOM 原始文件压缩包。逆向工作区应突出当前项目和融合工作目标,减少无效路径文本。
|
||||||
|
|
||||||
|
## 输入与输出
|
||||||
|
|
||||||
|
- 输入:`Head_CT_DICOM/` 中的 DICOM 序列、`Head_CT_ReConstruct/` 中的 STL 模型文件。
|
||||||
|
- 输出:改进后的前后端代码、可下载 DICOM 压缩包、当前切片 PNG、稳定可见的 3D 模型预览、更新后的工程分析文档和经验记录。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- `WebSite/server.ts`:DICOM 排序、重建裁切/边界增强、DICOM 压缩包下载、STL 预览数据增强。
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`:切片控制、旋转、PNG 下载、DICOM 压缩包下载、3D 模型视图。
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`:去除路径连接说明,保留项目上下文。
|
||||||
|
- `WebSite/src/lib/api.ts` / `WebSite/src/types.ts`:下载接口和预览数据结构补充。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- DICOM 多平面重建需要避免过度裁切,不能把真实影像内容裁掉。
|
||||||
|
- 当前 STL 可能体积较大,浏览器端预览应继续使用抽样数据,避免直接加载全部原始 STL。
|
||||||
|
- PNG 下载需要与当前前端旋转角度一致。
|
||||||
|
- 压缩包实现应避免引入额外不稳定依赖。
|
||||||
|
|
||||||
|
## 待确认问题
|
||||||
|
|
||||||
|
本次用户已明确无需二次人工确认,按合理工程假设直接执行。
|
||||||
Reference in New Issue
Block a user