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

@@ -87,7 +87,11 @@ function listFiles(dir: string, extension: string) {
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(extension))
.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) {
@@ -279,7 +283,7 @@ function getProjectDicomFiles(project: ProjectRecord) {
if (project.id !== 'head-ct-demo') {
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) {
@@ -375,10 +379,12 @@ function parseDicomPreview(filePath: string, mode: DicomDisplayMode = 'default')
pixels[i] = normalized;
}
const enhancedPixels = enhanceDicomEdges(pixels, columns, rows);
return {
width: columns,
height: rows,
pixels: pixels.toString('base64'),
pixels: enhancedPixels.toString('base64'),
windowCenter,
windowWidth,
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 {
width: outputWidth,
height: outputHeight,
pixels: pixels.toString('base64'),
width: cropped.width,
height: cropped.height,
pixels: enhancedPixels.toString('base64'),
windowCenter: volume.windowCenter,
windowWidth: volume.windowWidth,
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) {
const cacheKey = `${fileName}:${limit}`;
const cached = modelPreviewCache.get(cacheKey);
@@ -503,6 +577,52 @@ function createStlPreview(filePath: string, fileName: string, limit: number) {
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() {
const app = express();
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) => {
const project = findProject(readState(), req.params.projectId);
const fileName = path.basename(req.params.fileName);