2026-05-24-22-40-13 新增自动微调匹配工作区
This commit is contained in:
@@ -16,6 +16,7 @@ type SegmentationExportScope = 'all' | 'visible';
|
||||
type SegmentationExportMode = 'combined' | 'separate';
|
||||
type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||
type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
|
||||
type AutoMatchParameterKey = 'translateX' | 'translateY' | 'translateZ' | 'scale';
|
||||
|
||||
interface ModuleStyleRecord {
|
||||
visible: boolean;
|
||||
@@ -43,6 +44,33 @@ interface ModelPoseRecord {
|
||||
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 {
|
||||
id: string;
|
||||
schemaVersion?: number;
|
||||
@@ -465,7 +493,7 @@ function normalizeModelPoseValue(value: Partial<ModelPoseValue> | undefined): Mo
|
||||
translateX: read('translateX', defaultModelPose.translateX, -2, 2),
|
||||
translateY: read('translateY', defaultModelPose.translateY, -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),
|
||||
flipY: readBoolean('flipY', defaultModelPose.flipY),
|
||||
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 {
|
||||
const epsilon = 1e-5;
|
||||
const startDistance = start.z - targetZ;
|
||||
@@ -3007,6 +3412,42 @@ async function startServer() {
|
||||
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) => {
|
||||
assetUpload.array('files', 5000)(req, res, (uploadError) => {
|
||||
if (uploadError) {
|
||||
|
||||
Reference in New Issue
Block a user