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 })
|
||||
.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);
|
||||
|
||||
Reference in New Issue
Block a user