Compare commits
7 Commits
a08bf908c9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d167e1c10c | |||
| 5a9d09a5bf | |||
| 1e044fb349 | |||
| 60ebbf4196 | |||
| acdff763b5 | |||
| 21b372f705 | |||
| 27e0ec626e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,6 +14,9 @@ Docker部署/**/exports/
|
||||
Docker部署/**/locked-results/
|
||||
项目数据/锁定结果/
|
||||
|
||||
# Local deliverables not pushed to Gitea
|
||||
3. 代码汇总.docx
|
||||
|
||||
# Local env
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -33,6 +33,9 @@ interface ModelPoseValue {
|
||||
translateY: number;
|
||||
translateZ: number;
|
||||
scale: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
scaleZ: number;
|
||||
flipX: boolean;
|
||||
flipY: boolean;
|
||||
flipZ: boolean;
|
||||
@@ -179,6 +182,9 @@ const defaultModelPose: ModelPoseValue = {
|
||||
translateY: 0,
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
scaleZ: 1,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
flipZ: false,
|
||||
@@ -188,6 +194,9 @@ const modelPoseValuePrecision: Partial<Record<keyof ModelPoseValue, number>> = {
|
||||
translateY: 3,
|
||||
translateZ: 3,
|
||||
scale: 3,
|
||||
scaleX: 3,
|
||||
scaleY: 3,
|
||||
scaleZ: 3,
|
||||
};
|
||||
|
||||
interface DicomAttributes {
|
||||
@@ -494,6 +503,9 @@ function normalizeModelPoseValue(value: Partial<ModelPoseValue> | undefined): Mo
|
||||
translateY: read('translateY', defaultModelPose.translateY, -2, 2),
|
||||
translateZ: read('translateZ', defaultModelPose.translateZ, -2, 2),
|
||||
scale: read('scale', defaultModelPose.scale, 0.5, 3),
|
||||
scaleX: read('scaleX', defaultModelPose.scaleX, 0.2, 8),
|
||||
scaleY: read('scaleY', defaultModelPose.scaleY, 0.2, 8),
|
||||
scaleZ: read('scaleZ', defaultModelPose.scaleZ, 0.2, 8),
|
||||
flipX: readBoolean('flipX', defaultModelPose.flipX),
|
||||
flipY: readBoolean('flipY', defaultModelPose.flipY),
|
||||
flipZ: readBoolean('flipZ', defaultModelPose.flipZ),
|
||||
@@ -1030,9 +1042,9 @@ function transformPointForExportPose(x: number, y: number, z: number, metrics: E
|
||||
y: afterX.y,
|
||||
z: -afterX.x * sinY + afterX.z * cosY,
|
||||
};
|
||||
px = afterY.x * cosZ - afterY.y * sinZ;
|
||||
py = afterY.x * sinZ + afterY.y * cosZ;
|
||||
pz = afterY.z;
|
||||
px = (afterY.x * cosZ - afterY.y * sinZ) * pose.scaleX;
|
||||
py = (afterY.x * sinZ + afterY.y * cosZ) * pose.scaleY;
|
||||
pz = afterY.z * pose.scaleZ;
|
||||
|
||||
return {
|
||||
x: px + pose.translateX,
|
||||
@@ -1097,7 +1109,7 @@ function normalizeAutoMatchWeights(input: unknown): AutoMatchWeights {
|
||||
|
||||
function normalizeAutoMatchIterations(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? clampNumber(Math.round(value), 2, 12)
|
||||
? clampNumber(Math.round(value), 2, 50)
|
||||
: 6;
|
||||
}
|
||||
|
||||
@@ -2310,6 +2322,22 @@ function collectPreparedAssetFiles(kind: 'dicom' | 'stl', uploadedFiles: Express
|
||||
return expandedFiles.filter((file) => file.data.length > 0 && isImportableAsset(kind, file.name, file.data));
|
||||
}
|
||||
|
||||
function writePreparedAssetFiles(kind: 'dicom' | 'stl', targetDir: string, preparedFiles: PreparedAssetFile[]) {
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
ensureDir(targetDir);
|
||||
const usedNames = new Set<string>();
|
||||
preparedFiles.forEach((file, index) => {
|
||||
const fileName = safeImportedFileName(
|
||||
file.name,
|
||||
kind === 'dicom' ? `slice-${String(index + 1).padStart(4, '0')}.dcm` : `model-${index + 1}.stl`,
|
||||
kind === 'dicom' ? '.dcm' : '.stl',
|
||||
usedNames,
|
||||
);
|
||||
fs.writeFileSync(path.join(targetDir, fileName), file.data);
|
||||
});
|
||||
return listFiles(targetDir, kind === 'dicom' ? '.dcm' : '.stl');
|
||||
}
|
||||
|
||||
function cleanupUploadedTempFiles(files: Express.Multer.File[]) {
|
||||
files.forEach((file) => {
|
||||
if (file.path) {
|
||||
@@ -2318,6 +2346,296 @@ function cleanupUploadedTempFiles(files: Express.Multer.File[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function parseJsonObjectField(raw: unknown) {
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('metadata 必须是 JSON 对象');
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function parsePipelineMetadata(body: unknown) {
|
||||
const source = body && typeof body === 'object' && !Array.isArray(body) ? body as Record<string, unknown> : {};
|
||||
const metadata = parseJsonObjectField(source.metadata ?? source.payload ?? source.options);
|
||||
return {
|
||||
...source,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function parseFlexibleUploadedAssets(raw: unknown) {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const value = Array.isArray(raw) && raw.length === 1 && typeof raw[0] === 'string' ? raw[0] : raw;
|
||||
try {
|
||||
return parseUploadedAssets(typeof value === 'string' ? JSON.parse(value) as unknown : value);
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? error.message : '上传文件列表无效');
|
||||
}
|
||||
}
|
||||
|
||||
function requestFilesFromFields(files: unknown, fieldNames: string[]) {
|
||||
if (!files || Array.isArray(files) || typeof files !== 'object') {
|
||||
return [];
|
||||
}
|
||||
const groups = files as Record<string, Express.Multer.File[]>;
|
||||
return fieldNames.flatMap((name) => Array.isArray(groups[name]) ? groups[name] : []);
|
||||
}
|
||||
|
||||
function allRequestMulterFiles(files: unknown) {
|
||||
if (Array.isArray(files)) {
|
||||
return files as Express.Multer.File[];
|
||||
}
|
||||
if (!files || typeof files !== 'object') {
|
||||
return [];
|
||||
}
|
||||
return Object.values(files as Record<string, Express.Multer.File[]>).flatMap((group) => (
|
||||
Array.isArray(group) ? group : []
|
||||
));
|
||||
}
|
||||
|
||||
function readBooleanOption(value: unknown, fallback = false) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (['false', '0', 'no', 'n', 'off'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function readNumberOption(value: unknown) {
|
||||
const numberValue = typeof value === 'number' ? value : typeof value === 'string' ? Number.parseFloat(value) : NaN;
|
||||
return Number.isFinite(numberValue) ? numberValue : undefined;
|
||||
}
|
||||
|
||||
function readNestedObject(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function normalizePipelinePose(metadata: Record<string, unknown>) {
|
||||
const poseSource = readNestedObject(metadata.pose);
|
||||
const rotation = readNestedObject(metadata.rotation ?? metadata.rotate);
|
||||
const translation = readNestedObject(metadata.translation ?? metadata.translate);
|
||||
const mirror = readNestedObject(metadata.mirror ?? metadata.flip);
|
||||
const pose: Partial<ModelPoseValue> = {
|
||||
...poseSource,
|
||||
};
|
||||
const rotationX = readNumberOption(rotation.x ?? rotation.rotateX ?? metadata.rotateX);
|
||||
const rotationY = readNumberOption(rotation.y ?? rotation.rotateY ?? metadata.rotateY);
|
||||
const rotationZ = readNumberOption(rotation.z ?? rotation.rotateZ ?? metadata.rotateZ);
|
||||
const translateX = readNumberOption(translation.x ?? translation.translateX ?? metadata.translateX);
|
||||
const translateY = readNumberOption(translation.y ?? translation.translateY ?? metadata.translateY);
|
||||
const translateZ = readNumberOption(translation.z ?? translation.translateZ ?? metadata.translateZ);
|
||||
const scale = readNumberOption(metadata.scale ?? poseSource.scale);
|
||||
const axisScale = readNestedObject(metadata.axisScale ?? metadata.axisScales ?? metadata.stretchScale);
|
||||
const scaleX = readNumberOption(axisScale.x ?? axisScale.scaleX ?? metadata.scaleX);
|
||||
const scaleY = readNumberOption(axisScale.y ?? axisScale.scaleY ?? metadata.scaleY);
|
||||
const scaleZ = readNumberOption(axisScale.z ?? axisScale.scaleZ ?? metadata.scaleZ);
|
||||
|
||||
if (rotationX !== undefined) pose.rotateX = rotationX;
|
||||
if (rotationY !== undefined) pose.rotateY = rotationY;
|
||||
if (rotationZ !== undefined) pose.rotateZ = rotationZ;
|
||||
if (translateX !== undefined) pose.translateX = translateX;
|
||||
if (translateY !== undefined) pose.translateY = translateY;
|
||||
if (translateZ !== undefined) pose.translateZ = translateZ;
|
||||
if (scale !== undefined) pose.scale = scale;
|
||||
if (scaleX !== undefined) pose.scaleX = scaleX;
|
||||
if (scaleY !== undefined) pose.scaleY = scaleY;
|
||||
if (scaleZ !== undefined) pose.scaleZ = scaleZ;
|
||||
|
||||
(['X', 'Y', 'Z'] as const).forEach((axis) => {
|
||||
const lowerAxis = axis.toLowerCase();
|
||||
const key = `flip${axis}` as 'flipX' | 'flipY' | 'flipZ';
|
||||
const value = mirror[lowerAxis] ?? mirror[key] ?? metadata[key] ?? metadata[`mirror${axis}`];
|
||||
if (value !== undefined) {
|
||||
pose[key] = readBooleanOption(value);
|
||||
}
|
||||
});
|
||||
|
||||
return normalizeModelPoseValue(pose);
|
||||
}
|
||||
|
||||
function normalizePipelineAxis(value: unknown) {
|
||||
const raw = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
||||
return raw === 'x' || raw === 'y' || raw === 'z' ? raw as 'x' | 'y' | 'z' : null;
|
||||
}
|
||||
|
||||
function collectProjectModelBounds(project: ProjectRecord, previews: Record<string, ModelPreviewRecord>) {
|
||||
const bounds = (project.stlFiles ?? []).reduce<ModelBoundsRecord>((accumulator, fileName) => {
|
||||
const payload = previews[fileName];
|
||||
if (!payload) {
|
||||
return accumulator;
|
||||
}
|
||||
accumulator.min.x = Math.min(accumulator.min.x, payload.bounds.min.x);
|
||||
accumulator.min.y = Math.min(accumulator.min.y, payload.bounds.min.y);
|
||||
accumulator.min.z = Math.min(accumulator.min.z, payload.bounds.min.z);
|
||||
accumulator.max.x = Math.max(accumulator.max.x, payload.bounds.max.x);
|
||||
accumulator.max.y = Math.max(accumulator.max.y, payload.bounds.max.y);
|
||||
accumulator.max.z = Math.max(accumulator.max.z, payload.bounds.max.z);
|
||||
return accumulator;
|
||||
}, {
|
||||
min: { x: Infinity, y: Infinity, z: Infinity },
|
||||
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
||||
});
|
||||
return Number.isFinite(bounds.min.x) ? bounds : null;
|
||||
}
|
||||
|
||||
function rotatedModelSizeForPose(bounds: ModelBoundsRecord, pose: ModelPoseValue) {
|
||||
const center = {
|
||||
x: (bounds.min.x + bounds.max.x) / 2,
|
||||
y: (bounds.min.y + bounds.max.y) / 2,
|
||||
z: (bounds.min.z + bounds.max.z) / 2,
|
||||
};
|
||||
const rotateX = (pose.rotateX * Math.PI) / 180;
|
||||
const rotateY = (pose.rotateY * Math.PI) / 180;
|
||||
const rotateZ = (pose.rotateZ * Math.PI) / 180;
|
||||
const cosX = Math.cos(rotateX);
|
||||
const sinX = Math.sin(rotateX);
|
||||
const cosY = Math.cos(rotateY);
|
||||
const sinY = Math.sin(rotateY);
|
||||
const cosZ = Math.cos(rotateZ);
|
||||
const sinZ = Math.sin(rotateZ);
|
||||
const rotatedBounds = {
|
||||
min: { x: Infinity, y: Infinity, z: Infinity },
|
||||
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
||||
};
|
||||
[bounds.min.x, bounds.max.x].forEach((x) => {
|
||||
[bounds.min.y, bounds.max.y].forEach((y) => {
|
||||
[bounds.min.z, bounds.max.z].forEach((z) => {
|
||||
let px = (x - center.x) * (pose.flipX ? -1 : 1);
|
||||
let py = (y - center.y) * (pose.flipY ? -1 : 1);
|
||||
let pz = (z - center.z) * (pose.flipZ ? -1 : 1);
|
||||
const afterX = {
|
||||
x: px,
|
||||
y: py * cosX - pz * sinX,
|
||||
z: py * sinX + pz * cosX,
|
||||
};
|
||||
const afterY = {
|
||||
x: afterX.x * cosY + afterX.z * sinY,
|
||||
y: afterX.y,
|
||||
z: -afterX.x * sinY + afterX.z * cosY,
|
||||
};
|
||||
px = (afterY.x * cosZ - afterY.y * sinZ) * pose.scaleX;
|
||||
py = (afterY.x * sinZ + afterY.y * cosZ) * pose.scaleY;
|
||||
pz = afterY.z * pose.scaleZ;
|
||||
rotatedBounds.min.x = Math.min(rotatedBounds.min.x, px);
|
||||
rotatedBounds.min.y = Math.min(rotatedBounds.min.y, py);
|
||||
rotatedBounds.min.z = Math.min(rotatedBounds.min.z, pz);
|
||||
rotatedBounds.max.x = Math.max(rotatedBounds.max.x, px);
|
||||
rotatedBounds.max.y = Math.max(rotatedBounds.max.y, py);
|
||||
rotatedBounds.max.z = Math.max(rotatedBounds.max.z, pz);
|
||||
});
|
||||
});
|
||||
});
|
||||
return {
|
||||
x: Math.max(rotatedBounds.max.x - rotatedBounds.min.x, 1e-6),
|
||||
y: Math.max(rotatedBounds.max.y - rotatedBounds.min.y, 1e-6),
|
||||
z: Math.max(rotatedBounds.max.z - rotatedBounds.min.z, 1e-6),
|
||||
};
|
||||
}
|
||||
|
||||
function applyAxisStretchPose(project: ProjectRecord, files: string[], pose: ModelPoseValue, axis: 'x' | 'y' | 'z') {
|
||||
const volume = readDicomHuVolume(project, files);
|
||||
const previews = collectAutoMatchPreviews(project);
|
||||
const metrics = getExportMetrics(project, volume, previews);
|
||||
const bounds = collectProjectModelBounds(project, previews);
|
||||
if (!metrics || !bounds) {
|
||||
throw new Error('无法读取 STL 全局边界,不能执行轴向拉伸');
|
||||
}
|
||||
const rotatedSize = rotatedModelSizeForPose(bounds, pose);
|
||||
const dicomSize = {
|
||||
x: metrics.dicomWidth,
|
||||
y: metrics.dicomHeight,
|
||||
z: metrics.dicomDepth,
|
||||
};
|
||||
const axisFitScale = dicomSize[axis] / (rotatedSize[axis] * metrics.modelBaseScale);
|
||||
const containmentScale = Math.min(
|
||||
dicomSize.x / (rotatedSize.x * metrics.modelBaseScale),
|
||||
dicomSize.y / (rotatedSize.y * metrics.modelBaseScale),
|
||||
dicomSize.z / (rotatedSize.z * metrics.modelBaseScale),
|
||||
);
|
||||
return normalizeModelPoseValue({
|
||||
...pose,
|
||||
scale: Math.min(axisFitScale, containmentScale),
|
||||
});
|
||||
}
|
||||
|
||||
function parsePipelineExportConfig(metadata: Record<string, unknown>) {
|
||||
const exportConfig = readNestedObject(metadata.export);
|
||||
const targets = Array.isArray(exportConfig.targets)
|
||||
? parseExportTargets(exportConfig.targets.join(','))
|
||||
: parseExportTargets(exportConfig.targets ?? metadata.exportTargets ?? metadata.targets ?? '');
|
||||
const format = exportConfig.format === 'nii' || metadata.format === 'nii' ? 'nii' : 'nii.gz';
|
||||
return {
|
||||
targets,
|
||||
format: format as 'nii' | 'nii.gz',
|
||||
compressed: format === 'nii.gz',
|
||||
segmentationScope: parseSegmentationScope(exportConfig.segmentationScope ?? metadata.segmentationScope),
|
||||
segmentationExportMode: parseSegmentationExportMode(exportConfig.segmentationExportMode ?? metadata.segmentationExportMode),
|
||||
};
|
||||
}
|
||||
|
||||
function createPipelineProject({
|
||||
name,
|
||||
dicomFiles,
|
||||
stlFiles,
|
||||
moduleStyles,
|
||||
}: {
|
||||
name: string;
|
||||
dicomFiles: PreparedAssetFile[];
|
||||
stlFiles: PreparedAssetFile[];
|
||||
moduleStyles?: Record<string, Partial<ModuleStyleRecord>>;
|
||||
}) {
|
||||
const project = buildEmptyProject(name);
|
||||
const projectRoot = path.join(uploadDir, project.id);
|
||||
const dicomTargetDir = path.join(projectRoot, 'DICOM');
|
||||
const stlTargetDir = path.join(projectRoot, 'STL');
|
||||
const dicomFileNames = writePreparedAssetFiles('dicom', dicomTargetDir, dicomFiles);
|
||||
const stlFileNames = writePreparedAssetFiles('stl', stlTargetDir, stlFiles);
|
||||
|
||||
project.dicomPath = toRepoRelativePath(dicomTargetDir);
|
||||
project.modelPath = toRepoRelativePath(stlTargetDir);
|
||||
project.dicomCount = dicomFileNames.length;
|
||||
project.stlFiles = stlFileNames;
|
||||
project.modelCount = stlFileNames.length;
|
||||
project.hasModel = stlFileNames.length > 0;
|
||||
project.moduleStyles = buildModuleStyles(stlFileNames, moduleStyles);
|
||||
project.status = project.dicomCount > 0 && project.hasModel ? 'completed' : 'pending';
|
||||
touchProject(project);
|
||||
|
||||
if (project.dicomCount > 0) {
|
||||
try {
|
||||
writeCachedDicomInfo(project, dicomFileNames, createDicomInfo(project, dicomFileNames));
|
||||
} catch {
|
||||
// DICOM info is an optimization cache; export/registration will report hard parse failures later.
|
||||
}
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
function createNiftiExport(
|
||||
project: ProjectRecord,
|
||||
files: string[],
|
||||
@@ -2355,6 +2673,90 @@ function createPoseExport(project: ProjectRecord, activePose?: ModelPoseValue) {
|
||||
}, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function createZipArchive(entries: Array<{ name: string; data: Buffer }>) {
|
||||
const zip = new AdmZip();
|
||||
entries.forEach((entry) => {
|
||||
const normalizedName = entry.name
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.map((part) => sanitizeFilenamePart(part, 'entry'))
|
||||
.filter((part) => part && part !== '.' && part !== '..')
|
||||
.join('/');
|
||||
zip.addFile(normalizedName || 'entry', entry.data);
|
||||
});
|
||||
return zip.toBuffer();
|
||||
}
|
||||
|
||||
function splitArchiveFileName(fileName: string) {
|
||||
if (fileName.toLowerCase().endsWith('.nii.gz')) {
|
||||
return {
|
||||
stem: fileName.slice(0, -'.nii.gz'.length),
|
||||
extension: '.nii.gz',
|
||||
};
|
||||
}
|
||||
const parsed = path.posix.parse(fileName);
|
||||
return {
|
||||
stem: path.posix.join(parsed.dir, parsed.name),
|
||||
extension: parsed.ext,
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueArchiveEntryName(name: string, usedNames: Set<string>) {
|
||||
const normalized = name.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
const { stem, extension } = splitArchiveFileName(normalized);
|
||||
let candidate = normalized;
|
||||
let suffix = 2;
|
||||
while (usedNames.has(candidate.toLowerCase())) {
|
||||
candidate = `${stem}-${suffix}${extension}`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedNames.add(candidate.toLowerCase());
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function createProjectExportManifest({
|
||||
project,
|
||||
targets,
|
||||
format,
|
||||
activePose,
|
||||
segmentationScope,
|
||||
segmentationExportMode,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
targets: ProjectExportTarget[];
|
||||
format: 'nii' | 'nii.gz';
|
||||
activePose?: ModelPoseValue;
|
||||
segmentationScope: SegmentationExportScope;
|
||||
segmentationExportMode: SegmentationExportMode;
|
||||
}) {
|
||||
return Buffer.from(JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
generatedAt: now(),
|
||||
archiveFormat: 'zip',
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
createTime: project.createTime,
|
||||
dicomCount: project.dicomCount,
|
||||
modelCount: project.modelCount,
|
||||
dicomPath: project.dicomPath,
|
||||
modelPath: project.modelPath,
|
||||
},
|
||||
export: {
|
||||
targets,
|
||||
format,
|
||||
segmentationScope,
|
||||
segmentationExportMode,
|
||||
},
|
||||
activePose: activePose ?? null,
|
||||
notes: [
|
||||
'Archive entries are stored without a project-name root directory because the archive filename already carries the project name.',
|
||||
'segmentation/labels.json maps label values to STL component categories.',
|
||||
'When segmentationExportMode is separate, segmentation-parts/*.nii.gz files are single-category masks named by category.',
|
||||
],
|
||||
}, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function createProjectLockSnapshot(project: ProjectRecord, lockedAt: string) {
|
||||
const latestResult = latestSegmentationResult(project);
|
||||
const activePose = latestResult?.pose
|
||||
@@ -2401,7 +2803,6 @@ function createProjectExportBundle({
|
||||
activePose,
|
||||
segmentationScope,
|
||||
segmentationExportMode,
|
||||
exportRoot,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
files: string[];
|
||||
@@ -2410,18 +2811,32 @@ function createProjectExportBundle({
|
||||
activePose?: ModelPoseValue;
|
||||
segmentationScope: SegmentationExportScope;
|
||||
segmentationExportMode: SegmentationExportMode;
|
||||
exportRoot: string;
|
||||
}) {
|
||||
const entries: Array<{ name: string; data: Buffer; mtime?: number }> = [];
|
||||
const entries: Array<{ name: string; data: Buffer }> = [];
|
||||
const usedEntryNames = new Set<string>();
|
||||
const format = compressed ? 'nii.gz' : 'nii';
|
||||
let contentEntryCount = 0;
|
||||
const pushEntry = (name: string, data: Buffer) => {
|
||||
entries.push({ name: uniqueArchiveEntryName(name, usedEntryNames), data });
|
||||
};
|
||||
const pushContentEntry = (name: string, data: Buffer) => {
|
||||
contentEntryCount += 1;
|
||||
pushEntry(name, data);
|
||||
};
|
||||
const needsVolume = targets.includes('dicom') || targets.includes('segmentation');
|
||||
const volume = needsVolume ? readDicomHuVolume(project, files) : null;
|
||||
const format = compressed ? 'nii.gz' : 'nii';
|
||||
|
||||
pushEntry('manifest.json', createProjectExportManifest({
|
||||
project,
|
||||
targets,
|
||||
format,
|
||||
activePose,
|
||||
segmentationScope,
|
||||
segmentationExportMode,
|
||||
}));
|
||||
|
||||
if (targets.includes('dicom') && volume) {
|
||||
entries.push({
|
||||
name: `${exportRoot}/${project.id}-dicom-image.${format}`,
|
||||
data: createNiftiBuffer(volume, volume.data, 'dicom', compressed),
|
||||
});
|
||||
pushContentEntry(`dicom/image.${format}`, createNiftiBuffer(volume, volume.data, 'dicom', compressed));
|
||||
}
|
||||
|
||||
if (targets.includes('segmentation') && volume) {
|
||||
@@ -2432,38 +2847,32 @@ function createProjectExportBundle({
|
||||
return;
|
||||
}
|
||||
const moduleName = sanitizeFilenamePart(fileName.replace(/\.stl$/i, ''), `module-${index + 1}`);
|
||||
entries.push({
|
||||
name: `${exportRoot}/segmentation-parts/${String(style.partId).padStart(3, '0')}-${moduleName}-label.${format}`,
|
||||
data: createNiftiBuffer(
|
||||
pushContentEntry(
|
||||
`segmentation-parts/${moduleName}.${format}`,
|
||||
createNiftiBuffer(
|
||||
volume,
|
||||
createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope, fileName),
|
||||
'segmentation',
|
||||
compressed,
|
||||
),
|
||||
});
|
||||
);
|
||||
});
|
||||
} else {
|
||||
entries.push({
|
||||
name: `${exportRoot}/${project.id}-segmentation-label.${format}`,
|
||||
data: createNiftiBuffer(
|
||||
pushContentEntry(
|
||||
`segmentation/label.${format}`,
|
||||
createNiftiBuffer(
|
||||
volume,
|
||||
createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope),
|
||||
'segmentation',
|
||||
compressed,
|
||||
),
|
||||
});
|
||||
);
|
||||
}
|
||||
entries.push({
|
||||
name: `${exportRoot}/${project.id}-segmentation-labels.json`,
|
||||
data: createSegmentationLabelMetadata(project, segmentationScope, activePose),
|
||||
});
|
||||
pushContentEntry('segmentation/labels.json', createSegmentationLabelMetadata(project, segmentationScope, activePose));
|
||||
}
|
||||
|
||||
if (targets.includes('pose')) {
|
||||
entries.push({
|
||||
name: `${exportRoot}/${project.id}-pose-data.json`,
|
||||
data: createPoseExport(project, activePose),
|
||||
});
|
||||
pushContentEntry('pose/pose.json', createPoseExport(project, activePose));
|
||||
}
|
||||
|
||||
if (targets.includes('stl')) {
|
||||
@@ -2473,20 +2882,15 @@ function createProjectExportBundle({
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = fs.statSync(filePath);
|
||||
entries.push({
|
||||
name: `${exportRoot}/STL/${fileName}`,
|
||||
data: fs.readFileSync(filePath),
|
||||
mtime: stat.mtimeMs / 1000,
|
||||
});
|
||||
pushContentEntry(`stl/${fileName}`, fs.readFileSync(filePath));
|
||||
});
|
||||
}
|
||||
|
||||
if (!entries.length) {
|
||||
if (contentEntryCount === 0) {
|
||||
throw new Error('未选择可导出的内容');
|
||||
}
|
||||
|
||||
return createTarGz(entries);
|
||||
return createZipArchive(entries);
|
||||
}
|
||||
|
||||
function findProject(state: AppState, projectId: string) {
|
||||
@@ -3234,6 +3638,166 @@ async function startServer() {
|
||||
res.json(publicSession(state));
|
||||
});
|
||||
|
||||
app.post('/api/reverse-pipeline', (req, res) => {
|
||||
assetUpload.fields([
|
||||
{ name: 'dicomFiles', maxCount: 5000 },
|
||||
{ name: 'dicom', maxCount: 5000 },
|
||||
{ name: 'stlFiles', maxCount: 2000 },
|
||||
{ name: 'stl', maxCount: 2000 },
|
||||
{ name: 'models', maxCount: 2000 },
|
||||
{ name: 'files', maxCount: 5000 },
|
||||
])(req, res, (uploadError) => {
|
||||
const multerFiles = allRequestMulterFiles(req.files);
|
||||
let project: ProjectRecord | null = null;
|
||||
let persisted = false;
|
||||
|
||||
try {
|
||||
if (uploadError) {
|
||||
res.status(413).json({ message: uploadError instanceof Error ? uploadError.message : '上传文件过大或格式无效' });
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = parsePipelineMetadata(req.body);
|
||||
const fallbackFiles = requestFilesFromFields(req.files, ['files']);
|
||||
const dicomMulterFiles = [
|
||||
...requestFilesFromFields(req.files, ['dicomFiles', 'dicom']),
|
||||
...fallbackFiles,
|
||||
];
|
||||
const stlMulterFiles = [
|
||||
...requestFilesFromFields(req.files, ['stlFiles', 'stl', 'models']),
|
||||
...fallbackFiles,
|
||||
];
|
||||
const dicomLegacyFiles = parseFlexibleUploadedAssets(metadata.dicomAssets ?? metadata.dicomFilesJson);
|
||||
const stlLegacyFiles = parseFlexibleUploadedAssets(metadata.stlAssets ?? metadata.stlFilesJson ?? metadata.modelAssets);
|
||||
const preparedDicomFiles = collectPreparedAssetFiles('dicom', dicomMulterFiles, dicomLegacyFiles);
|
||||
const preparedStlFiles = collectPreparedAssetFiles('stl', stlMulterFiles, stlLegacyFiles);
|
||||
|
||||
if (!preparedDicomFiles.length) {
|
||||
res.status(400).json({ message: '请上传 DICOM 文件、DICOM 文件夹压缩包或 dicomAssets' });
|
||||
return;
|
||||
}
|
||||
if (!preparedStlFiles.length) {
|
||||
res.status(400).json({ message: '请上传 STL 文件、STL 压缩包或 stlAssets' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawProjectName = typeof metadata.name === 'string' && metadata.name.trim()
|
||||
? metadata.name.trim()
|
||||
: typeof metadata.projectName === 'string' && metadata.projectName.trim()
|
||||
? metadata.projectName.trim()
|
||||
: `API逆向项目-${timestampForFilename()}`;
|
||||
project = createPipelineProject({
|
||||
name: rawProjectName,
|
||||
dicomFiles: preparedDicomFiles,
|
||||
stlFiles: preparedStlFiles,
|
||||
moduleStyles: readNestedObject(metadata.moduleStyles) as Record<string, Partial<ModuleStyleRecord>>,
|
||||
});
|
||||
|
||||
const files = getProjectDicomFiles(project);
|
||||
let activePose = normalizePipelinePose(metadata);
|
||||
const stretchConfig = readNestedObject(metadata.stretch ?? metadata.axisStretch);
|
||||
const stretchAxis = normalizePipelineAxis(
|
||||
stretchConfig.axis
|
||||
?? metadata.stretch
|
||||
?? metadata.axisStretch
|
||||
?? metadata.stretchAxis,
|
||||
);
|
||||
if (stretchAxis) {
|
||||
activePose = applyAxisStretchPose(project, files, activePose, stretchAxis);
|
||||
}
|
||||
project.modelPoses = normalizeModelPoses([{ id: 'default', name: 'API 位姿', pose: activePose }]);
|
||||
|
||||
const autoMatchConfig = readNestedObject(metadata.autoMatch);
|
||||
const autoMatchEnabled = readBooleanOption(autoMatchConfig.enabled ?? metadata.autoMatch, false);
|
||||
let autoMatchResult: ReturnType<typeof runProjectAutoMatch> | null = null;
|
||||
if (autoMatchEnabled) {
|
||||
autoMatchResult = runProjectAutoMatch(project, {
|
||||
...autoMatchConfig,
|
||||
pose: activePose,
|
||||
});
|
||||
activePose = autoMatchResult.bestPose;
|
||||
applyAutoMatchedPose(project, activePose);
|
||||
}
|
||||
|
||||
const shouldLock = readBooleanOption(metadata.lock ?? metadata.locked, false);
|
||||
const shouldRecord = readBooleanOption(metadata.recordProject ?? metadata.saveToProjectLibrary, false) || shouldLock;
|
||||
if (shouldLock) {
|
||||
const lockedAt = now();
|
||||
project.locked = true;
|
||||
project.lockedAt = lockedAt;
|
||||
project.unlockedAt = null;
|
||||
project.lockedPoseSnapshotPath = writeProjectLockSnapshot(project, lockedAt);
|
||||
touchProject(project, lockedAt);
|
||||
}
|
||||
|
||||
const exportConfig = parsePipelineExportConfig(metadata);
|
||||
let exportPayload: Buffer | null = null;
|
||||
let exportFilename = '';
|
||||
if (exportConfig.targets.length) {
|
||||
const exportProject = projectWithSegmentationResultStyles(project);
|
||||
const exportBase = `${sanitizeFilenamePart(project.name, project.id)}_${timestampForFilename()}`;
|
||||
exportPayload = createProjectExportBundle({
|
||||
project: exportProject,
|
||||
files,
|
||||
targets: exportConfig.targets,
|
||||
compressed: exportConfig.compressed,
|
||||
activePose,
|
||||
segmentationScope: exportConfig.segmentationScope,
|
||||
segmentationExportMode: exportConfig.segmentationExportMode,
|
||||
});
|
||||
exportFilename = `${exportBase}.zip`;
|
||||
fs.writeFileSync(path.join(exportDir, exportFilename), exportPayload);
|
||||
project.exportedMaskCount += exportConfig.targets.includes('segmentation') ? 1 : 0;
|
||||
touchProject(project);
|
||||
}
|
||||
|
||||
if (shouldRecord) {
|
||||
const state = readState();
|
||||
state.projects = state.projects.filter((candidate) => candidate.id !== project?.id);
|
||||
state.projects.push(project);
|
||||
writeState(state);
|
||||
persisted = true;
|
||||
}
|
||||
|
||||
if (exportPayload) {
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', contentDispositionAttachment(exportFilename));
|
||||
res.send(exportPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
recorded: shouldRecord,
|
||||
locked: project.locked,
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
dicomCount: project.dicomCount,
|
||||
modelCount: project.modelCount,
|
||||
dicomPath: persisted ? project.dicomPath : null,
|
||||
modelPath: persisted ? project.modelPath : null,
|
||||
},
|
||||
activePose,
|
||||
autoMatch: autoMatchResult,
|
||||
export: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[reverse-pipeline] failed', {
|
||||
message: error instanceof Error ? error.message : error,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.status(422).json({ message: error instanceof Error ? error.message : '自动逆向处理失败' });
|
||||
}
|
||||
} finally {
|
||||
cleanupUploadedTempFiles(multerFiles);
|
||||
if (project && !persisted) {
|
||||
fs.rmSync(path.join(uploadDir, project.id), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/users', (_req, res) => {
|
||||
res.json(readState().users.map(publicUser));
|
||||
});
|
||||
@@ -3890,15 +4454,14 @@ async function startServer() {
|
||||
activePose,
|
||||
segmentationScope,
|
||||
segmentationExportMode,
|
||||
exportRoot: exportBase,
|
||||
});
|
||||
const filename = `${exportBase}.tar.gz`;
|
||||
const filename = `${exportBase}.zip`;
|
||||
fs.writeFileSync(path.join(exportDir, filename), payload);
|
||||
project.exportedMaskCount += targets.includes('segmentation') ? 1 : 0;
|
||||
touchProject(project);
|
||||
writeState(state);
|
||||
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', contentDispositionAttachment(filename));
|
||||
res.send(payload);
|
||||
} catch (error) {
|
||||
|
||||
@@ -31,6 +31,9 @@ const defaultModelPose: ModelPose = {
|
||||
translateY: 0,
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
scaleZ: 1,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
flipZ: false,
|
||||
@@ -521,9 +524,9 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={12}
|
||||
max={50}
|
||||
value={iterations}
|
||||
onChange={(event) => setIterations(Math.max(2, Math.min(12, Number(event.target.value) || 2)))}
|
||||
onChange={(event) => setIterations(Math.max(2, Math.min(50, Number(event.target.value) || 2)))}
|
||||
className="mt-1 h-10 w-full rounded-lg border border-slate-200 px-3 font-mono text-sm text-slate-700 outline-none focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
@@ -583,6 +586,18 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px] font-black text-slate-500">
|
||||
{[
|
||||
{ label: '轴拉伸 X', value: comparePose.scaleX },
|
||||
{ label: '轴拉伸 Y', value: comparePose.scaleY },
|
||||
{ label: '轴拉伸 Z', value: comparePose.scaleZ },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-lg border border-slate-100 bg-white px-3 py-2">
|
||||
<span className="mr-2 text-slate-400">{item.label}</span>
|
||||
<span className="font-mono text-slate-800">{formatPoseNumber(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{result && !resultHasPoseChange && (
|
||||
<div className="mt-4 rounded-lg border border-amber-100 bg-amber-50 px-4 py-3 text-sm font-bold text-amber-800">
|
||||
|
||||
@@ -53,6 +53,9 @@ interface ModelPose {
|
||||
translateY: number;
|
||||
translateZ: number;
|
||||
scale: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
scaleZ: number;
|
||||
flipX: boolean;
|
||||
flipY: boolean;
|
||||
flipZ: boolean;
|
||||
@@ -101,6 +104,9 @@ const defaultModelPose: ModelPose = {
|
||||
translateY: 0,
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
scaleZ: 1,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
flipZ: false,
|
||||
@@ -124,12 +130,18 @@ const modelPoseLimits: Record<ModelPoseKey, { min: number; max: number }> = {
|
||||
translateY: { min: -2, max: 2 },
|
||||
translateZ: { min: -2, max: 2 },
|
||||
scale: { min: 0.5, max: 2.5 },
|
||||
scaleX: { min: 0.2, max: 8 },
|
||||
scaleY: { min: 0.2, max: 8 },
|
||||
scaleZ: { min: 0.2, max: 8 },
|
||||
};
|
||||
const modelPoseStepPrecision: Partial<Record<ModelPoseKey, number>> = {
|
||||
translateX: 3,
|
||||
translateY: 3,
|
||||
translateZ: 3,
|
||||
scale: 3,
|
||||
scaleX: 3,
|
||||
scaleY: 3,
|
||||
scaleZ: 3,
|
||||
};
|
||||
|
||||
function clampModelPoseValue(key: ModelPoseKey, value: number) {
|
||||
@@ -161,6 +173,9 @@ function clampModelPose(next: ModelPose): ModelPose {
|
||||
translateY: clampModelPoseValue('translateY', next.translateY),
|
||||
translateZ: clampModelPoseValue('translateZ', next.translateZ),
|
||||
scale: clampModelPoseValue('scale', next.scale),
|
||||
scaleX: clampModelPoseValue('scaleX', next.scaleX),
|
||||
scaleY: clampModelPoseValue('scaleY', next.scaleY),
|
||||
scaleZ: clampModelPoseValue('scaleZ', next.scaleZ),
|
||||
flipX: Boolean(next.flipX),
|
||||
flipY: Boolean(next.flipY),
|
||||
flipZ: Boolean(next.flipZ),
|
||||
@@ -699,8 +714,12 @@ function NativeStlViewer({
|
||||
scene.add(fillLight);
|
||||
|
||||
const poseGroup = new THREE.Group();
|
||||
const axisScaleGroup = new THREE.Group();
|
||||
const rotationScaleGroup = new THREE.Group();
|
||||
const pivotGroup = new THREE.Group();
|
||||
poseGroup.add(pivotGroup);
|
||||
rotationScaleGroup.add(pivotGroup);
|
||||
axisScaleGroup.add(rotationScaleGroup);
|
||||
poseGroup.add(axisScaleGroup);
|
||||
let baseScale = 1;
|
||||
scene.add(poseGroup);
|
||||
let loaded = 0;
|
||||
@@ -774,7 +793,7 @@ function NativeStlViewer({
|
||||
pivotGroup.position.set(0, 0, 0);
|
||||
baseScale = 4.2 / maxSize;
|
||||
const initialPoseScale = baseScale * poseRef.current.scale;
|
||||
pivotGroup.scale.set(
|
||||
rotationScaleGroup.scale.set(
|
||||
poseRef.current.flipX ? -initialPoseScale : initialPoseScale,
|
||||
poseRef.current.flipY ? -initialPoseScale : initialPoseScale,
|
||||
poseRef.current.flipZ ? -initialPoseScale : initialPoseScale,
|
||||
@@ -803,13 +822,14 @@ function NativeStlViewer({
|
||||
if (disposed) return;
|
||||
const currentPose = poseRef.current;
|
||||
poseGroup.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ);
|
||||
pivotGroup.rotation.set(
|
||||
axisScaleGroup.scale.set(currentPose.scaleX, currentPose.scaleY, currentPose.scaleZ);
|
||||
rotationScaleGroup.rotation.set(
|
||||
THREE.MathUtils.degToRad(currentPose.rotateX),
|
||||
THREE.MathUtils.degToRad(currentPose.rotateY),
|
||||
THREE.MathUtils.degToRad(currentPose.rotateZ),
|
||||
);
|
||||
const poseScale = baseScale * currentPose.scale;
|
||||
pivotGroup.scale.set(
|
||||
rotationScaleGroup.scale.set(
|
||||
currentPose.flipX ? -poseScale : poseScale,
|
||||
currentPose.flipY ? -poseScale : poseScale,
|
||||
currentPose.flipZ ? -poseScale : poseScale,
|
||||
@@ -1353,6 +1373,9 @@ export default function ProjectLibrary({
|
||||
translateY: 0,
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
scaleZ: 1,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -2143,8 +2166,11 @@ export default function ProjectLibrary({
|
||||
{ key: 'translateY' as const, label: '平移 Y', min: -2, max: 2, step: 0.001, value: modelPose.translateY, minus: '-0.001', plus: '+0.001', delta: 0.001 },
|
||||
{ key: 'translateZ' as const, label: '平移 Z', min: -2, max: 2, step: 0.001, value: modelPose.translateZ, minus: '-0.001', plus: '+0.001', delta: 0.001 },
|
||||
{ key: 'scale' as const, label: '缩放', min: 0.5, max: 2.5, step: 0.001, value: modelPose.scale, minus: '-0.001', plus: '+0.001', delta: 0.001 },
|
||||
{ key: 'scaleX' as const, label: '轴拉伸 X', min: 0.2, max: 8, step: 0.001, value: modelPose.scaleX, minus: '-0.001', plus: '+0.001', delta: 0.001 },
|
||||
{ key: 'scaleY' as const, label: '轴拉伸 Y', min: 0.2, max: 8, step: 0.001, value: modelPose.scaleY, minus: '-0.001', plus: '+0.001', delta: 0.001 },
|
||||
{ key: 'scaleZ' as const, label: '轴拉伸 Z', min: 0.2, max: 8, step: 0.001, value: modelPose.scaleZ, minus: '-0.001', plus: '+0.001', delta: 0.001 },
|
||||
].map((item) => (
|
||||
<div key={item.key} className="grid grid-cols-[48px_48px_1fr_48px_42px] items-center gap-2">
|
||||
<div key={item.key} className="grid grid-cols-[62px_48px_1fr_48px_42px] items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-slate-500">{item.label}</span>
|
||||
<button
|
||||
onClick={() => nudgeModelPose(item.key, -item.delta)}
|
||||
@@ -2366,6 +2392,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">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="rounded-lg bg-slate-50 px-2 py-1.5">SX {formatPoseCompactValue(latestResultPose.scaleX, 3)}</span>
|
||||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">SY {formatPoseCompactValue(latestResultPose.scaleY, 3)}</span>
|
||||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">SZ {formatPoseCompactValue(latestResultPose.scaleZ, 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>
|
||||
|
||||
@@ -41,6 +41,7 @@ type ModelPoseKey = Exclude<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
|
||||
type ModelPoseFlipKey = Extract<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
|
||||
type PoseDraftValues = Record<ModelPoseKey, string>;
|
||||
type AxisKey = 'x' | 'y' | 'z';
|
||||
type StretchMode = 'uniform' | 'axis';
|
||||
|
||||
interface AxisVector2D {
|
||||
dx: number;
|
||||
@@ -60,7 +61,17 @@ interface WorkspaceLoadState {
|
||||
error: string;
|
||||
}
|
||||
|
||||
const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale'];
|
||||
const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale', 'scaleX', 'scaleY', 'scaleZ'];
|
||||
const axisScalePoseKeys: Record<AxisKey, Extract<ModelPoseKey, 'scaleX' | 'scaleY' | 'scaleZ'>> = {
|
||||
x: 'scaleX',
|
||||
y: 'scaleY',
|
||||
z: 'scaleZ',
|
||||
};
|
||||
const axisTranslatePoseKeys: Record<AxisKey, Extract<ModelPoseKey, 'translateX' | 'translateY' | 'translateZ'>> = {
|
||||
x: 'translateX',
|
||||
y: 'translateY',
|
||||
z: 'translateZ',
|
||||
};
|
||||
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 },
|
||||
@@ -92,6 +103,9 @@ const poseStepConfig: Record<ModelPoseKey, { min: number; max: number; step: num
|
||||
translateY: { min: -2, max: 2, step: 0.001, minus: '-0.001', plus: '+0.001' },
|
||||
translateZ: { min: -2, max: 2, step: 0.001, minus: '-0.001', plus: '+0.001' },
|
||||
scale: { min: 0.5, max: 3, step: 0.001, minus: '-0.001', plus: '+0.001' },
|
||||
scaleX: { min: 0.2, max: 8, step: 0.001, minus: '-0.001', plus: '+0.001' },
|
||||
scaleY: { min: 0.2, max: 8, step: 0.001, minus: '-0.001', plus: '+0.001' },
|
||||
scaleZ: { min: 0.2, max: 8, step: 0.001, minus: '-0.001', plus: '+0.001' },
|
||||
};
|
||||
const poseRepeatDelayMs = 240;
|
||||
const poseRepeatIntervalMs = 55;
|
||||
@@ -100,6 +114,9 @@ const poseRepeatDeltaMultiplier: Partial<Record<ModelPoseKey, number>> = {
|
||||
translateY: 5,
|
||||
translateZ: 5,
|
||||
scale: 5,
|
||||
scaleX: 5,
|
||||
scaleY: 5,
|
||||
scaleZ: 5,
|
||||
};
|
||||
|
||||
const defaultModelPose: ModelPose = {
|
||||
@@ -110,6 +127,9 @@ const defaultModelPose: ModelPose = {
|
||||
translateY: 0,
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
scaleZ: 1,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
flipZ: false,
|
||||
@@ -646,11 +666,15 @@ export function FusionThreeView({
|
||||
|
||||
const fusionRoot = new THREE.Group();
|
||||
const dicomGroup = new THREE.Group();
|
||||
const modelTranslateGroup = new THREE.Group();
|
||||
const modelAxisScaleGroup = new THREE.Group();
|
||||
const modelPoseGroup = new THREE.Group();
|
||||
const modelPivot = new THREE.Group();
|
||||
modelPoseGroup.add(modelPivot);
|
||||
modelAxisScaleGroup.add(modelPoseGroup);
|
||||
modelTranslateGroup.add(modelAxisScaleGroup);
|
||||
fusionRoot.add(dicomGroup);
|
||||
fusionRoot.add(modelPoseGroup);
|
||||
fusionRoot.add(modelTranslateGroup);
|
||||
scene.add(fusionRoot);
|
||||
|
||||
const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
|
||||
@@ -791,7 +815,8 @@ export function FusionThreeView({
|
||||
modelBounds.visible = showBounds;
|
||||
modelPivot.add(modelBounds);
|
||||
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
|
||||
modelPoseGroup.position.set(0, 0, 0);
|
||||
modelTranslateGroup.position.set(0, 0, 0);
|
||||
modelAxisScaleGroup.scale.set(1, 1, 1);
|
||||
modelPivot.position.set(0, 0, 0);
|
||||
setLoadProgress(100);
|
||||
setStatus(visibleStlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前没有显示的 STL 构件');
|
||||
@@ -897,11 +922,12 @@ export function FusionThreeView({
|
||||
THREE.MathUtils.degToRad(pose.rotateY),
|
||||
THREE.MathUtils.degToRad(pose.rotateZ),
|
||||
);
|
||||
modelPoseGroup.position.set(
|
||||
modelTranslateGroup.position.set(
|
||||
pose.translateX,
|
||||
pose.translateY,
|
||||
pose.translateZ,
|
||||
);
|
||||
modelAxisScaleGroup.scale.set(pose.scaleX, pose.scaleY, pose.scaleZ);
|
||||
const poseScale = modelBaseScale * pose.scale;
|
||||
modelPoseGroup.scale.set(
|
||||
pose.flipX ? -poseScale : poseScale,
|
||||
@@ -1080,10 +1106,14 @@ function CutSectionPreview({
|
||||
scene.add(rimLight);
|
||||
|
||||
const fusionRoot = new THREE.Group();
|
||||
const modelTranslateGroup = new THREE.Group();
|
||||
const modelAxisScaleGroup = new THREE.Group();
|
||||
const modelPoseGroup = new THREE.Group();
|
||||
const modelPivot = new THREE.Group();
|
||||
modelPoseGroup.add(modelPivot);
|
||||
fusionRoot.add(modelPoseGroup);
|
||||
modelAxisScaleGroup.add(modelPoseGroup);
|
||||
modelTranslateGroup.add(modelAxisScaleGroup);
|
||||
fusionRoot.add(modelTranslateGroup);
|
||||
scene.add(fusionRoot);
|
||||
|
||||
const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
|
||||
@@ -1172,7 +1202,8 @@ function CutSectionPreview({
|
||||
}
|
||||
});
|
||||
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.98;
|
||||
modelPoseGroup.position.set(0, 0, 0);
|
||||
modelTranslateGroup.position.set(0, 0, 0);
|
||||
modelAxisScaleGroup.scale.set(1, 1, 1);
|
||||
modelPivot.position.set(0, 0, 0);
|
||||
});
|
||||
|
||||
@@ -1263,7 +1294,8 @@ function CutSectionPreview({
|
||||
THREE.MathUtils.degToRad(pose.rotateY),
|
||||
THREE.MathUtils.degToRad(pose.rotateZ),
|
||||
);
|
||||
modelPoseGroup.position.set(pose.translateX, pose.translateY, pose.translateZ);
|
||||
modelTranslateGroup.position.set(pose.translateX, pose.translateY, pose.translateZ);
|
||||
modelAxisScaleGroup.scale.set(pose.scaleX, pose.scaleY, pose.scaleZ);
|
||||
const poseScale = modelBaseScale * pose.scale;
|
||||
modelPoseGroup.scale.set(
|
||||
pose.flipX ? -poseScale : poseScale,
|
||||
@@ -1520,9 +1552,9 @@ function transformPointForPose(x: number, y: number, z: number, metrics: ModelSc
|
||||
y: afterX.y,
|
||||
z: -afterX.x * sinY + afterX.z * cosY,
|
||||
};
|
||||
px = afterY.x * cosZ - afterY.y * sinZ;
|
||||
py = afterY.x * sinZ + afterY.y * cosZ;
|
||||
pz = afterY.z;
|
||||
px = (afterY.x * cosZ - afterY.y * sinZ) * pose.scaleX;
|
||||
py = (afterY.x * sinZ + afterY.y * cosZ) * pose.scaleY;
|
||||
pz = afterY.z * pose.scaleZ;
|
||||
|
||||
return {
|
||||
x: px + pose.translateX,
|
||||
@@ -2440,6 +2472,9 @@ export function VoxelizationMappingView({
|
||||
modelPose.translateY,
|
||||
modelPose.translateZ,
|
||||
modelPose.scale,
|
||||
modelPose.scaleX,
|
||||
modelPose.scaleY,
|
||||
modelPose.scaleZ,
|
||||
modelPose.flipX,
|
||||
modelPose.flipY,
|
||||
modelPose.flipZ,
|
||||
@@ -2825,7 +2860,8 @@ export default function ReverseWorkspace({
|
||||
const [fusionError, setFusionError] = useState('');
|
||||
const [saveStatus, setSaveStatus] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [stretchingAxis, setStretchingAxis] = useState<AxisKey | null>(null);
|
||||
const [exportProgress, setExportProgress] = useState({ active: false, percent: 0, phase: '' });
|
||||
const [stretchingState, setStretchingState] = useState<{ mode: StretchMode; axis: AxisKey } | null>(null);
|
||||
const modelBoundsCacheRef = useRef(new Map<string, { min: THREE.Vector3; max: THREE.Vector3 }>());
|
||||
const [workspaceLoadState, setWorkspaceLoadState] = useState<WorkspaceLoadState>({
|
||||
ready: false,
|
||||
@@ -2840,9 +2876,17 @@ export default function ReverseWorkspace({
|
||||
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const visualToolbarScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const saveToastTimerRef = useRef<number | null>(null);
|
||||
const exportProgressTimerRef = useRef<number | null>(null);
|
||||
const savedWorkspaceSnapshotRef = useRef('');
|
||||
const initialZStretchRef = useRef<{ projectId: string; pending: boolean }>({ projectId: '', pending: false });
|
||||
|
||||
const clearExportProgressTimer = () => {
|
||||
if (exportProgressTimerRef.current !== null) {
|
||||
window.clearInterval(exportProgressTimerRef.current);
|
||||
exportProgressTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportSelected = async () => {
|
||||
const selectedItems = exportOptions
|
||||
.filter((option) => exportSelection[option.id])
|
||||
@@ -2854,18 +2898,46 @@ export default function ReverseWorkspace({
|
||||
|
||||
setExporting(true);
|
||||
setFusionError('');
|
||||
clearExportProgressTimer();
|
||||
setExportProgress({ active: true, percent: 8, phase: '准备导出压缩包' });
|
||||
exportProgressTimerRef.current = window.setInterval(() => {
|
||||
setExportProgress((current) => {
|
||||
if (!current.active || current.percent >= 88) {
|
||||
return current;
|
||||
}
|
||||
const step = current.percent < 50 ? 5 : 2;
|
||||
return { ...current, percent: Math.min(88, current.percent + step), phase: '正在生成导出内容' };
|
||||
});
|
||||
}, 450);
|
||||
try {
|
||||
await downloadProjectExportBundle(projectId, selectedItems, 'nii.gz', {
|
||||
pose: modelPose,
|
||||
segmentationScope: segmentationExportScope,
|
||||
segmentationExportMode,
|
||||
moduleStyles,
|
||||
}, (progress) => {
|
||||
if (progress.percent <= 0) {
|
||||
return;
|
||||
}
|
||||
setExportProgress((current) => ({
|
||||
active: true,
|
||||
percent: Math.max(current.percent, Math.min(99, progress.percent)),
|
||||
phase: '正在下载压缩包',
|
||||
}));
|
||||
});
|
||||
window.setTimeout(() => setExporting(false), 900);
|
||||
clearExportProgressTimer();
|
||||
setExportProgress({ active: true, percent: 100, phase: '导出完成' });
|
||||
window.setTimeout(() => {
|
||||
setExporting(false);
|
||||
setExportProgress({ active: false, percent: 0, phase: '' });
|
||||
}, 900);
|
||||
setShowExportMenu(false);
|
||||
} catch (error) {
|
||||
clearExportProgressTimer();
|
||||
setFusionError(error instanceof Error ? error.message : '导出失败');
|
||||
setExporting(false);
|
||||
setExportProgress({ active: true, percent: 100, phase: '导出失败' });
|
||||
window.setTimeout(() => setExportProgress({ active: false, percent: 0, phase: '' }), 1200);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2893,6 +2965,13 @@ export default function ReverseWorkspace({
|
||||
cutEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (exportProgressTimerRef.current !== null) {
|
||||
window.clearInterval(exportProgressTimerRef.current);
|
||||
exportProgressTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSaveSegmentationResult = useCallback(async (options: { showToast?: boolean } = {}) => {
|
||||
if (!project) {
|
||||
return false;
|
||||
@@ -3052,7 +3131,8 @@ export default function ReverseWorkspace({
|
||||
return bounds;
|
||||
};
|
||||
|
||||
const applyModelStretchByAxis = async (axis: AxisKey, options: { silentInitial?: boolean } = {}) => {
|
||||
const applyModelStretchByAxis = async (axis: AxisKey, options: { mode?: StretchMode; silentInitial?: boolean } = {}) => {
|
||||
const mode = options.mode ?? 'uniform';
|
||||
if (!project || !fusionVolume) {
|
||||
setFusionError('请等待 DICOM 与 STL 数据加载完成后再拉伸模型');
|
||||
return;
|
||||
@@ -3063,7 +3143,7 @@ export default function ReverseWorkspace({
|
||||
return;
|
||||
}
|
||||
|
||||
setStretchingAxis(axis);
|
||||
setStretchingState({ mode, axis });
|
||||
setFusionError('');
|
||||
try {
|
||||
const bounds = await loadGlobalModelBounds();
|
||||
@@ -3087,15 +3167,32 @@ export default function ReverseWorkspace({
|
||||
};
|
||||
const baseScale = (Math.max(dicomSize.x, dicomSize.y, dicomSize.z) / maxModelSize) * 0.92;
|
||||
const rotatedAxisSize = Math.max(rotatedSize[axis], 1e-6);
|
||||
const axisFitScale = dicomSize[axis] / (rotatedAxisSize * baseScale);
|
||||
const containmentScale = Math.min(
|
||||
dicomSize.x / (Math.max(rotatedSize.x, 1e-6) * baseScale),
|
||||
dicomSize.y / (Math.max(rotatedSize.y, 1e-6) * baseScale),
|
||||
dicomSize.z / (Math.max(rotatedSize.z, 1e-6) * baseScale),
|
||||
);
|
||||
const nextScale = clampPoseValue('scale', Math.min(axisFitScale, containmentScale));
|
||||
const nextPose = { ...modelPose, scale: nextScale };
|
||||
updateModelPose({ scale: nextScale }, { markCustom: !options.silentInitial, keepStatus: true });
|
||||
let nextPose: ModelPose;
|
||||
if (mode === 'axis') {
|
||||
const scaleKey = axisScalePoseKeys[axis];
|
||||
const translateKey = axisTranslatePoseKeys[axis];
|
||||
const nextAxisScale = clampPoseValue(scaleKey, dicomSize[axis] / (rotatedAxisSize * baseScale * modelPose.scale));
|
||||
nextPose = {
|
||||
...modelPose,
|
||||
[scaleKey]: nextAxisScale,
|
||||
[translateKey]: 0,
|
||||
};
|
||||
updateModelPose(
|
||||
{ [scaleKey]: nextAxisScale, [translateKey]: 0 } as Partial<ModelPose>,
|
||||
{ markCustom: !options.silentInitial, keepStatus: true },
|
||||
);
|
||||
} else {
|
||||
const axisScale = Math.max(modelPose[axisScalePoseKeys[axis]], 1e-6);
|
||||
const axisFitScale = dicomSize[axis] / (rotatedAxisSize * baseScale * axisScale);
|
||||
const containmentScale = Math.min(
|
||||
dicomSize.x / (Math.max(rotatedSize.x, 1e-6) * baseScale * Math.max(modelPose.scaleX, 1e-6)),
|
||||
dicomSize.y / (Math.max(rotatedSize.y, 1e-6) * baseScale * Math.max(modelPose.scaleY, 1e-6)),
|
||||
dicomSize.z / (Math.max(rotatedSize.z, 1e-6) * baseScale * Math.max(modelPose.scaleZ, 1e-6)),
|
||||
);
|
||||
const nextScale = clampPoseValue('scale', Math.min(axisFitScale, containmentScale));
|
||||
nextPose = { ...modelPose, scale: nextScale };
|
||||
updateModelPose({ scale: nextScale }, { markCustom: !options.silentInitial, keepStatus: true });
|
||||
}
|
||||
setPoseImportStatus('');
|
||||
if (options.silentInitial) {
|
||||
savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({
|
||||
@@ -3114,7 +3211,7 @@ export default function ReverseWorkspace({
|
||||
} catch (error) {
|
||||
setFusionError(error instanceof Error ? error.message : '模型自动拉伸失败');
|
||||
} finally {
|
||||
setStretchingAxis(null);
|
||||
setStretchingState(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3251,6 +3348,9 @@ export default function ReverseWorkspace({
|
||||
modelPose.translateY,
|
||||
modelPose.translateZ,
|
||||
modelPose.scale,
|
||||
modelPose.scaleX,
|
||||
modelPose.scaleY,
|
||||
modelPose.scaleZ,
|
||||
]);
|
||||
|
||||
const clampPoseValue = (key: ModelPoseKey, value: number) => {
|
||||
@@ -3367,6 +3467,9 @@ export default function ReverseWorkspace({
|
||||
translateY: 0,
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
scaleZ: 1,
|
||||
}));
|
||||
setSelectedPoseId('custom');
|
||||
setPoseImportStatus('');
|
||||
@@ -3716,6 +3819,19 @@ export default function ReverseWorkspace({
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-2 flex flex-col gap-6">
|
||||
{exportProgress.active && (
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-[80]">
|
||||
<div className="h-1 w-full bg-slate-200/70">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${exportProgress.phase === '导出失败' ? 'bg-rose-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${Math.max(3, Math.min(100, exportProgress.percent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-6 top-2 rounded-full border border-emerald-100 bg-white/95 px-3 py-1 text-[11px] font-bold text-emerald-700 shadow-lg shadow-slate-950/10">
|
||||
{exportProgress.phase} {Math.round(exportProgress.percent)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{saveStatus && (
|
||||
<>
|
||||
<style>
|
||||
@@ -3864,24 +3980,41 @@ export default function ReverseWorkspace({
|
||||
<span className="text-[10px] font-mono text-slate-400">
|
||||
Layer: {displaySliceRange.start}-{displaySliceRange.end}/{project?.dicomCount ?? 0}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 rounded-xl bg-slate-100 p-1">
|
||||
<div className="flex flex-wrap 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">
|
||||
<Maximize2 size={11} />
|
||||
自动拉伸
|
||||
等比
|
||||
</span>
|
||||
{(['x', 'y', 'z'] as AxisKey[]).map((axis) => (
|
||||
<button
|
||||
key={axis}
|
||||
onClick={() => void applyModelStretchByAxis(axis)}
|
||||
disabled={!stretchEnabled || stretchingAxis !== null}
|
||||
key={`uniform-${axis}`}
|
||||
onClick={() => void applyModelStretchByAxis(axis, { mode: 'uniform' })}
|
||||
disabled={!stretchEnabled || stretchingState !== null}
|
||||
className={`rounded-lg px-2 py-1 text-[9px] font-bold transition ${
|
||||
stretchEnabled && stretchingAxis === null
|
||||
stretchEnabled && stretchingState === null
|
||||
? 'bg-white text-blue-600 shadow-sm hover:text-blue-700'
|
||||
: 'cursor-not-allowed text-slate-300'
|
||||
}`}
|
||||
title={stretchEnabled ? `按 ${axis.toUpperCase()} 方向自动等比例拉伸模型` : '仅当旋转 X/Y/Z 均为 90° 的整数倍时可用'}
|
||||
>
|
||||
{stretchingAxis === axis ? '...' : `${axis.toUpperCase()}拉伸`}
|
||||
{stretchingState?.mode === 'uniform' && stretchingState.axis === axis ? '...' : `${axis.toUpperCase()}拉伸`}
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-0.5 h-4 w-px bg-slate-200" />
|
||||
<span className="hidden px-1 text-[9px] font-bold text-slate-400 2xl:inline">单轴</span>
|
||||
{(['x', 'y', 'z'] as AxisKey[]).map((axis) => (
|
||||
<button
|
||||
key={`axis-${axis}`}
|
||||
onClick={() => void applyModelStretchByAxis(axis, { mode: 'axis' })}
|
||||
disabled={!stretchEnabled || stretchingState !== null}
|
||||
className={`rounded-lg px-2 py-1 text-[9px] font-bold transition ${
|
||||
stretchEnabled && stretchingState === null
|
||||
? 'bg-white text-emerald-600 shadow-sm hover:text-emerald-700'
|
||||
: 'cursor-not-allowed text-slate-300'
|
||||
}`}
|
||||
title={stretchEnabled ? `仅沿融合坐标 ${axis.toUpperCase()} 轴拉伸,并将平移 ${axis.toUpperCase()} 置 0` : '仅当旋转 X/Y/Z 均为 90° 的整数倍时可用'}
|
||||
>
|
||||
{stretchingState?.mode === 'axis' && stretchingState.axis === axis ? '...' : `${axis.toUpperCase()}轴`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -4129,8 +4262,11 @@ export default function ReverseWorkspace({
|
||||
{ key: 'translateY' as const, label: '平移 Y', value: modelPose.translateY },
|
||||
{ key: 'translateZ' as const, label: '平移 Z', value: modelPose.translateZ },
|
||||
{ key: 'scale' as const, label: '缩放', value: modelPose.scale },
|
||||
{ key: 'scaleX' as const, label: '轴拉伸 X', value: modelPose.scaleX },
|
||||
{ key: 'scaleY' as const, label: '轴拉伸 Y', value: modelPose.scaleY },
|
||||
{ key: 'scaleZ' as const, label: '轴拉伸 Z', value: modelPose.scaleZ },
|
||||
].map((item) => (
|
||||
<div key={item.key} className="grid grid-cols-[44px_28px_1fr_28px_72px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
||||
<div key={item.key} className="grid grid-cols-[58px_28px_1fr_28px_72px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
||||
<span>{item.label}</span>
|
||||
<button
|
||||
onPointerDown={() => {
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface ProjectAssetImportProgress {
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export type ProjectExportDownloadProgress = ProjectAssetImportProgress;
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
headers: {
|
||||
@@ -200,6 +202,83 @@ function triggerFileDownload(url: string) {
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob: Blob, filename: string) {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
link.rel = 'noopener';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1500);
|
||||
}
|
||||
|
||||
function filenameFromContentDisposition(header: string | null, fallback: string) {
|
||||
if (!header) {
|
||||
return fallback;
|
||||
}
|
||||
const encodedMatch = header.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (encodedMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(encodedMatch[1].trim().replace(/^"|"$/g, ''));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
const plainMatch = header.match(/filename="([^"]+)"/i) ?? header.match(/filename=([^;]+)/i);
|
||||
return plainMatch?.[1]?.trim() || fallback;
|
||||
}
|
||||
|
||||
function downloadFileWithProgress(
|
||||
url: string,
|
||||
fallbackFilename: string,
|
||||
onProgress?: (progress: ProjectExportDownloadProgress) => void,
|
||||
) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url);
|
||||
xhr.responseType = 'blob';
|
||||
xhr.onprogress = (event) => {
|
||||
const total = event.lengthComputable ? event.total : 0;
|
||||
const loaded = event.loaded;
|
||||
const percent = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
|
||||
onProgress?.({ loaded, total, percent });
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
const filename = filenameFromContentDisposition(xhr.getResponseHeader('Content-Disposition'), fallbackFilename);
|
||||
triggerBlobDownload(xhr.response, filename);
|
||||
onProgress?.({ loaded: xhr.response?.size ?? 0, total: xhr.response?.size ?? 0, percent: 100 });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
let message = `请求失败:${xhr.status}`;
|
||||
try {
|
||||
const data = JSON.parse(String(reader.result ?? ''));
|
||||
if (typeof data?.message === 'string') {
|
||||
message = data.message;
|
||||
}
|
||||
} catch {
|
||||
const text = String(reader.result ?? '').trim();
|
||||
if (text) {
|
||||
message = text.slice(0, 240);
|
||||
}
|
||||
}
|
||||
reject(new Error(message));
|
||||
};
|
||||
reader.onerror = () => reject(new Error(`请求失败:${xhr.status}`));
|
||||
reader.readAsText(xhr.response);
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('网络连接中断,导出失败'));
|
||||
xhr.onabort = () => reject(new Error('导出已取消'));
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
function appendPose(params: URLSearchParams, pose?: ModelPose) {
|
||||
if (pose) {
|
||||
params.set('pose', JSON.stringify(pose));
|
||||
@@ -232,7 +311,13 @@ export async function downloadProjectExport(projectId: string, target: ProjectEx
|
||||
triggerFileDownload(`/api/projects/${projectId}/export-nifti?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function downloadProjectExportBundle(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode; moduleStyles?: Record<string, ModuleStyle> } = {}) {
|
||||
export async function downloadProjectExportBundle(
|
||||
projectId: string,
|
||||
targets: ProjectExportTarget[],
|
||||
format: 'nii' | 'nii.gz' = 'nii.gz',
|
||||
options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode; moduleStyles?: Record<string, ModuleStyle> } = {},
|
||||
onProgress?: (progress: ProjectExportDownloadProgress) => void,
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
targets: targets.join(','),
|
||||
format,
|
||||
@@ -241,7 +326,11 @@ export async function downloadProjectExportBundle(projectId: string, targets: Pr
|
||||
});
|
||||
appendPose(params, options.pose);
|
||||
appendModuleStyles(params, options.moduleStyles);
|
||||
triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`);
|
||||
await downloadFileWithProgress(
|
||||
`/api/projects/${projectId}/export-bundle?${params.toString()}`,
|
||||
`${projectId}-export.zip`,
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadDicomArchive(projectId: string) {
|
||||
|
||||
@@ -46,6 +46,9 @@ export interface ModelPose {
|
||||
translateY: number;
|
||||
translateZ: number;
|
||||
scale: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
scaleZ: number;
|
||||
flipX: boolean;
|
||||
flipY: boolean;
|
||||
flipZ: boolean;
|
||||
|
||||
59
工程分析/实现方案-2026-05-25-12-30-27.md
Normal file
59
工程分析/实现方案-2026-05-25-12-30-27.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 实现方案-2026-05-25-12-30-27
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-25-12-30-27.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
将 `3. 代码汇总.docx` 整理为约 10000 行真实、连续、无省略占位的项目源码汇总文档,避免出现以 `...` 表示删减的软著材料风险。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `3. 代码汇总.docx`
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `工程分析/需求分析-2026-05-25-12-30-27.md`
|
||||
- `工程分析/实现方案-2026-05-25-12-30-27.md`
|
||||
- `工程分析/测试方案-2026-05-25-12-30-27.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 读取既有工程分析、整体分析和经验记录,确认项目约束。
|
||||
2. 统计可选源码文件行数,优先选择完整文件而非局部摘录。
|
||||
3. 使用 `python-docx` 读取并重写 docx 正文,保留为 `.docx` 格式。
|
||||
4. 正文中写入标题、整理说明、文件清单和完整源码内容;每个源码文件从第一行到最后一行连续写入。
|
||||
5. 对生成后的 docx 做结构可读性检查、行数检查和省略占位检查。
|
||||
6. 执行构建、重新启动 `tmux` 服务并验证 `/api/health` 和首页。
|
||||
7. 追加经验记录,并对本次相关文档和 docx 做 Git 备份 commit。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 确认 `工程分析/经验记录.md` 在执行修改前已再次阅读。
|
||||
2. 选定完整源码文件组合,控制总行数约 10000 行。
|
||||
3. 生成 docx:使用等宽字体、小字号、紧凑段落,每行保留行号和原始源码文本。
|
||||
4. 检查 docx 中是否存在以独立 `...` 表示的省略占位;若源码语法中存在合法 `...`,评估是否需要改选文件或在材料说明中避免误判。
|
||||
5. 运行 `npm run build`。
|
||||
6. 使用 `tmux` 会话 `revoxelseg-dicom` 运行 `npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
7. 使用 `curl` 验证本地 API 与页面。
|
||||
8. 追加经验记录并提交备份 commit。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若 docx 生成异常,可从 Git 工作区或修改前备份文件恢复。
|
||||
- 若选定文件中合法 `...` 过多导致材料观感不佳,可改为选择不含该字符或更少使用扩展运算符的完整源码文件组合。
|
||||
- 本次不修改项目运行源码;构建和部署异常按既有服务回滚方式恢复上一版服务。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 更新:`3. 代码汇总.docx`
|
||||
- 更新:`工程分析/经验记录.md`
|
||||
- 新增:本次需求分析、实现方案、测试方案三件套
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Git commit message 包含 `2026-05-25-12-30-27` 和“整理软著代码汇总文档”。
|
||||
- 只暂存本次相关文件,避免提交无关未跟踪或运行态文件。
|
||||
- 构建成功后重启本地 4000 服务,并验证健康检查和首页响应。
|
||||
55
工程分析/实现方案-2026-05-25-12-45-33.md
Normal file
55
工程分析/实现方案-2026-05-25-12-45-33.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 实现方案-2026-05-25-12-45-33
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-25-12-45-33.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
从未推送的本地 commit 中移除 `3. 代码汇总.docx`,保留本地文件,并阻止后续误提交该 docx。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `3. 代码汇总.docx`
|
||||
- `.gitignore`
|
||||
- `工程分析/需求分析-2026-05-25-12-45-33.md`
|
||||
- `工程分析/实现方案-2026-05-25-12-45-33.md`
|
||||
- `工程分析/测试方案-2026-05-25-12-45-33.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 确认上一次 commit 尚未推送,当前分支仅本地领先远程。
|
||||
2. 使用 `git rm --cached` 从索引中移除 `3. 代码汇总.docx`,不删除工作区文件。
|
||||
3. 在 `.gitignore` 中加入 `3. 代码汇总.docx`,降低后续误提交风险。
|
||||
4. 使用 `git commit --amend` 修正未推送的上一个本地 commit,使其不包含 docx。
|
||||
5. 本次不执行 `git push`。
|
||||
6. 验证 `git status` 中 docx 不再显示为待提交文件,且 `git ls-files` 不包含 docx。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 执行前再次阅读 `工程分析/经验记录.md`。
|
||||
2. 检查 `git status --short --branch` 和 `git log -1`。
|
||||
3. 取消跟踪 `3. 代码汇总.docx`,保留本地文件。
|
||||
4. 更新 `.gitignore`。
|
||||
5. 追加经验记录。
|
||||
6. 暂存 `.gitignore`、工程分析文档和经验记录,修正上一个本地 commit。
|
||||
7. 不推送远程。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若需要恢复 docx 入库,可删除 `.gitignore` 对应规则后手动 `git add`。
|
||||
- 若 amend 后发现提交范围异常,可通过 `git reflog` 找回 amend 前的 commit。
|
||||
- 本次不修改业务源码,部署服务无需因本次 Git 治理变化重启。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 新增:本次需求分析、实现方案、测试方案。
|
||||
- 更新:`.gitignore`、`工程分析/经验记录.md`。
|
||||
- Git 历史:上一个未推送 commit 被 amend,不再包含 `3. 代码汇总.docx`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 本次遵循用户指令,不推送 Gitea。
|
||||
- 备份 commit 仅保留工程分析文档和忽略规则,不包含 `3. 代码汇总.docx`。
|
||||
- 因不修改运行代码,本次仅做状态验证;如服务仍在运行,可验证 4000 服务健康。
|
||||
53
工程分析/实现方案-2026-05-25-12-48-00.md
Normal file
53
工程分析/实现方案-2026-05-25-12-48-00.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 实现方案-2026-05-25-12-48-00
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-25-12-48-00.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
重写本地 `3. 代码汇总.docx`,移除源码行号前缀和 `文件:XXX` 标题,仅保留连续源码文本。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `3. 代码汇总.docx`
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `工程分析/需求分析-2026-05-25-12-48-00.md`
|
||||
- `工程分析/实现方案-2026-05-25-12-48-00.md`
|
||||
- `工程分析/测试方案-2026-05-25-12-48-00.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 再次确认执行前已阅读 `工程分析/经验记录.md`。
|
||||
2. 使用 `python-docx` 重新生成 `3. 代码汇总.docx`。
|
||||
3. 读取三个完整源码文件,按原始行内容写入 docx,不添加行号。
|
||||
4. 文件之间只添加空行,不写 `文件:XXX` 标题。
|
||||
5. 校验 docx 可读取、无 `文件:` 文案、无行号前缀、源码行数仍为 10835。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 写入本次三件套。
|
||||
2. 重新生成 docx。
|
||||
3. 使用 `python-docx` 读取段落文本并检查。
|
||||
4. 使用 `unzip -t` 检查 docx 包结构。
|
||||
5. 追加经验记录。
|
||||
6. 只提交工程分析文档和经验记录,不提交、不推送 `3. 代码汇总.docx`。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 如新格式不符合要求,可用同一脚本重新生成带标题或带其他格式的版本。
|
||||
- `3. 代码汇总.docx` 未入库,Git 回滚不影响本地交付文件;必要时可从源码重新生成。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 本地修改:`3. 代码汇总.docx`。
|
||||
- Git 文档变更:新增本次三件套,更新 `工程分析/经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 本次不提交、不推送 `3. 代码汇总.docx`。
|
||||
- 可将本次工程分析文档 amend 到尚未推送的本地备份 commit。
|
||||
- 不修改业务源码,不需要重新构建;用健康检查确认现有服务即可。
|
||||
52
工程分析/实现方案-2026-05-25-13-42-13.md
Normal file
52
工程分析/实现方案-2026-05-25-13-42-13.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 实现方案-2026-05-25-13-42-13
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-25-13-42-13.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
将自动微调匹配工作区“迭代轮次”的最高可选值从 12 提升到 50,并保证后端接口同步支持。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/需求分析-2026-05-25-13-42-13.md`
|
||||
- `工程分析/实现方案-2026-05-25-13-42-13.md`
|
||||
- `工程分析/测试方案-2026-05-25-13-42-13.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 前端:把迭代轮次输入框 `max` 和 `onChange` clamp 上限改为 50。
|
||||
- 后端:把 `normalizeAutoMatchIterations` 的 clamp 上限改为 50。
|
||||
- 保持默认 `iterations = 6`,避免进入页面后默认就触发长耗时运行。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 确认已读 `工程分析/经验记录.md`。
|
||||
2. 修改前端迭代轮次输入限制。
|
||||
3. 修改后端接口参数归一化限制。
|
||||
4. 执行 `npm run build`。
|
||||
5. 重启 `tmux` 会话 `revoxelseg-dicom` 服务。
|
||||
6. 验证本机与公网访问。
|
||||
7. 追加经验记录并提交推送。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 旧请求中 `iterations <= 12` 行为不变。
|
||||
- 若 50 次导致耗时过长,可再引入运行中取消、进度显示或更严格候选限制。
|
||||
- 回滚只需恢复前后端上限为 12。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 两处代码常量级修改。
|
||||
- 新增本次工程分析三件套。
|
||||
- 追加经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 只暂存本次代码和文档变更。
|
||||
- commit message 包含 `2026-05-25-13-42-13` 和简要描述。
|
||||
- 提交后推送到 Gitea,并完成重新部署验证。
|
||||
62
工程分析/实现方案-2026-05-25-14-00-24.md
Normal file
62
工程分析/实现方案-2026-05-25-14-00-24.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 实现方案-2026-05-25-14-00-24
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-25-14-00-24.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
1. 将“导出项目及结果”从 tar.gz 改为 ZIP,消除 `PaxHeaders` 暴露问题。
|
||||
2. 统一 ZIP 内部结构:根目录直接包含 `manifest.json`、`pose/`、`segmentation/`、`segmentation-parts/`、`dicom/`、`stl/` 等目录。
|
||||
3. 分类分割导出时使用构件类别名生成 `.nii.gz` 文件名。
|
||||
4. 前端导出时显示顶部细进度条,并通过下载进度回调更新。
|
||||
5. 新增自动化 API,支持 DICOM/STL 上传、位姿参数、轴向自动拉伸、可选自动匹配、可选导出、可选记录项目库和锁定。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/需求分析-2026-05-25-14-00-24.md`
|
||||
- `工程分析/实现方案-2026-05-25-14-00-24.md`
|
||||
- `工程分析/测试方案-2026-05-25-14-00-24.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 使用项目现有 `adm-zip` 依赖生成 ZIP,而不是继续维护手写 tar.gz 的 PAX 扩展路径。
|
||||
- 在后端导出包中新增根级 `manifest.json`,记录项目、导出目标、格式、分割范围、分类导出模式、位姿等信息。
|
||||
- 后端分类导出命名为 `segmentation-parts/{类别名}.nii.gz`,若重名则自动追加序号。
|
||||
- 前端下载函数改为 `XMLHttpRequest`,支持 `onprogress` 并从 `Content-Disposition` 解析文件名。
|
||||
- `ReverseWorkspace` 增加 `exportProgress` 状态和顶部 fixed 细进度条;服务端生成阶段使用平滑进度,下载阶段使用真实进度。
|
||||
- 新增 `POST /api/reverse-pipeline`,接收 multipart 的 `dicomFiles`、`stlFiles`、可选 `metadata` JSON;复用现有资产解包、NIfTI 导出和自动匹配函数。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 调整服务端项目导出包创建逻辑,返回 ZIP buffer 并改为 `.zip` 下载。
|
||||
2. 更新导出包内部文件命名与 manifest 内容。
|
||||
3. 更新前端下载 API,加入进度回调。
|
||||
4. 在逆向工作区渲染导出进度条并接入进度回调。
|
||||
5. 增加批处理 API 的参数解析、项目创建、资产写入、位姿归一化、自动拉伸、自动匹配、导出、记录与锁定逻辑。
|
||||
6. 执行 TypeScript 构建和必要接口验证。
|
||||
7. 重新部署到 `tmux` 会话 `revoxelseg-dicom` 并验证本地与公网。
|
||||
8. 追加经验记录,提交并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 单文件 `export-nifti` 和 `dicom-archive` 接口保持原行为。
|
||||
- 若 ZIP 导出出现问题,可回滚 `createProjectExportBundle` 和 `/export-bundle` 响应相关修改。
|
||||
- 新增 API 为独立路径,不影响现有页面流程。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 后端:导出包结构、批处理 API、辅助函数。
|
||||
- 前端:下载 helper 和导出进度 UI。
|
||||
- 工程分析文档:三件套与经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 修改完成后运行 `npm run build`。
|
||||
- 使用 `tmux` 会话 `revoxelseg-dicom` 重启 `npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
- 验证 `http://127.0.0.1:4000/api/health`、`http://127.0.0.1:4000/`、`https://revoxel.huijutec.cn/`。
|
||||
- commit message 包含 `2026-05-25-14-00-24` 和简要描述,并推送到 Gitea。
|
||||
59
工程分析/实现方案-2026-06-04-09-46-36.md
Normal file
59
工程分析/实现方案-2026-06-04-09-46-36.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 实现方案-2026-06-04-09-46-36
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-06-04-09-46-36.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
完成一次无源码变更的重新部署,让用户可以通过 `http://192.168.3.11:4000/` 访问当前程序。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/`
|
||||
- `WebSite/package.json`
|
||||
- `工程分析/需求分析-2026-06-04-09-46-36.md`
|
||||
- `工程分析/实现方案-2026-06-04-09-46-36.md`
|
||||
- `工程分析/测试方案-2026-06-04-09-46-36.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 检查工作区状态、部署脚本、已有 `tmux` 会话和 `4000` 端口占用。
|
||||
2. 在 `WebSite` 执行 `npm run build`,生成最新生产构建。
|
||||
3. 使用或重建 `tmux` 会话 `revoxelseg-dicom`,在其中启动:
|
||||
`npm run serve -- --host 0.0.0.0 --port 4000`
|
||||
4. 验证本机健康检查、本机首页和局域网首页。
|
||||
5. 追加经验记录,并仅提交本次工程分析文档与经验记录。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 执行前再次确认已读 `工程分析/经验记录.md`。
|
||||
- 若存在旧 `revoxelseg-dicom` 会话,先发送 `Ctrl-C` 停止旧服务,再启动新服务。
|
||||
- 若不存在该会话,则创建新的 detached `tmux` 会话。
|
||||
- 使用 `curl` 验证:
|
||||
- `http://127.0.0.1:4000/api/health`
|
||||
- `http://127.0.0.1:4000/`
|
||||
- `http://192.168.3.11:4000/api/health`
|
||||
- `http://192.168.3.11:4000/`
|
||||
- 检查 `tmux` 会话和端口监听状态,确认服务长期运行。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若新服务启动失败,保留构建和启动日志,停止新会话后回滚到上一次可用部署命令。
|
||||
- 若端口被非本项目进程占用,先记录占用来源,再决定是否停止或改端口。
|
||||
- 若局域网访问失败但本机访问成功,优先排查监听地址、服务器防火墙和网络路由。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 新增本次三件套文档。
|
||||
- 追加 `工程分析/经验记录.md`。
|
||||
- `WebSite/dist/` 会因构建更新,但不纳入本次文档备份 commit。
|
||||
- 不预计修改源码。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 先完成构建、服务重启和访问验证。
|
||||
- 再追加经验记录。
|
||||
- 仅暂存本次工程分析文档和 `工程分析/经验记录.md`。
|
||||
- commit message 使用:`2026-06-04-09-46-36 部署并验证局域网 4000 服务`。
|
||||
71
工程分析/实现方案-2026-06-23-12-07-35.md
Normal file
71
工程分析/实现方案-2026-06-23-12-07-35.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 实现方案-2026-06-23-12-07-35
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-06-23-12-07-35.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
完成一次无业务源码变更的重新部署,使当前程序可通过 `http://192.168.3.11:4000/` 访问,并保留本次操作的工程分析记录。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `AGENTS.md`
|
||||
- `README.md`
|
||||
- `WebSite/README.md`
|
||||
- `WebSite/package.json`
|
||||
- `WebSite/vite.config.ts`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/代码编纂工作流.md`
|
||||
- `工程分析/工程整体分析.md`
|
||||
- `工程分析/经验记录.md`
|
||||
- `工程分析/需求分析-2026-06-23-12-07-35.md`
|
||||
- `工程分析/实现方案-2026-06-23-12-07-35.md`
|
||||
- `工程分析/测试方案-2026-06-23-12-07-35.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 通过仓库文档、脚本和服务入口确认项目是 Express + Vite 的前后端一体服务。
|
||||
- 使用 `npm run build` 生成生产构建产物。
|
||||
- 使用 `tmux` 会话 `revoxelseg-dicom` 启动 `npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
- 使用 `curl` 同时验证本机地址和目标局域网地址的 API 与首页。
|
||||
- 仅将本次工程分析文档和经验记录纳入 Git 备份提交。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 检查工作区状态、Node/npm 版本、依赖目录、目标网卡地址、`4000` 端口和 `tmux` 会话。
|
||||
2. 在 `WebSite/` 执行 `npm run build`。
|
||||
3. 如存在旧 `revoxelseg-dicom` 会话,先确认并重建该会话;如不存在则创建新会话。
|
||||
4. 在会话内执行 `npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
5. 验证:
|
||||
- `http://127.0.0.1:4000/api/health`
|
||||
- `http://127.0.0.1:4000/`
|
||||
- `http://192.168.3.11:4000/api/health`
|
||||
- `http://192.168.3.11:4000/`
|
||||
6. 检查 `tmux` 会话日志和 `4000` 端口监听状态。
|
||||
7. 追加 `工程分析/经验记录.md` 的 A/B/C/D 四段式记录。
|
||||
8. 暂存并提交本次工程分析文档,commit message 使用 `2026-06-23-12-07-35 部署并验证局域网 4000 服务`。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 如果构建失败,不重启旧服务,保留错误并汇报。
|
||||
- 如果新服务启动失败,查看 `tmux` 日志和端口占用,停止失败会话后恢复上一条可用部署命令。
|
||||
- 如果 `4000` 被非本项目进程占用,先记录占用来源,再决定是否停止或改用既有服务验证。
|
||||
- 如果 `192.168.3.11` 不在本机网卡上,应停止局域网地址部署验证并报告网络前置条件不满足。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 新增:
|
||||
- `工程分析/需求分析-2026-06-23-12-07-35.md`
|
||||
- `工程分析/实现方案-2026-06-23-12-07-35.md`
|
||||
- `工程分析/测试方案-2026-06-23-12-07-35.md`
|
||||
- 追加:
|
||||
- `工程分析/经验记录.md`
|
||||
- 运行构建可能更新或生成 `WebSite/dist/`,该目录为运行态构建产物,不纳入本次文档备份提交。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 先完成构建、部署和访问验证,再追加经验记录。
|
||||
- 使用 Git 仅提交本次工程分析文档和经验记录。
|
||||
- 不提交 DICOM/STL 数据、`WebSite/data/`、`WebSite/exports/`、`WebSite/dist/` 或无关工作区变化。
|
||||
- 提交信息:`2026-06-23-12-07-35 部署并验证局域网 4000 服务`。
|
||||
71
工程分析/实现方案-2026-06-23-13-38-48.md
Normal file
71
工程分析/实现方案-2026-06-23-13-38-48.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 实现方案-2026-06-23-13-38-48
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-06-23-13-38-48.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
新增模型位姿的单轴拉伸能力,使逆向工作区可按某一坐标轴非等比例拉伸模型,并保证缩放、保存、导出、项目库预览和自动微调匹配都使用该拉伸后的统一模型位姿。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/需求分析-2026-06-23-13-38-48.md`
|
||||
- `工程分析/实现方案-2026-06-23-13-38-48.md`
|
||||
- `工程分析/测试方案-2026-06-23-13-38-48.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 扩展 `ModelPose` / `ModelPoseValue`,新增 `scaleX`、`scaleY`、`scaleZ`,默认值为 `1`。
|
||||
- 统一归一化和 clamp 范围,让旧数据自动补齐新字段。
|
||||
- 前端三维视图和二维分割映射采用同一变换顺序:
|
||||
- 原始模型中心化。
|
||||
- 应用统一缩放、镜像和旋转。
|
||||
- 在融合坐标系下应用 `scaleX/scaleY/scaleZ` 单轴拉伸。
|
||||
- 应用平移。
|
||||
- 后端 `transformPointForExportPose` 使用相同变换顺序,保证导出和自动匹配评分一致。
|
||||
- 逆向工作区保留现有等比例自动拉伸,再新增单轴拉伸按钮;单轴拉伸时自动把对应平移置 0。
|
||||
- 自动微调匹配工作区不额外优化 `scaleX/scaleY/scaleZ`,但请求、评分、对比视图和保存结果保留并应用这些字段。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 扩展前后端位姿类型、默认值、格式化、归一化和 reset 行为。
|
||||
2. 修改 `ReverseWorkspace` 的模型变换、二维映射和工具栏按钮。
|
||||
3. 修改 `ProjectLibrary` 结果预览中的模型变换,兼容新字段。
|
||||
4. 修改 `server.ts` 的位姿归一化、管线参数解析、自动匹配评分和导出分割变换。
|
||||
5. 运行 `npm run lint` 与 `npm run build`。
|
||||
6. 重启 `tmux` 会话 `revoxelseg-dicom` 并验证本机/局域网访问。
|
||||
7. 追加经验记录并提交本次文档与源码备份 commit。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 兼容性:旧位姿不含 `scaleX/scaleY/scaleZ` 时默认按 `1` 处理,与旧行为完全一致。
|
||||
- 回滚:如新拉伸或导出异常,可回退本次 commit 并重新构建部署。
|
||||
- 数据风险:本次不修改医学数据;运行态 `state.json` 只会在用户实际保存项目时记录新字段。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 修改:
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/经验记录.md`
|
||||
- 新增:
|
||||
- `工程分析/需求分析-2026-06-23-13-38-48.md`
|
||||
- `工程分析/实现方案-2026-06-23-13-38-48.md`
|
||||
- `工程分析/测试方案-2026-06-23-13-38-48.md`
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 只暂存本次源码和工程分析文档。
|
||||
- 不提交 DICOM/STL 数据、运行态 `WebSite/data/`、导出文件或无关变更。
|
||||
- commit message 使用:`2026-06-23-13-38-48 新增逆向工作区单轴拉伸`。
|
||||
- 构建通过后使用 `tmux` 会话 `revoxelseg-dicom` 重新部署 `NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
47
工程分析/测试方案-2026-05-25-12-30-27.md
Normal file
47
工程分析/测试方案-2026-05-25-12-30-27.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 测试方案-2026-05-25-12-30-27
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-05-25-12-30-27.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 检查 `3. 代码汇总.docx` 可被 `python-docx` 打开。
|
||||
- 统计写入源码行数,确认约 10000 行。
|
||||
- 检查文档中不存在独立 `...` 段落或明显的省略占位。
|
||||
- 检查每个收录文件从第 1 行到末行连续写入,不存在人为跳行。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 在 `WebSite/` 执行 `npm run build`。
|
||||
- 本次不修改源码,构建用于确认当前项目仍可正常打包。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 验证更新后的 docx 包含标题、整理说明、文件清单和完整源码正文。
|
||||
- 验证收录源码来自项目真实文件,且文件边界明确。
|
||||
- 验证未将 `node_modules`、`dist`、医学影像数据或运行态导出内容写入 docx。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 本次不读取、不修改、不提交 `Head_CT_DICOM/`、`Head_CT_ReConstruct/` 原始医学数据。
|
||||
- 本次不修改 DICOM/STL 解析、融合、导出算法,仅验证部署服务仍可启动。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 构建完成后沿用或重建 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- 启动命令:`npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
- 验证 `http://127.0.0.1:4000/api/health` 返回成功。
|
||||
- 验证 `http://127.0.0.1:4000/` 返回页面 HTML。
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- 使用 `git status --short` 确认暂存范围只包含本次相关文件。
|
||||
- 提交消息包含 `2026-05-25-12-30-27` 和简要描述。
|
||||
- 提交后记录 commit 哈希。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- docx 修改为二进制变化,需重点确认文档可打开和源码行数正确。
|
||||
- 若服务端口 4000 已被占用,需要确认占用来源,优先使用既有 `tmux` 会话重启。
|
||||
- 当前 `3. 代码汇总.docx` 原本未被 Git 跟踪,提交时需明确纳入备份。
|
||||
43
工程分析/测试方案-2026-05-25-12-45-33.md
Normal file
43
工程分析/测试方案-2026-05-25-12-45-33.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 测试方案-2026-05-25-12-45-33
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-05-25-12-45-33.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- `git ls-files -- '3. 代码汇总.docx'` 应无输出。
|
||||
- `git status --short --ignored` 应显示 `3. 代码汇总.docx` 为忽略文件或至少不在待提交列表中。
|
||||
- `git show --name-only HEAD` 不应包含 `3. 代码汇总.docx`。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 本次不修改业务源码,可不重复完整构建。
|
||||
- 若需要确认服务状态,使用健康检查验证当前部署仍可访问。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 本地 `3. 代码汇总.docx` 文件仍存在。
|
||||
- Git 备份 commit 中不包含该 docx。
|
||||
- 不执行 `git push`,避免远程收到 docx。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 本次不触碰 DICOM/STL 医学数据目录。
|
||||
- 不提交运行态导出和数据文件。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 本次不修改服务代码,不需要重新部署。
|
||||
- 验证现有 `http://127.0.0.1:4000/api/health` 仍返回成功即可。
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- 本地 commit message 包含 `2026-05-25-12-45-33` 或在修正后的提交历史中保留对应说明。
|
||||
- 不推送 Gitea。
|
||||
- 分支仍可能领先远程,但领先 commit 不包含 `3. 代码汇总.docx`。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- amend 会改变未推送 commit 哈希;由于前一 commit 未推送,此风险可控。
|
||||
- 必须确认 docx 没有被删除,只是未被 Git 跟踪。
|
||||
42
工程分析/测试方案-2026-05-25-12-48-00.md
Normal file
42
工程分析/测试方案-2026-05-25-12-48-00.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 测试方案-2026-05-25-12-48-00
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-05-25-12-48-00.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- `python-docx` 能打开 `3. 代码汇总.docx`。
|
||||
- 文档中不包含以 `文件:` 开头的段落。
|
||||
- 文档源码正文不包含形如 ` 1 ` 的行号前缀。
|
||||
- 源码正文行数为 10835 行。
|
||||
- 独立省略占位 `...` 数量为 0。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 本次不修改业务源码,可不重复执行 `npm run build`。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 打开 docx 后看到的是源码本身,不显示文件标题和行号编号。
|
||||
- 代码仍来自完整连续源码文件,不是人工摘录。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 不读取、不修改、不提交 DICOM/STL 医学数据。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 本次不修改服务代码,不重新部署。
|
||||
- 验证当前 `http://127.0.0.1:4000/api/health` 仍为 200。
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- `3. 代码汇总.docx` 保持被 `.gitignore` 忽略。
|
||||
- `git show --name-only HEAD` 不包含 `3. 代码汇总.docx`。
|
||||
- 不执行 `git push`。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- 去掉文件标题后不要误删源码首行。
|
||||
- 去掉行号后要保持空行、缩进和源码文本本身。
|
||||
45
工程分析/测试方案-2026-05-25-13-42-13.md
Normal file
45
工程分析/测试方案-2026-05-25-13-42-13.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 测试方案-2026-05-25-13-42-13
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-05-25-13-42-13.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 搜索 `normalizeAutoMatchIterations` 和“迭代轮次”输入框,确认前后端上限一致为 50。
|
||||
- 检查工作区状态,避免提交无关文件。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 在 `WebSite` 执行 `npm run build`。
|
||||
- 关注 TypeScript/Vite 构建错误。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 自动微调匹配页的迭代轮次输入框允许输入 50。
|
||||
- 默认迭代轮次仍为 6。
|
||||
- 后端接口收到 50 时不会截断到 12。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 本次不修改 DICOM/STL 坐标、采样、评分或导出算法。
|
||||
- 50 次迭代只影响用户主动选择后的优化循环次数。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 使用 `tmux` 会话 `revoxelseg-dicom` 重启服务。
|
||||
- 验证:
|
||||
- `http://127.0.0.1:4000/api/health`
|
||||
- `http://127.0.0.1:4000/`
|
||||
- `https://revoxel.huijutec.cn/api/health`
|
||||
- `https://revoxel.huijutec.cn/`
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- commit message 包含 `2026-05-25-13-42-13`。
|
||||
- 推送到 `origin/main`。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- 迭代轮次越高,单次自动匹配耗时越长;后续如需常用 50 次,应考虑进度、取消和服务端超时保护。
|
||||
- 注意前端输入限制和后端 clamp 不要再次出现不一致。
|
||||
50
工程分析/测试方案-2026-05-25-14-00-24.md
Normal file
50
工程分析/测试方案-2026-05-25-14-00-24.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 测试方案-2026-05-25-14-00-24
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-05-25-14-00-24.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 检查 TypeScript 类型是否通过 Vite 构建。
|
||||
- 检查新增导出和 API 函数没有破坏现有 `ProjectExportTarget`、`ModelPoseValue`、`AutoMatchRequest` 使用。
|
||||
- 检查 ZIP 内路径不包含项目名根目录。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 在 `WebSite/` 执行:
|
||||
- `npm run build`
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 请求 `/api/projects/:projectId/export-bundle` 导出分割、位姿等内容,确认响应为 `.zip`。
|
||||
- 使用 `unzip -l` 查看 ZIP,确认存在 `manifest.json`、`segmentation/labels.json`,分类模式下存在 `segmentation-parts/{类别名}.nii.gz`。
|
||||
- 前端导出按钮点击后确认顶部进度条出现并完成后自动消失。
|
||||
- 新增 `/api/reverse-pipeline` 在默认不导出时返回 JSON;在指定导出内容时返回 ZIP。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 分割导出应继续使用当前 DICOM 体数据和当前位姿。
|
||||
- 可见类别导出时仅导出可见构件;所有类别导出时导出全部构件。
|
||||
- 分类导出的单构件 `.nii.gz` 文件命名可直接对应类别,类别映射 JSON 同包提供。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 重启 `tmux` 会话 `revoxelseg-dicom` 中的服务。
|
||||
- 验证:
|
||||
- `http://127.0.0.1:4000/api/health`
|
||||
- `http://127.0.0.1:4000/`
|
||||
- `https://revoxel.huijutec.cn/`
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- `git status --short` 确认本次改动范围。
|
||||
- commit message 包含 `2026-05-25-14-00-24`。
|
||||
- 推送至 Gitea 后确认 `origin/main` 更新。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- ZIP 下载文件名需要兼容中文项目名。
|
||||
- 批处理 API 的临时项目目录需要在不入库时清理,避免磁盘堆积。
|
||||
- 自动匹配失败不能导致已上传临时文件残留或项目状态异常。
|
||||
- 导出进度条不能遮挡和阻塞其他功能。
|
||||
47
工程分析/测试方案-2026-06-04-09-46-36.md
Normal file
47
工程分析/测试方案-2026-06-04-09-46-36.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 测试方案-2026-06-04-09-46-36
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-06-04-09-46-36.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 检查 `WebSite/package.json` 中 `build` 与 `serve` 脚本存在。
|
||||
- 检查本机网卡包含 `192.168.3.11`。
|
||||
- 检查 `tmux` 会话 `revoxelseg-dicom` 与 `4000` 端口状态,避免重复服务冲突。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 在 `WebSite` 执行 `npm run build`。
|
||||
- 通过 Vite 构建输出确认无构建错误。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 验证 `http://127.0.0.1:4000/api/health` 返回健康状态。
|
||||
- 验证 `http://127.0.0.1:4000/` 返回首页 HTML。
|
||||
- 验证 `http://192.168.3.11:4000/api/health` 返回健康状态。
|
||||
- 验证 `http://192.168.3.11:4000/` 返回首页 HTML。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 本次仅重新部署,不修改 DICOM/STL 读取、预览、融合、分割映射和 Mask 导出逻辑。
|
||||
- 不重新生成医学影像导出结果,避免把运行态产物混入提交。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 服务命令:`npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
- 运行载体:`tmux` 会话 `revoxelseg-dicom`。
|
||||
- 端口验证:确认 `4000` 端口由当前 Node 服务监听。
|
||||
- 页面验证:确认局域网首页可通过 HTTP 访问。
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- 使用 `git status --short` 确认只暂存本次工程分析文档和经验记录。
|
||||
- commit message 包含 `2026-06-04-09-46-36`。
|
||||
- 避免提交 `WebSite/dist/`、医学数据、导出文件和无关工作区变化。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- 如果构建失败,应停止部署并汇报具体错误。
|
||||
- 如果服务启动后本机成功但局域网失败,应继续检查网络访问链路。
|
||||
- 如果 `tmux` 会话中存在旧命令残留,应清屏或重新启动,确保会话内运行的是本次部署命令。
|
||||
65
工程分析/测试方案-2026-06-23-12-07-35.md
Normal file
65
工程分析/测试方案-2026-06-23-12-07-35.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 测试方案-2026-06-23-12-07-35
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-06-23-12-07-35.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 检查 `WebSite/package.json` 中存在 `build` 与 `serve` 脚本。
|
||||
- 检查 `WebSite/server.ts` 会解析 `--host`、`--port` 参数并提供 `/api/health`。
|
||||
- 检查本机是否包含目标网卡地址 `192.168.3.11`。
|
||||
- 检查当前 Git 工作区,避免提交无关变更。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 在 `WebSite/` 执行 `npm run build`。
|
||||
- 如果构建失败,停止部署并记录错误。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 验证首页 HTML 可正常返回,确认前端入口可访问。
|
||||
- 验证 `/api/health` 返回健康状态,确认 Express API 可用。
|
||||
- 可选验证 `/api/projects` 返回项目列表,确认默认项目状态可读。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 本次仅重新部署,不修改 DICOM/STL 解析、融合、分割映射或 Mask 导出逻辑。
|
||||
- 检查默认演示数据目录存在并记录数量,不生成新的医学导出文件。
|
||||
- 避免将 `Head_CT_DICOM/`、`Head_CT_ReConstruct/`、`WebSite/data/`、`WebSite/exports/` 混入 Git 提交。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 服务命令:`npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
- 运行载体:`tmux` 会话 `revoxelseg-dicom`。
|
||||
- 端口验证:确认 `4000` 端口由当前 Node 服务监听。
|
||||
- 访问验证:
|
||||
- `http://127.0.0.1:4000/api/health`
|
||||
- `http://127.0.0.1:4000/`
|
||||
- `http://192.168.3.11:4000/api/health`
|
||||
- `http://192.168.3.11:4000/`
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- 只暂存本次工程分析三件套和 `工程分析/经验记录.md`。
|
||||
- 提交后用 `git show --stat --oneline HEAD` 验证 commit message 包含 `2026-06-23-12-07-35` 和部署简述。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- 如果 `tmux` 会话已存在,需确认旧服务关闭后新服务能稳定启动。
|
||||
- 如果局域网 IP 不属于当前机器,服务即使监听成功也无法通过目标地址访问。
|
||||
- 如果只验证 localhost,不能代表 `192.168.3.11` 可访问。
|
||||
- 如果误提交构建产物或医学数据,会污染备份提交,需要在提交前复查 staged 文件。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `npm run lint`:通过,`tsc --noEmit` 无错误。
|
||||
- `npm run build`:通过,Vite 成功生成 `dist/`;存在 chunk 大于 500 kB 的体积提示,不影响本次部署。
|
||||
- 本机网卡:已确认 `ens34` 包含 `192.168.3.11/24`。
|
||||
- 端口与会话:`tmux` 会话 `revoxelseg-dicom` 已创建,`4000` 端口由当前 Node 进程监听在 `0.0.0.0:4000`。
|
||||
- 启动命令:`NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
- `http://127.0.0.1:4000/api/health`:HTTP 200,返回 `ok: true`。
|
||||
- `http://127.0.0.1:4000/`:HTTP 200,返回 HTML。
|
||||
- `http://192.168.3.11:4000/api/health`:HTTP 200,返回 `ok: true`。
|
||||
- `http://192.168.3.11:4000/`:HTTP 200,返回 HTML。
|
||||
- `http://127.0.0.1:4000/api/projects`:HTTP 200,当前返回 2 个项目,默认项目 `head-ct-demo` 包含 300 张 DICOM 与 9 个 STL。
|
||||
70
工程分析/测试方案-2026-06-23-13-38-48.md
Normal file
70
工程分析/测试方案-2026-06-23-13-38-48.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# 测试方案-2026-06-23-13-38-48
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-06-23-13-38-48.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 执行 `npm run lint`,验证 TypeScript 类型。
|
||||
- 检查 `ModelPose` 前后端字段一致。
|
||||
- 检查旧位姿数据默认补齐 `scaleX/scaleY/scaleZ = 1`。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 在 `WebSite/` 执行 `npm run build`。
|
||||
- 确认 Vite 构建成功。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 逆向工作区仍能加载默认项目、DICOM/STL 和映射视图。
|
||||
- 现有等比例 `X拉伸/Y拉伸/Z拉伸` 行为保留。
|
||||
- 新增单轴拉伸在旋转 X/Y/Z 为 90 度整数倍时可用。
|
||||
- 单轴拉伸某轴后,对应 `平移 X/Y/Z` 自动为 `0`。
|
||||
- 调整统一 `缩放` 后,模型仍保留单轴拉伸比例。
|
||||
- 点击自动微调匹配时,传入的初始位姿包含单轴拉伸字段。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 不修改 DICOM/STL 原始数据。
|
||||
- 不改变 DICOM 切片索引、窗宽窗位和模型文件读取逻辑。
|
||||
- 验证分割映射和导出使用同一位姿变换,避免前端显示与后端结果不一致。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 使用 `tmux` 会话 `revoxelseg-dicom` 重启服务。
|
||||
- 验证:
|
||||
- `http://127.0.0.1:4000/api/health`
|
||||
- `http://127.0.0.1:4000/`
|
||||
- `http://192.168.3.11:4000/api/health`
|
||||
- `http://192.168.3.11:4000/`
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- 暂存本次源码和工程分析文档。
|
||||
- commit message 包含 `2026-06-23-13-38-48` 和 `新增逆向工作区单轴拉伸`。
|
||||
- 使用 `git show --stat --oneline HEAD` 核对提交范围。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- 三维模型、二维映射、导出和自动匹配必须使用同一变换顺序。
|
||||
- 自动匹配候选只改统一缩放和平移时,必须保留单轴拉伸字段不丢失。
|
||||
- 单轴拉伸控件不能挤压现有左侧融合视图标题栏。
|
||||
- 旧项目状态文件缺少新字段时必须正常加载。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `npm run lint`:通过,`tsc --noEmit` 无错误。
|
||||
- `npm run build`:通过,Vite 成功生成 `dist/`;仍有大 chunk 体积提示,不影响本次部署。
|
||||
- Playwright 桌面视口 `1440x900`:已登录并进入逆向工作区,点击 `X轴` 单轴拉伸按钮;页面包含 `X轴/Y轴/Z轴` 与 `轴拉伸 X` 控件;截图 `/tmp/revoxelseg-axis-stretch-desktop.png` 非背景像素约 513328。
|
||||
- Playwright 移动视口 `420x900`:已登录并进入逆向工作区,点击 `X轴` 单轴拉伸按钮;页面包含单轴拉伸控件;截图 `/tmp/revoxelseg-axis-stretch-mobile.png` 非背景像素约 263176。
|
||||
- Canvas 检查:桌面/移动视口的 DICOM 与映射 2D canvas 均有非空像素;WebGL canvas 截图可见,但 `readPixels` 返回 0,判断与 Three.js 默认绘图缓冲行为有关,因此以截图像素统计作为视觉验证补充。
|
||||
- 部署:已重启 `tmux` 会话 `revoxelseg-dicom`,运行 `NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
- 端口:`4000` 由当前 Node 进程监听在 `0.0.0.0:4000`。
|
||||
- `http://127.0.0.1:4000/api/health`:HTTP 200,返回 `ok: true`。
|
||||
- `http://127.0.0.1:4000/`:HTTP 200,返回 HTML。
|
||||
- `http://192.168.3.11:4000/api/health`:HTTP 200,返回 `ok: true`。
|
||||
- `http://192.168.3.11:4000/`:HTTP 200,返回 HTML。
|
||||
- `http://127.0.0.1:4000/api/projects`:HTTP 200,返回项目位姿已自动补齐 `scaleX/scaleY/scaleZ`,旧项目数据兼容。
|
||||
- Git 备份:本地 commit `5a9d09a` 已完成,commit message 为 `2026-06-23-13-38-48 新增逆向工作区单轴拉伸`。
|
||||
- Gitea 推送:首次执行 `git push origin main` 因无法读取用户名凭据失败;用户提供 Gitea 凭据后已使用临时 `GIT_ASKPASS` 重试成功,`origin/main` 已同步到 `5a9d09a`。
|
||||
144
工程分析/经验记录.md
144
工程分析/经验记录.md
@@ -1905,3 +1905,147 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
部署验证不能只看 API 健康检查,还要同时验证首页 HTML,因为 Vite host 检查、静态资源路径和反代规则可能只影响页面入口。只要继续用 Vite middleware 方式提供公网访问,就要把公网域名写入 `allowedHosts`;如果后续改为 `NODE_ENV=production` 纯静态部署,也需要单独验证静态资源和 API 是否都通过同一反代链路。
|
||||
|
||||
## 2026-05-25-12-30-27 软著代码汇总应选完整连续源码文件
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户要求整理 `3. 代码汇总.docx`,原文档不能继续保留用三点字符表示删减的代码摘录;软著材料需要无省略、连续、逻辑完整的源程序内容,约 10000 行即可,不需要放入项目全部代码。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
软著源程序材料关注的是完整连续的代码页。若为了节省篇幅在函数体、类定义或模块中间放置省略占位,即使整体看起来像代码汇总,也会被判断为摘录而不是完整源程序。项目中的 TypeScript/JavaScript 真实源码也可能包含扩展运算符等合法三点语法,需要和删减占位区分检查。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
本次直接重写 `3. 代码汇总.docx`,选择 `WebSite/server.ts`、`WebSite/src/components/ReverseWorkspace.tsx`、`WebSite/src/components/ProjectLibrary.tsx` 三个完整源码文件,合计 10835 行,从每个文件第 1 行连续写入到末行。生成后用 `python-docx` 重新打开文档,逐行比对三个文件内容,确认连续匹配;同时检查独立省略占位数量为 0,并用 `unzip -t` 确认 docx 包结构正常。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
后续整理软著代码材料时,优先选择若干完整源码文件或连续页码区间,不要在函数体、类型定义、类定义和模块内部人工删减。校验时既要统计收录行数和文件连续性,也要专门检查独立省略占位;若源码语言自身有合法三点语法,应在材料说明或审核沟通中明确其不是省略占位。
|
||||
|
||||
## 2026-05-25-12-45-33 本地交付 docx 不应进入 Gitea 备份提交
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户明确要求不要将 `3. 代码汇总.docx` 做 Gitea commit,也不要推送该文件。此前本地 commit 尚未推送成功,但已经把 docx 纳入了本地待推送历史。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
工作流默认要求对本次文档做 Git/Gitea 备份提交,而 `3. 代码汇总.docx` 是给用户本地交付的软著材料,不一定适合进入代码仓库。若只是不再推送而不修正未推送 commit,后续认证恢复后执行 `git push` 仍可能把该 docx 传到远程。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
在远程推送失败且 commit 尚未进入 Gitea 的前提下,使用取消索引跟踪和 amend 的方式修正最后一个本地 commit:保留工作区里的 `3. 代码汇总.docx`,但从 Git 历史中移除。同步在 `.gitignore` 加入该文件名,避免后续 `git add .` 再次误提交。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
后续交付软著、合同、截图、压缩包等本地材料前,应先确认是否允许入库;如果用户要求不要提交或推送,应加入 `.gitignore` 或只保留在工作区。对已经生成但未推送的误提交,应优先修正未推送 commit,避免把敏感或本地交付材料带到 Gitea。
|
||||
|
||||
## 2026-05-25-12-48-00 代码汇总格式应避免额外行号和文件标题
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户要求 `3. 代码汇总.docx` 不需要每行最前方的行号编号,也不需要 `文件:XXX` 形式的文件标题。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
上一版为了便于人工核对源码连续性,在 docx 中给每行增加了行号,并给每个完整源码文件增加了文件标题。该格式虽然便于审阅,但不符合用户希望直接呈现纯源码内容的材料样式。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
重新生成本地 `3. 代码汇总.docx`,保留此前三个完整源码文件的连续内容,但写入段落时不再添加行号前缀,也不再插入 `文件:XXX` 标题。文件之间仅保留空行分隔。生成后用 `python-docx` 校验正文与源码逐行一致,`文件:` 文案数量为 0,行号前缀匹配数量为 0。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
整理软著代码汇总时,先确认用户要的是“带核对信息的审阅版”还是“纯源码版”。若目标是提交材料,默认减少额外说明、文件标题和行号,只保留源码文本;用于内部核对的行号应放在临时检查结果里,不直接写入最终 docx。
|
||||
|
||||
## 2026-05-25-13-42-13 自动匹配参数上限要前后端同步
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户要求自动微调匹配工作区“迭代轮次”最高变为 50 次。页面旧输入框最大值为 12,后端 `normalizeAutoMatchIterations` 也会把请求轮次截断到 12。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
自动匹配的轮次上限同时存在于前端输入控件和后端请求归一化中。若只修改其中一侧,会出现界面可以输入但接口不执行,或接口支持但页面无法选择的状态。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
将 `AutoMatchWorkspace.tsx` 中迭代轮次输入框的 `max` 和 `onChange` clamp 上限改为 50;同步将 `server.ts` 中 `normalizeAutoMatchIterations` 的服务端 clamp 上限改为 50。默认值仍保留 6,避免进入页面后默认运行变慢。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
后续调整自动匹配参数上限时,要同时检查前端控件、API 类型、服务端归一化、默认值和运行耗时提示。对可能显著增加耗时的参数,只提高上限不改变默认值;若用户常用高上限,应补充进度、取消和超时保护。
|
||||
|
||||
## 2026-05-25-14-00-24 项目导出包不应使用会暴露 PAX 头的 tar.gz
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户在 Windows 解压“导出项目及结果”压缩包时,只能看到 `entries`、`PaxHeaders` 和无扩展名编号文件,无法直接看到 JSON、分割类别映射和 `.nii.gz` 分割结果。同时分类分割导出的文件名带编号和 `-label` 后缀,不利于后续按类别处理。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
旧导出包使用手写 tar.gz。tar 路径一旦包含中文项目名或较长路径,服务端会写入 PAX 扩展头;部分 Windows 解压工具会把 PAX 扩展头当成普通目录展示,并把真实文件显示成 `entries/000001` 之类的中转名。压缩包内部还额外套了项目名根目录,而下载文件名本身已经包含项目名。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
将“项目及结果导出”改为 ZIP,包内不再套项目名根目录,固定输出 `manifest.json`、`pose/pose.json`、`segmentation/labels.json`、`segmentation/label.nii.gz` 或 `segmentation-parts/{类别名}.nii.gz`、`dicom/image.nii.gz`、`stl/{原文件名}`。前端下载改为 XHR blob 下载,接入顶部导出进度条。新增 `POST /api/reverse-pipeline`,支持上传 DICOM/STL、应用旋转/平移/缩放/镜像/轴向自动拉伸、可选自动匹配、可选导出、可选记录项目库和锁定。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
面向 Windows 用户直接查看的复杂导出包优先使用 ZIP,不要用需要 PAX 扩展的 tar.gz;如果必须使用 tar.gz,必须避免非 ASCII 和长路径,或确认目标解压器正确隐藏 PAX。导出包内部结构应以机器可读和人工可读为准,分类分割文件名保持类别名,映射关系单独放入 `labels.json`。
|
||||
|
||||
## 2026-06-04-09-46-36 局域网部署要同时确认网卡地址和监听地址
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户要求通过 `http://192.168.3.11:4000/` 访问程序。本次部署前没有现存 `revoxelseg-dicom` tmux 会话,`4000` 端口也未被占用,需要重新创建长期运行会话并确保服务对局域网开放。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
局域网访问不仅依赖服务启动成功,还依赖本机实际存在目标网卡地址、Node 服务监听 `0.0.0.0` 而不是只监听 `127.0.0.1`。如果只验证本机 localhost,可能遗漏局域网地址不可达的问题。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
先确认本机网卡包含 `192.168.3.11`,再执行 `npm run build`。构建通过后创建 `tmux` 会话 `revoxelseg-dicom`,在 `WebSite` 中启动 `npm run serve -- --host 0.0.0.0 --port 4000`。随后同时验证 `127.0.0.1` 和 `192.168.3.11` 的 `/api/health` 与首页,确认均返回正常响应。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
后续做局域网部署时,部署验证必须同时覆盖三点:目标 IP 是否属于当前机器、端口是否监听在 `0.0.0.0`、目标 IP 的 API 和首页是否都能访问。若没有既有 tmux 会话,可直接创建同名会话;若端口被占用,则先记录占用来源再处理。
|
||||
|
||||
## 2026-06-23-12-07-35 无源码变更部署仍要记录实际运行命令
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户要求阅读整个工作区并部署到 `http://192.168.3.11:4000/`。本次没有业务源码变更,但仍需要完成构建、长期运行托管、局域网访问验证和文档备份提交。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
部署类需求会改变运行态服务,即使不改源码也可能受端口占用、`tmux` 会话残留、网卡地址、生产/开发服务模式和前端入口访问影响。如果只记录“服务已启动”,后续排查时无法确认实际运行命令和验证覆盖面。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
按工作流创建本次需求分析、实现方案和测试方案。执行前再次阅读部署相关经验,确认 `192.168.3.11` 属于本机网卡且 `4000` 端口空闲。通过 `npm run lint`、`npm run build` 后,在 `tmux` 会话 `revoxelseg-dicom` 中使用 `NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 4000` 启动服务,并分别验证 `127.0.0.1` 与 `192.168.3.11` 的 `/api/health` 和首页均返回 HTTP 200。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
后续无源码变更的重新部署也要记录实际启动命令、运行载体、端口监听地址、目标 IP 归属以及 API/首页双验证结果。若使用 `npm run build` 后部署,优先记录是否以 `NODE_ENV=production` 服务 `dist/`,避免开发中间件 Host 校验或源码热更新状态影响访问判断。
|
||||
|
||||
## 2026-06-23-13-38-48 单轴拉伸位姿字段必须统一变换顺序
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户要求在逆向工作区新增直接沿某一个轴拉伸模型的方式,并要求后续缩放和自动微调匹配都使用拉伸后的模型。旧位姿只有统一 `scale`,现有 X/Y/Z 拉伸也都是等比例缩放,无法表达非等比例的单轴变形。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
模型位姿会同时进入三维融合视图、二维逆向分割映射、项目库回放、自动匹配评分和服务端导出。如果只在某个前端按钮里临时改变显示比例,保存、导出或自动匹配会丢失该变形;如果前端和后端的变换顺序不同,又会出现页面位置和导出 Label Map 不一致。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
为 `ModelPose` / `ModelPoseValue` 增加 `scaleX`、`scaleY`、`scaleZ`,默认补齐为 `1`。统一采用“中心化后统一缩放与镜像、旋转、融合坐标系单轴拉伸、平移”的变换顺序。逆向工作区保留等比例 `X拉伸/Y拉伸/Z拉伸`,新增 `X轴/Y轴/Z轴` 单轴拉伸;单轴拉伸时自动把对应 `translateX/Y/Z` 置为 `0`。前端三维、二维映射、项目库预览和后端 `transformPointForExportPose` 使用同一套字段,自动匹配继续只优化平移与统一缩放,但保留并应用单轴拉伸后的基准模型。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
后续新增位姿字段时,必须同步检查类型、默认值、状态归一化、导入/保存、三维渲染层级、二维映射、服务端导出坐标变换、自动匹配评分和项目库回放。涉及非等比例变换时要先明确是在模型局部轴还是融合/DICOM 坐标轴上生效,并在前后端保持完全一致。
|
||||
|
||||
47
工程分析/需求分析-2026-05-25-12-30-27.md
Normal file
47
工程分析/需求分析-2026-05-25-12-30-27.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 需求分析-2026-05-25-12-30-27
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-05-25-12-30-27
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户要求修改 `3. 代码汇总.docx`:不需要收录所有代码,选取约 10000 行左右即可;文档中的代码不能出现大量 `...` 省略占位,必须整理为无省略、连续、逻辑完整的源程序内容,函数体和类定义不能被 `...` 截断。
|
||||
|
||||
## 业务目标
|
||||
|
||||
生成符合软著源程序材料要求的代码汇总文档,使用项目真实源码的完整连续内容替换原有删减摘录,降低因省略号占位导致材料不合规的风险。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:现有 `3. 代码汇总.docx`、项目源码、既有工程分析文档。
|
||||
- 输出:更新后的 `3. 代码汇总.docx`,本次需求分析、实现方案、测试方案,以及追加后的经验记录和备份 commit。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 主要影响 `3. 代码汇总.docx` 文档内容。
|
||||
- 新增 `工程分析/需求分析-2026-05-25-12-30-27.md`、`工程分析/实现方案-2026-05-25-12-30-27.md`、`工程分析/测试方案-2026-05-25-12-30-27.md`。
|
||||
- 完成后追加 `工程分析/经验记录.md`。
|
||||
- 不修改 WebSite 业务源码。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 必须先遵守 `工程分析/代码编纂工作流.md`。
|
||||
- 本次时间戳统一使用 `2026-05-25-12-30-27`。
|
||||
- 选取约 10000 行真实源码,且不使用 `...` 作为省略占位。
|
||||
- 尽量选完整文件,保证函数体、类型定义和模块逻辑连续完整。
|
||||
- Git 提交只包含本次相关文档和明确修改文件,避免混入医学数据、运行态导出或无关改动。
|
||||
- 修改完成后需执行构建、重新部署并验证服务。
|
||||
|
||||
## 风险点
|
||||
|
||||
- docx 是二进制文档,修改后需要确认能被 Word/LibreOffice 正常解析。
|
||||
- TypeScript/React 源码本身可能存在合法的扩展运算符 `...`,需要区分“省略占位”和真实代码语法;若最终材料希望完全不出现三个点字符,应优先选取或转换不含该字符的代码来源。
|
||||
- 文档行数过多会增大 docx 体积,但约 10000 行仍在可接受范围。
|
||||
- 当前 `3. 代码汇总.docx` 在 Git 状态中为未跟踪文件,备份 commit 时需明确纳入。
|
||||
|
||||
## 待确认问题或默认假设
|
||||
|
||||
- 默认保留原文件名 `3. 代码汇总.docx`,直接覆盖其正文内容。
|
||||
- 默认将“约 10000 行”理解为可在 9000 至 11000 行附近浮动,优先保证完整文件和无省略占位。
|
||||
- 默认不额外改动项目功能源码。
|
||||
42
工程分析/需求分析-2026-05-25-12-45-33.md
Normal file
42
工程分析/需求分析-2026-05-25-12-45-33.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 需求分析-2026-05-25-12-45-33
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-05-25-12-45-33
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户明确要求不要将 `3. 代码汇总.docx` 做 Gitea commit,也不要推送该文件。
|
||||
|
||||
## 业务目标
|
||||
|
||||
保证已生成的 `3. 代码汇总.docx` 保留在本地工作区供用户使用,但不再包含在本地备份 commit 中,也不会被推送到 Gitea 远程仓库。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:用户最新指令、当前 Git 历史和工作区状态。
|
||||
- 输出:移除上一次本地 commit 中的 `3. 代码汇总.docx`;必要时加入忽略规则防止误提交;保留本地 docx 文件。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- Git 历史:上一次尚未推送的本地 commit 需要修正。
|
||||
- 工作区:`3. 代码汇总.docx` 应保留为本地文件,不进入暂存区。
|
||||
- 工程分析:新增本次三件套并追加经验记录。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 用户明确要求不提交、不推送 `3. 代码汇总.docx`。
|
||||
- 远程推送此前已失败,当前只需确保后续不把该 docx 推送出去。
|
||||
- 不删除用户需要的本地 docx 文件。
|
||||
- 不混入医学数据、运行态文件或无关改动。
|
||||
|
||||
## 风险点
|
||||
|
||||
- 如果只在新提交中删除 docx,远程将来仍可能收到历史提交中的 docx;因此需要修正未推送的上一个本地 commit。
|
||||
- 若不加入忽略规则,后续 `git add .` 可能再次误加该 docx。
|
||||
- 修改 Git 历史只应作用于当前未推送且由本轮生成的最后一个 commit。
|
||||
|
||||
## 待确认问题或默认假设
|
||||
|
||||
- 默认用户说的“不要 Gitea commit 和推送”包含不要把该 docx 保存在本地待推送 commit 历史中。
|
||||
- 默认允许修正尚未推送的最后一个本地 commit,以彻底移除该 docx。
|
||||
42
工程分析/需求分析-2026-05-25-12-48-00.md
Normal file
42
工程分析/需求分析-2026-05-25-12-48-00.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 需求分析-2026-05-25-12-48-00
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-05-25-12-48-00
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户要求修改本地 `3. 代码汇总.docx`,去掉代码行最前方的行号编号,例如 ` 1 `,并去掉 `文件:XXX` 这类文件标题。
|
||||
|
||||
## 业务目标
|
||||
|
||||
让软著代码汇总文档正文更接近纯源码展示,不出现额外行号前缀和文件路径标题,同时继续保持代码内容真实、连续、无省略占位。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:当前本地 `3. 代码汇总.docx`、项目源码文件。
|
||||
- 输出:更新后的本地 `3. 代码汇总.docx`,其中源码行不带行号,不包含 `文件:XXX` 标题。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 仅修改本地忽略文件 `3. 代码汇总.docx`。
|
||||
- 新增本次工程分析三件套,追加 `工程分析/经验记录.md`。
|
||||
- 不修改业务源码。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- `3. 代码汇总.docx` 已按用户要求加入 `.gitignore`,本次仍不得提交或推送该 docx。
|
||||
- 继续使用完整连续源码内容,不使用 `...` 作为省略占位。
|
||||
- 保持约 10000 行左右源码内容。
|
||||
- 不触碰医学数据和运行态导出。
|
||||
|
||||
## 风险点
|
||||
|
||||
- 去掉文件标题后,多个源码文件之间缺少文字边界;默认用空行分隔,避免出现 `文件:XXX`。
|
||||
- 去掉行号后需要重新校验文档内容与源码逐行一致。
|
||||
- docx 是二进制本地交付文件,应确认生成后可正常打开。
|
||||
|
||||
## 待确认问题或默认假设
|
||||
|
||||
- 默认允许保留文档标题 `代码汇总`,但不保留 `文件:XXX` 或源码行号。
|
||||
- 默认继续使用此前选定的三个完整源码文件。
|
||||
43
工程分析/需求分析-2026-05-25-13-42-13.md
Normal file
43
工程分析/需求分析-2026-05-25-13-42-13.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 需求分析-2026-05-25-13-42-13
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-05-25-13-42-13
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户要求将自动微调匹配工作区中的“迭代轮次”最高值调整为 50 次。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 自动微调匹配页允许用户输入最多 50 次迭代。
|
||||
- 后端自动匹配接口也接受并执行最多 50 次迭代,避免前后端上限不一致。
|
||||
- 保持默认轮次不变,避免无意增加普通运行耗时。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:自动微调匹配工作区的迭代轮次输入框、自动匹配 API 请求参数。
|
||||
- 输出:界面 `max` 和输入截断上限为 50,服务端 `iterations` 归一化上限为 50。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- 工程分析本次三件套和经验记录
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 必须执行工程分析工作流。
|
||||
- 不修改自动匹配默认轮次、候选数量、评分函数和位姿算法。
|
||||
- 修改后需要重新构建、部署并验证服务。
|
||||
|
||||
## 风险点
|
||||
|
||||
- 50 次迭代可能明显增加自动匹配耗时,需要保留用户手动选择。
|
||||
- 如果只改前端不改后端,接口仍会把 50 截为旧上限。
|
||||
- 如果只改后端不改前端,用户仍无法通过页面输入 50。
|
||||
|
||||
## 默认假设
|
||||
|
||||
- 用户只要求提升最大上限,不要求默认值从 6 改为 50。
|
||||
- 每轮候选数量上限仍保持当前 80,不在本次调整范围内。
|
||||
54
工程分析/需求分析-2026-05-25-14-00-24.md
Normal file
54
工程分析/需求分析-2026-05-25-14-00-24.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 需求分析-2026-05-25-14-00-24
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-05-25-14-00-24
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户反馈“导出项目及结果”的压缩包内容异常,只能看到 `entries`、`PaxHeaders` 和无扩展名编号文件,缺少可直接识别的 JSON、分割类别映射和 `.nii.gz` 分割结果。用户希望压缩包内不再套项目名目录,内容格式统一;分类导出的 `.nii.gz` 直接使用类别名作为文件名。同时,点击导出后需要在页面最上方显示不影响其他操作的导出进度条。最后需要提供一个可调用 API:给定 DICOM 和 STL,能够指定 X/Y/Z 轴拉伸、旋转、平移、缩放、镜像、是否自动配准、导出内容、是否记录项目库、是否上锁。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 让项目导出包在 Windows 常见解压工具中可直接识别文件结构,不出现 PAX 头目录。
|
||||
- 让导出包内的分割结果、类别映射、位姿和模型文件按稳定目录命名,便于后续处理脚本读取。
|
||||
- 让用户点击导出后能获得明确进度反馈,同时不阻塞逆向工作区其他功能。
|
||||
- 为外部系统或脚本提供一条“上传 DICOM/STL -> 应用位姿/自动配准 -> 可选记录/锁定 -> 可选导出”的自动化接口。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:
|
||||
- 当前项目库中的 DICOM、STL、构件可见性、构件 ID、位姿和导出选项。
|
||||
- 批处理 API 的 multipart 文件或 JSON 资产、位姿参数、自动匹配参数、导出目标、项目记录与锁定开关。
|
||||
- 输出:
|
||||
- ZIP 格式项目导出包。
|
||||
- 顶部导出进度条。
|
||||
- 新增批处理 API 的 JSON 响应或 ZIP 下载响应。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `WebSite/server.ts`:项目导出包生成、批处理 API、导出响应类型与文件名。
|
||||
- `WebSite/src/lib/api.ts`:项目导出包下载进度回调。
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`:顶部导出进度条和导出调用。
|
||||
- `工程分析/经验记录.md`:完成后追加本次经验。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 继续保持单独 DICOM 原始影像下载接口兼容,不主动改动其 tar.gz 行为。
|
||||
- 项目导出包内不再以项目名作为根目录,因为下载文件名已经包含项目标识。
|
||||
- 分类分割导出时,`.nii.gz` 文件名优先使用构件类别名。
|
||||
- API 默认不导出任何内容,避免未明确指定时产生大文件下载。
|
||||
- 当前系统的位姿模型为统一缩放;X/Y/Z 轴拉伸沿用现有工作区语义,即选择轴向后计算统一缩放以适配 DICOM 尺寸,而不是引入三轴非等比例缩放字段。
|
||||
|
||||
## 风险点
|
||||
|
||||
- ZIP 替代 tar.gz 会改变导出包扩展名和 MIME 类型,需要前端下载逻辑正确读取服务端文件名。
|
||||
- 导出进度条如果只依赖响应下载进度,在服务端生成阶段可能没有真实百分比,需要结合平滑进度和下载事件。
|
||||
- 批处理 API 上传文件可能较大,需要复用现有导入解包逻辑并在临时项目不入库时清理文件。
|
||||
- 自动配准可能因未指定骨骼构件或 DICOM/STL 不完整而失败,应返回可读错误。
|
||||
|
||||
## 默认假设
|
||||
|
||||
- 用户当前要修复的是“导出项目及结果”按钮生成的压缩包,不包括“下载原始 DICOM 归档”。
|
||||
- 外部调用 API 可接受 multipart/form-data,并可通过 `metadata` JSON 字段传入复杂参数。
|
||||
- 当 `recordProject=false` 且 `lock=true` 时,锁定没有项目库持久对象可附着,因此仅在 `recordProject=true` 或 `lock=true` 时保留项目记录。
|
||||
50
工程分析/需求分析-2026-06-04-09-46-36.md
Normal file
50
工程分析/需求分析-2026-06-04-09-46-36.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 需求分析-2026-06-04-09-46-36
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-06-04-09-46-36
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户要求部署当前 ReVoxelSeg DICOM 程序,并希望通过 `http://192.168.3.11:4000/` 访问。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 使用项目约定的 `WebSite` 一体服务完成重新部署。
|
||||
- 服务监听 `0.0.0.0:4000`,确保局域网地址 `192.168.3.11:4000` 可访问。
|
||||
- 完成构建、健康检查、首页访问和部署状态验证。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:当前工作区代码、`WebSite/package.json` 部署脚本、已有 `tmux` 会话和本机网络环境。
|
||||
- 输出:运行在 `4000` 端口的 WebSite 服务、可访问的局域网地址、部署验证结果和文档备份 commit。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 运行态影响:`WebSite/dist/` 构建产物、`tmux` 会话 `revoxelseg-dicom` 中的 Node 服务。
|
||||
- 文档影响:新增本次需求分析、实现方案、测试方案,并追加经验记录。
|
||||
- 不涉及源码逻辑、医学影像数据、STL 数据和导出算法变更。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 必须执行 `工程分析/代码编纂工作流.md`。
|
||||
- 部署优先使用:
|
||||
- `cd WebSite`
|
||||
- `npm run build`
|
||||
- `npm run serve -- --host 0.0.0.0 --port 4000`
|
||||
- 长期运行优先沿用 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- 部署后至少验证 `/api/health` 和首页。
|
||||
- 文档备份 commit message 必须包含 `2026-06-04-09-46-36`。
|
||||
|
||||
## 风险点
|
||||
|
||||
- `4000` 端口可能已有旧服务或其他进程占用,需要先识别并重启到当前版本。
|
||||
- 若构建失败,不能直接复用旧服务冒充新部署。
|
||||
- 若本机可访问但局域网地址不可访问,可能是监听地址、防火墙或网络链路问题。
|
||||
- 构建产物和运行态文件不应混入文档备份 commit。
|
||||
|
||||
## 待确认问题或默认假设
|
||||
|
||||
- 默认当前工作区代码就是用户希望部署的版本。
|
||||
- 默认无需修改源码,只做构建、启动、验证和工程分析文档备份。
|
||||
- 默认使用本机实际网卡地址 `192.168.3.11` 作为局域网访问地址。
|
||||
53
工程分析/需求分析-2026-06-23-12-07-35.md
Normal file
53
工程分析/需求分析-2026-06-23-12-07-35.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 需求分析-2026-06-23-12-07-35
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-06-23-12-07-35
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户要求阅读整个工作区,并部署当前程序,使其可以通过 `http://192.168.3.11:4000/` 被访问。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 确认当前仓库结构、部署约束、运行脚本和历史经验。
|
||||
- 在不修改业务源码的前提下完成一次当前版本构建。
|
||||
- 使用长期运行方式启动前后端一体服务。
|
||||
- 确认本机与局域网目标地址均可访问。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:当前工作区 `/home/wkmgc/Desktop/ReVoxelSeg_DICOM`、`WebSite/package.json` 脚本、`WebSite/server.ts` 服务入口、DICOM/STL 默认演示数据、已有 `tmux` 和端口状态。
|
||||
- 输出:运行在 `0.0.0.0:4000` 的 ReVoxelSeg DICOM 服务、验证通过的 `http://127.0.0.1:4000/` 与 `http://192.168.3.11:4000/` 访问结果、本次工程分析文档和备份提交。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 运行态:`WebSite/dist/` 构建产物、`tmux` 会话 `revoxelseg-dicom`、`4000` 端口监听进程。
|
||||
- 文档:新增本次 `需求分析`、`实现方案`、`测试方案`,并在完成后追加 `工程分析/经验记录.md`。
|
||||
- 不修改业务源码、医学影像数据、STL 模型数据、运行态状态和导出结果。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 必须先执行 `工程分析/代码编纂工作流.md`。
|
||||
- 必须阅读 `工程分析/工程整体分析.md` 和 `工程分析/经验记录.md`。
|
||||
- 最终执行方案前需再次确认已读 `工程分析/经验记录.md`。
|
||||
- 部署优先使用:
|
||||
- `cd WebSite`
|
||||
- `npm run build`
|
||||
- `npm run serve -- --host 0.0.0.0 --port 4000`
|
||||
- 长期运行优先沿用 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- Git/Gitea 备份 commit message 必须包含时间戳和简要描述。
|
||||
|
||||
## 风险点
|
||||
|
||||
- `4000` 端口可能被旧服务或 Docker 容器占用。
|
||||
- `tmux` 会话中可能残留旧命令,需要确认实际启动的是当前构建版本。
|
||||
- 目标 IP `192.168.3.11` 必须属于当前机器网卡,否则局域网地址无法访问。
|
||||
- 如果只验证 `/api/health`,可能遗漏首页 HTML 或前端资源访问异常。
|
||||
- 不能将医学数据、运行态产物或无关工作区变更混入备份提交。
|
||||
|
||||
## 待确认问题或默认假设
|
||||
|
||||
- 默认假设当前工作区代码即为用户希望部署的版本。
|
||||
- 默认假设用户要求的是本机 `tmux` 方式部署,而不是 Docker/FRPC 公网部署。
|
||||
- 默认假设本次仅做部署、验证和工程分析文档维护,不做功能代码变更。
|
||||
55
工程分析/需求分析-2026-06-23-13-38-48.md
Normal file
55
工程分析/需求分析-2026-06-23-13-38-48.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 需求分析-2026-06-23-13-38-48
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-06-23-13-38-48
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户指出“逆向工作区”现有 `X拉伸`、`Y拉伸`、`Z拉伸` 都是等比例拉伸,希望新增一种可直接沿某一个轴拉伸模型的方式。该方式仍要求旋转 X/Y/Z 为 0 或 90 度整数倍,并且拉伸某轴时对应的平移 X/Y/Z 为 0;后续缩放、自动微调匹配工作区都应使用新拉伸后的模型。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 在保留现有等比例自动拉伸的基础上,新增单轴非等比例拉伸能力。
|
||||
- 单轴拉伸后的模型位姿要成为统一数据源,后续缩放、保存、导出、逆向分割映射和自动微调匹配都沿用该位姿。
|
||||
- 保持旧项目和旧位姿数据兼容,旧数据没有单轴拉伸字段时按 `1` 处理。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:当前 `WebSite/src/components/ReverseWorkspace.tsx` 的拉伸交互、`ModelPose` 类型、`server.ts` 位姿归一化/导出/自动匹配逻辑、项目库预览逻辑。
|
||||
- 输出:新增轴向拉伸系数、逆向工作区单轴拉伸按钮和状态反馈、前后端统一识别并应用单轴拉伸后的位姿。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 前端类型与组件:
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- 后端:
|
||||
- `WebSite/server.ts`
|
||||
- 工程文档:
|
||||
- 本次需求分析、实现方案、测试方案
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 必须按 `工程分析/代码编纂工作流.md` 执行。
|
||||
- 单轴拉伸启用条件:
|
||||
- 旋转 X/Y/Z 均为 0 或 90 度整数倍。
|
||||
- 拉伸 X/Y/Z 时,对应平移 X/Y/Z 为 0。
|
||||
- 新增字段必须向后兼容,不破坏已有项目状态、位姿 JSON、自动匹配结果和导出包。
|
||||
- 自动匹配可继续只微调平移与统一缩放,但评分和结果展示必须基于单轴拉伸后的模型。
|
||||
|
||||
## 风险点
|
||||
|
||||
- 前端三维视图和二维映射如果使用不同变换顺序,会导致所见与导出/自动匹配不一致。
|
||||
- 后端导出和自动匹配若未同步新增轴向拉伸字段,会出现页面正确但结果错误。
|
||||
- 旧保存位姿缺少新字段时,如果归一化不完整,可能导致 `NaN` 或类型检查失败。
|
||||
- 单轴拉伸按钮过多可能挤压现有工具栏,需要保持紧凑可读。
|
||||
|
||||
## 待确认问题或默认假设
|
||||
|
||||
- 默认将新增单轴拉伸理解为 DICOM/融合视图坐标系下的 X/Y/Z 非等比例拉伸。
|
||||
- 默认保留现有 `X拉伸/Y拉伸/Z拉伸` 等比例行为,并新增另一组 `X轴/Y轴/Z轴` 单轴拉伸按钮。
|
||||
- 默认单轴拉伸会把对应平移自动归零,而不是仅提示用户手动归零。
|
||||
Reference in New Issue
Block a user