2026-05-24-22-40-13 新增自动微调匹配工作区
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
- 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`。
|
- 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`。
|
||||||
- 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。
|
- 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。
|
||||||
- 逆向分割映射视图按当前可见构件加载高精度 STL 预览,并始终用全部 STL 边界保持统一模型坐标系;实体模式最高使用 80 万三角面预览,“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。
|
- 逆向分割映射视图按当前可见构件加载高精度 STL 预览,并始终用全部 STL 边界保持统一模型坐标系;实体模式最高使用 80 万三角面预览,“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。
|
||||||
|
- 新增“自动微调匹配工作区”,可从逆向工作区当前位姿进入;默认仅微调平移 X/Y/Z 与缩放,旋转锁定,使用所选骨骼 STL 与 DICOM HU 骨窗进行候选迭代评分,并可将最佳位姿写回项目库。
|
||||||
|
|
||||||
## 一、本机部署
|
## 一、本机部署
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type SegmentationExportScope = 'all' | 'visible';
|
|||||||
type SegmentationExportMode = 'combined' | 'separate';
|
type SegmentationExportMode = 'combined' | 'separate';
|
||||||
type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||||
type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
|
type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
|
||||||
|
type AutoMatchParameterKey = 'translateX' | 'translateY' | 'translateZ' | 'scale';
|
||||||
|
|
||||||
interface ModuleStyleRecord {
|
interface ModuleStyleRecord {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -43,6 +44,33 @@ interface ModelPoseRecord {
|
|||||||
pose: ModelPoseValue;
|
pose: ModelPoseValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AutoMatchParameterSelection {
|
||||||
|
translateX: boolean;
|
||||||
|
translateY: boolean;
|
||||||
|
translateZ: boolean;
|
||||||
|
scale: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoMatchWeights {
|
||||||
|
boneReward: number;
|
||||||
|
missPenalty: number;
|
||||||
|
movementPenalty: number;
|
||||||
|
scalePenalty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoMatchCandidateRecord {
|
||||||
|
iteration: number;
|
||||||
|
mode: string;
|
||||||
|
pose: ModelPoseValue;
|
||||||
|
score: number;
|
||||||
|
boneReward: number;
|
||||||
|
missPenalty: number;
|
||||||
|
movementPenalty: number;
|
||||||
|
scalePenalty: number;
|
||||||
|
contributors: number;
|
||||||
|
changed: AutoMatchParameterKey[];
|
||||||
|
}
|
||||||
|
|
||||||
interface SegmentationResultRecord {
|
interface SegmentationResultRecord {
|
||||||
id: string;
|
id: string;
|
||||||
schemaVersion?: number;
|
schemaVersion?: number;
|
||||||
@@ -465,7 +493,7 @@ function normalizeModelPoseValue(value: Partial<ModelPoseValue> | undefined): Mo
|
|||||||
translateX: read('translateX', defaultModelPose.translateX, -2, 2),
|
translateX: read('translateX', defaultModelPose.translateX, -2, 2),
|
||||||
translateY: read('translateY', defaultModelPose.translateY, -2, 2),
|
translateY: read('translateY', defaultModelPose.translateY, -2, 2),
|
||||||
translateZ: read('translateZ', defaultModelPose.translateZ, -2, 2),
|
translateZ: read('translateZ', defaultModelPose.translateZ, -2, 2),
|
||||||
scale: read('scale', defaultModelPose.scale, 0.5, 2),
|
scale: read('scale', defaultModelPose.scale, 0.5, 3),
|
||||||
flipX: readBoolean('flipX', defaultModelPose.flipX),
|
flipX: readBoolean('flipX', defaultModelPose.flipX),
|
||||||
flipY: readBoolean('flipY', defaultModelPose.flipY),
|
flipY: readBoolean('flipY', defaultModelPose.flipY),
|
||||||
flipZ: readBoolean('flipZ', defaultModelPose.flipZ),
|
flipZ: readBoolean('flipZ', defaultModelPose.flipZ),
|
||||||
@@ -996,6 +1024,383 @@ function transformPointForExportPose(x: number, y: number, z: number, metrics: E
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultAutoMatchWeights: AutoMatchWeights = {
|
||||||
|
boneReward: 1,
|
||||||
|
missPenalty: 0.45,
|
||||||
|
movementPenalty: 0.08,
|
||||||
|
scalePenalty: 0.12,
|
||||||
|
};
|
||||||
|
const defaultAutoMatchAdjustable: AutoMatchParameterSelection = {
|
||||||
|
translateX: true,
|
||||||
|
translateY: true,
|
||||||
|
translateZ: true,
|
||||||
|
scale: true,
|
||||||
|
};
|
||||||
|
const autoMatchParameterKeys: AutoMatchParameterKey[] = ['translateX', 'translateY', 'translateZ', 'scale'];
|
||||||
|
const autoMatchBoneNamePattern = /(rib|bone|hipbone|hip|vertebra|spine|sternum|pelvis|sacrum|costal|skull|肋|骨)/i;
|
||||||
|
|
||||||
|
interface AutoMatchContext {
|
||||||
|
project: ProjectRecord;
|
||||||
|
volume: DicomHuVolume;
|
||||||
|
metrics: ExportSceneMetrics;
|
||||||
|
samples: Point3DRecord[];
|
||||||
|
basePose: ModelPoseValue;
|
||||||
|
weights: AutoMatchWeights;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAutoMatchAdjustable(input: unknown): AutoMatchParameterSelection {
|
||||||
|
const source = input && typeof input === 'object' && !Array.isArray(input)
|
||||||
|
? input as Partial<Record<AutoMatchParameterKey, unknown>>
|
||||||
|
: {};
|
||||||
|
return autoMatchParameterKeys.reduce<AutoMatchParameterSelection>((accumulator, key) => {
|
||||||
|
accumulator[key] = typeof source[key] === 'boolean' ? source[key] === true : defaultAutoMatchAdjustable[key];
|
||||||
|
return accumulator;
|
||||||
|
}, { ...defaultAutoMatchAdjustable });
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAutoMatchWeights(input: unknown): AutoMatchWeights {
|
||||||
|
const source = input && typeof input === 'object' && !Array.isArray(input)
|
||||||
|
? input as Partial<Record<keyof AutoMatchWeights, unknown>>
|
||||||
|
: {};
|
||||||
|
const readWeight = (key: keyof AutoMatchWeights, min: number, max: number) => {
|
||||||
|
const value = source[key];
|
||||||
|
return typeof value === 'number' && Number.isFinite(value)
|
||||||
|
? Number(clampNumber(value, min, max).toFixed(3))
|
||||||
|
: defaultAutoMatchWeights[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
boneReward: readWeight('boneReward', 0.2, 2),
|
||||||
|
missPenalty: readWeight('missPenalty', 0, 1.5),
|
||||||
|
movementPenalty: readWeight('movementPenalty', 0, 0.4),
|
||||||
|
scalePenalty: readWeight('scalePenalty', 0, 0.6),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAutoMatchIterations(value: unknown) {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value)
|
||||||
|
? clampNumber(Math.round(value), 2, 12)
|
||||||
|
: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAutoMatchCandidatesPerRound(value: unknown) {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value)
|
||||||
|
? clampNumber(Math.round(value), 12, 80)
|
||||||
|
: 36;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAutoMatchBoneFiles(project: ProjectRecord, input: unknown) {
|
||||||
|
const requested = Array.isArray(input)
|
||||||
|
? input.filter((item): item is string => typeof item === 'string' && project.stlFiles.includes(item))
|
||||||
|
: [];
|
||||||
|
if (requested.length) {
|
||||||
|
return [...new Set(requested)];
|
||||||
|
}
|
||||||
|
const matched = project.stlFiles.filter((fileName) => autoMatchBoneNamePattern.test(fileName));
|
||||||
|
return matched.length ? matched : project.stlFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseAutoMatchSampleSlices(input: unknown, depth: number) {
|
||||||
|
const maxSlice = Math.max(depth - 1, 0);
|
||||||
|
const requested = Array.isArray(input)
|
||||||
|
? input
|
||||||
|
.map((item) => typeof item === 'number' && Number.isFinite(item) ? Math.round(item) : NaN)
|
||||||
|
.filter((item) => Number.isFinite(item))
|
||||||
|
.map((item) => clampNumber(item, 0, maxSlice))
|
||||||
|
: [];
|
||||||
|
if (requested.length) {
|
||||||
|
return [...new Set(requested)].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fractions = [0.12, 0.22, 0.32, 0.42, 0.5, 0.58, 0.68, 0.78, 0.88];
|
||||||
|
return [...new Set(fractions.map((fraction) => clampNumber(Math.round(maxSlice * fraction), 0, maxSlice)))]
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAutoMatchPreviews(project: ProjectRecord) {
|
||||||
|
return (project.stlFiles ?? []).reduce<Record<string, ModelPreviewRecord>>((accumulator, fileName) => {
|
||||||
|
const filePath = getProjectModelFilePath(project, fileName);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
accumulator[fileName] = createStlPreview(filePath, fileName, 5000);
|
||||||
|
}
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAutoMatchSamples(project: ProjectRecord, boneFiles: string[]) {
|
||||||
|
const sampleBudgetPerFile = Math.max(1200, Math.floor(70000 / Math.max(boneFiles.length, 1)));
|
||||||
|
const samples: Point3DRecord[] = [];
|
||||||
|
|
||||||
|
boneFiles.forEach((fileName) => {
|
||||||
|
const filePath = getProjectModelFilePath(project, fileName);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const preview = createStlPreview(filePath, fileName, sampleBudgetPerFile);
|
||||||
|
const vertices = preview.vertices;
|
||||||
|
for (let offset = 0; offset + 8 < vertices.length; offset += 9) {
|
||||||
|
samples.push({
|
||||||
|
x: (vertices[offset] + vertices[offset + 3] + vertices[offset + 6]) / 3,
|
||||||
|
y: (vertices[offset + 1] + vertices[offset + 4] + vertices[offset + 7]) / 3,
|
||||||
|
z: (vertices[offset + 2] + vertices[offset + 5] + vertices[offset + 8]) / 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAutoMatchVolumeHu(volume: DicomHuVolume, slice: number, x: number, y: number) {
|
||||||
|
if (slice < 0 || slice >= volume.depth || x < 0 || x >= volume.width || y < 0 || y >= volume.height) {
|
||||||
|
return -Infinity;
|
||||||
|
}
|
||||||
|
return volume.data.readInt16LE((slice * volume.width * volume.height + y * volume.width + x) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleAutoMatchBoneWindow(volume: DicomHuVolume, slice: number, x: number, y: number) {
|
||||||
|
const pixelX = Math.round(x);
|
||||||
|
const pixelY = Math.round(y);
|
||||||
|
if (pixelX < 0 || pixelX >= volume.width || pixelY < 0 || pixelY >= volume.height) {
|
||||||
|
return { value: -0.6, outside: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const centerHu = readAutoMatchVolumeHu(volume, slice, pixelX, pixelY);
|
||||||
|
let bestHu = centerHu;
|
||||||
|
for (let dy = -2; dy <= 2; dy += 1) {
|
||||||
|
for (let dx = -2; dx <= 2; dx += 1) {
|
||||||
|
bestHu = Math.max(bestHu, readAutoMatchVolumeHu(volume, slice, pixelX + dx, pixelY + dy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (centerHu >= 180) {
|
||||||
|
return { value: 1, outside: false };
|
||||||
|
}
|
||||||
|
if (bestHu >= 220) {
|
||||||
|
return { value: 0.65, outside: false };
|
||||||
|
}
|
||||||
|
if (bestHu >= 140) {
|
||||||
|
return { value: 0.3, outside: false };
|
||||||
|
}
|
||||||
|
return { value: -1, outside: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAutoMatchPointToVolume(point: Point3DRecord, metrics: ExportSceneMetrics, volume: DicomHuVolume) {
|
||||||
|
const slice = volume.depth <= 1
|
||||||
|
? 0
|
||||||
|
: Math.round(((point.z + metrics.dicomDepth / 2) / metrics.dicomDepth) * (volume.depth - 1));
|
||||||
|
const x = ((point.x + metrics.dicomWidth / 2) / metrics.dicomWidth) * volume.width;
|
||||||
|
const y = volume.height - ((point.y + metrics.dicomHeight / 2) / metrics.dicomHeight) * volume.height;
|
||||||
|
return { slice, x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateAutoMatchPose(
|
||||||
|
context: AutoMatchContext,
|
||||||
|
pose: ModelPoseValue,
|
||||||
|
iteration: number,
|
||||||
|
mode: string,
|
||||||
|
changed: AutoMatchParameterKey[],
|
||||||
|
): AutoMatchCandidateRecord {
|
||||||
|
let hitReward = 0;
|
||||||
|
let missPenalty = 0;
|
||||||
|
let contributors = 0;
|
||||||
|
|
||||||
|
context.samples.forEach((sample) => {
|
||||||
|
const transformed = transformPointForExportPose(sample.x, sample.y, sample.z, context.metrics, pose);
|
||||||
|
const mapped = mapAutoMatchPointToVolume(transformed, context.metrics, context.volume);
|
||||||
|
contributors += 1;
|
||||||
|
|
||||||
|
if (mapped.slice < 0 || mapped.slice >= context.volume.depth) {
|
||||||
|
missPenalty += 0.8;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bone = sampleAutoMatchBoneWindow(context.volume, mapped.slice, mapped.x, mapped.y);
|
||||||
|
if (bone.value > 0) {
|
||||||
|
hitReward += bone.value;
|
||||||
|
} else {
|
||||||
|
missPenalty += bone.outside ? 0.65 : 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const safeContributors = Math.max(contributors, 1);
|
||||||
|
const movement = Math.sqrt(
|
||||||
|
(pose.translateX - context.basePose.translateX) ** 2
|
||||||
|
+ (pose.translateY - context.basePose.translateY) ** 2
|
||||||
|
+ (pose.translateZ - context.basePose.translateZ) ** 2,
|
||||||
|
);
|
||||||
|
const movementPenalty = (movement / 0.05) * context.weights.movementPenalty;
|
||||||
|
const scalePenalty = (Math.abs(pose.scale - context.basePose.scale) / 0.02) * context.weights.scalePenalty;
|
||||||
|
const normalizedHitReward = (hitReward / safeContributors) * context.weights.boneReward;
|
||||||
|
const normalizedMissPenalty = (missPenalty / safeContributors) * context.weights.missPenalty;
|
||||||
|
const score = normalizedHitReward - normalizedMissPenalty - movementPenalty - scalePenalty;
|
||||||
|
|
||||||
|
return {
|
||||||
|
iteration,
|
||||||
|
mode,
|
||||||
|
pose: normalizeModelPoseValue(pose),
|
||||||
|
score: Number(score.toFixed(6)),
|
||||||
|
boneReward: Number(normalizedHitReward.toFixed(6)),
|
||||||
|
missPenalty: Number(normalizedMissPenalty.toFixed(6)),
|
||||||
|
movementPenalty: Number(movementPenalty.toFixed(6)),
|
||||||
|
scalePenalty: Number(scalePenalty.toFixed(6)),
|
||||||
|
contributors,
|
||||||
|
changed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoMatchStepForParameter(key: AutoMatchParameterKey, iteration: number) {
|
||||||
|
const translationSteps = [0.04, 0.025, 0.014, 0.008, 0.004, 0.002, 0.001, 0.001];
|
||||||
|
const scaleSteps = [0.035, 0.02, 0.011, 0.006, 0.003, 0.0015, 0.001, 0.001];
|
||||||
|
const steps = key === 'scale' ? scaleSteps : translationSteps;
|
||||||
|
return steps[Math.min(iteration, steps.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function poseWithAutoMatchDelta(pose: ModelPoseValue, key: AutoMatchParameterKey, delta: number) {
|
||||||
|
return normalizeModelPoseValue({
|
||||||
|
...pose,
|
||||||
|
[key]: pose[key] + delta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAutoMatchCandidates(
|
||||||
|
pose: ModelPoseValue,
|
||||||
|
adjustable: AutoMatchParameterSelection,
|
||||||
|
iteration: number,
|
||||||
|
limit: number,
|
||||||
|
) {
|
||||||
|
const candidates = new Map<string, { pose: ModelPoseValue; mode: string; changed: AutoMatchParameterKey[] }>();
|
||||||
|
const addCandidate = (candidatePose: ModelPoseValue, mode: string, changed: AutoMatchParameterKey[]) => {
|
||||||
|
const key = JSON.stringify(candidatePose);
|
||||||
|
if (!candidates.has(key)) {
|
||||||
|
candidates.set(key, { pose: candidatePose, mode, changed });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const enabledKeys = autoMatchParameterKeys.filter((key) => adjustable[key]);
|
||||||
|
|
||||||
|
addCandidate(pose, '保持当前', []);
|
||||||
|
enabledKeys.forEach((key) => {
|
||||||
|
const step = autoMatchStepForParameter(key, iteration);
|
||||||
|
addCandidate(poseWithAutoMatchDelta(pose, key, step), `${key} +`, [key]);
|
||||||
|
addCandidate(poseWithAutoMatchDelta(pose, key, -step), `${key} -`, [key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let left = 0; left < enabledKeys.length; left += 1) {
|
||||||
|
for (let right = left + 1; right < enabledKeys.length; right += 1) {
|
||||||
|
const leftKey = enabledKeys[left];
|
||||||
|
const rightKey = enabledKeys[right];
|
||||||
|
const leftStep = autoMatchStepForParameter(leftKey, iteration) * 0.65;
|
||||||
|
const rightStep = autoMatchStepForParameter(rightKey, iteration) * 0.65;
|
||||||
|
[-1, 1].forEach((leftSign) => {
|
||||||
|
[-1, 1].forEach((rightSign) => {
|
||||||
|
const candidatePose = poseWithAutoMatchDelta(
|
||||||
|
poseWithAutoMatchDelta(pose, leftKey, leftStep * leftSign),
|
||||||
|
rightKey,
|
||||||
|
rightStep * rightSign,
|
||||||
|
);
|
||||||
|
addCandidate(candidatePose, `${leftKey}/${rightKey} 联合`, [leftKey, rightKey]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...candidates.values()].slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAutoMatchContext(project: ProjectRecord, body: Record<string, unknown>) {
|
||||||
|
const files = getProjectDicomFiles(project);
|
||||||
|
if (!files.length) {
|
||||||
|
throw new Error('当前项目没有可匹配的 DICOM 文件');
|
||||||
|
}
|
||||||
|
if (!project.stlFiles.length) {
|
||||||
|
throw new Error('当前项目没有可匹配的 STL 文件');
|
||||||
|
}
|
||||||
|
|
||||||
|
const volume = readDicomHuVolume(project, files);
|
||||||
|
const previews = collectAutoMatchPreviews(project);
|
||||||
|
const metrics = getExportMetrics(project, volume, previews);
|
||||||
|
if (!metrics) {
|
||||||
|
throw new Error('无法读取 STL 全局边界');
|
||||||
|
}
|
||||||
|
|
||||||
|
const boneFiles = resolveAutoMatchBoneFiles(project, body.boneFiles);
|
||||||
|
const samples = collectAutoMatchSamples(project, boneFiles);
|
||||||
|
if (!samples.length) {
|
||||||
|
throw new Error('未能从骨骼 STL 中采样到可匹配点');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: {
|
||||||
|
project,
|
||||||
|
volume,
|
||||||
|
metrics,
|
||||||
|
samples,
|
||||||
|
basePose: normalizeModelPoseValue(body.pose as Partial<ModelPoseValue> | undefined),
|
||||||
|
weights: normalizeAutoMatchWeights(body.weights),
|
||||||
|
} satisfies AutoMatchContext,
|
||||||
|
boneFiles,
|
||||||
|
sampleSlices: chooseAutoMatchSampleSlices(body.sampleSlices, volume.depth),
|
||||||
|
adjustable: normalizeAutoMatchAdjustable(body.adjustable),
|
||||||
|
iterations: normalizeAutoMatchIterations(body.iterations),
|
||||||
|
candidatesPerRound: normalizeAutoMatchCandidatesPerRound(body.candidatesPerRound),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runProjectAutoMatch(project: ProjectRecord, body: Record<string, unknown>) {
|
||||||
|
const {
|
||||||
|
context,
|
||||||
|
boneFiles,
|
||||||
|
sampleSlices,
|
||||||
|
adjustable,
|
||||||
|
iterations,
|
||||||
|
candidatesPerRound,
|
||||||
|
} = createAutoMatchContext(project, body);
|
||||||
|
let best = evaluateAutoMatchPose(context, context.basePose, -1, '初始位姿', []);
|
||||||
|
const trace: AutoMatchCandidateRecord[] = [];
|
||||||
|
let evaluated = 1;
|
||||||
|
|
||||||
|
for (let iteration = 0; iteration < iterations; iteration += 1) {
|
||||||
|
const candidates = generateAutoMatchCandidates(best.pose, adjustable, iteration, candidatesPerRound);
|
||||||
|
const evaluatedCandidates = candidates
|
||||||
|
.map((candidate) => evaluateAutoMatchPose(context, candidate.pose, iteration, candidate.mode, candidate.changed))
|
||||||
|
.sort((left, right) => right.score - left.score);
|
||||||
|
evaluated += evaluatedCandidates.length;
|
||||||
|
trace.push(...evaluatedCandidates.slice(0, Math.min(6, evaluatedCandidates.length)));
|
||||||
|
if (evaluatedCandidates[0] && evaluatedCandidates[0].score > best.score + 0.000001) {
|
||||||
|
best = evaluatedCandidates[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId: project.id,
|
||||||
|
basePose: context.basePose,
|
||||||
|
bestPose: best.pose,
|
||||||
|
bestScore: best.score,
|
||||||
|
iterations,
|
||||||
|
evaluated,
|
||||||
|
boneFiles,
|
||||||
|
sampleSlices,
|
||||||
|
weights: context.weights,
|
||||||
|
trace,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAutoMatchedPose(project: ProjectRecord, pose: ModelPoseValue) {
|
||||||
|
const normalizedPose = normalizeModelPoseValue(pose);
|
||||||
|
const poses = normalizeModelPoses(project.modelPoses).filter((record) => record.id !== 'auto-match');
|
||||||
|
project.modelPoses = normalizeModelPoses([
|
||||||
|
...poses,
|
||||||
|
{
|
||||||
|
id: 'auto-match',
|
||||||
|
name: '自动微调匹配',
|
||||||
|
pose: normalizedPose,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const latestResult = project.segmentationResults[project.segmentationResults.length - 1];
|
||||||
|
if (latestResult) {
|
||||||
|
latestResult.pose = normalizedPose;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function intersectExportEdgeWithPlane(start: Point3DRecord, end: Point3DRecord, targetZ: number): Point2DRecord | null {
|
function intersectExportEdgeWithPlane(start: Point3DRecord, end: Point3DRecord, targetZ: number): Point2DRecord | null {
|
||||||
const epsilon = 1e-5;
|
const epsilon = 1e-5;
|
||||||
const startDistance = start.z - targetZ;
|
const startDistance = start.z - targetZ;
|
||||||
@@ -3007,6 +3412,42 @@ async function startServer() {
|
|||||||
res.json(project);
|
res.json(project);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:projectId/auto-match', (req, res) => {
|
||||||
|
const project = findProject(readState(), req.params.projectId);
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ message: '项目不存在' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
res.json(runProjectAutoMatch(project, req.body && typeof req.body === 'object' ? req.body as Record<string, unknown> : {}));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(422).json({ message: error instanceof Error ? error.message : '自动微调匹配失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/projects/:projectId/model-pose', (req, res) => {
|
||||||
|
const state = readState();
|
||||||
|
const project = findProject(state, req.params.projectId);
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ message: '项目不存在' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (project.locked) {
|
||||||
|
res.status(423).json({ message: '项目已锁定,请先解锁后再写入自动匹配位姿' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
applyAutoMatchedPose(project, normalizeModelPoseValue(req.body?.pose as Partial<ModelPoseValue> | undefined));
|
||||||
|
touchProject(project);
|
||||||
|
writeState(state);
|
||||||
|
res.json(project);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(422).json({ message: error instanceof Error ? error.message : '位姿保存失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/projects/:projectId/import-assets', (req, res) => {
|
app.post('/api/projects/:projectId/import-assets', (req, res) => {
|
||||||
assetUpload.array('files', 5000)(req, res, (uploadError) => {
|
assetUpload.array('files', 5000)(req, res, (uploadError) => {
|
||||||
if (uploadError) {
|
if (uploadError) {
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import Sidebar from './components/Sidebar';
|
|||||||
import Overview from './components/Overview';
|
import Overview from './components/Overview';
|
||||||
import ProjectLibrary from './components/ProjectLibrary';
|
import ProjectLibrary from './components/ProjectLibrary';
|
||||||
import ReverseWorkspace from './components/ReverseWorkspace';
|
import ReverseWorkspace from './components/ReverseWorkspace';
|
||||||
|
import AutoMatchWorkspace from './components/AutoMatchWorkspace';
|
||||||
import UserManagement from './components/UserManagement';
|
import UserManagement from './components/UserManagement';
|
||||||
import { ViewType } from './types';
|
import { ModelPose, ViewType } from './types';
|
||||||
import { api } from './lib/api';
|
import { api } from './lib/api';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -25,13 +26,14 @@ export default function App() {
|
|||||||
const [activeView, setActiveView] = useState<ViewType>(ViewType.OVERVIEW);
|
const [activeView, setActiveView] = useState<ViewType>(ViewType.OVERVIEW);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [activeProjectId, setActiveProjectId] = useState('head-ct-demo');
|
const [activeProjectId, setActiveProjectId] = useState('head-ct-demo');
|
||||||
|
const [autoMatchInitialPose, setAutoMatchInitialPose] = useState<ModelPose | null>(null);
|
||||||
const [projectLibraryInitialView, setProjectLibraryInitialView] = useState<'dicom' | 'model' | 'mask'>('dicom');
|
const [projectLibraryInitialView, setProjectLibraryInitialView] = useState<'dicom' | 'model' | 'mask'>('dicom');
|
||||||
const workspaceLeaveGuardRef = useRef<(() => Promise<boolean>) | null>(null);
|
const workspaceLeaveGuardRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||||
const bootSessionResetRef = useRef(false);
|
const bootSessionResetRef = useRef(false);
|
||||||
|
|
||||||
// Automatically collapse main sidebar when entering Project Library or Workspace
|
// Automatically collapse main sidebar when entering Project Library or Workspace
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeView === ViewType.PROJECTS || activeView === ViewType.WORKSPACE) {
|
if (activeView === ViewType.PROJECTS || activeView === ViewType.WORKSPACE || activeView === ViewType.AUTO_MATCH) {
|
||||||
setSidebarCollapsed(true);
|
setSidebarCollapsed(true);
|
||||||
} else {
|
} else {
|
||||||
setSidebarCollapsed(false);
|
setSidebarCollapsed(false);
|
||||||
@@ -94,6 +96,9 @@ export default function App() {
|
|||||||
if (leaveWorkspace && nextView === ViewType.PROJECTS) {
|
if (leaveWorkspace && nextView === ViewType.PROJECTS) {
|
||||||
setProjectLibraryInitialView('mask');
|
setProjectLibraryInitialView('mask');
|
||||||
}
|
}
|
||||||
|
if (nextView === ViewType.AUTO_MATCH) {
|
||||||
|
setAutoMatchInitialPose(null);
|
||||||
|
}
|
||||||
setActiveView(nextView);
|
setActiveView(nextView);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,6 +128,12 @@ export default function App() {
|
|||||||
setActiveView(ViewType.OVERVIEW);
|
setActiveView(ViewType.OVERVIEW);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAutoMatchWorkspace = (projectId: string, pose?: ModelPose) => {
|
||||||
|
setActiveProjectId(projectId);
|
||||||
|
setAutoMatchInitialPose(pose ?? null);
|
||||||
|
setActiveView(ViewType.AUTO_MATCH);
|
||||||
|
};
|
||||||
|
|
||||||
if (sessionLoading) {
|
if (sessionLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center text-slate-500 font-medium">
|
<div className="min-h-screen bg-neutral-50 flex items-center justify-center text-slate-500 font-medium">
|
||||||
@@ -153,6 +164,7 @@ export default function App() {
|
|||||||
{activeView === ViewType.OVERVIEW && '总体概况'}
|
{activeView === ViewType.OVERVIEW && '总体概况'}
|
||||||
{activeView === ViewType.PROJECTS && '项目库'}
|
{activeView === ViewType.PROJECTS && '项目库'}
|
||||||
{activeView === ViewType.WORKSPACE && '逆向工作区'}
|
{activeView === ViewType.WORKSPACE && '逆向工作区'}
|
||||||
|
{activeView === ViewType.AUTO_MATCH && '自动微调匹配工作区'}
|
||||||
{activeView === ViewType.SYSTEM && '系统管理工作区'}
|
{activeView === ViewType.SYSTEM && '系统管理工作区'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,11 +197,22 @@ export default function App() {
|
|||||||
{activeView === ViewType.WORKSPACE && (
|
{activeView === ViewType.WORKSPACE && (
|
||||||
<ReverseWorkspace
|
<ReverseWorkspace
|
||||||
projectId={activeProjectId}
|
projectId={activeProjectId}
|
||||||
|
onAutoMatch={openAutoMatchWorkspace}
|
||||||
onLeaveGuardChange={(handler) => {
|
onLeaveGuardChange={(handler) => {
|
||||||
workspaceLeaveGuardRef.current = handler;
|
workspaceLeaveGuardRef.current = handler;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeView === ViewType.AUTO_MATCH && (
|
||||||
|
<AutoMatchWorkspace
|
||||||
|
projectId={activeProjectId}
|
||||||
|
initialPose={autoMatchInitialPose}
|
||||||
|
onOpenReverse={(projectId) => {
|
||||||
|
setActiveProjectId(projectId);
|
||||||
|
setActiveView(ViewType.WORKSPACE);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activeView === ViewType.SYSTEM && <UserManagement />}
|
{activeView === ViewType.SYSTEM && <UserManagement />}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
559
WebSite/src/components/AutoMatchWorkspace.tsx
Normal file
559
WebSite/src/components/AutoMatchWorkspace.tsx
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Bone,
|
||||||
|
CheckCircle2,
|
||||||
|
Crosshair,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
Play,
|
||||||
|
RefreshCcw,
|
||||||
|
Save,
|
||||||
|
SlidersHorizontal,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
AutoMatchCandidate,
|
||||||
|
AutoMatchParameterKey,
|
||||||
|
AutoMatchParameterSelection,
|
||||||
|
AutoMatchWeights,
|
||||||
|
ModelPose,
|
||||||
|
Project,
|
||||||
|
} from '../types';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
const defaultModelPose: ModelPose = {
|
||||||
|
rotateX: 0,
|
||||||
|
rotateY: 0,
|
||||||
|
rotateZ: 0,
|
||||||
|
translateX: 0,
|
||||||
|
translateY: 0,
|
||||||
|
translateZ: 0,
|
||||||
|
scale: 1,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
flipZ: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultAdjustable: AutoMatchParameterSelection = {
|
||||||
|
translateX: true,
|
||||||
|
translateY: true,
|
||||||
|
translateZ: true,
|
||||||
|
scale: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultWeights: AutoMatchWeights = {
|
||||||
|
boneReward: 1,
|
||||||
|
missPenalty: 0.45,
|
||||||
|
movementPenalty: 0.08,
|
||||||
|
scalePenalty: 0.12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parameterOptions: Array<{ key: AutoMatchParameterKey; label: string }> = [
|
||||||
|
{ key: 'translateX', label: '平移 X' },
|
||||||
|
{ key: 'translateY', label: '平移 Y' },
|
||||||
|
{ key: 'translateZ', label: '平移 Z' },
|
||||||
|
{ key: 'scale', label: '缩放' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const weightOptions: Array<{ key: keyof AutoMatchWeights; label: string; min: number; max: number; step: number }> = [
|
||||||
|
{ key: 'boneReward', label: '骨窗命中奖励', min: 0.2, max: 2, step: 0.05 },
|
||||||
|
{ key: 'missPenalty', label: '非骨区域惩罚', min: 0, max: 1.5, step: 0.05 },
|
||||||
|
{ key: 'movementPenalty', label: '移动惩罚', min: 0, max: 0.4, step: 0.01 },
|
||||||
|
{ key: 'scalePenalty', label: '缩放惩罚', min: 0, max: 0.6, step: 0.01 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const boneNamePattern = /(rib|bone|hipbone|hip|vertebra|spine|sternum|pelvis|sacrum|costal|skull|肋|骨)/i;
|
||||||
|
|
||||||
|
function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose | null) {
|
||||||
|
if (incomingPose) {
|
||||||
|
return incomingPose;
|
||||||
|
}
|
||||||
|
const latestResult = project?.segmentationResults?.[project.segmentationResults.length - 1];
|
||||||
|
return latestResult?.pose
|
||||||
|
?? project?.modelPoses?.find((pose) => pose.id === 'auto-match')?.pose
|
||||||
|
?? project?.modelPoses?.find((pose) => pose.id === 'default')?.pose
|
||||||
|
?? project?.modelPoses?.[0]?.pose
|
||||||
|
?? defaultModelPose;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultBoneFiles(project: Project | null) {
|
||||||
|
const files = project?.stlFiles ?? [];
|
||||||
|
const matched = files.filter((fileName) => boneNamePattern.test(fileName));
|
||||||
|
return matched.length ? matched : files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPoseNumber(value: number) {
|
||||||
|
return Number.isFinite(value) ? value.toFixed(3) : '0.000';
|
||||||
|
}
|
||||||
|
|
||||||
|
function poseDelta(base: ModelPose, next: ModelPose, key: AutoMatchParameterKey) {
|
||||||
|
return next[key] - base[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoMatchWorkspaceProps {
|
||||||
|
projectId: string;
|
||||||
|
initialPose?: ModelPose | null;
|
||||||
|
onOpenReverse?: (projectId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AutoMatchWorkspace({ projectId, initialPose, onOpenReverse }: AutoMatchWorkspaceProps) {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState(projectId);
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [basePose, setBasePose] = useState<ModelPose>(initialPose ?? defaultModelPose);
|
||||||
|
const [selectedBoneFiles, setSelectedBoneFiles] = useState<string[]>([]);
|
||||||
|
const [adjustable, setAdjustable] = useState<AutoMatchParameterSelection>(defaultAdjustable);
|
||||||
|
const [weights, setWeights] = useState<AutoMatchWeights>(defaultWeights);
|
||||||
|
const [iterations, setIterations] = useState(6);
|
||||||
|
const [candidatesPerRound, setCandidatesPerRound] = useState(36);
|
||||||
|
const [result, setResult] = useState<{
|
||||||
|
bestPose: ModelPose;
|
||||||
|
bestScore: number;
|
||||||
|
evaluated: number;
|
||||||
|
trace: AutoMatchCandidate[];
|
||||||
|
sampleSlices: number[];
|
||||||
|
} | null>(null);
|
||||||
|
const [loadingProject, setLoadingProject] = useState(true);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [loadingPercent, setLoadingPercent] = useState(0);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedProjectId(projectId);
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
api.getProjects()
|
||||||
|
.then((items) => {
|
||||||
|
if (mounted) {
|
||||||
|
setProjects(items);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
setLoadingProject(true);
|
||||||
|
setError('');
|
||||||
|
api.getProject(selectedProjectId)
|
||||||
|
.then((nextProject) => {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const incomingPose = selectedProjectId === projectId ? initialPose : null;
|
||||||
|
setProject(nextProject);
|
||||||
|
setBasePose(latestPoseFromProject(nextProject, incomingPose));
|
||||||
|
setSelectedBoneFiles(defaultBoneFiles(nextProject));
|
||||||
|
setResult(null);
|
||||||
|
})
|
||||||
|
.catch((loadError) => {
|
||||||
|
if (mounted) {
|
||||||
|
setError(loadError instanceof Error ? loadError.message : '项目加载失败');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (mounted) {
|
||||||
|
setLoadingProject(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [selectedProjectId, projectId, initialPose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
setLoadingPercent(8);
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setLoadingPercent((current) => Math.min(92, current + Math.max(1, (94 - current) * 0.08)));
|
||||||
|
}, 320);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [running]);
|
||||||
|
|
||||||
|
const enabledParameterCount = useMemo(
|
||||||
|
() => parameterOptions.filter((option) => adjustable[option.key]).length,
|
||||||
|
[adjustable],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleBoneFile = (fileName: string) => {
|
||||||
|
setSelectedBoneFiles((current) => (
|
||||||
|
current.includes(fileName)
|
||||||
|
? current.filter((item) => item !== fileName)
|
||||||
|
: [...current, fileName]
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAutoMatch = async () => {
|
||||||
|
if (!project) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedBoneFiles.length) {
|
||||||
|
setError('请选择至少一个骨骼区域 STL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!enabledParameterCount) {
|
||||||
|
setError('请选择至少一个可微调参数');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRunning(true);
|
||||||
|
setError('');
|
||||||
|
setStatus('');
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const data = await api.runAutoMatch(project.id, {
|
||||||
|
pose: basePose,
|
||||||
|
adjustable,
|
||||||
|
boneFiles: selectedBoneFiles,
|
||||||
|
iterations,
|
||||||
|
candidatesPerRound,
|
||||||
|
weights,
|
||||||
|
});
|
||||||
|
setResult({
|
||||||
|
bestPose: data.bestPose,
|
||||||
|
bestScore: data.bestScore,
|
||||||
|
evaluated: data.evaluated,
|
||||||
|
trace: data.trace,
|
||||||
|
sampleSlices: data.sampleSlices,
|
||||||
|
});
|
||||||
|
setLoadingPercent(100);
|
||||||
|
setStatus('已完成自动微调匹配');
|
||||||
|
} catch (runError) {
|
||||||
|
setError(runError instanceof Error ? runError.message : '自动微调匹配失败');
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyBestPose = async (returnToWorkspace = false) => {
|
||||||
|
if (!project || !result) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
setStatus('');
|
||||||
|
try {
|
||||||
|
const updated = await api.applyProjectModelPose(project.id, result.bestPose);
|
||||||
|
setProject(updated);
|
||||||
|
setBasePose(result.bestPose);
|
||||||
|
setStatus('最佳位姿已写入项目库');
|
||||||
|
if (returnToWorkspace) {
|
||||||
|
onOpenReverse?.(project.id);
|
||||||
|
}
|
||||||
|
} catch (applyError) {
|
||||||
|
setError(applyError instanceof Error ? applyError.message : '保存最佳位姿失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const traceRows = result?.trace.slice(0, 10) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full min-h-0 overflow-y-auto pr-2">
|
||||||
|
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={selectedProjectId}
|
||||||
|
onChange={(event) => setSelectedProjectId(event.target.value)}
|
||||||
|
className="h-11 min-w-64 rounded-lg border border-slate-200 bg-white px-3 text-sm font-bold text-slate-700 shadow-sm outline-none focus:border-blue-400"
|
||||||
|
>
|
||||||
|
{projects.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="rounded-lg bg-blue-50 px-3 py-2 text-sm font-bold text-blue-700">DICOM {project?.dicomCount ?? '-'}</span>
|
||||||
|
<span className="rounded-lg bg-slate-100 px-3 py-2 text-sm font-bold text-slate-700">STL {project?.modelCount ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenReverse?.(selectedProjectId)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-bold text-slate-700 shadow-sm transition hover:border-blue-200 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={17} />
|
||||||
|
返回逆向工作区
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(running || loadingProject) && (
|
||||||
|
<div className="mb-4 overflow-hidden rounded-lg border border-blue-100 bg-white shadow-sm">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 text-sm font-bold text-slate-700">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Loader2 size={16} className="animate-spin text-blue-600" />
|
||||||
|
{loadingProject ? '正在读取项目数据' : '正在迭代匹配'}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-blue-700">{Math.round(loadingProject ? 28 : loadingPercent)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-100">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-600 transition-all"
|
||||||
|
style={{ width: `${loadingProject ? 28 : loadingPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-100 bg-red-50 px-4 py-3 text-sm font-bold text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status && (
|
||||||
|
<div className="mb-4 rounded-lg border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-700">
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid min-h-0 grid-cols-[minmax(360px,0.85fr)_minmax(520px,1.15fr)] gap-5 max-xl:grid-cols-1">
|
||||||
|
<section className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SlidersHorizontal size={18} className="text-blue-600" />
|
||||||
|
<h2 className="text-base font-black text-slate-800">匹配参数</h2>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-lg bg-slate-100 px-2.5 py-1 text-[11px] font-black text-slate-600">
|
||||||
|
<Lock size={13} />
|
||||||
|
旋转锁定
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5 p-5">
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 text-xs font-black uppercase text-slate-400">可调整参数</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{parameterOptions.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.key}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between rounded-lg border px-3 py-2.5 text-sm font-bold transition',
|
||||||
|
adjustable[option.key] ? 'border-blue-200 bg-blue-50 text-blue-700' : 'border-slate-200 bg-slate-50 text-slate-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={adjustable[option.key]}
|
||||||
|
onChange={(event) => setAdjustable((current) => ({ ...current, [option.key]: event.target.checked }))}
|
||||||
|
className="h-4 w-4 accent-blue-600"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-black uppercase text-slate-400">骨骼区域</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedBoneFiles(defaultBoneFiles(project))}
|
||||||
|
className="text-xs font-black text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
重置选择
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-60 space-y-2 overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-2">
|
||||||
|
{(project?.stlFiles ?? []).map((fileName) => (
|
||||||
|
<label
|
||||||
|
key={fileName}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-bold transition',
|
||||||
|
selectedBoneFiles.includes(fileName) ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedBoneFiles.includes(fileName)}
|
||||||
|
onChange={() => toggleBoneFile(fileName)}
|
||||||
|
className="h-4 w-4 accent-blue-600"
|
||||||
|
/>
|
||||||
|
<Bone size={16} className={selectedBoneFiles.includes(fileName) ? 'text-amber-500' : 'text-slate-300'} />
|
||||||
|
<span className="min-w-0 truncate">{fileName.replace(/\.stl$/i, '')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 text-xs font-black uppercase text-slate-400">评分权重</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{weightOptions.map((option) => (
|
||||||
|
<label key={option.key} className="grid grid-cols-[112px_1fr_64px] items-center gap-3 text-xs font-bold text-slate-500">
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={option.min}
|
||||||
|
max={option.max}
|
||||||
|
step={option.step}
|
||||||
|
value={weights[option.key]}
|
||||||
|
onChange={(event) => setWeights((current) => ({ ...current, [option.key]: Number(event.target.value) }))}
|
||||||
|
className="accent-blue-600"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={option.min}
|
||||||
|
max={option.max}
|
||||||
|
step={option.step}
|
||||||
|
value={weights[option.key]}
|
||||||
|
onChange={(event) => setWeights((current) => ({ ...current, [option.key]: Number(event.target.value) }))}
|
||||||
|
className="h-8 rounded-md border border-slate-200 bg-white px-2 text-right font-mono text-xs text-slate-700 outline-none focus:border-blue-400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="text-xs font-bold text-slate-500">
|
||||||
|
迭代轮次
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={2}
|
||||||
|
max={12}
|
||||||
|
value={iterations}
|
||||||
|
onChange={(event) => setIterations(Math.max(2, Math.min(12, 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>
|
||||||
|
<label className="text-xs font-bold text-slate-500">
|
||||||
|
每轮候选
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={12}
|
||||||
|
max={80}
|
||||||
|
value={candidatesPerRound}
|
||||||
|
onChange={(event) => setCandidatesPerRound(Math.max(12, Math.min(80, Number(event.target.value) || 12)))}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => void runAutoMatch()}
|
||||||
|
disabled={running || loadingProject || !project}
|
||||||
|
className="flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-blue-600 text-sm font-black text-white shadow-lg shadow-blue-950/10 transition hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{running ? <Loader2 size={18} className="animate-spin" /> : <Play size={18} />}
|
||||||
|
开始自动微调匹配
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Crosshair size={18} className="text-blue-600" />
|
||||||
|
<h2 className="text-base font-black text-slate-800">匹配结果</h2>
|
||||||
|
</div>
|
||||||
|
{result && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-lg bg-emerald-50 px-2.5 py-1 text-[11px] font-black text-emerald-700">
|
||||||
|
<CheckCircle2 size={13} />
|
||||||
|
score {result.bestScore.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="grid grid-cols-4 gap-3 max-lg:grid-cols-2">
|
||||||
|
{parameterOptions.map((option) => (
|
||||||
|
<div key={option.key} className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">
|
||||||
|
<div className="text-[11px] font-black uppercase text-slate-400">{option.label}</div>
|
||||||
|
<div className="mt-2 font-mono text-lg font-black text-slate-800">
|
||||||
|
{formatPoseNumber((result?.bestPose ?? basePose)[option.key])}
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
'mt-1 font-mono text-xs font-bold',
|
||||||
|
result && poseDelta(basePose, result.bestPose, option.key) >= 0 ? 'text-emerald-600' : 'text-red-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{result ? `${poseDelta(basePose, result.bestPose, option.key) >= 0 ? '+' : ''}${formatPoseNumber(poseDelta(basePose, result.bestPose, option.key))}` : '+0.000'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid grid-cols-3 gap-3 max-lg:grid-cols-1">
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div className="text-xs font-black text-slate-400">候选评估</div>
|
||||||
|
<div className="mt-2 font-mono text-xl font-black text-slate-800">{result?.evaluated ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div className="text-xs font-black text-slate-400">采样切片</div>
|
||||||
|
<div className="mt-2 truncate font-mono text-sm font-black text-slate-800">
|
||||||
|
{result?.sampleSlices.map((slice) => slice + 1).join(', ') ?? '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div className="text-xs font-black text-slate-400">骨骼构件</div>
|
||||||
|
<div className="mt-2 font-mono text-xl font-black text-slate-800">{selectedBoneFiles.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => void applyBestPose(false)}
|
||||||
|
disabled={!result}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2.5 text-sm font-black text-white shadow-lg shadow-emerald-950/10 transition hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save size={17} />
|
||||||
|
应用最佳位姿
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void applyBestPose(true)}
|
||||||
|
disabled={!result}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-black text-white shadow-lg shadow-slate-950/10 transition hover:bg-slate-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save size={17} />
|
||||||
|
应用并返回逆向工作区
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setResult(null);
|
||||||
|
setBasePose(latestPoseFromProject(project, selectedProjectId === projectId ? initialPose : null));
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-black text-slate-600 transition hover:border-blue-200 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
<RefreshCcw size={17} />
|
||||||
|
重置结果
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 overflow-hidden rounded-lg border border-slate-200">
|
||||||
|
<div className="grid grid-cols-[72px_1fr_100px_96px_96px] bg-slate-50 px-3 py-2 text-[11px] font-black uppercase text-slate-400">
|
||||||
|
<span>轮次</span>
|
||||||
|
<span>模式</span>
|
||||||
|
<span>评分</span>
|
||||||
|
<span>命中</span>
|
||||||
|
<span>贡献点</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 overflow-y-auto">
|
||||||
|
{traceRows.length ? traceRows.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={`${item.iteration}-${item.mode}-${index}`}
|
||||||
|
className="grid grid-cols-[72px_1fr_100px_96px_96px] border-t border-slate-100 px-3 py-2 text-xs font-bold text-slate-600"
|
||||||
|
>
|
||||||
|
<span className="font-mono">{item.iteration + 1}</span>
|
||||||
|
<span className="truncate">{item.mode}</span>
|
||||||
|
<span className="font-mono text-slate-900">{item.score.toFixed(4)}</span>
|
||||||
|
<span className="font-mono text-emerald-600">{item.boneReward.toFixed(3)}</span>
|
||||||
|
<span className="font-mono">{item.contributors}</span>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="px-3 py-10 text-center text-sm font-bold text-slate-400">
|
||||||
|
尚未运行自动微调匹配
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
FlipHorizontal2,
|
FlipHorizontal2,
|
||||||
FlipVertical2,
|
FlipVertical2,
|
||||||
Move3d,
|
Move3d,
|
||||||
|
Crosshair,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
|
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
|
||||||
@@ -2782,9 +2783,11 @@ export function VoxelizationMappingView({
|
|||||||
export default function ReverseWorkspace({
|
export default function ReverseWorkspace({
|
||||||
projectId,
|
projectId,
|
||||||
onLeaveGuardChange,
|
onLeaveGuardChange,
|
||||||
|
onAutoMatch,
|
||||||
}: {
|
}: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
onLeaveGuardChange?: (handler: WorkspaceLeaveGuard | null) => void;
|
onLeaveGuardChange?: (handler: WorkspaceLeaveGuard | null) => void;
|
||||||
|
onAutoMatch?: (projectId: string, pose: ModelPose) => void;
|
||||||
}) {
|
}) {
|
||||||
const [sliceStart, setSliceStart] = useState(0);
|
const [sliceStart, setSliceStart] = useState(0);
|
||||||
const [sliceEnd, setSliceEnd] = useState(49);
|
const [sliceEnd, setSliceEnd] = useState(49);
|
||||||
@@ -3732,6 +3735,14 @@ export default function ReverseWorkspace({
|
|||||||
{!project && <p className="text-sm text-slate-500">配准 DICOM 影像与三维模型,生成像素映射关系</p>}
|
{!project && <p className="text-sm text-slate-500">配准 DICOM 影像与三维模型,生成像素映射关系</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => project && onAutoMatch?.(project.id, modelPose)}
|
||||||
|
disabled={!project}
|
||||||
|
className="bg-indigo-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-indigo-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Crosshair size={18} />
|
||||||
|
自动微调匹配
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleSaveSegmentationResult()}
|
onClick={() => void handleSaveSegmentationResult()}
|
||||||
disabled={!project}
|
disabled={!project}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
FolderRoot,
|
FolderRoot,
|
||||||
Workflow,
|
Workflow,
|
||||||
|
Crosshair,
|
||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -32,6 +33,7 @@ export default function Sidebar({
|
|||||||
{ id: ViewType.OVERVIEW, icon: BarChart3, label: '总体概况' },
|
{ id: ViewType.OVERVIEW, icon: BarChart3, label: '总体概况' },
|
||||||
{ id: ViewType.PROJECTS, icon: FolderRoot, label: '项目库' },
|
{ id: ViewType.PROJECTS, icon: FolderRoot, label: '项目库' },
|
||||||
{ id: ViewType.WORKSPACE, icon: Workflow, label: '逆向工作区' },
|
{ id: ViewType.WORKSPACE, icon: Workflow, label: '逆向工作区' },
|
||||||
|
{ id: ViewType.AUTO_MATCH, icon: Crosshair, label: '自动微调匹配工作区' },
|
||||||
{ id: ViewType.SYSTEM, icon: Settings, label: '系统管理工作区' },
|
{ id: ViewType.SYSTEM, icon: Settings, label: '系统管理工作区' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SegmentationDicomOpacityLevel, SegmentationDisplayLevel, SegmentationExportScope, SessionState, UserRecord } from '../types';
|
import { AutoMatchRequest, AutoMatchResult, DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SegmentationDicomOpacityLevel, SegmentationDisplayLevel, SegmentationExportScope, SessionState, UserRecord } from '../types';
|
||||||
|
|
||||||
export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
|
export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
|
||||||
export type SegmentationExportMode = 'combined' | 'separate';
|
export type SegmentationExportMode = 'combined' | 'separate';
|
||||||
@@ -129,6 +129,16 @@ export const api = {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ modelPoses }),
|
body: JSON.stringify({ modelPoses }),
|
||||||
}),
|
}),
|
||||||
|
runAutoMatch: (projectId: string, payload: AutoMatchRequest) =>
|
||||||
|
request<AutoMatchResult>(`/api/projects/${projectId}/auto-match`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
applyProjectModelPose: (projectId: string, pose: ModelPose) =>
|
||||||
|
request<Project>(`/api/projects/${projectId}/model-pose`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ pose }),
|
||||||
|
}),
|
||||||
importProjectAssets: (
|
importProjectAssets: (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
kind: ProjectAssetImportKind,
|
kind: ProjectAssetImportKind,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export enum ViewType {
|
|||||||
OVERVIEW = 'overview',
|
OVERVIEW = 'overview',
|
||||||
PROJECTS = 'projects',
|
PROJECTS = 'projects',
|
||||||
WORKSPACE = 'workspace',
|
WORKSPACE = 'workspace',
|
||||||
|
AUTO_MATCH = 'auto-match',
|
||||||
SYSTEM = 'system',
|
SYSTEM = 'system',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +57,58 @@ export interface SavedModelPose {
|
|||||||
pose: ModelPose;
|
pose: ModelPose;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AutoMatchParameterKey = 'translateX' | 'translateY' | 'translateZ' | 'scale';
|
||||||
|
|
||||||
|
export interface AutoMatchParameterSelection {
|
||||||
|
translateX: boolean;
|
||||||
|
translateY: boolean;
|
||||||
|
translateZ: boolean;
|
||||||
|
scale: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoMatchWeights {
|
||||||
|
boneReward: number;
|
||||||
|
missPenalty: number;
|
||||||
|
movementPenalty: number;
|
||||||
|
scalePenalty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoMatchRequest {
|
||||||
|
pose: ModelPose;
|
||||||
|
adjustable: AutoMatchParameterSelection;
|
||||||
|
boneFiles: string[];
|
||||||
|
sampleSlices?: number[];
|
||||||
|
iterations?: number;
|
||||||
|
candidatesPerRound?: number;
|
||||||
|
weights?: Partial<AutoMatchWeights>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoMatchCandidate {
|
||||||
|
iteration: number;
|
||||||
|
mode: string;
|
||||||
|
pose: ModelPose;
|
||||||
|
score: number;
|
||||||
|
boneReward: number;
|
||||||
|
missPenalty: number;
|
||||||
|
movementPenalty: number;
|
||||||
|
scalePenalty: number;
|
||||||
|
contributors: number;
|
||||||
|
changed: AutoMatchParameterKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoMatchResult {
|
||||||
|
projectId: string;
|
||||||
|
basePose: ModelPose;
|
||||||
|
bestPose: ModelPose;
|
||||||
|
bestScore: number;
|
||||||
|
iterations: number;
|
||||||
|
evaluated: number;
|
||||||
|
boneFiles: string[];
|
||||||
|
sampleSlices: number[];
|
||||||
|
weights: AutoMatchWeights;
|
||||||
|
trace: AutoMatchCandidate[];
|
||||||
|
}
|
||||||
|
|
||||||
export type SegmentationExportScope = 'all' | 'visible';
|
export type SegmentationExportScope = 'all' | 'visible';
|
||||||
export type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
export type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||||
export type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
|
export type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
|
||||||
|
|||||||
64
工程分析/实现方案-2026-05-24-22-40-13.md
Normal file
64
工程分析/实现方案-2026-05-24-22-40-13.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# 实现方案-2026-05-24-22-40-13
|
||||||
|
|
||||||
|
## 实现方案文档路径
|
||||||
|
|
||||||
|
`工程分析/实现方案-2026-05-24-22-40-13.md`
|
||||||
|
|
||||||
|
## 修改目标
|
||||||
|
|
||||||
|
- 新增自动微调匹配工作区和逆向工作区入口按钮。
|
||||||
|
- 新增后端自动微调匹配接口,固定旋转,只优化允许的平移和缩放。
|
||||||
|
- 实现骨窗/骨骼构件匹配的首版可解释评分。
|
||||||
|
- 支持将最佳结果应用回项目位姿。
|
||||||
|
|
||||||
|
## 涉及路径
|
||||||
|
|
||||||
|
- `WebSite/src/App.tsx`
|
||||||
|
- `WebSite/src/components/Sidebar.tsx`
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||||
|
- `WebSite/src/lib/api.ts`
|
||||||
|
- `WebSite/src/types.ts`
|
||||||
|
- `WebSite/server.ts`
|
||||||
|
- `Docker部署/README.md`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 技术路线
|
||||||
|
|
||||||
|
- 路由层新增 `auto-match` 视图,左侧导航新增图标入口。
|
||||||
|
- 逆向工作区保存按钮左侧增加“自动微调匹配”,点击进入自动匹配页面并携带当前项目 ID。
|
||||||
|
- 后端新增 `POST /api/projects/:projectId/auto-match`,接收位姿、构件、参数开关和迭代设置,返回最佳 pose 与轨迹。
|
||||||
|
- 后端新增 `PATCH /api/projects/:projectId/model-pose`,用于应用最佳 pose 到项目库。
|
||||||
|
- 评分函数采用:骨窗命中奖励、离骨惩罚、移动惩罚、缩放偏移惩罚;多切片抽样并对骨骼 STL 顶点/三角采样投影评分。
|
||||||
|
- 优化策略采用多模式坐标搜索:粗到细步长,逐轮生成候选,允许每轮只改一个参数或组合改多个参数,选择最高分收敛。
|
||||||
|
|
||||||
|
## 执行步骤
|
||||||
|
|
||||||
|
1. 阅读现有 App、Sidebar、ReverseWorkspace、api、server 类型与项目状态逻辑。
|
||||||
|
2. 新增 API 类型与后端自动匹配/位姿应用接口。
|
||||||
|
3. 新增 AutoMatchWorkspace 页面,提供项目选择、参数开关、构件选择、权重和运行结果。
|
||||||
|
4. 在逆向工作区顶部接入入口按钮。
|
||||||
|
5. 更新 Docker 文档和经验记录。
|
||||||
|
6. 执行类型检查、构建和接口 smoke test。
|
||||||
|
7. 重启服务并验证本机与公网入口。
|
||||||
|
8. 提交并推送到 Gitea。
|
||||||
|
|
||||||
|
## 兼容性与回滚方案
|
||||||
|
|
||||||
|
- 不改变原有保存项目和导出结果接口。
|
||||||
|
- 自动匹配结果只有点击应用后才写入项目位姿。
|
||||||
|
- 若评分效果不理想,可回滚本次 commit 或替换后端评分函数。
|
||||||
|
|
||||||
|
## 预计文件变更
|
||||||
|
|
||||||
|
- 4-5 个前端源码文件。
|
||||||
|
- 1 个后端服务文件。
|
||||||
|
- 1 个 Docker 部署说明文件。
|
||||||
|
- 3 个工程分析当次文档。
|
||||||
|
- 1 个经验记录追加。
|
||||||
|
|
||||||
|
## 提交与部署策略
|
||||||
|
|
||||||
|
- Commit message 使用 `2026-05-24-22-40-13 新增自动微调匹配工作区`。
|
||||||
|
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||||
|
- 验证本机和公网入口。
|
||||||
50
工程分析/测试方案-2026-05-24-22-40-13.md
Normal file
50
工程分析/测试方案-2026-05-24-22-40-13.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 测试方案-2026-05-24-22-40-13
|
||||||
|
|
||||||
|
## 测试方案文档路径
|
||||||
|
|
||||||
|
`工程分析/测试方案-2026-05-24-22-40-13.md`
|
||||||
|
|
||||||
|
## 静态检查
|
||||||
|
|
||||||
|
- 执行 `cd WebSite && npm run lint`。
|
||||||
|
- 搜索新增路由、API 和自动匹配类型,确认前后端字段一致。
|
||||||
|
|
||||||
|
## 构建检查
|
||||||
|
|
||||||
|
- 执行 `cd WebSite && npm run build`。
|
||||||
|
- 确认生产构建成功。
|
||||||
|
|
||||||
|
## 关键业务场景验证
|
||||||
|
|
||||||
|
- 左侧出现“自动微调匹配工作区”入口。
|
||||||
|
- 逆向工作区“保存至项目库”左侧出现“自动微调匹配”按钮。
|
||||||
|
- 自动匹配页面默认选中平移 X/Y/Z 和缩放,旋转不可选。
|
||||||
|
- 默认骨骼构件包含 rib、vertebrae、sternum、hipbone、sacrum 等。
|
||||||
|
- 点击运行后返回最佳位姿、评分轨迹和候选结果。
|
||||||
|
- 点击应用后项目位姿更新,回到逆向工作区可看到新位姿。
|
||||||
|
|
||||||
|
## 医学影像数据相关边界验证
|
||||||
|
|
||||||
|
- 本次不修改原始 DICOM/STL。
|
||||||
|
- 第一版评分是演示级骨窗匹配,不承诺临床级配准。
|
||||||
|
- DICOM 切片顺序、分割导出和锁定项目逻辑不应受影响。
|
||||||
|
|
||||||
|
## 部署验证
|
||||||
|
|
||||||
|
- 重启 `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 备份验证
|
||||||
|
|
||||||
|
- 暂存本次相关源码、Docker 说明和工程分析文档。
|
||||||
|
- 提交 message 包含本次时间戳。
|
||||||
|
- 推送到 Gitea `main` 后确认本地分支与远端同步。
|
||||||
|
|
||||||
|
## 风险与回归关注点
|
||||||
|
|
||||||
|
- 自动匹配不能自动修改旋转。
|
||||||
|
- 应用最佳结果前不应覆盖项目位姿。
|
||||||
|
- 评分接口要限制迭代规模,避免阻塞服务太久。
|
||||||
|
- 锁定项目不应被自动应用位姿覆盖。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -1833,3 +1833,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
涉及 DICOM/STL 配准的坐标系必须独立于可见性、筛选条件和 UI 状态;性能优化可以只加载可见构件的高精度网格,但全局中心、缩放、slice Z 映射应来自稳定的全部模型边界。凡是用户反馈“隐藏某构件后其他构件位置变了”,优先检查 bounds 是否被可见列表重算。
|
涉及 DICOM/STL 配准的坐标系必须独立于可见性、筛选条件和 UI 状态;性能优化可以只加载可见构件的高精度网格,但全局中心、缩放、slice Z 映射应来自稳定的全部模型边界。凡是用户反馈“隐藏某构件后其他构件位置变了”,优先检查 bounds 是否被可见列表重算。
|
||||||
|
|
||||||
|
## 2026-05-24-22-40-13 自动微调匹配要锁定人工旋转并使用同一导出坐标系
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
用户希望在逆向工作区先人工调好大致旋转,然后进入一个新的自动微调匹配工作区,只让系统自动尝试平移 X/Y/Z 与缩放,使用骨骼区域和 DICOM 骨窗收敛到更好的位置。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
项目此前只有人工位姿控件和导出时的 STL/DICOM 映射逻辑,没有单独的自动优化入口。若自动匹配重新定义坐标系或允许修改旋转,会破坏用户已经完成的人工粗配准;若评分只奖励重合,不限制移动和缩放,也容易出现“为了命中骨窗而漂移”的局部最优。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
新增 `AUTO_MATCH` 视图、侧边栏入口和逆向工作区顶部“自动微调匹配”按钮,点击时携带当前位姿。服务端新增 `POST /api/projects/:projectId/auto-match` 和 `PATCH /api/projects/:projectId/model-pose`:匹配接口复用导出模块的 STL 全局 bounds、DICOM HU 体数据和 `transformPointForExportPose` 位姿变换;默认选择骨骼命名 STL,按三角面中心采样,使用 HU 骨窗阈值给骨命中奖励、非骨/越界惩罚,并叠加移动惩罚和缩放惩罚;候选搜索从粗到细尝试单参数和组合参数。应用接口会写入 `auto-match` 位姿,并在项目锁定时拒绝覆盖。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
后续继续优化自动配准时,要保持“人工旋转锁定、自动只微调平移/缩放”的边界,且必须复用导出/映射同一坐标系。评分函数可以继续替换为 Dice、Chamfer、距离变换或多线程 Worker,但移动惩罚、缩放惩罚、候选上限和锁定项目保护不能省;否则会出现匹配看似得分更高、实际位置漂移或覆盖锁定项目的问题。
|
||||||
|
|||||||
50
工程分析/需求分析-2026-05-24-22-40-13.md
Normal file
50
工程分析/需求分析-2026-05-24-22-40-13.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 需求分析-2026-05-24-22-40-13
|
||||||
|
|
||||||
|
## 开始时间
|
||||||
|
|
||||||
|
2026-05-24-22-40-13
|
||||||
|
|
||||||
|
## 原始需求摘要
|
||||||
|
|
||||||
|
用户要求新增“自动微调匹配工作区”:左侧新增入口;逆向工作区“保存至项目库”左侧增加“自动微调匹配”按钮。用户会先在逆向工作区大致调整旋转,进入自动微调后选择允许修改的参数,默认平移 X/Y/Z 与缩放,不允许自动调整旋转;选择骨骼区域,使用 DICOM 骨窗作为匹配依据,自动迭代优化,可尝试多种模式并收敛到最佳位置。
|
||||||
|
|
||||||
|
## 业务目标
|
||||||
|
|
||||||
|
- 提供从逆向工作区进入自动微调匹配的工作流。
|
||||||
|
- 保留人工旋转作为前置步骤,自动优化只调整用户允许的平移和缩放参数。
|
||||||
|
- 以骨窗 DICOM 与骨性 STL 构件为初始匹配目标,形成可解释的评分、奖励项和惩罚项。
|
||||||
|
- 自动迭代输出最佳位姿,可应用回项目状态,供用户回到逆向工作区复核和保存。
|
||||||
|
|
||||||
|
## 输入与输出
|
||||||
|
|
||||||
|
- 输入:项目 ID、当前模型位姿、构件样式、允许调整参数、骨骼构件范围、迭代模式和惩罚权重。
|
||||||
|
- 输出:最佳位姿、评分变化、每轮候选结果、可应用到项目的位姿更新。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- 前端导航与工作区路由。
|
||||||
|
- `ReverseWorkspace` 顶部操作入口。
|
||||||
|
- 新增 `AutoMatchWorkspace` 前端页面。
|
||||||
|
- `WebSite/src/lib/api.ts` API 封装与类型。
|
||||||
|
- `WebSite/server.ts` 自动微调匹配 API、位姿评分和项目位姿应用逻辑。
|
||||||
|
- `Docker部署/README.md` 和 `工程分析/经验记录.md`。
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
|
||||||
|
- 自动匹配不能修改旋转,只能使用已有旋转作为前提。
|
||||||
|
- 默认只允许平移 X/Y/Z 与缩放,可由用户关闭某些参数。
|
||||||
|
- 不修改原始 DICOM/STL 数据,不伪装成临床级配准算法。
|
||||||
|
- 第一版以可解释、可复核、可继续替换真实算法为目标。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 如果评分函数只看重合奖励,可能为了覆盖骨窗而过度平移或放大,需要移动惩罚和尺度惩罚。
|
||||||
|
- 如果只使用单切片评分,容易局部最优;需要多切片抽样。
|
||||||
|
- 如果迭代步长过大,可能跳过最佳点;步长过小会运行太慢。
|
||||||
|
- 运行时间不能阻塞页面太久,需要限制样本数量和候选数量。
|
||||||
|
|
||||||
|
## 待确认问题或默认假设
|
||||||
|
|
||||||
|
- 默认骨骼区域包含文件名含 `rib`、`vertebrae`、`sternum`、`hipbone`、`sacrum` 的 STL 构件。
|
||||||
|
- 默认使用 DICOM 骨窗阈值筛选高亮骨性像素,并以 STL 骨性构件投影点附近的骨窗响应作为奖励。
|
||||||
|
- 第一版内部使用多候选并发评分的坐标搜索,而不是引入外部优化库。
|
||||||
Reference in New Issue
Block a user