2026-05-24-10-45-43 修正分割显示镜像导出与DICOM交互
This commit is contained in:
@@ -10,6 +10,14 @@
|
|||||||
- `revoxelseg_web`:ReVoxelSeg DICOM 前后端一体服务,容器端口 `4000`。
|
- `revoxelseg_web`:ReVoxelSeg DICOM 前后端一体服务,容器端口 `4000`。
|
||||||
- `revoxelseg_frpc`:FRPC 客户端,将 `revoxelseg_web:4000` 映射到 `82.157.255.195` 的远程端口 `10008`。
|
- `revoxelseg_frpc`:FRPC 客户端,将 `revoxelseg_web:4000` 映射到 `82.157.255.195` 的远程端口 `10008`。
|
||||||
|
|
||||||
|
当前 Docker 构建会同步包含以下能力:
|
||||||
|
|
||||||
|
- 二维逆向分割映射按实体填充显示,导出的分割 Label Map 也按填充区域写入。
|
||||||
|
- 模型位姿支持以模型中心沿 X/Y/Z 轴镜像翻转,保存、项目库预览和导出均沿用该位姿。
|
||||||
|
- “构件分别导出”会把所有构件 NIfTI 文件集中到导出包内的 `segmentation-parts/` 目录。
|
||||||
|
- 项目库 DICOM 首页支持滚轮缩放、拖拽平移和位置重置。
|
||||||
|
- 项目库与工作区的 DICOM 切片编号按医学影像顺序显示,滑条使用非进度条样式。
|
||||||
|
|
||||||
## 一、本机部署
|
## 一、本机部署
|
||||||
|
|
||||||
在项目根目录执行:
|
在项目根目录执行:
|
||||||
@@ -125,4 +133,3 @@ cd WebSite
|
|||||||
npm run build
|
npm run build
|
||||||
npm run serve -- --host 0.0.0.0 --port 4000
|
npm run serve -- --host 0.0.0.0 --port 4000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ name: revoxelseg-dicom-qnap
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
revoxelseg_web:
|
revoxelseg_web:
|
||||||
image: revoxelseg-dicom:web-qnap-20260521
|
image: revoxelseg-dicom:web-qnap-20260524
|
||||||
build:
|
build:
|
||||||
context: /share/Container/revoxelseg_dicom
|
context: /share/Container/revoxelseg_dicom
|
||||||
dockerfile: Docker部署/Dockerfile
|
dockerfile: Docker部署/Dockerfile
|
||||||
@@ -67,4 +67,3 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
revoxelseg_web:
|
revoxelseg_web:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ name: revoxelseg-dicom-local
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
revoxelseg_web:
|
revoxelseg_web:
|
||||||
image: revoxelseg-dicom:web-local-20260521
|
image: revoxelseg-dicom:web-local-20260524
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: Docker部署/Dockerfile
|
dockerfile: Docker部署/Dockerfile
|
||||||
@@ -57,4 +57,3 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
revoxelseg_web:
|
revoxelseg_web:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ interface ModelPoseValue {
|
|||||||
translateY: number;
|
translateY: number;
|
||||||
translateZ: number;
|
translateZ: number;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
flipX: boolean;
|
||||||
|
flipY: boolean;
|
||||||
|
flipZ: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelPoseRecord {
|
interface ModelPoseRecord {
|
||||||
@@ -141,6 +144,9 @@ const defaultModelPose: ModelPoseValue = {
|
|||||||
translateY: 0,
|
translateY: 0,
|
||||||
translateZ: 0,
|
translateZ: 0,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
flipZ: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface DicomAttributes {
|
interface DicomAttributes {
|
||||||
@@ -379,6 +385,9 @@ function normalizeModelPoseValue(value: Partial<ModelPoseValue> | undefined): Mo
|
|||||||
? clampNumber(nextValue, min, max)
|
? clampNumber(nextValue, min, max)
|
||||||
: fallback;
|
: fallback;
|
||||||
};
|
};
|
||||||
|
const readBoolean = (key: keyof ModelPoseValue, fallback: boolean) => (
|
||||||
|
typeof value?.[key] === 'boolean' ? Boolean(value?.[key]) : fallback
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rotateX: read('rotateX', defaultModelPose.rotateX, -180, 180),
|
rotateX: read('rotateX', defaultModelPose.rotateX, -180, 180),
|
||||||
@@ -388,6 +397,9 @@ function normalizeModelPoseValue(value: Partial<ModelPoseValue> | undefined): Mo
|
|||||||
translateY: read('translateY', defaultModelPose.translateY, -2, 2),
|
translateY: read('translateY', defaultModelPose.translateY, -2, 2),
|
||||||
translateZ: read('translateZ', defaultModelPose.translateZ, -2, 2),
|
translateZ: read('translateZ', defaultModelPose.translateZ, -2, 2),
|
||||||
scale: read('scale', defaultModelPose.scale, 0.5, 2),
|
scale: read('scale', defaultModelPose.scale, 0.5, 2),
|
||||||
|
flipX: readBoolean('flipX', defaultModelPose.flipX),
|
||||||
|
flipY: readBoolean('flipY', defaultModelPose.flipY),
|
||||||
|
flipZ: readBoolean('flipZ', defaultModelPose.flipZ),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,9 +871,10 @@ function getExportMetrics(project: ProjectRecord, volume: DicomHuVolume, preview
|
|||||||
|
|
||||||
function transformPointForExportPose(x: number, y: number, z: number, metrics: ExportSceneMetrics, pose: ModelPoseValue): Point3DRecord {
|
function transformPointForExportPose(x: number, y: number, z: number, metrics: ExportSceneMetrics, pose: ModelPoseValue): Point3DRecord {
|
||||||
const scalar = metrics.modelBaseScale * pose.scale;
|
const scalar = metrics.modelBaseScale * pose.scale;
|
||||||
let px = (x - metrics.center.x) * scalar;
|
let px = (x - metrics.center.x) * scalar * (pose.flipX ? -1 : 1);
|
||||||
let py = (y - metrics.center.y) * scalar;
|
let py = (y - metrics.center.y) * scalar * (pose.flipY ? -1 : 1);
|
||||||
let pz = (z - metrics.center.z + metrics.modelPivotOffsetZ) * scalar;
|
let pz = (z - metrics.center.z) * scalar * (pose.flipZ ? -1 : 1);
|
||||||
|
pz += metrics.modelPivotOffsetZ * scalar;
|
||||||
const rotateX = (pose.rotateX * Math.PI) / 180;
|
const rotateX = (pose.rotateX * Math.PI) / 180;
|
||||||
const rotateY = (pose.rotateY * Math.PI) / 180;
|
const rotateY = (pose.rotateY * Math.PI) / 180;
|
||||||
const rotateZ = (pose.rotateZ * Math.PI) / 180;
|
const rotateZ = (pose.rotateZ * Math.PI) / 180;
|
||||||
@@ -1135,6 +1148,70 @@ function fillExportRows(data: Buffer, width: number, height: number, slice: numb
|
|||||||
return filledPixels;
|
return filledPixels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fillExportFallbackClosedRegion(
|
||||||
|
data: Buffer,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
slice: number,
|
||||||
|
segments: PlaneSegmentRecord[],
|
||||||
|
label: number,
|
||||||
|
) {
|
||||||
|
const points = segments.flatMap((segment) => [segment.a, segment.b])
|
||||||
|
.filter((point) => (
|
||||||
|
Number.isFinite(point.x)
|
||||||
|
&& Number.isFinite(point.y)
|
||||||
|
&& point.x >= -width
|
||||||
|
&& point.x <= width * 2
|
||||||
|
&& point.y >= -height
|
||||||
|
&& point.y <= height * 2
|
||||||
|
));
|
||||||
|
|
||||||
|
if (points.length < 3) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniquePoints: Point2DRecord[] = [];
|
||||||
|
points.forEach((point) => {
|
||||||
|
if (!uniquePoints.some((current) => exportPointDistanceSquared(current, point) < 1e-6)) {
|
||||||
|
uniquePoints.push(point);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (uniquePoints.length < 3) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...uniquePoints].sort((left, right) => (
|
||||||
|
Math.abs(left.x - right.x) > 1e-6 ? left.x - right.x : left.y - right.y
|
||||||
|
));
|
||||||
|
const cross = (origin: Point2DRecord, a: Point2DRecord, b: Point2DRecord) => (
|
||||||
|
(a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x)
|
||||||
|
);
|
||||||
|
const lower: Point2DRecord[] = [];
|
||||||
|
sorted.forEach((point) => {
|
||||||
|
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) {
|
||||||
|
lower.pop();
|
||||||
|
}
|
||||||
|
lower.push(point);
|
||||||
|
});
|
||||||
|
const upper: Point2DRecord[] = [];
|
||||||
|
[...sorted].reverse().forEach((point) => {
|
||||||
|
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) {
|
||||||
|
upper.pop();
|
||||||
|
}
|
||||||
|
upper.push(point);
|
||||||
|
});
|
||||||
|
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
|
||||||
|
const polygon = hull.length >= 3 ? hull : uniquePoints;
|
||||||
|
const rows = Array.from({ length: height }, () => [] as number[]);
|
||||||
|
|
||||||
|
polygon.forEach((point, index) => {
|
||||||
|
const nextPoint = polygon[(index + 1) % polygon.length];
|
||||||
|
addExportSegmentToRows(rows, width, height, { a: point, b: nextPoint });
|
||||||
|
});
|
||||||
|
|
||||||
|
return fillExportRows(data, width, height, slice, rows, label);
|
||||||
|
}
|
||||||
|
|
||||||
function getModuleStyle(project: ProjectRecord, fileName: string, index: number): ModuleStyleRecord {
|
function getModuleStyle(project: ProjectRecord, fileName: string, index: number): ModuleStyleRecord {
|
||||||
return project.moduleStyles[fileName] ?? {
|
return project.moduleStyles[fileName] ?? {
|
||||||
visible: true,
|
visible: true,
|
||||||
@@ -1192,15 +1269,18 @@ function createSegmentationData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const label = clampNumber(Math.round(style.partId || index + 1), 1, 255);
|
const label = clampNumber(Math.round(style.partId || index + 1), 1, 255);
|
||||||
const rowsBySlice = new Map<number, number[][]>();
|
const slicesByIndex = new Map<number, { rows: number[][]; segments: PlaneSegmentRecord[] }>();
|
||||||
const rowsForSlice = (slice: number) => {
|
const entryForSlice = (slice: number) => {
|
||||||
const existing = rowsBySlice.get(slice);
|
const existing = slicesByIndex.get(slice);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
const rows = Array.from({ length: volume.height }, () => [] as number[]);
|
const entry = {
|
||||||
rowsBySlice.set(slice, rows);
|
rows: Array.from({ length: volume.height }, () => [] as number[]),
|
||||||
return rows;
|
segments: [] as PlaneSegmentRecord[],
|
||||||
|
};
|
||||||
|
slicesByIndex.set(slice, entry);
|
||||||
|
return entry;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filePath = getProjectModelFilePath(project, fileName);
|
const filePath = getProjectModelFilePath(project, fileName);
|
||||||
@@ -1245,15 +1325,21 @@ function createSegmentationData(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
addExportSegmentToRows(rowsForSlice(slice), volume.width, volume.height, {
|
const mappedSegment = {
|
||||||
a: mapPoint(segment.a),
|
a: mapPoint(segment.a),
|
||||||
b: mapPoint(segment.b),
|
b: mapPoint(segment.b),
|
||||||
});
|
};
|
||||||
|
const entry = entryForSlice(slice);
|
||||||
|
addExportSegmentToRows(entry.rows, volume.width, volume.height, mappedSegment);
|
||||||
|
entry.segments.push(mappedSegment);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
rowsBySlice.forEach((rows, slice) => {
|
slicesByIndex.forEach(({ rows, segments }, slice) => {
|
||||||
fillExportRows(data, volume.width, volume.height, slice, rows, label);
|
const filledPixels = fillExportRows(data, volume.width, volume.height, slice, rows, label);
|
||||||
|
if (filledPixels < Math.max(12, Math.round(segments.length * 0.45)) && segments.length >= 3) {
|
||||||
|
fillExportFallbackClosedRegion(data, volume.width, volume.height, slice, segments, label);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1568,7 +1654,7 @@ function createProjectExportBundle({
|
|||||||
}
|
}
|
||||||
const moduleName = sanitizeFilenamePart(fileName.replace(/\.stl$/i, ''), `module-${index + 1}`);
|
const moduleName = sanitizeFilenamePart(fileName.replace(/\.stl$/i, ''), `module-${index + 1}`);
|
||||||
entries.push({
|
entries.push({
|
||||||
name: `${exportRoot}/segmentation/${String(style.partId).padStart(3, '0')}-${moduleName}-label.${format}`,
|
name: `${exportRoot}/segmentation-parts/${String(style.partId).padStart(3, '0')}-${moduleName}-label.${format}`,
|
||||||
data: createNiftiBuffer(
|
data: createNiftiBuffer(
|
||||||
volume,
|
volume,
|
||||||
createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope, fileName),
|
createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope, fileName),
|
||||||
@@ -2134,17 +2220,35 @@ function writeOctal(buffer: Buffer, value: number, offset: number, length: numbe
|
|||||||
buffer.write(`${text}\0`, offset, length, 'ascii');
|
buffer.write(`${text}\0`, offset, length, 'ascii');
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTarEntryHeader(name: string, size: number, mtime: number) {
|
function writeTarText(buffer: Buffer, value: string, offset: number, length: number) {
|
||||||
|
const source = Buffer.from(value, 'utf8');
|
||||||
|
source.copy(buffer, offset, 0, Math.min(length, source.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPaxRecord(key: string, value: string) {
|
||||||
|
const payload = `${key}=${value}\n`;
|
||||||
|
let length = Buffer.byteLength(payload, 'utf8') + 3;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const record = `${length} ${payload}`;
|
||||||
|
const nextLength = Buffer.byteLength(record, 'utf8');
|
||||||
|
if (nextLength === length) {
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
length = nextLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTarEntryHeader(name: string, size: number, mtime: number, typeFlag: '0' | 'x' = '0') {
|
||||||
const header = Buffer.alloc(512);
|
const header = Buffer.alloc(512);
|
||||||
const safeName = name.slice(0, 100);
|
writeTarText(header, name, 0, 100);
|
||||||
header.write(safeName, 0, 100, 'utf8');
|
|
||||||
writeOctal(header, 0o644, 100, 8);
|
writeOctal(header, 0o644, 100, 8);
|
||||||
writeOctal(header, 0, 108, 8);
|
writeOctal(header, 0, 108, 8);
|
||||||
writeOctal(header, 0, 116, 8);
|
writeOctal(header, 0, 116, 8);
|
||||||
writeOctal(header, size, 124, 12);
|
writeOctal(header, size, 124, 12);
|
||||||
writeOctal(header, Math.floor(mtime), 136, 12);
|
writeOctal(header, Math.floor(mtime), 136, 12);
|
||||||
header.fill(' ', 148, 156);
|
header.fill(' ', 148, 156);
|
||||||
header.write('0', 156, 1, 'ascii');
|
header.write(typeFlag, 156, 1, 'ascii');
|
||||||
header.write('ustar', 257, 6, 'ascii');
|
header.write('ustar', 257, 6, 'ascii');
|
||||||
header.write('00', 263, 2, 'ascii');
|
header.write('00', 263, 2, 'ascii');
|
||||||
|
|
||||||
@@ -2159,14 +2263,29 @@ function createTarEntryHeader(name: string, size: number, mtime: number) {
|
|||||||
function createTarGz(entries: Array<{ name: string; data: Buffer; mtime?: number }>) {
|
function createTarGz(entries: Array<{ name: string; data: Buffer; mtime?: number }>) {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
const pushEntry = (name: string, data: Buffer, mtime: number, typeFlag: '0' | 'x' = '0') => {
|
||||||
const data = entry.data;
|
chunks.push(createTarEntryHeader(name, data.length, mtime, typeFlag));
|
||||||
chunks.push(createTarEntryHeader(entry.name, data.length, entry.mtime ?? Date.now() / 1000));
|
|
||||||
chunks.push(data);
|
chunks.push(data);
|
||||||
const remainder = data.length % 512;
|
const remainder = data.length % 512;
|
||||||
if (remainder > 0) {
|
if (remainder > 0) {
|
||||||
chunks.push(Buffer.alloc(512 - remainder));
|
chunks.push(Buffer.alloc(512 - remainder));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
const data = entry.data;
|
||||||
|
const mtime = entry.mtime ?? Date.now() / 1000;
|
||||||
|
const needsPaxPath = Buffer.byteLength(entry.name, 'utf8') > 100 || /[^\x20-\x7e]/.test(entry.name);
|
||||||
|
let headerName = entry.name;
|
||||||
|
|
||||||
|
if (needsPaxPath) {
|
||||||
|
const paxData = Buffer.from(createPaxRecord('path', entry.name), 'utf8');
|
||||||
|
const paxName = `PaxHeaders/${String(index + 1).padStart(6, '0')}`;
|
||||||
|
headerName = `entries/${String(index + 1).padStart(6, '0')}`;
|
||||||
|
pushEntry(paxName, paxData, mtime, 'x');
|
||||||
|
}
|
||||||
|
|
||||||
|
pushEntry(headerName, data, mtime);
|
||||||
});
|
});
|
||||||
|
|
||||||
chunks.push(Buffer.alloc(1024));
|
chunks.push(Buffer.alloc(1024));
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
X,
|
X,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload
|
Upload,
|
||||||
|
RefreshCcw,
|
||||||
|
FlipHorizontal2,
|
||||||
|
FlipVertical2,
|
||||||
|
Move3d
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types';
|
import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types';
|
||||||
@@ -47,6 +51,9 @@ interface ModelPose {
|
|||||||
translateY: number;
|
translateY: number;
|
||||||
translateZ: number;
|
translateZ: number;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
flipX: boolean;
|
||||||
|
flipY: boolean;
|
||||||
|
flipZ: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelPreviewPayload {
|
interface ModelPreviewPayload {
|
||||||
@@ -60,7 +67,8 @@ interface ModelPreviewPayload {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelPoseKey = keyof ModelPose;
|
type ModelPoseKey = Exclude<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
|
||||||
|
type ModelPoseFlipKey = Extract<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
|
||||||
|
|
||||||
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||||||
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
|
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
|
||||||
@@ -75,7 +83,7 @@ const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: stri
|
|||||||
];
|
];
|
||||||
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
|
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
|
||||||
{ id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
|
{ id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
|
||||||
{ id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' },
|
{ id: 'separate', label: '构件分别导出', description: '全部构件集中到同一目录' },
|
||||||
];
|
];
|
||||||
const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [
|
const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [
|
||||||
{ id: 'standard', label: '标准', limit: 16000 },
|
{ id: 'standard', label: '标准', limit: 16000 },
|
||||||
@@ -91,7 +99,15 @@ const defaultModelPose: ModelPose = {
|
|||||||
translateY: 0,
|
translateY: 0,
|
||||||
translateZ: 0,
|
translateZ: 0,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
flipZ: false,
|
||||||
};
|
};
|
||||||
|
const modelPoseFlipOptions: Array<{ key: ModelPoseFlipKey; label: string; axis: string; icon: typeof FlipHorizontal2 }> = [
|
||||||
|
{ key: 'flipX', label: '镜像 X', axis: 'X', icon: FlipHorizontal2 },
|
||||||
|
{ key: 'flipY', label: '镜像 Y', axis: 'Y', icon: FlipVertical2 },
|
||||||
|
{ key: 'flipZ', label: '镜像 Z', axis: 'Z', icon: Move3d },
|
||||||
|
];
|
||||||
const emptyOverlayStats: OverlayStats = {
|
const emptyOverlayStats: OverlayStats = {
|
||||||
activeModules: 0,
|
activeModules: 0,
|
||||||
filledPixels: 0,
|
filledPixels: 0,
|
||||||
@@ -140,9 +156,22 @@ function clampModelPose(next: ModelPose): ModelPose {
|
|||||||
translateY: clampModelPoseValue('translateY', next.translateY),
|
translateY: clampModelPoseValue('translateY', next.translateY),
|
||||||
translateZ: clampModelPoseValue('translateZ', next.translateZ),
|
translateZ: clampModelPoseValue('translateZ', next.translateZ),
|
||||||
scale: clampModelPoseValue('scale', next.scale),
|
scale: clampModelPoseValue('scale', next.scale),
|
||||||
|
flipX: Boolean(next.flipX),
|
||||||
|
flipY: Boolean(next.flipY),
|
||||||
|
flipZ: Boolean(next.flipZ),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeModelPose(pose: Partial<ModelPose> | undefined): ModelPose {
|
||||||
|
return clampModelPose({
|
||||||
|
...defaultModelPose,
|
||||||
|
...(pose ?? {}),
|
||||||
|
flipX: typeof pose?.flipX === 'boolean' ? pose.flipX : defaultModelPose.flipX,
|
||||||
|
flipY: typeof pose?.flipY === 'boolean' ? pose.flipY : defaultModelPose.flipY,
|
||||||
|
flipZ: typeof pose?.flipZ === 'boolean' ? pose.flipZ : defaultModelPose.flipZ,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function formatPoseCompactValue(value: number, digits = 2) {
|
function formatPoseCompactValue(value: number, digits = 2) {
|
||||||
return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0';
|
return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0';
|
||||||
}
|
}
|
||||||
@@ -290,8 +319,26 @@ function displayDicomValue(value: string | number | null | undefined) {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDicomDisplaySliceNumber(sliceIndex: number, totalSlices: number) {
|
||||||
|
const total = Math.max(Math.round(totalSlices), 0);
|
||||||
|
if (!total) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return total - Math.max(0, Math.min(total - 1, Math.round(sliceIndex)));
|
||||||
|
}
|
||||||
|
|
||||||
function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) {
|
function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const [viewport, setViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||||
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
const panRef = useRef({
|
||||||
|
active: false,
|
||||||
|
pointerId: 0,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -301,11 +348,86 @@ function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: n
|
|||||||
drawDicomPreviewToCanvas(canvas, preview, rotation);
|
drawDicomPreviewToCanvas(canvas, preview, rotation);
|
||||||
}, [preview, rotation]);
|
}, [preview, rotation]);
|
||||||
|
|
||||||
|
const resetViewport = () => {
|
||||||
|
setViewport({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||||
|
};
|
||||||
|
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
setViewport((current) => ({
|
||||||
|
...current,
|
||||||
|
scale: Math.max(0.35, Math.min(6, current.scale * scaleFactor)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panRef.current = {
|
||||||
|
active: true,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
offsetX: viewport.offsetX,
|
||||||
|
offsetY: viewport.offsetY,
|
||||||
|
};
|
||||||
|
setIsPanning(true);
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
};
|
||||||
|
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const dragState = panRef.current;
|
||||||
|
if (!dragState.active || dragState.pointerId !== event.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setViewport((current) => ({
|
||||||
|
...current,
|
||||||
|
offsetX: dragState.offsetX + event.clientX - dragState.startX,
|
||||||
|
offsetY: dragState.offsetY + event.clientY - dragState.startY,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const stopPointerDrag = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const dragState = panRef.current;
|
||||||
|
if (!dragState.active || dragState.pointerId !== event.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panRef.current = { ...dragState, active: false };
|
||||||
|
setIsPanning(false);
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<div
|
||||||
ref={canvasRef}
|
className={`relative flex h-full w-full items-center justify-center overflow-hidden rounded-xl bg-black ${isPanning ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||||
className="max-h-full max-w-full object-contain rounded-xl bg-black shadow-2xl ring-1 ring-white/25"
|
onWheel={handleWheel}
|
||||||
/>
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={stopPointerDrag}
|
||||||
|
onPointerCancel={stopPointerDrag}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex max-h-full max-w-full items-center justify-center"
|
||||||
|
style={{
|
||||||
|
transform: `translate3d(${viewport.offsetX}px, ${viewport.offsetY}px, 0) scale(${viewport.scale})`,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="max-h-full max-w-full select-none object-contain rounded-xl bg-black shadow-2xl ring-1 ring-white/25"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={resetViewport}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
className="absolute right-3 top-3 z-10 flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-black/65 px-3 text-[10px] font-bold text-white/70 shadow-lg hover:border-cyan-300/30 hover:text-cyan-100"
|
||||||
|
title="重置 DICOM 图片位置"
|
||||||
|
>
|
||||||
|
<RefreshCcw size={13} />
|
||||||
|
位置重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,9 +440,9 @@ function OrientationGizmo({ pose }: { pose: ModelPose }) {
|
|||||||
'XYZ',
|
'XYZ',
|
||||||
));
|
));
|
||||||
return [
|
return [
|
||||||
{ id: 'X', color: '#ef4444', vector: new THREE.Vector3(1, 0, 0).applyMatrix4(rotation) },
|
{ id: 'X', color: '#ef4444', vector: new THREE.Vector3(pose.flipX ? -1 : 1, 0, 0).applyMatrix4(rotation) },
|
||||||
{ id: 'Y', color: '#10b981', vector: new THREE.Vector3(0, 1, 0).applyMatrix4(rotation) },
|
{ id: 'Y', color: '#10b981', vector: new THREE.Vector3(0, pose.flipY ? -1 : 1, 0).applyMatrix4(rotation) },
|
||||||
{ id: 'Z', color: '#3b82f6', vector: new THREE.Vector3(0, 0, 1).applyMatrix4(rotation) },
|
{ id: 'Z', color: '#3b82f6', vector: new THREE.Vector3(0, 0, pose.flipZ ? -1 : 1).applyMatrix4(rotation) },
|
||||||
]
|
]
|
||||||
.map((axis) => ({
|
.map((axis) => ({
|
||||||
...axis,
|
...axis,
|
||||||
@@ -331,7 +453,7 @@ function OrientationGizmo({ pose }: { pose: ModelPose }) {
|
|||||||
opacity: 0.55 + Math.max(-axis.vector.z, 0) * 0.45,
|
opacity: 0.55 + Math.max(-axis.vector.z, 0) * 0.45,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.vector.z - a.vector.z);
|
.sort((a, b) => b.vector.z - a.vector.z);
|
||||||
}, [pose.rotateX, pose.rotateY, pose.rotateZ]);
|
}, [pose.rotateX, pose.rotateY, pose.rotateZ, pose.flipX, pose.flipY, pose.flipZ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute bottom-4 right-4 rounded-xl border border-slate-200 bg-white/90 px-3 py-2 shadow-sm">
|
<div className="pointer-events-none absolute bottom-4 right-4 rounded-xl border border-slate-200 bg-white/90 px-3 py-2 shadow-sm">
|
||||||
@@ -609,7 +731,12 @@ function NativeStlViewer({
|
|||||||
poseGroup.position.set(0, 0, 0);
|
poseGroup.position.set(0, 0, 0);
|
||||||
pivotGroup.position.set(0, 0, 0);
|
pivotGroup.position.set(0, 0, 0);
|
||||||
baseScale = 4.2 / maxSize;
|
baseScale = 4.2 / maxSize;
|
||||||
pivotGroup.scale.setScalar(baseScale * poseRef.current.scale);
|
const initialPoseScale = baseScale * poseRef.current.scale;
|
||||||
|
pivotGroup.scale.set(
|
||||||
|
poseRef.current.flipX ? -initialPoseScale : initialPoseScale,
|
||||||
|
poseRef.current.flipY ? -initialPoseScale : initialPoseScale,
|
||||||
|
poseRef.current.flipZ ? -initialPoseScale : initialPoseScale,
|
||||||
|
);
|
||||||
camera.lookAt(0, 0, 0);
|
camera.lookAt(0, 0, 0);
|
||||||
setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成');
|
setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成');
|
||||||
}
|
}
|
||||||
@@ -639,7 +766,12 @@ function NativeStlViewer({
|
|||||||
THREE.MathUtils.degToRad(currentPose.rotateY),
|
THREE.MathUtils.degToRad(currentPose.rotateY),
|
||||||
THREE.MathUtils.degToRad(currentPose.rotateZ),
|
THREE.MathUtils.degToRad(currentPose.rotateZ),
|
||||||
);
|
);
|
||||||
pivotGroup.scale.setScalar(baseScale * currentPose.scale);
|
const poseScale = baseScale * currentPose.scale;
|
||||||
|
pivotGroup.scale.set(
|
||||||
|
currentPose.flipX ? -poseScale : poseScale,
|
||||||
|
currentPose.flipY ? -poseScale : poseScale,
|
||||||
|
currentPose.flipZ ? -poseScale : poseScale,
|
||||||
|
);
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
animationId = window.requestAnimationFrame(animate);
|
animationId = window.requestAnimationFrame(animate);
|
||||||
};
|
};
|
||||||
@@ -812,6 +944,12 @@ export default function ProjectLibrary({
|
|||||||
];
|
];
|
||||||
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;
|
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0;
|
||||||
|
const dicomSliceTotal = sliceTotal || selectedProject?.dicomCount || 0;
|
||||||
|
const dicomMaxSlice = Math.max(dicomSliceTotal - 1, 0);
|
||||||
|
const safeDicomSlice = Math.max(0, Math.min(dicomMaxSlice, sliceIndex));
|
||||||
|
const dicomDisplaySlice = getDicomDisplaySliceNumber(safeDicomSlice, dicomSliceTotal);
|
||||||
|
const dicomSliderValue = dicomMaxSlice - safeDicomSlice;
|
||||||
|
const dicomSlicePercent = dicomMaxSlice > 0 ? (dicomSliderValue / dicomMaxSlice) * 100 : 0;
|
||||||
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0];
|
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0];
|
||||||
const savedSegmentationResults = selectedProject?.segmentationResults ?? [];
|
const savedSegmentationResults = selectedProject?.segmentationResults ?? [];
|
||||||
const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1];
|
const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1];
|
||||||
@@ -955,8 +1093,9 @@ export default function ProjectLibrary({
|
|||||||
nextStyles[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? updated.moduleStyles?.[fileName]);
|
nextStyles[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? updated.moduleStyles?.[fileName]);
|
||||||
});
|
});
|
||||||
setModuleStyles(nextStyles);
|
setModuleStyles(nextStyles);
|
||||||
setModelPose(latestResult?.pose ?? defaultModelPose);
|
const nextPose = normalizeModelPose(latestResult?.pose);
|
||||||
setResultPose(latestResult?.pose ?? defaultModelPose);
|
setModelPose(nextPose);
|
||||||
|
setResultPose(nextPose);
|
||||||
setSliceIndex(0);
|
setSliceIndex(0);
|
||||||
setDicomPreview(null);
|
setDicomPreview(null);
|
||||||
setDicomError('');
|
setDicomError('');
|
||||||
@@ -994,15 +1133,17 @@ export default function ProjectLibrary({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const latestResult = selectedProject?.segmentationResults?.[selectedProject.segmentationResults.length - 1];
|
const latestResult = selectedProject?.segmentationResults?.[selectedProject.segmentationResults.length - 1];
|
||||||
|
const maxIndex = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0);
|
||||||
const next: Record<string, ModuleStyle> = {};
|
const next: Record<string, ModuleStyle> = {};
|
||||||
stlFiles.forEach((fileName, index) => {
|
stlFiles.forEach((fileName, index) => {
|
||||||
next[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? selectedProject?.moduleStyles?.[fileName] ?? moduleStyles[fileName]);
|
next[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? selectedProject?.moduleStyles?.[fileName] ?? moduleStyles[fileName]);
|
||||||
});
|
});
|
||||||
setModuleStyles(next);
|
setModuleStyles(next);
|
||||||
setSliceIndex(0);
|
setSliceIndex(0);
|
||||||
setModelPose(latestResult?.pose ?? defaultModelPose);
|
const nextPose = normalizeModelPose(latestResult?.pose);
|
||||||
setResultPose(latestResult?.pose ?? defaultModelPose);
|
setModelPose(nextPose);
|
||||||
setResultPreviewSlice(Math.max(0, Math.min(Math.max((selectedProject?.dicomCount ?? 1) - 1, 0), latestResult?.mappingSlice ?? 0)));
|
setResultPose(nextPose);
|
||||||
|
setResultPreviewSlice(Math.max(0, Math.min(maxIndex, latestResult?.mappingSlice ?? maxIndex)));
|
||||||
setResultDisplayMode('soft');
|
setResultDisplayMode('soft');
|
||||||
setResultRotation(0);
|
setResultRotation(0);
|
||||||
}, [selectedProject?.id]);
|
}, [selectedProject?.id]);
|
||||||
@@ -1163,6 +1304,22 @@ export default function ProjectLibrary({
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleModelFlip = (key: ModelPoseFlipKey) => {
|
||||||
|
setModelPose((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: !current[key],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetModelFlipPose = () => {
|
||||||
|
setModelPose((current) => ({
|
||||||
|
...current,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
flipZ: false,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const rotateDicom = (delta: number) => {
|
const rotateDicom = (delta: number) => {
|
||||||
setRotation((current) => ((current + delta) % 360 + 360) % 360);
|
setRotation((current) => ((current + delta) % 360 + 360) % 360);
|
||||||
};
|
};
|
||||||
@@ -1178,8 +1335,9 @@ export default function ProjectLibrary({
|
|||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
const planeLabel = planeOptions.find((option) => option.id === plane)?.label ?? plane;
|
const planeLabel = planeOptions.find((option) => option.id === plane)?.label ?? plane;
|
||||||
const modeLabel = displayModes.find((mode) => mode.id === displayMode)?.label ?? displayMode;
|
const modeLabel = displayModes.find((mode) => mode.id === displayMode)?.label ?? displayMode;
|
||||||
|
const displaySlice = getDicomDisplaySliceNumber(dicomPreview.slice, dicomPreview.total);
|
||||||
link.href = canvas.toDataURL('image/png');
|
link.href = canvas.toDataURL('image/png');
|
||||||
link.download = `${safeFilePart(selectedProject.name)}_${planeLabel}_slice-${dicomPreview.slice + 1}-of-${dicomPreview.total}_${modeLabel}_rot-${rotation}.png`;
|
link.download = `${safeFilePart(selectedProject.name)}_${planeLabel}_slice-${displaySlice}-of-${dicomPreview.total}_${modeLabel}_rot-${rotation}.png`;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
@@ -1618,7 +1776,7 @@ export default function ProjectLibrary({
|
|||||||
key={option.id}
|
key={option.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPlane(option.id);
|
setPlane(option.id);
|
||||||
setSliceIndex(option.id === 'axial' ? Math.floor((selectedProject.dicomCount || 1) / 2) : 256);
|
setSliceIndex(0);
|
||||||
}}
|
}}
|
||||||
className={`px-3 py-1.5 rounded-md text-[10px] font-bold transition-all ${
|
className={`px-3 py-1.5 rounded-md text-[10px] font-bold transition-all ${
|
||||||
plane === option.id ? 'bg-blue-600 text-white' : 'text-white/50 hover:text-white'
|
plane === option.id ? 'bg-blue-600 text-white' : 'text-white/50 hover:text-white'
|
||||||
@@ -1678,38 +1836,15 @@ export default function ProjectLibrary({
|
|||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
|
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
|
||||||
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span>
|
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span>
|
||||||
<span>第 {selectedProject.dicomCount ? sliceIndex + 1 : 0} / {dicomPreview?.total ?? selectedProject.dicomCount} 张</span>
|
<span>第 {selectedProject.dicomCount ? dicomDisplaySlice : 0} / {dicomSliceTotal || selectedProject.dicomCount} 张</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Right: Vertical Progress Bar */}
|
{/* Right: Vertical Progress Bar */}
|
||||||
<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} / {sliceTotal || selectedProject.dicomCount}
|
{dicomDisplaySlice} / {dicomSliceTotal || 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
|
|
||||||
type="range"
|
|
||||||
min="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
|
<button
|
||||||
onMouseDown={() => startSliceStep(-1)}
|
onMouseDown={() => startSliceStep(-1)}
|
||||||
onMouseUp={stopSliceStep}
|
onMouseUp={stopSliceStep}
|
||||||
@@ -1719,12 +1854,42 @@ export default function ProjectLibrary({
|
|||||||
startSliceStep(-1);
|
startSliceStep(-1);
|
||||||
}}
|
}}
|
||||||
onTouchEnd={stopSliceStep}
|
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>
|
||||||
|
<div className="relative min-h-[260px] w-10 flex-1">
|
||||||
|
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-800/70" />
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-cyan-400"
|
||||||
|
style={{ height: `${dicomSlicePercent}%` }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={dicomMaxSlice}
|
||||||
|
value={dicomSliderValue}
|
||||||
|
onChange={(event) => setSliceIndex(dicomMaxSlice - Number(event.target.value))}
|
||||||
|
className="mapping-slice-dark-vertical-input"
|
||||||
|
aria-label="项目库 DICOM 切片导航"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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"
|
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="长按向下移动切片"
|
title="长按向下移动切片"
|
||||||
>
|
>
|
||||||
<ChevronDown size={16} />
|
<ChevronDown size={16} />
|
||||||
</button>
|
</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">#{dicomDisplaySlice}</span>
|
||||||
<div className="mt-5 flex w-full flex-col gap-2 px-2">
|
<div className="mt-5 flex w-full flex-col gap-2 px-2">
|
||||||
<button
|
<button
|
||||||
onClick={downloadCurrentDicomPng}
|
onClick={downloadCurrentDicomPng}
|
||||||
@@ -1807,16 +1972,43 @@ export default function ProjectLibrary({
|
|||||||
onClick={resetModelRotationPose}
|
onClick={resetModelRotationPose}
|
||||||
className="rounded-md bg-white px-2 py-1 text-[10px] font-bold text-blue-600 shadow-sm border border-slate-100 hover:bg-blue-50"
|
className="rounded-md bg-white px-2 py-1 text-[10px] font-bold text-blue-600 shadow-sm border border-slate-100 hover:bg-blue-50"
|
||||||
>
|
>
|
||||||
重置旋转位姿
|
旋转复位
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={resetModelTransformPose}
|
onClick={resetModelTransformPose}
|
||||||
className="rounded-md bg-white px-2 py-1 text-[10px] font-bold text-blue-600 shadow-sm border border-slate-100 hover:bg-blue-50"
|
className="rounded-md bg-white px-2 py-1 text-[10px] font-bold text-blue-600 shadow-sm border border-slate-100 hover:bg-blue-50"
|
||||||
>
|
>
|
||||||
重置平移缩放位姿
|
平移缩放复位
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={resetModelFlipPose}
|
||||||
|
className="rounded-md bg-white px-2 py-1 text-[10px] font-bold text-blue-600 shadow-sm border border-slate-100 hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
镜像复位
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{modelPoseFlipOptions.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const enabled = modelPose[item.key];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => toggleModelFlip(item.key)}
|
||||||
|
className={`flex h-8 items-center justify-center gap-1.5 rounded-lg border text-[10px] font-bold transition ${
|
||||||
|
enabled
|
||||||
|
? 'border-emerald-200 bg-emerald-600 text-white shadow-sm'
|
||||||
|
: 'border-slate-100 bg-white text-slate-500 shadow-sm hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700'
|
||||||
|
}`}
|
||||||
|
title={`以模型中心沿 ${item.axis} 轴镜像翻转`}
|
||||||
|
>
|
||||||
|
<Icon size={13} />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
{[
|
{[
|
||||||
{ key: 'rotateX' as const, label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX, minus: '-90°', plus: '+90°', delta: 90 },
|
{ key: 'rotateX' as const, label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX, minus: '-90°', plus: '+90°', delta: 90 },
|
||||||
{ key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY, minus: '-90°', plus: '+90°', delta: 90 },
|
{ key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY, minus: '-90°', plus: '+90°', delta: 90 },
|
||||||
@@ -2048,6 +2240,9 @@ export default function ProjectLibrary({
|
|||||||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">TY {formatPoseCompactValue(latestResultPose.translateY, 3)}</span>
|
<span className="rounded-lg bg-slate-50 px-2 py-1.5">TY {formatPoseCompactValue(latestResultPose.translateY, 3)}</span>
|
||||||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">TZ {formatPoseCompactValue(latestResultPose.translateZ, 3)}</span>
|
<span className="rounded-lg bg-slate-50 px-2 py-1.5">TZ {formatPoseCompactValue(latestResultPose.translateZ, 3)}</span>
|
||||||
<span className="col-span-3 rounded-lg bg-slate-50 px-2 py-1.5">Scale {formatPoseCompactValue(latestResultPose.scale, 3)}</span>
|
<span className="col-span-3 rounded-lg bg-slate-50 px-2 py-1.5">Scale {formatPoseCompactValue(latestResultPose.scale, 3)}</span>
|
||||||
|
<span className="rounded-lg bg-slate-50 px-2 py-1.5">FX {latestResultPose.flipX ? '开' : '关'}</span>
|
||||||
|
<span className="rounded-lg bg-slate-50 px-2 py-1.5">FY {latestResultPose.flipY ? '开' : '关'}</span>
|
||||||
|
<span className="rounded-lg bg-slate-50 px-2 py-1.5">FZ {latestResultPose.flipZ ? '开' : '关'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Save,
|
Save,
|
||||||
Upload,
|
Upload,
|
||||||
|
FlipHorizontal2,
|
||||||
|
FlipVertical2,
|
||||||
|
Move3d,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
|
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
|
||||||
@@ -32,7 +35,8 @@ export interface ModelPreviewPayload {
|
|||||||
export type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
export type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||||
export type DicomOpacityLevel = 'low' | 'medium' | 'high';
|
export type DicomOpacityLevel = 'low' | 'medium' | 'high';
|
||||||
export type MappingDisplayMode = DicomPreview['mode'];
|
export type MappingDisplayMode = DicomPreview['mode'];
|
||||||
type ModelPoseKey = keyof ModelPose;
|
type ModelPoseKey = Exclude<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
|
||||||
|
type ModelPoseFlipKey = Extract<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
|
||||||
type PoseDraftValues = Record<ModelPoseKey, string>;
|
type PoseDraftValues = Record<ModelPoseKey, string>;
|
||||||
type AxisKey = 'x' | 'y' | 'z';
|
type AxisKey = 'x' | 'y' | 'z';
|
||||||
|
|
||||||
@@ -55,6 +59,11 @@ interface WorkspaceLoadState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale'];
|
const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale'];
|
||||||
|
const modelPoseFlipOptions: Array<{ key: ModelPoseFlipKey; label: string; axis: string; icon: typeof FlipHorizontal2 }> = [
|
||||||
|
{ key: 'flipX', label: '镜像 X', axis: 'X', icon: FlipHorizontal2 },
|
||||||
|
{ key: 'flipY', label: '镜像 Y', axis: 'Y', icon: FlipVertical2 },
|
||||||
|
{ key: 'flipZ', label: '镜像 Z', axis: 'Z', icon: Move3d },
|
||||||
|
];
|
||||||
|
|
||||||
export const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [
|
export const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [
|
||||||
{ id: 'standard', label: '标准', limit: 16000 },
|
{ id: 'standard', label: '标准', limit: 16000 },
|
||||||
@@ -91,6 +100,9 @@ const defaultModelPose: ModelPose = {
|
|||||||
translateY: 0,
|
translateY: 0,
|
||||||
translateZ: 0,
|
translateZ: 0,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
flipZ: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultSavedPoses: SavedModelPose[] = [
|
const defaultSavedPoses: SavedModelPose[] = [
|
||||||
@@ -110,7 +122,7 @@ const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: stri
|
|||||||
];
|
];
|
||||||
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
|
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
|
||||||
{ id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
|
{ id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
|
||||||
{ id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' },
|
{ id: 'separate', label: '构件分别导出', description: '全部构件集中到同一目录' },
|
||||||
];
|
];
|
||||||
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||||||
const fusionBaseExtent = 4.6;
|
const fusionBaseExtent = 4.6;
|
||||||
@@ -195,6 +207,23 @@ function clamp(value: number, min: number, max: number) {
|
|||||||
return Math.max(min, Math.min(max, value));
|
return Math.max(min, Math.min(max, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDicomDisplaySliceNumber(sliceIndex: number, totalSlices: number) {
|
||||||
|
const total = Math.max(Math.round(totalSlices), 0);
|
||||||
|
if (!total) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return total - clamp(Math.round(sliceIndex), 0, total - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDicomDisplayRange(startIndex: number, endIndex: number, totalSlices: number) {
|
||||||
|
const first = getDicomDisplaySliceNumber(startIndex, totalSlices);
|
||||||
|
const second = getDicomDisplaySliceNumber(endIndex, totalSlices);
|
||||||
|
return {
|
||||||
|
start: Math.min(first, second),
|
||||||
|
end: Math.max(first, second),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getStepPrecision(step: number) {
|
function getStepPrecision(step: number) {
|
||||||
if (step >= 1) {
|
if (step >= 1) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -271,6 +300,14 @@ function normalizePoseValue(input: unknown, fallback: ModelPose = defaultModelPo
|
|||||||
normalized[key] = clamp(numericValue, limit.min, limit.max);
|
normalized[key] = clamp(numericValue, limit.min, limit.max);
|
||||||
hasPoseValue = true;
|
hasPoseValue = true;
|
||||||
});
|
});
|
||||||
|
modelPoseFlipOptions.forEach(({ key }) => {
|
||||||
|
const rawValue = input[key];
|
||||||
|
if (typeof rawValue !== 'boolean') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
normalized[key] = rawValue;
|
||||||
|
hasPoseValue = true;
|
||||||
|
});
|
||||||
|
|
||||||
return hasPoseValue ? normalized : null;
|
return hasPoseValue ? normalized : null;
|
||||||
}
|
}
|
||||||
@@ -327,7 +364,8 @@ function mergeImportedModelPoses(imported: SavedModelPose[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function poseValuesMatch(left: ModelPose, right: ModelPose) {
|
function poseValuesMatch(left: ModelPose, right: ModelPose) {
|
||||||
return modelPoseKeys.every((key) => Math.abs(left[key] - right[key]) < 1e-6);
|
return modelPoseKeys.every((key) => Math.abs(left[key] - right[key]) < 1e-6)
|
||||||
|
&& modelPoseFlipOptions.every(({ key }) => left[key] === right[key]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stableModuleStyles(styles: Record<string, ModuleStyle>) {
|
function stableModuleStyles(styles: Record<string, ModuleStyle>) {
|
||||||
@@ -852,7 +890,12 @@ export function FusionThreeView({
|
|||||||
pose.translateY,
|
pose.translateY,
|
||||||
pose.translateZ,
|
pose.translateZ,
|
||||||
);
|
);
|
||||||
modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale);
|
const poseScale = modelBaseScale * pose.scale;
|
||||||
|
modelPoseGroup.scale.set(
|
||||||
|
pose.flipX ? -poseScale : poseScale,
|
||||||
|
pose.flipY ? -poseScale : poseScale,
|
||||||
|
pose.flipZ ? -poseScale : poseScale,
|
||||||
|
);
|
||||||
modelPoseGroup.updateMatrixWorld(true);
|
modelPoseGroup.updateMatrixWorld(true);
|
||||||
const nextAxisProjection = projectModelAxisDirections(camera, modelPoseGroup);
|
const nextAxisProjection = projectModelAxisDirections(camera, modelPoseGroup);
|
||||||
const nextAxisSignature = axisProjectionSignature(nextAxisProjection);
|
const nextAxisSignature = axisProjectionSignature(nextAxisProjection);
|
||||||
@@ -908,6 +951,8 @@ export function FusionThreeView({
|
|||||||
viewPreset,
|
viewPreset,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const volumeDisplayRange = volume ? getDicomDisplayRange(volume.start, volume.end, volume.total) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full min-h-[520px] overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
|
<div className="relative h-full min-h-[520px] overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
|
||||||
<div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
|
<div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
|
||||||
@@ -924,7 +969,7 @@ export function FusionThreeView({
|
|||||||
{status}
|
{status}
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-none absolute right-4 top-4 rounded-xl border border-cyan-400/20 bg-cyan-950/50 px-3 py-2 text-[10px] font-mono text-cyan-100">
|
<div className="pointer-events-none absolute right-4 top-4 rounded-xl border border-cyan-400/20 bg-cyan-950/50 px-3 py-2 text-[10px] font-mono text-cyan-100">
|
||||||
DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
|
DICOM {volume && volumeDisplayRange ? `${volumeDisplayRange.start}-${volumeDisplayRange.end}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => resetFusionViewRef.current()}
|
onClick={() => resetFusionViewRef.current()}
|
||||||
@@ -1207,7 +1252,12 @@ function CutSectionPreview({
|
|||||||
THREE.MathUtils.degToRad(pose.rotateZ),
|
THREE.MathUtils.degToRad(pose.rotateZ),
|
||||||
);
|
);
|
||||||
modelPoseGroup.position.set(pose.translateX, pose.translateY, pose.translateZ);
|
modelPoseGroup.position.set(pose.translateX, pose.translateY, pose.translateZ);
|
||||||
modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale);
|
const poseScale = modelBaseScale * pose.scale;
|
||||||
|
modelPoseGroup.scale.set(
|
||||||
|
pose.flipX ? -poseScale : poseScale,
|
||||||
|
pose.flipY ? -poseScale : poseScale,
|
||||||
|
pose.flipZ ? -poseScale : poseScale,
|
||||||
|
);
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
animationId = window.requestAnimationFrame(animate);
|
animationId = window.requestAnimationFrame(animate);
|
||||||
};
|
};
|
||||||
@@ -1433,9 +1483,10 @@ function getModelSceneMetrics(
|
|||||||
|
|
||||||
function transformPointForPose(x: number, y: number, z: number, metrics: ModelSceneMetrics, pose: ModelPose): Point3D {
|
function transformPointForPose(x: number, y: number, z: number, metrics: ModelSceneMetrics, pose: ModelPose): Point3D {
|
||||||
const scalar = metrics.modelBaseScale * pose.scale;
|
const scalar = metrics.modelBaseScale * pose.scale;
|
||||||
let px = (x - metrics.center.x) * scalar;
|
let px = (x - metrics.center.x) * scalar * (pose.flipX ? -1 : 1);
|
||||||
let py = (y - metrics.center.y) * scalar;
|
let py = (y - metrics.center.y) * scalar * (pose.flipY ? -1 : 1);
|
||||||
let pz = (z - metrics.center.z + metrics.modelPivotOffsetZ) * scalar;
|
let pz = (z - metrics.center.z) * scalar * (pose.flipZ ? -1 : 1);
|
||||||
|
pz += metrics.modelPivotOffsetZ * scalar;
|
||||||
|
|
||||||
const rotateX = THREE.MathUtils.degToRad(pose.rotateX);
|
const rotateX = THREE.MathUtils.degToRad(pose.rotateX);
|
||||||
const rotateY = THREE.MathUtils.degToRad(pose.rotateY);
|
const rotateY = THREE.MathUtils.degToRad(pose.rotateY);
|
||||||
@@ -1637,16 +1688,31 @@ function drawFallbackClosedRegion(
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const center = points.reduce((accumulator, point) => ({
|
const sorted = [...points].sort((left, right) => (
|
||||||
x: accumulator.x + point.x / points.length,
|
Math.abs(left.x - right.x) > 1e-6 ? left.x - right.x : left.y - right.y
|
||||||
y: accumulator.y + point.y / points.length,
|
|
||||||
}), { x: 0, y: 0 });
|
|
||||||
const ordered = [...points].sort((left, right) => (
|
|
||||||
Math.atan2(left.y - center.y, left.x - center.x) - Math.atan2(right.y - center.y, right.x - center.x)
|
|
||||||
));
|
));
|
||||||
|
const cross = (origin: Point2D, a: Point2D, b: Point2D) => (
|
||||||
|
(a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x)
|
||||||
|
);
|
||||||
|
const lower: Point2D[] = [];
|
||||||
|
sorted.forEach((point) => {
|
||||||
|
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) {
|
||||||
|
lower.pop();
|
||||||
|
}
|
||||||
|
lower.push(point);
|
||||||
|
});
|
||||||
|
const upper: Point2D[] = [];
|
||||||
|
[...sorted].reverse().forEach((point) => {
|
||||||
|
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) {
|
||||||
|
upper.pop();
|
||||||
|
}
|
||||||
|
upper.push(point);
|
||||||
|
});
|
||||||
|
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
|
||||||
|
const ordered = hull.length >= 3 ? hull : points;
|
||||||
|
|
||||||
context.save();
|
context.save();
|
||||||
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.48;
|
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.62;
|
||||||
context.fillStyle = color;
|
context.fillStyle = color;
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
ordered.forEach((point, index) => {
|
ordered.forEach((point, index) => {
|
||||||
@@ -1660,7 +1726,7 @@ function drawFallbackClosedRegion(
|
|||||||
context.fill();
|
context.fill();
|
||||||
context.restore();
|
context.restore();
|
||||||
|
|
||||||
return Math.max(1, Math.round(points.length / 2));
|
return Math.max(1, Math.round(ordered.length / 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillSegmentsAsSolidMask(
|
function fillSegmentsAsSolidMask(
|
||||||
@@ -1757,23 +1823,25 @@ function fillSegmentsAsSolidMask(
|
|||||||
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
|
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
|
||||||
maskContext.putImageData(maskData, 0, 0);
|
maskContext.putImageData(maskData, 0, 0);
|
||||||
context.drawImage(maskCanvas, 0, 0);
|
context.drawImage(maskCanvas, 0, 0);
|
||||||
if (filledPixels === 0 && segments.length >= 3) {
|
if (filledPixels < Math.max(12, Math.round(segments.length * 0.45)) && segments.length >= 3) {
|
||||||
filledPixels = drawFallbackClosedRegion(context, width, height, segments, color, opacity);
|
filledPixels += drawFallbackClosedRegion(context, width, height, segments, color, opacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.save();
|
if (filledPixels === 0) {
|
||||||
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.82;
|
context.save();
|
||||||
context.strokeStyle = color;
|
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.42;
|
||||||
context.lineWidth = Math.max(1.2, Math.max(width, height) * 0.003);
|
context.strokeStyle = color;
|
||||||
context.lineCap = 'round';
|
context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
|
||||||
context.lineJoin = 'round';
|
context.lineCap = 'round';
|
||||||
context.beginPath();
|
context.lineJoin = 'round';
|
||||||
segments.forEach((segment) => {
|
context.beginPath();
|
||||||
context.moveTo(segment.a.x, segment.a.y);
|
segments.forEach((segment) => {
|
||||||
context.lineTo(segment.b.x, segment.b.y);
|
context.moveTo(segment.a.x, segment.a.y);
|
||||||
});
|
context.lineTo(segment.b.x, segment.b.y);
|
||||||
context.stroke();
|
});
|
||||||
context.restore();
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
return filledPixels;
|
return filledPixels;
|
||||||
}
|
}
|
||||||
@@ -2059,6 +2127,9 @@ export function VoxelizationMappingView({
|
|||||||
modelPose.translateY,
|
modelPose.translateY,
|
||||||
modelPose.translateZ,
|
modelPose.translateZ,
|
||||||
modelPose.scale,
|
modelPose.scale,
|
||||||
|
modelPose.flipX,
|
||||||
|
modelPose.flipY,
|
||||||
|
modelPose.flipZ,
|
||||||
safeSlice,
|
safeSlice,
|
||||||
totalSlices,
|
totalSlices,
|
||||||
]);
|
]);
|
||||||
@@ -2066,7 +2137,9 @@ export function VoxelizationMappingView({
|
|||||||
const stepSlice = (delta: number) => {
|
const stepSlice = (delta: number) => {
|
||||||
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
|
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
|
||||||
};
|
};
|
||||||
const slicePercent = maxSlice > 0 ? (safeSlice / maxSlice) * 100 : 0;
|
const sliderSliceValue = maxSlice - safeSlice;
|
||||||
|
const slicePercent = maxSlice > 0 ? (sliderSliceValue / maxSlice) * 100 : 0;
|
||||||
|
const displaySliceNumber = getDicomDisplaySliceNumber(safeSlice, Math.max(totalSlices, 1));
|
||||||
const resetMappingViewport = () => {
|
const resetMappingViewport = () => {
|
||||||
setMappingViewport({ scale: 1, offsetX: 0, offsetY: 0 });
|
setMappingViewport({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||||
};
|
};
|
||||||
@@ -2194,7 +2267,7 @@ export function VoxelizationMappingView({
|
|||||||
<div className="pointer-events-none absolute bottom-4 right-4 rounded-xl border border-white/10 bg-black/70 px-3 py-2 text-right shadow-lg">
|
<div className="pointer-events-none absolute bottom-4 right-4 rounded-xl border border-white/10 bg-black/70 px-3 py-2 text-right shadow-lg">
|
||||||
<p className="text-[9px] font-bold text-white/45">DICOM 切片位置</p>
|
<p className="text-[9px] font-bold text-white/45">DICOM 切片位置</p>
|
||||||
<p className="mt-1 font-mono text-[12px] font-bold text-cyan-100">
|
<p className="mt-1 font-mono text-[12px] font-bold text-cyan-100">
|
||||||
{safeSlice + 1} / {Math.max(totalSlices, 1)}
|
{displaySliceNumber} / {Math.max(totalSlices, 1)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2212,8 +2285,8 @@ export function VoxelizationMappingView({
|
|||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max={maxSlice}
|
max={maxSlice}
|
||||||
value={safeSlice}
|
value={sliderSliceValue}
|
||||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
onChange={(event) => onSliceChange(maxSlice - Number(event.target.value))}
|
||||||
className="mapping-slice-dark-vertical-input"
|
className="mapping-slice-dark-vertical-input"
|
||||||
aria-label="项目库逆向分割映射视图切片导航"
|
aria-label="项目库逆向分割映射视图切片导航"
|
||||||
/>
|
/>
|
||||||
@@ -2236,7 +2309,7 @@ export function VoxelizationMappingView({
|
|||||||
Overlay Label Map
|
Overlay Label Map
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-lg bg-slate-100 px-2.5 py-1 text-[9px] font-mono font-bold text-slate-500">
|
<span className="rounded-lg bg-slate-100 px-2.5 py-1 text-[9px] font-mono font-bold text-slate-500">
|
||||||
Z {safeSlice + 1}/{Math.max(totalSlices, 1)}
|
Z {displaySliceNumber}/{Math.max(totalSlices, 1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -2310,7 +2383,7 @@ export function VoxelizationMappingView({
|
|||||||
<div className="w-full rounded-2xl border border-slate-100 bg-white px-2 py-3 text-center shadow-sm">
|
<div className="w-full rounded-2xl border border-slate-100 bg-white px-2 py-3 text-center shadow-sm">
|
||||||
<p className="text-[10px] font-bold text-slate-700">DICOM 切片位置</p>
|
<p className="text-[10px] font-bold text-slate-700">DICOM 切片位置</p>
|
||||||
<span className="mt-1 block font-mono text-[10px] font-bold text-blue-600">
|
<span className="mt-1 block font-mono text-[10px] font-bold text-blue-600">
|
||||||
{safeSlice + 1} / {Math.max(totalSlices, 1)}
|
{displaySliceNumber} / {Math.max(totalSlices, 1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -2331,8 +2404,8 @@ export function VoxelizationMappingView({
|
|||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max={maxSlice}
|
max={maxSlice}
|
||||||
value={safeSlice}
|
value={sliderSliceValue}
|
||||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
onChange={(event) => onSliceChange(maxSlice - Number(event.target.value))}
|
||||||
className="mapping-slice-vertical-input"
|
className="mapping-slice-vertical-input"
|
||||||
aria-label="逆向分割映射视图切片导航"
|
aria-label="逆向分割映射视图切片导航"
|
||||||
/>
|
/>
|
||||||
@@ -2347,7 +2420,7 @@ export function VoxelizationMappingView({
|
|||||||
</button>
|
</button>
|
||||||
<div className="grid w-full grid-cols-1 gap-1 text-center text-[9px] font-bold text-slate-500">
|
<div className="grid w-full grid-cols-1 gap-1 text-center text-[9px] font-bold text-slate-500">
|
||||||
<span>顶层 {Math.max(totalSlices, 1)}</span>
|
<span>顶层 {Math.max(totalSlices, 1)}</span>
|
||||||
<span className="text-blue-600">当前 {safeSlice + 1}</span>
|
<span className="text-blue-600">当前 {displaySliceNumber}</span>
|
||||||
<span>底层 1</span>
|
<span>底层 1</span>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -2707,9 +2780,14 @@ export default function ReverseWorkspace({
|
|||||||
setSliceStart(restoredSliceStart);
|
setSliceStart(restoredSliceStart);
|
||||||
setSliceEnd(restoredSliceEnd);
|
setSliceEnd(restoredSliceEnd);
|
||||||
setMappingSlice(restoredMappingSlice);
|
setMappingSlice(restoredMappingSlice);
|
||||||
const nextPoses = item.modelPoses?.length ? item.modelPoses : defaultSavedPoses;
|
const nextPoses = (item.modelPoses?.length ? item.modelPoses : defaultSavedPoses).map((pose) => ({
|
||||||
|
...pose,
|
||||||
|
pose: normalizePoseValue(pose.pose) ?? defaultModelPose,
|
||||||
|
}));
|
||||||
const preferredPose = nextPoses.find((pose) => pose.id === 'default') ?? nextPoses[0];
|
const preferredPose = nextPoses.find((pose) => pose.id === 'default') ?? nextPoses[0];
|
||||||
const restoredPose = latestResult?.pose ?? preferredPose?.pose ?? defaultModelPose;
|
const restoredPose = normalizePoseValue(latestResult?.pose)
|
||||||
|
?? normalizePoseValue(preferredPose?.pose)
|
||||||
|
?? defaultModelPose;
|
||||||
initialZStretchRef.current = { projectId: item.id, pending: !latestResult };
|
initialZStretchRef.current = { projectId: item.id, pending: !latestResult };
|
||||||
setModelPose(restoredPose);
|
setModelPose(restoredPose);
|
||||||
setPoseValueDrafts(formatPoseDraftValues(restoredPose));
|
setPoseValueDrafts(formatPoseDraftValues(restoredPose));
|
||||||
@@ -2919,6 +2997,30 @@ export default function ReverseWorkspace({
|
|||||||
setPoseImportStatus('');
|
setPoseImportStatus('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleModelFlip = (key: ModelPoseFlipKey) => {
|
||||||
|
const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null;
|
||||||
|
setModelPose((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: !current[key],
|
||||||
|
}));
|
||||||
|
setSelectedPoseId('custom');
|
||||||
|
setPoseImportStatus('');
|
||||||
|
restoreVisualToolbarScroll(scrollTop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetModelFlipPose = () => {
|
||||||
|
const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null;
|
||||||
|
setModelPose((current) => ({
|
||||||
|
...current,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
flipZ: false,
|
||||||
|
}));
|
||||||
|
setSelectedPoseId('custom');
|
||||||
|
setPoseImportStatus('');
|
||||||
|
restoreVisualToolbarScroll(scrollTop);
|
||||||
|
};
|
||||||
|
|
||||||
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
|
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
|
||||||
const stlFiles = project?.stlFiles ?? [];
|
const stlFiles = project?.stlFiles ?? [];
|
||||||
const index = Math.max(0, stlFiles.indexOf(fileName));
|
const index = Math.max(0, stlFiles.indexOf(fileName));
|
||||||
@@ -3019,6 +3121,7 @@ export default function ReverseWorkspace({
|
|||||||
const safeMappingSlice = clamp(mappingSlice, 0, maxSlice);
|
const safeMappingSlice = clamp(mappingSlice, 0, maxSlice);
|
||||||
const displayStart = Math.min(safeSliceStart, safeSliceEnd);
|
const displayStart = Math.min(safeSliceStart, safeSliceEnd);
|
||||||
const displayEnd = Math.max(safeSliceStart, safeSliceEnd);
|
const displayEnd = Math.max(safeSliceStart, safeSliceEnd);
|
||||||
|
const displaySliceRange = getDicomDisplayRange(displayStart, displayEnd, project?.dicomCount ?? 0);
|
||||||
const rangeStartPercent = maxSlice > 0 ? (displayStart / maxSlice) * 100 : 0;
|
const rangeStartPercent = maxSlice > 0 ? (displayStart / maxSlice) * 100 : 0;
|
||||||
const rangeEndPercent = maxSlice > 0 ? (displayEnd / maxSlice) * 100 : 0;
|
const rangeEndPercent = maxSlice > 0 ? (displayEnd / maxSlice) * 100 : 0;
|
||||||
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
|
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
|
||||||
@@ -3339,7 +3442,7 @@ export default function ReverseWorkspace({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||||
<span className="text-[10px] font-mono text-slate-400">
|
<span className="text-[10px] font-mono text-slate-400">
|
||||||
Layer: {displayStart + 1}-{displayEnd + 1}/{project?.dicomCount ?? 0}
|
Layer: {displaySliceRange.start}-{displaySliceRange.end}/{project?.dicomCount ?? 0}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1 rounded-xl bg-slate-100 p-1">
|
<div className="flex items-center gap-1 rounded-xl bg-slate-100 p-1">
|
||||||
<span className="hidden items-center gap-1 px-1 text-[9px] font-bold text-slate-400 2xl:flex">
|
<span className="hidden items-center gap-1 px-1 text-[9px] font-bold text-slate-400 2xl:flex">
|
||||||
@@ -3398,7 +3501,7 @@ export default function ReverseWorkspace({
|
|||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<p className="text-xs font-bold text-slate-700">DICOM 切片范围</p>
|
<p className="text-xs font-bold text-slate-700">DICOM 切片范围</p>
|
||||||
<span className="text-[10px] font-mono text-blue-600">
|
<span className="text-[10px] font-mono text-blue-600">
|
||||||
{displayStart + 1} - {displayEnd + 1} / {project?.dicomCount ?? 0}
|
{displaySliceRange.start} - {displaySliceRange.end} / {project?.dicomCount ?? 0}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
@@ -3433,9 +3536,9 @@ export default function ReverseWorkspace({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 grid grid-cols-3 text-[10px] font-bold text-slate-500">
|
<div className="mt-1 grid grid-cols-3 text-[10px] font-bold text-slate-500">
|
||||||
<span>起点 {safeSliceStart + 1}</span>
|
<span>起点 {getDicomDisplaySliceNumber(safeSliceStart, project?.dicomCount ?? 0)}</span>
|
||||||
<span className="text-center text-blue-600">范围</span>
|
<span className="text-center text-blue-600">范围</span>
|
||||||
<span className="text-right">终点 {safeSliceEnd + 1}</span>
|
<span className="text-right">终点 {getDicomDisplaySliceNumber(safeSliceEnd, project?.dicomCount ?? 0)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3508,7 +3611,7 @@ export default function ReverseWorkspace({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p className="rounded-lg bg-orange-50 px-2 py-2 text-[10px] font-bold leading-5 text-orange-700">
|
<p className="rounded-lg bg-orange-50 px-2 py-2 text-[10px] font-bold leading-5 text-orange-700">
|
||||||
按 DICOM 切片范围 {displayStart + 1}-{displayEnd + 1} 保留模型中间区域
|
按 DICOM 切片范围 {displaySliceRange.start}-{displaySliceRange.end} 保留模型中间区域
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3556,7 +3659,7 @@ export default function ReverseWorkspace({
|
|||||||
{poseImportStatus}
|
{poseImportStatus}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={resetRotationPose}
|
onClick={resetRotationPose}
|
||||||
className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
|
className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
|
||||||
@@ -3569,6 +3672,33 @@ export default function ReverseWorkspace({
|
|||||||
>
|
>
|
||||||
重置平移缩放位姿
|
重置平移缩放位姿
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={resetModelFlipPose}
|
||||||
|
className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
|
||||||
|
>
|
||||||
|
重置镜像位姿
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
{modelPoseFlipOptions.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const enabled = modelPose[item.key];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => toggleModelFlip(item.key)}
|
||||||
|
className={`flex h-8 items-center justify-center gap-1.5 rounded-lg border text-[10px] font-bold transition ${
|
||||||
|
enabled
|
||||||
|
? 'border-emerald-200 bg-emerald-600 text-white shadow-sm'
|
||||||
|
: 'border-slate-100 bg-white text-slate-500 shadow-sm hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700'
|
||||||
|
}`}
|
||||||
|
title={`以模型中心沿 ${item.axis} 轴镜像翻转`}
|
||||||
|
>
|
||||||
|
<Icon size={13} />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{[
|
{[
|
||||||
|
|||||||
@@ -145,14 +145,15 @@
|
|||||||
.mapping-slice-vertical-input::-webkit-slider-thumb {
|
.mapping-slice-vertical-input::-webkit-slider-thumb {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
background: #2563eb;
|
background: #60a5fa;
|
||||||
border: 3px solid #ffffff;
|
border: 2px solid #dbeafe;
|
||||||
border-radius: 9999px;
|
border-radius: 5px;
|
||||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
|
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18), 0 4px 10px rgba(30, 64, 175, 0.28);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
height: 22px;
|
height: 18px;
|
||||||
margin-left: -7px;
|
margin-left: -5px;
|
||||||
width: 22px;
|
transform: rotate(45deg);
|
||||||
|
width: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapping-slice-vertical-input::-moz-range-track {
|
.mapping-slice-vertical-input::-moz-range-track {
|
||||||
@@ -162,13 +163,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mapping-slice-vertical-input::-moz-range-thumb {
|
.mapping-slice-vertical-input::-moz-range-thumb {
|
||||||
background: #2563eb;
|
background: #60a5fa;
|
||||||
border: 3px solid #ffffff;
|
border: 2px solid #dbeafe;
|
||||||
border-radius: 9999px;
|
border-radius: 5px;
|
||||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
|
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18), 0 4px 10px rgba(30, 64, 175, 0.28);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
height: 16px;
|
height: 14px;
|
||||||
width: 16px;
|
transform: rotate(45deg);
|
||||||
|
width: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapping-slice-vertical-input:active::-webkit-slider-thumb {
|
.mapping-slice-vertical-input:active::-webkit-slider-thumb {
|
||||||
@@ -207,14 +209,15 @@
|
|||||||
.mapping-slice-dark-vertical-input::-webkit-slider-thumb {
|
.mapping-slice-dark-vertical-input::-webkit-slider-thumb {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
background: #22d3ee;
|
background: #60a5fa;
|
||||||
border: 3px solid #0f172a;
|
border: 2px solid #dbeafe;
|
||||||
border-radius: 9999px;
|
border-radius: 5px;
|
||||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
|
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.18), 0 7px 16px rgba(8, 47, 73, 0.45);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
height: 20px;
|
height: 18px;
|
||||||
margin-left: -7px;
|
margin-left: -6px;
|
||||||
width: 20px;
|
transform: rotate(45deg);
|
||||||
|
width: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapping-slice-dark-vertical-input::-moz-range-track {
|
.mapping-slice-dark-vertical-input::-moz-range-track {
|
||||||
@@ -224,12 +227,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mapping-slice-dark-vertical-input::-moz-range-thumb {
|
.mapping-slice-dark-vertical-input::-moz-range-thumb {
|
||||||
background: #22d3ee;
|
background: #60a5fa;
|
||||||
border: 3px solid #0f172a;
|
border: 2px solid #dbeafe;
|
||||||
border-radius: 9999px;
|
border-radius: 5px;
|
||||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
|
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.18), 0 7px 16px rgba(8, 47, 73, 0.45);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
|
transform: rotate(45deg);
|
||||||
width: 14px;
|
width: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export interface ModelPose {
|
|||||||
translateY: number;
|
translateY: number;
|
||||||
translateZ: number;
|
translateZ: number;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
flipX: boolean;
|
||||||
|
flipY: boolean;
|
||||||
|
flipZ: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedModelPose {
|
export interface SavedModelPose {
|
||||||
|
|||||||
68
工程分析/实现方案-2026-05-24-10-45-43.md
Normal file
68
工程分析/实现方案-2026-05-24-10-45-43.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# 实现方案-2026-05-24-10-45-43
|
||||||
|
|
||||||
|
## 实现方案文档路径
|
||||||
|
|
||||||
|
`工程分析/实现方案-2026-05-24-10-45-43.md`
|
||||||
|
|
||||||
|
## 修改目标
|
||||||
|
|
||||||
|
- 定位并修正分割映射显示为空心线段的问题,使二维映射区域默认填充显示。
|
||||||
|
- 为模型位姿增加 X/Y/Z 镜像翻转开关,并贯通保存、导入、导出、前后端类型和渲染。
|
||||||
|
- 修改“构件分别导出”包结构,将所有构件文件集中到同一目录层级。
|
||||||
|
- 为项目库 DICOM 首页预览增加滚轮缩放、拖拽平移和位置重置。
|
||||||
|
- 修正 DICOM 切片编号显示与滑块样式。
|
||||||
|
- 同步更新 `Docker部署/README.md`,说明容器构建会包含这些前后端能力和生产模式注意事项。
|
||||||
|
|
||||||
|
## 涉及路径
|
||||||
|
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `WebSite/src/types.ts`
|
||||||
|
- `WebSite/server.ts`
|
||||||
|
- `Docker部署/README.md`
|
||||||
|
- `工程分析/需求分析-2026-05-24-10-45-43.md`
|
||||||
|
- `工程分析/实现方案-2026-05-24-10-45-43.md`
|
||||||
|
- `工程分析/测试方案-2026-05-24-10-45-43.md`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 技术路线
|
||||||
|
|
||||||
|
1. 使用 `rg` 定位分割映射、DICOM 预览、位姿控件、导出包逻辑。
|
||||||
|
2. 梳理现有 `ModelPoseValue` 与服务端导出函数,增加镜像字段和默认值。
|
||||||
|
3. 在前端三维/二维变换路径中使用统一位姿对象,新增镜像按钮组。
|
||||||
|
4. 调整二维分割映射绘制逻辑,优先使用闭合轮廓填充;对 sparse/edge-only 结果提供填充兜底。
|
||||||
|
5. 修改导出包 `separate` 模式的目录结构。
|
||||||
|
6. 为项目库 DICOM 画布加入 viewport state:scale、offset、dragging、reset。
|
||||||
|
7. 封装或局部实现 DICOM 切片显示编号反向映射与暗色菱形 range 样式。
|
||||||
|
8. 更新 Docker 部署说明。
|
||||||
|
|
||||||
|
## 执行步骤
|
||||||
|
|
||||||
|
1. 创建本次三件套,最终执行前再次阅读 `经验记录.md`。
|
||||||
|
2. 阅读相关源码和 API,确认现有数据结构。
|
||||||
|
3. 修改类型、服务端和前端实现。
|
||||||
|
4. 更新 Docker 部署文档。
|
||||||
|
5. 执行 `npm run lint`、`npm run build`。
|
||||||
|
6. 重新部署并验证本机、公网、关键 API。
|
||||||
|
7. 追加经验记录。
|
||||||
|
8. 提交并推送 Gitea。
|
||||||
|
|
||||||
|
## 兼容性与回滚方案
|
||||||
|
|
||||||
|
- 新增镜像字段设置默认值,旧状态文件和旧位姿导出没有该字段时按不翻转处理。
|
||||||
|
- 导出包结构改变只影响 `separate` 模式;如需回滚,可恢复服务端包路径生成逻辑。
|
||||||
|
- DICOM 显示缩放/拖动仅改变前端视图,不改变后端切片、导出或空间基准。
|
||||||
|
- 所有历史文档可从 Git 历史恢复。
|
||||||
|
|
||||||
|
## 预计文件变更
|
||||||
|
|
||||||
|
- 前端组件和类型文件。
|
||||||
|
- 后端导出、位姿解析和体素坐标变换逻辑。
|
||||||
|
- Docker 部署说明。
|
||||||
|
- 本次工程分析与经验记录。
|
||||||
|
|
||||||
|
## 提交与部署策略
|
||||||
|
|
||||||
|
- commit message:`2026-05-24-10-45-43 修正分割显示镜像导出与DICOM交互`
|
||||||
|
- 提交范围包含本次源码、Docker 文档和工程分析文档。
|
||||||
|
- 部署使用 `NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 4000`,并保留现有 frpc 容器公网入口。
|
||||||
50
工程分析/测试方案-2026-05-24-10-45-43.md
Normal file
50
工程分析/测试方案-2026-05-24-10-45-43.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 测试方案-2026-05-24-10-45-43
|
||||||
|
|
||||||
|
## 测试方案文档路径
|
||||||
|
|
||||||
|
`工程分析/测试方案-2026-05-24-10-45-43.md`
|
||||||
|
|
||||||
|
## 静态检查
|
||||||
|
|
||||||
|
- `git status --short --branch`:确认只包含本次相关文件。
|
||||||
|
- `rg` 检查新增镜像字段在前端、后端和类型中均有默认值与使用点。
|
||||||
|
- 检查 Docker 部署文档已同步说明。
|
||||||
|
|
||||||
|
## 构建检查
|
||||||
|
|
||||||
|
- `cd WebSite && npm run lint`
|
||||||
|
- `cd WebSite && npm run build`
|
||||||
|
|
||||||
|
## 关键业务场景验证
|
||||||
|
|
||||||
|
- 项目库 DICOM 首页:滚轮缩放、拖拽平移、位置重置可用。
|
||||||
|
- 逆向工作区:X/Y/Z 镜像翻转按钮可改变模型位姿,并能保存/导出。
|
||||||
|
- 分割映射:构件区域以填充实体显示,不再只呈现稀疏线段。
|
||||||
|
- 导出项目结果:`separate` 模式中所有构件 NIfTI 文件位于同一目录层级。
|
||||||
|
- DICOM 切片编号:初始显示符合用户期望,滑块视觉接近截图 5。
|
||||||
|
|
||||||
|
## 医学影像数据相关边界验证
|
||||||
|
|
||||||
|
- 默认 DICOM/STL 项目仍可加载。
|
||||||
|
- 分割填充显示不改变 DICOM 图像本身。
|
||||||
|
- 导出的 NIfTI 文件仍能生成 `.nii` 或 `.nii.gz`。
|
||||||
|
- 镜像翻转参与导出坐标变换,不只停留在 UI。
|
||||||
|
|
||||||
|
## 部署验证
|
||||||
|
|
||||||
|
- 重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||||
|
- 验证 `http://127.0.0.1:4000/api/health`。
|
||||||
|
- 验证 `http://127.0.0.1:4000/`。
|
||||||
|
- 验证 `https://revoxel.huijutec.cn/` 与公网 API。
|
||||||
|
|
||||||
|
## Git/Gitea 备份验证
|
||||||
|
|
||||||
|
- commit message 包含 `2026-05-24-10-45-43`。
|
||||||
|
- 推送后确认远端 `main` 指向新提交。
|
||||||
|
|
||||||
|
## 风险与回归关注点
|
||||||
|
|
||||||
|
- 分割填充不能把构件之间错误合并。
|
||||||
|
- 镜像翻转字段必须向后兼容旧状态。
|
||||||
|
- 切片显示编号与实际 `sliceIndex` 请求不能错位。
|
||||||
|
- 导出包结构改变应在文档和最终汇报中明确。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -1657,3 +1657,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
后续做程序修改时优先阅读核心三文档和当次三件套,不要在目录中长期堆积几十个历史流水文件。若某次需求产生可复用经验,应写入 `经验记录.md`;若只是一时执行细节,则交由 Git commit 历史保存。文档治理提交前必须确认没有混入源码、医学数据或运行态文件。
|
后续做程序修改时优先阅读核心三文档和当次三件套,不要在目录中长期堆积几十个历史流水文件。若某次需求产生可复用经验,应写入 `经验记录.md`;若只是一时执行细节,则交由 Git commit 历史保存。文档治理提交前必须确认没有混入源码、医学数据或运行态文件。
|
||||||
|
|
||||||
|
## 2026-05-24-10-45-43 分割显示、镜像位姿、DICOM 顺序与导出包结构联动修正
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
逆向分割映射中部分 STL 构件在二维 DICOM 切片上显示为线段或横向连接线,不像填充实体;模型位姿缺少以模型中心沿 X/Y/Z 轴镜像翻转的能力;“构件分别导出”需要所有构件 NIfTI 集中在同一目录;项目库 DICOM 首页缺少滚轮缩放、拖拽平移和位置重置;项目库和工作区的 DICOM 切片编号使用了数组索引顺序,初始显示为 `1/300` 而不是用户期望的 `300/300`,滑条也被误读成进度条。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
前端叠加层虽然已有扫描线填充,但在交点稀疏或切面轮廓退化时仍会绘制大量切面线段,视觉上变成“线连接”;服务端导出只保留扫描线交点,缺少稀疏轮廓兜底。`ModelPose` 只包含旋转、平移、缩放,保存、预览、导出都没有镜像字段。DICOM 预览使用零基索引直显,未转换为医学查看习惯的反向层号。自写 tar 导出头只写 100 字节 name 字段,长中文路径会被截断,集中目录后更容易暴露。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
为 `ModelPose` 和 `ModelPoseValue` 增加 `flipX/flipY/flipZ`,在默认值、归一化、保存、导入、三维预览、二维映射和服务端 NIfTI 导出中统一生效。二维叠加层改为优先实体填充,稀疏切面用闭合外轮廓兜底,已有填充结果不再叠加粗线段;服务端导出同步增加闭合外轮廓兜底写入。项目库 DICOM 首页新增独立 viewport 状态,支持滚轮缩放、拖拽平移和位置重置。DICOM 显示层号统一使用 `total - sliceIndex`,滑条值反向映射且使用菱形非进度条样式。`separate` 导出改到 `segmentation-parts/` 同一目录,并给 tar 生成补充 PAX path 头,保证中文长路径和 `.nii.gz` 扩展完整。Docker Compose 镜像标签和 README 同步到 20260524 能力说明。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
凡是新增位姿字段,必须同时检查前端类型、默认状态、保存/导入、三维渲染、二维映射、服务端归一化、导出坐标变换和项目库回放,不能只改一个界面。涉及 DICOM 层号时要明确区分“数组索引”和“对用户显示的医学层号”。导出压缩包如果包含中文或深层路径,必须用 `tar -tzf` 验证路径完整,不要依赖 100 字节 tar name 字段。分割显示的视觉修正也应同步确认导出数据路径,避免 UI 看似实体而 NIfTI 仍是轮廓。
|
||||||
|
|||||||
60
工程分析/需求分析-2026-05-24-10-45-43.md
Normal file
60
工程分析/需求分析-2026-05-24-10-45-43.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 需求分析-2026-05-24-10-45-43
|
||||||
|
|
||||||
|
## 开始时间
|
||||||
|
|
||||||
|
2026-05-24-10-45-43
|
||||||
|
|
||||||
|
## 原始需求摘要
|
||||||
|
|
||||||
|
用户要求修正逆向工作区和项目库中的分割显示、镜像翻转、导出结构、DICOM 预览交互和 DICOM 切片序号/滚动条样式问题,并同步修正 `Docker部署/` 中相关内容。
|
||||||
|
|
||||||
|
## 业务目标
|
||||||
|
|
||||||
|
- 让二维分割映射中的所有构件尽量以填充实体呈现,避免只显示为线段连接的空心效果。
|
||||||
|
- 在模型位姿中增加沿 X/Y/Z 轴、以模型中心为基准的镜像翻转能力。
|
||||||
|
- 调整“构件分别导出”结果结构,方便后续统一处理各构件 NIfTI 文件。
|
||||||
|
- 项目库 DICOM 首页支持滚轮缩放、拖拽移动和位置重置。
|
||||||
|
- 修正项目库与工作区 DICOM 切片显示顺序和滑块样式,使切片编号符合用户观察习惯。
|
||||||
|
- 保证 Docker 部署说明和容器化运行包含本次能力。
|
||||||
|
|
||||||
|
## 输入与输出
|
||||||
|
|
||||||
|
- 输入:
|
||||||
|
- 用户提供的 5 张截图与问题描述。
|
||||||
|
- 当前 React/Express 一体工程源码。
|
||||||
|
- 现有 Docker 部署目录。
|
||||||
|
- 输出:
|
||||||
|
- 前端 DICOM/分割/位姿 UI 与交互修正。
|
||||||
|
- 后端导出结构修正。
|
||||||
|
- 类型、构建、部署验证结果。
|
||||||
|
- 更新后的 `Docker部署/` 说明或配置。
|
||||||
|
- 本次工程分析文档、经验记录和 Git/Gitea 备份提交。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `WebSite/src/types.ts`
|
||||||
|
- `WebSite/server.ts`
|
||||||
|
- `Docker部署/`
|
||||||
|
- `工程分析/`
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
|
||||||
|
- 不伪造真实医学级算法能力;如果显示填充来自二维映射渲染增强,应明确其与导出体素结果的关系。
|
||||||
|
- 镜像翻转必须纳入保存位姿、导出位姿和服务端导出计算链路,避免 UI 与导出不一致。
|
||||||
|
- DICOM/STL 空间基准不能因显示缩放或拖动而改变。
|
||||||
|
- 提交时避免混入运行态数据、医学数据和无关文件。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 现有分割映射可能基于 STL 与 DICOM 切面交线或投影采样,空心线段与真实截面、视角或采样算法有关;直接填充需要避免把多个不连通区域错误合并。
|
||||||
|
- 镜像翻转会影响三维显示、二维映射、保存位姿和导出体素化坐标,需要统一变换模型。
|
||||||
|
- NIfTI 导出包结构改变可能影响已有用户脚本,需兼容命名清晰。
|
||||||
|
- 切片顺序修正需要兼顾 axial/sagittal/coronal 与已有 API `sliceIndex` 约定。
|
||||||
|
|
||||||
|
## 待确认问题或默认假设
|
||||||
|
|
||||||
|
- 默认假设:用户希望前端所有分割映射默认显示为填充实体,并且导出体素结果也应按填充体素生成,而不是仅导出轮廓线。
|
||||||
|
- 默认假设:“构建分别导出”指“构件分别导出”,目标是一个导出包内统一放置所有构件 NIfTI 文件,而不是为每个构件再套一个独立目录。
|
||||||
|
- 默认假设:切片编号应按用户看到的顺序显示,初始页显示最后一张即 `300 / 300`,滑块视觉采用截图 5 的暗色轨道和蓝色菱形滑块样式。
|
||||||
Reference in New Issue
Block a user