@@ -65,6 +65,56 @@ const AUTO_PROGRESS_STEPS = [
{ id : "score" , label : "评分" } ,
{ id : "apply" , label : "应用" } ,
] ;
const BONE _BRIGHT _THRESHOLD = 185 ;
const BONE _EDGE _THRESHOLD = 165 ;
const BONE _FIXED _REWARD = 1 ;
const BONE _EDGE _REWARD = 0.22 ;
const STL _DISPLAY _MODES = [ "hidden" , "solid" , "translucent" ] ;
const STL _DISPLAY _LABELS = {
hidden : "取消显示" ,
solid : "实体显示" ,
translucent : "半透明" ,
} ;
const LIVER _STL _GROUPS = [
{ key : "liver_segments" , label : "肝段" , order : 10 } ,
{ key : "liver_body" , label : "肝脏" , order : 20 } ,
{ key : "liver_vessels" , label : "肝血管" , order : 30 } ,
{ key : "liver_lesions" , label : "肿瘤和囊肿" , order : 40 } ,
{ key : "body_surface" , label : "骨骼皮肤" , order : 50 } ,
{ key : "other" , label : "其他" , order : 90 } ,
] ;
const LIVER _STL _GROUP _LOOKUP = Object . fromEntries ( LIVER _STL _GROUPS . map ( ( group ) => [ group . key , group ] ) ) ;
const LIVER _STL _LABELS = {
bile _duct : "胆管" ,
cholecyst : "胆囊" ,
gallbladder : "胆囊" ,
hipbone : "髋骨" ,
liver : "肝脏整体" ,
liver _artery : "肝动脉" ,
liver _left : "左肝叶" ,
liver _right : "右肝叶" ,
liver _vein : "肝静脉" ,
pancreas : "胰腺" ,
portal _vein : "门静脉" ,
rib : "肋骨" ,
ribs : "肋骨" ,
sacrum : "骶骨" ,
skin : "皮肤" ,
spleen : "脾脏" ,
sternum : "胸骨" ,
vertebra : "椎骨" ,
vertebrae : "椎骨" ,
} ;
const ROMAN _NUMERALS = {
1 : "I" ,
2 : "II" ,
3 : "III" ,
4 : "IV" ,
5 : "V" ,
6 : "VI" ,
7 : "VII" ,
8 : "VIII" ,
} ;
const state = {
token : localStorage . getItem ( "dicom_upp_registration_token" ) || "" ,
@@ -92,6 +142,8 @@ const state = {
registrationSeriesUid : "" ,
stlFiles : [ ] ,
selectedStlIds : new Set ( ) ,
stlDisplayModes : { } ,
collapsedStlFamilies : new Set ( ) ,
algorithmModel : "" ,
registrationStatus : "unregistered" ,
pose : { ... DEFAULT _POSE } ,
@@ -109,6 +161,7 @@ const state = {
autoDeltaBaseline : null ,
autoFineRunning : false ,
poseControlsLocked : false ,
collapsedPoseSections : { manual : false , auto : false } ,
dirty : false ,
dirtyReason : "" ,
savedPoseSignature : "" ,
@@ -533,7 +586,7 @@ function stlSelectionSignature() {
}
function visibleModelRecords ( ) {
return state . modelMeshes . filter ( ( record ) => state . selectedStlIds . has ( Number ( record . file ? . id ) ) && record . mesh ? . visible !== false ) ;
return state . modelMeshes . filter ( ( record ) => stlMode ( record . file ) !== "hidden" && record . mesh ? . visible !== false ) ;
}
function modelPoseMatrix ( ) {
@@ -609,6 +662,8 @@ function clearDetail() {
state . series = [ ] ;
state . stlFiles = [ ] ;
state . selectedStlIds = new Set ( ) ;
state . stlDisplayModes = { } ;
state . collapsedStlFamilies = new Set ( ) ;
state . selectedSeriesUid = "" ;
state . registrationSeriesUid = "" ;
$ ( "activeTitle" ) . textContent = "未选择 CT" ;
@@ -662,6 +717,8 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
const savedIds = new Set ( ( registration . selected _stl _files || [ ] ) . map ( ( item ) => Number ( item . id ) ) . filter ( Number . isFinite ) ) ;
state . selectedStlIds = savedIds . size ? savedIds : pickDefaultStlIds ( state . stlFiles ) ;
state . stlDisplayModes = initStlDisplayModes ( state . stlFiles , state . selectedStlIds , registration . module _styles || { } ) ;
state . collapsedStlFamilies = new Set ( ) ;
const defaultSeries = pickDefaultSeries ( state . series ) ;
const savedSeries = state . series . find ( ( item ) => item . series _uid === registration . series _instance _uid ) ;
const shouldUseSavedSeries = registration . registration _status === "registered" && savedSeries ;
@@ -728,6 +785,237 @@ function pickDefaultStlIds(files) {
return new Set ( ranked . slice ( 0 , Math . min ( 6 , ranked . length ) ) . map ( ( file ) => Number ( file . id ) ) ) ;
}
function normalizedStlId ( fileOrId ) {
const id = typeof fileOrId === "object" ? Number ( fileOrId ? . id ) : Number ( fileOrId ) ;
return Number . isFinite ( id ) ? id : null ;
}
function validStlDisplayMode ( mode ) {
return STL _DISPLAY _MODES . includes ( mode ) ? mode : "" ;
}
function stlMode ( fileOrId ) {
const id = normalizedStlId ( fileOrId ) ;
if ( id == null || ! state . selectedStlIds . has ( id ) ) return "hidden" ;
return validStlDisplayMode ( state . stlDisplayModes [ id ] ) || "translucent" ;
}
function stlFamilyLabel ( file ) {
return String ( file . family || "未分类" ) ;
}
function normalizedStlText ( file ) {
return [
file . family ,
file . segment _name ,
file . file _name ,
file . category ,
] . map ( ( value ) => String ( value || "" ) . toLowerCase ( ) ) . join ( " " ) ;
}
function normalizedStlName ( file ) {
return String ( file . segment _name || file . family || file . file _name || "" )
. replace ( /\.stl$/i , "" )
. toLowerCase ( ) ;
}
function stlSegmentNumber ( file ) {
const text = normalizedStlText ( file ) ;
const match = text . match ( /(?:segment[_\s-]*s?|肝段)\s*([0-9]+)/i ) || text . match ( /_s([0-9]+)/i ) ;
return match ? Number ( match [ 1 ] ) : 999 ;
}
function lesionNumber ( file ) {
const text = normalizedStlText ( file ) ;
const match = text . match ( /(?:^|[_\s-])(?:liver[_\s-]*)?(?:tumor|lesion|cyst)[_\s-]*([0-9]+)/i )
|| text . match ( /(?:肿瘤|囊肿)[_\s-]*([0-9]+)/i ) ;
return match ? Number ( match [ 1 ] ) : 99 ;
}
function isTumorStl ( file , category = String ( file . category || "" ) ) {
const text = normalizedStlText ( file ) ;
return /(?:^|[_\s-])(?:liver[_\s-]*)?(?:tumor|lesion)(?:[_\s-]|\d|$)/i . test ( text ) || category . includes ( "肿瘤" ) ;
}
function isCystStl ( file , category = String ( file . category || "" ) ) {
const text = normalizedStlText ( file ) ;
return /(?:^|[_\s-])(?:liver[_\s-]*)?cyst(?:[_\s-]|\d|$)/i . test ( text ) || category . includes ( "囊肿" ) ;
}
function numberedLesionLabel ( file , baseLabel ) {
const number = lesionNumber ( file ) ;
return number < 99 ? ` ${ baseLabel } ${ number } ` : baseLabel ;
}
function liverStlPresentation ( file ) {
const text = normalizedStlText ( file ) ;
const name = normalizedStlName ( file ) ;
const category = String ( file . category || "" ) ;
let groupKey = "other" ;
let itemOrder = 900 ;
let label = LIVER _STL _LABELS [ name ] || file . segment _name || file . family || file . file _name ;
if ( text . includes ( "liver_segment" ) || category . includes ( "肝段" ) ) {
const segment = stlSegmentNumber ( file ) ;
groupKey = "liver_segments" ;
itemOrder = segment ;
label = ` 肝段 ${ ROMAN _NUMERALS [ segment ] || segment } ` ;
} else if ( [ "liver" , "liver_left" , "liver_right" ] . includes ( name ) || category . includes ( "肝脏主体" ) ) {
groupKey = "liver_body" ;
itemOrder = { liver : 1 , liver _left : 2 , liver _right : 3 } [ name ] || 99 ;
} else if ( [ "liver_artery" , "liver_vein" , "portal_vein" ] . includes ( name ) ) {
groupKey = "liver_vessels" ;
itemOrder = { liver _artery : 1 , liver _vein : 2 , portal _vein : 3 } [ name ] || 99 ;
} else if ( isTumorStl ( file , category ) || isCystStl ( file , category ) ) {
const tumor = isTumorStl ( file , category ) ;
groupKey = "liver_lesions" ;
itemOrder = ( tumor ? 1 : 20 ) + lesionNumber ( file ) / 100 ;
label = tumor
? numberedLesionLabel ( file , "肝脏肿瘤" )
: numberedLesionLabel ( file , "肝囊肿" ) ;
} else if ( category . includes ( "体表骨骼" ) || [ "hipbone" , "rib" , "ribs" , "sacrum" , "skin" , "sternum" , "vertebra" , "vertebrae" ] . includes ( name ) ) {
groupKey = "body_surface" ;
itemOrder = { vertebrae : 1 , vertebra : 1 , rib : 2 , ribs : 2 , sternum : 3 , sacrum : 4 , hipbone : 5 , skin : 6 } [ name ] || 99 ;
} else {
itemOrder = { bile _duct : 1 , cholecyst : 2 , gallbladder : 2 , pancreas : 3 , spleen : 4 } [ name ] || 99 ;
}
const group = LIVER _STL _GROUP _LOOKUP [ groupKey ] || LIVER _STL _GROUP _LOOKUP . other ;
return {
key : group . key ,
label : group . label ,
order : group . order ,
itemOrder ,
itemLabel : label ,
hierarchy : true ,
} ;
}
function stlPresentation ( file ) {
if ( isLiverBiliaryModel ( file . algorithm _model || state . algorithmModel ) ) return liverStlPresentation ( file ) ;
const label = stlFamilyLabel ( file ) ;
return {
key : label ,
label ,
order : 100 ,
itemOrder : 100 ,
itemLabel : file . segment _name || file . file _name ,
hierarchy : false ,
} ;
}
function compactStlName ( file ) {
return String ( file . segment _name || file . family || file . file _name || ` STL_ ${ file . id || "" } ` )
. replace ( /\.stl$/i , "" ) ;
}
function stlFileIndex ( file ) {
const id = normalizedStlId ( file ) ;
const index = state . stlFiles . findIndex ( ( item ) => normalizedStlId ( item ) === id ) ;
return index >= 0 ? index : 0 ;
}
function stlColor ( file , fallbackIndex = 0 ) {
const index = stlFileIndex ( file ) ;
return cssColor ( index >= 0 ? index : fallbackIndex ) ;
}
function syncStlDisplayModesForSelection ( defaultMode = "translucent" ) {
const next = { } ;
state . stlFiles . forEach ( ( file ) => {
const id = normalizedStlId ( file ) ;
if ( id == null || ! state . selectedStlIds . has ( id ) ) return ;
next [ id ] = validStlDisplayMode ( state . stlDisplayModes [ id ] ) || defaultMode ;
} ) ;
state . stlDisplayModes = next ;
}
function initStlDisplayModes ( files , selectedIds , moduleStyles = { } ) {
const modes = { } ;
files . forEach ( ( file ) => {
const id = normalizedStlId ( file ) ;
if ( id == null || ! selectedIds . has ( id ) ) return ;
const style = moduleStyles ? . [ file . file _name ] || moduleStyles ? . [ file . segment _name ] || { } ;
const savedMode = validStlDisplayMode ( style . mode ) ;
if ( savedMode && savedMode !== "hidden" ) {
modes [ id ] = savedMode ;
return ;
}
modes [ id ] = Number ( style . opacity ) >= 0.95 ? "solid" : "translucent" ;
} ) ;
return modes ;
}
async function setStlMode ( fileOrId , mode ) {
const id = normalizedStlId ( fileOrId ) ;
const nextMode = validStlDisplayMode ( mode ) ;
if ( id == null || ! nextMode ) return ;
if ( nextMode === "hidden" ) {
state . selectedStlIds . delete ( id ) ;
delete state . stlDisplayModes [ id ] ;
} else {
state . selectedStlIds . add ( id ) ;
state . stlDisplayModes [ id ] = nextMode ;
}
await applyStlSelectionChange ( ) ;
}
async function cycleStlMode ( file ) {
const next = {
hidden : "solid" ,
solid : "translucent" ,
translucent : "hidden" ,
} [ stlMode ( file ) ] || "translucent" ;
await setStlMode ( file , next ) ;
}
async function setAllStlMode ( mode ) {
const nextMode = validStlDisplayMode ( mode ) ;
if ( ! state . stlFiles . length || ! nextMode ) return ;
if ( nextMode === "hidden" ) {
state . selectedStlIds = new Set ( ) ;
state . stlDisplayModes = { } ;
} else {
state . selectedStlIds = new Set ( state . stlFiles . map ( ( file ) => normalizedStlId ( file ) ) . filter ( ( id ) => id != null ) ) ;
state . stlDisplayModes = { } ;
state . selectedStlIds . forEach ( ( id ) => {
state . stlDisplayModes [ id ] = nextMode ;
} ) ;
}
await applyStlSelectionChange ( ) ;
}
function aggregateStlMode ( ) {
if ( ! state . stlFiles . length || ! state . selectedStlIds . size ) return "hidden" ;
const modes = state . stlFiles . map ( ( file ) => stlMode ( file ) ) ;
if ( modes . every ( ( mode ) => mode === "solid" ) ) return "solid" ;
if ( modes . every ( ( mode ) => mode === "translucent" ) ) return "translucent" ;
if ( modes . every ( ( mode ) => mode === "hidden" ) ) return "hidden" ;
return "mixed" ;
}
function renderStlVisibilityAction ( ) {
const button = $ ( "cycleAllStlModeBtn" ) ;
if ( ! button ) return ;
const mode = aggregateStlMode ( ) ;
button . classList . remove ( "hidden-eye" , "solid-eye" , "translucent-eye" , "mixed-eye" ) ;
button . classList . add ( ` ${ mode === "mixed" ? "mixed" : mode } -eye ` ) ;
const label = {
hidden : "当前全部隐藏,点击切换为全部实体" ,
solid : "当前全部实体,点击切换为全部半透明" ,
translucent : "当前全部半透明,点击切换为全部隐藏" ,
mixed : "当前为混合显示,点击切换为全部实体" ,
} [ mode ] ;
button . title = label ;
button . setAttribute ( "aria-label" , label ) ;
}
async function cycleAllStlMode ( ) {
const mode = aggregateStlMode ( ) ;
const next = mode === "solid" ? "translucent" : mode === "translucent" ? "hidden" : "solid" ;
await setAllStlMode ( next ) ;
}
function currentSeries ( ) {
return state . series . find ( ( item ) => item . series _uid === state . selectedSeriesUid ) || null ;
}
@@ -829,56 +1117,105 @@ async function selectSeries(uid, options = {}) {
}
function renderStl ( ) {
$ ( "stlCount" ) . textContent = state . stlFiles . length ;
$ ( "stlCount" ) . textContent = state . stlFiles . length ? ` ${ state . selectedStlIds . size } / ${ state . stlFiles . length } ` : "0" ;
renderStlVisibilityAction ( ) ;
const models = [ ... new Set ( state . stlFiles . map ( ( item ) => item . algorithm _model || state . algorithmModel || "未指定模型" ) ) ] ;
$ ( "modelRail" ) . innerHTML = models . map ( ( model ) => ` <span class="chip active"> ${ escapeHtml ( model ) } </span> ` ) . join ( "" ) ;
$ ( "stlList" ) . innerHTML = state . stlFiles . length
? state . stlFiles
. map ( ( file , index ) => {
const checked = state . selectedStlIds . has ( Number ( file . id ) ) ;
return `
<label class="stl-row ${ check ed ? "active" : "" } " title=" ${ escapeHtml ( file . file _name ) } ">
<input type="checkbox" data-stl=" ${ Number ( file . id ) } " ${ checked ? "checked" : "" } />
<span>
<strong> ${ escapeHtml ( file . segment _name || file . file _name ) } </strong>
<small> ${ escapeHtml ( file . family || "未分类" ) } · ${ escapeHtml ( file . category || "" ) } · ${ formatBytes ( file . size _bytes ) } </small>
<small style="color: ${ cssColor ( index ) } "> ${ escapeHtml ( file . file _name ) } </small>
</span>
</label>
` ;
} )
. join ( "" )
: ` <div class="empty-state">当前算法模型没有 STL 文件</div> ` ;
$ ( "stlList" ) . querySelectorAll ( "input[data-stl]" ) . forEach ( ( input ) => {
input . addEventListener ( "change" , async ( ) => {
const id = Number ( input . dataset . stl ) ;
if ( input . checked ) state . selectedStlIds . add ( id ) ;
else state . selectedStlIds . delete ( id ) ;
await applyStlSelectionChange ( ) ;
if ( ! state . stlFiles . length ) {
$ ( "stlList" ) . innerHTML = ` <div class="empty-state">当前算法模型没有 STL 文件</div> ` ;
renderAutoBoneOptions ( ) ;
return ;
}
const group ed = new Map ( ) ;
state . stlFiles . forEach ( ( file , index ) => {
const presentation = stlPresentation ( file ) ;
if ( ! grouped . has ( presentation . key ) ) {
grouped . set ( presentation . key , {
key : presentation . key ,
label : presentation . label ,
order : presentation . order ,
hierarchy : presentation . hierarchy ,
rows : [ ] ,
} ) ;
}
grouped . get ( presentation . key ) . rows . push ( { file , index , presentation } ) ;
} ) ;
$ ( "stlList" ) . innerHTML = [ ... grouped . values ( ) ]
. sort ( ( a , b ) => a . order - b . order || a . label . localeCompare ( b . label ) )
. map ( ( group ) => {
const rows = group . rows . sort ( ( a , b ) => (
a . presentation . itemOrder - b . presentation . itemOrder
|| String ( a . presentation . itemLabel ) . localeCompare ( String ( b . presentation . itemLabel ) )
|| a . index - b . index
) ) ;
const visibleCount = rows . filter ( ( { file } ) => stlMode ( file ) !== "hidden" ) . length ;
const collapsed = state . collapsedStlFamilies . has ( group . key ) ;
return `
<section class="stl-family ${ group . hierarchy ? "stl-hierarchy-group" : "" } ${ collapsed ? "collapsed" : "" } ">
<button class="stl-family-head" type="button" data-family-toggle=" ${ escapeHtml ( group . key ) } " title="收起/展开 ${ escapeHtml ( group . label ) } ">
<span> ${ escapeHtml ( group . label ) } </span>
<em> ${ visibleCount } / ${ rows . length } </em>
<i></i>
</button>
<div class="stl-family-list">
${ rows . map ( ( { file , index , presentation } ) => {
const id = normalizedStlId(file);
const mode = stlMode(file);
const color = stlColor(file, index);
return `
< button class = "stl-row stl-mode-${mode} ${mode !== " hidden " ? " active " : " "}" type = "button" data - stl - mode = "${id}" style = "--stl-color:${escapeHtml(color)}" title = "${escapeHtml(file.file_name)}" >
< span class = "stl-row-main" >
< strong > $ { escapeHtml ( compactStlName ( file ) ) } < / s t r o n g >
< / s p a n >
< span class = "stl-visibility-swatch ${mode}" aria - label = "${escapeHtml(STL_DISPLAY_LABELS[mode])}" > < / s p a n >
< / b u t t o n >
` ;
}).join("")}
</div>
</section>
` ;
} ) . join ( "" ) ;
$ ( "stlList" ) . querySelectorAll ( "[data-family-toggle]" ) . forEach ( ( button ) => {
button . addEventListener ( "click" , ( ) => {
const groupKey = button . dataset . familyToggle ;
if ( state . collapsedStlFamilies . has ( groupKey ) ) state . collapsedStlFamilies . delete ( groupKey ) ;
else state . collapsedStlFamilies . add ( groupKey ) ;
renderStl ( ) ;
} ) ;
} ) ;
$ ( "stlList" ) . querySelectorAll ( "[data-stl-mode]" ) . forEach ( ( button ) => {
button . addEventListener ( "click" , async ( ) => {
const id = Number ( button . dataset . stlMode ) ;
const file = state . stlFiles . find ( ( item ) => normalizedStlId ( item ) === id ) ;
if ( file ) await cycleStlMode ( file ) ;
} ) ;
} ) ;
renderAutoBoneOptions ( ) ;
}
async function selectAllStl ( ) {
if ( ! state . stlFiles . length ) return ;
state . selectedStlIds = new Set ( state . stlFiles . map ( ( file ) => Number ( file . id ) ) . filter ( Number . isFinite ) ) ;
await applyStlSelectionChange ( ) ;
await setAllStlMode ( "translucent" ) ;
}
async function invertStlSelection ( ) {
if ( ! state . stlFiles . length ) return ;
const next = new Set ( ) ;
const nextSelected = new Set ( ) ;
const nextModes = { } ;
state . stlFiles . forEach ( ( file ) => {
const id = Number ( file . id );
if ( Number . isFinite ( id ) && ! state . selectedStlIds . has ( id ) ) next . add ( id ) ;
const id = normalizedStlId ( file ) ;
if ( id != null && ! state . selectedStlIds . has ( id ) ) {
nextSelected . add ( id ) ;
nextModes [ id ] = "translucent" ;
}
} ) ;
state . selectedStlIds = next ;
state . selectedStlIds = nextSelected ;
state . stlDisplayModes = nextModes ;
await applyStlSelectionChange ( ) ;
}
async function applyStlSelectionChange ( ) {
markDirty ( "stl" ) ;
syncStlDisplayModesForSelection ( ) ;
renderStl ( ) ;
const loadedIds = new Set ( state . modelMeshes . map ( ( record ) => Number ( record . file ? . id ) ) . filter ( Number . isFinite ) ) ;
const needsLoad = [ ... state . selectedStlIds ] . some ( ( id ) => ! loadedIds . has ( Number ( id ) ) ) ;
@@ -1090,6 +1427,15 @@ function updateStretchButtons() {
} ) ;
}
function renderPoseSectionCollapse ( ) {
const manualCollapsed = Boolean ( state . collapsedPoseSections . manual ) ;
const autoCollapsed = Boolean ( state . collapsedPoseSections . auto ) ;
$ ( "poseManualBody" ) ? . classList . toggle ( "collapsed" , manualCollapsed ) ;
$ ( "poseAutoBody" ) ? . classList . toggle ( "collapsed" , autoCollapsed ) ;
$ ( "poseManualToggle" ) ? . classList . toggle ( "collapsed" , manualCollapsed ) ;
$ ( "poseAutoToggle" ) ? . classList . toggle ( "collapsed" , autoCollapsed ) ;
}
function applyPoseLockState ( ) {
const locked = Boolean ( state . poseControlsLocked ) ;
const selectors = [
@@ -1099,6 +1445,7 @@ function applyPoseLockState() {
"#resetRotationBtn" ,
"#resetTransformBtn" ,
"#resetFlipBtn" ,
"#openAutoModalBtn" ,
"#autoSettingsPanel input" ,
"#autoSettingsPanel button" ,
"#savePoseBtn" ,
@@ -1518,9 +1865,11 @@ function applyModelDetail() {
state . modelMeshes . forEach ( ( record ) => {
const material = record . mesh . material ;
if ( ! material ) return ;
material . opacity = detail . opacity ;
material . transparent = detail . opacity < 1 ;
material . depthWr ite = detail . depthWrite ;
const mode = stlMode ( record . file ) ;
const opacity = mode === "solid" ? 1 : Math . min ( 0.52 , detail . opacity ) ;
material . opac ity = opacity ;
material . transparent = opacity < 1 ;
material . depthWrite = mode === "solid" ? true : false ;
material . wireframe = detail . wireframe ;
material . metalness = detail . metalness ;
material . roughness = detail . roughness ;
@@ -1530,9 +1879,8 @@ function applyModelDetail() {
}
function applyStlVisibility ( ) {
const selected = state . selectedStlIds ;
state . modelMeshes . forEach ( ( record ) => {
record . mesh . visible = selected . has ( Number ( record . file ? . id ) ) ;
record . mesh . visible = stlMode ( record . file ) !== "hidden" ;
} ) ;
if ( state . modelBoundsFrame ) state . modelBoundsFrame . visible = state . modelMeshes . some ( ( record ) => record . mesh . visible ) ;
if ( state . dicomGroup ) state . dicomGroup . visible = state . fusionMode !== "model" ;
@@ -2325,8 +2673,9 @@ async function buildStlModels(volume, onProgress = null) {
const geometry = await loader . loadAsync ( ` /api/stl/file? ${ params . toString ( ) } ` ) ;
geometry . computeVertexNormals ( ) ;
geometry . computeBoundingBox ( ) ;
const color = stlColor ( file , index ) ;
const material = new THREE . MeshStandardMaterial ( {
color : STL _COLORS [ index % STL _COLORS . length ] ,
color ,
metalness : detail . metalness ,
roughness : detail . roughness ,
transparent : true ,
@@ -2338,9 +2687,9 @@ async function buildStlModels(volume, onProgress = null) {
const mesh = new THREE . Mesh ( geometry , material ) ;
mesh . name = file . segment _name || file . file _name ;
mesh . userData . file = file ;
mesh . userData . color = cssC olor ( index ) ;
mesh . userData . color = color ;
state . rawModelGroup . add ( mesh ) ;
const record = { mesh , file , color : cssColor ( Number ( file . id ) || index ) , index : Number ( file . id ) || index } ;
const record = { mesh , file , color , index } ;
state . modelMeshes . push ( record ) ;
freshRecords . push ( record ) ;
onProgress ? . ( ( ( index + 1 ) / Math . max ( files . length , 1 ) ) * 100 ) ;
@@ -2385,6 +2734,7 @@ async function buildStlModels(volume, onProgress = null) {
updateBaseModelScaleFromBounds ( ) ;
if ( size . x || size . y || size . z ) addModelBoundsFrame ( size ) ;
applyStlVisibility ( ) ;
applyModelDetail ( ) ;
applyPose ( ) ;
scheduleMappingDraw ( ) ;
onProgress ? . ( 100 ) ;
@@ -2524,6 +2874,39 @@ function readAutoSettings() {
} ;
}
function resetAutoSettings ( ) {
if ( state . poseControlsLocked ) return ;
const checkedDefaults = {
autoX : true ,
autoY : true ,
autoZ : true ,
autoScale : true ,
} ;
Object . entries ( checkedDefaults ) . forEach ( ( [ id , value ] ) => {
const input = $ ( id ) ;
if ( input ) input . checked = value ;
} ) ;
const valueDefaults = {
autoSampleSlices : 9 ,
autoSampleSlicesNumber : 9 ,
autoBoneReward : 1 ,
autoOutsidePenalty : 0.1 ,
autoMovePenalty : 0 ,
autoScalePenalty : 0 ,
autoIterations : 30 ,
autoCandidates : 36 ,
} ;
Object . entries ( valueDefaults ) . forEach ( ( [ id , value ] ) => {
const input = $ ( id ) ;
if ( input ) input . value = value ;
} ) ;
renderAutoBoneOptions ( ) ;
state . autoResult = null ;
setAutoProgress ( false ) ;
setAutoState ( "已恢复" , "ok" ) ;
$ ( "autoResult" ) . textContent = "迭代参数已恢复初始设置。" ;
}
function poseDelta ( from , to ) {
return {
rotateX : Number ( ( ( to . rotateX || 0 ) - ( from . rotateX || 0 ) ) . toFixed ( 3 ) ) ,
@@ -2708,10 +3091,9 @@ function scorePoseAgainstBoneImages(pose, context, settings) {
continue ;
}
const lum = image . data [ ( y * image . width + x ) * 4 ] || 0 ;
if ( lum >= 170 ) hitReward += 1 ;
else if ( lum >= 115 ) hitReward += 0.45 ;
else if ( lum >= 80 ) hitReward += 0.16 ;
else missPenalty += 0.9 - lum / 255 ;
if ( lum >= BONE _BRIGHT _THRESHOLD ) hitReward += BONE _FIXED _REWARD ;
else if ( lum >= BONE _EDGE _THRESHOLD ) hitReward += BONE _EDGE _REWARD ;
else missPenalty += 1.05 - ( lum / Math . max ( BONE _EDGE _THRESHOLD , 1 ) ) * 0.28 ;
contributors += 1 ;
}
const safeContributors = Math . max ( contributors , 1 ) ;
@@ -2778,6 +3160,14 @@ function candidateScore(pose, settings = null) {
return geometryScore ;
}
function describeAutoReference ( settings ) {
const records = autoReferenceRecords ( settings ) ;
if ( ! records . length ) return "当前没有可用参考 STL" ;
const names = records . slice ( 0 , 4 ) . map ( ( record ) => compactStlName ( record . file ) ) ;
const suffix = records . length > names . length ? ` 等 ${ records . length } 个 ` : "" ;
return ` 参考 STL: ${ names . join ( "、" ) } ${ suffix } ` ;
}
function estimateCoarsePose ( settings ) {
const records = autoReferenceRecords ( settings ) ;
const modelBox = visibleModelBox ( records ) ;
@@ -2867,7 +3257,7 @@ async function runAutoFine(options = {}) {
setAutoProgress ( true , 22 , "采样参考 STL" , "正在从勾选的参考 STL 和骨窗切片中构建评分样本。" , "sample" ) ;
settings . imageContext = await prepareAutoFineImageContext ( settings ) ;
const imageContextText = settings . imageContext
? ` 已采样 ${ settings . imageContext . samples . length } 个 STL 点、 ${ settings . imageContext . frames . length } 张骨窗切片。 `
? ` 已采样 ${ settings . imageContext . samples . length } 个 STL 点、 ${ settings . imageContext . frames . length } 张骨窗切片;仅高亮骨性像素参与正向奖励 。 `
: "未能建立骨窗像素样本,将退回 DICOM/STL 三维盒体评分。" ;
setAutoProgress ( true , 38 , "建立候选队列" , imageContextText , "score" ) ;
await waitForUiFrame ( ) ;
@@ -2875,6 +3265,7 @@ async function runAutoFine(options = {}) {
let best = { pose : { ... state . pose } , score : candidateScore ( state . pose , settings ) , mode : "初始位姿" } ;
let evaluated = 1 ;
const coarsePose = estimateCoarsePose ( settings ) ;
const coarseSeed = coarsePose ? { pose : coarsePose , mode : "粗配准种子" } : null ;
if ( coarsePose ) {
const coarseScore = candidateScore ( coarsePose , settings ) ;
evaluated += 1 ;
@@ -2897,27 +3288,40 @@ async function runAutoFine(options = {}) {
const nextValue = key === "scale" ? Math . max ( 0.2 , Math . min ( 3 , baseValue + delta ) ) : baseValue + delta ;
return { ... sourcePose , [ key ] : Number ( nextValue . toFixed ( key === "scale" ? 3 : 4 ) ) } ;
} ;
enabledKeys . forEach ( ( key ) => {
candidates . push ( { pose : poseWithDelta ( best . pose , key , stepForKey ( key ) ) , mode : ` ${ key } + ` } ) ;
candidates . push ( { pose : poseWithDelta ( best . pose , key , - stepForKey ( key ) ) , mode : ` ${ key } - ` } ) ;
} ) ;
for ( let left = 0 ; left < enabledKeys . length ; left += 1 ) {
for ( let right = left + 1 ; right < enabledKeys . length ; right += 1 ) {
[ - 1 , 1 ] . forEach ( ( leftSign ) => {
[ - 1 , 1 ] . forEach ( ( rightSign ) => {
const leftKey = enabledKeys [ left ] ;
const rightKey = enabledKeys [ right ] ;
const pose = poseWithDelta (
poseWithDelta ( best . pose , leftK ey, stepForKey ( leftKey , 0.65 ) * leftSign ) ,
rightKey ,
stepForKey ( rightKey , 0.65 ) * rightSign ,
) ;
candidates . push ( { pose , mode : ` ${ leftKey } / ${ rightKey } 联合 ` } ) ;
const searchBases = [ { pose : best . pose , mode : best . mode } ] ;
if ( coarseSeed && poseSignature ( coarseSeed . pose ) !== poseSignature ( best . pose ) ) searchBases . push ( coarseSeed ) ;
const seenCandidates = new Set ( ) ;
const addCandidate = ( pose , mode ) => {
const signature = poseSignature ( pose ) ;
i f ( seenCandidates . has ( signature ) ) return ;
seenCandidates . add ( signature ) ;
candidates . push ( { pose , mode } ) ;
} ;
searchBases . forEach ( ( base ) => {
enabledKeys . forEach ( ( key ) => {
addCandidate ( poseWithDelta ( base . pose , k ey, stepForKey ( key ) ) , ` ${ base . mode } · ${ key } + ` ) ;
addCandidate ( poseWithDelta ( base . pose , key , - stepForKey ( key ) ) , ` ${ base . mode } · ${ key } - ` ) ;
} ) ;
for ( let left = 0 ; left < enabledKeys . length ; left += 1 ) {
for ( let right = left + 1 ; right < enabledKeys . length ; right += 1 ) {
[ - 1 , 1 ] . forEach ( ( leftSign ) => {
[ - 1 , 1 ] . forEach ( ( rightSign ) => {
const leftKey = enabledKeys [ left ] ;
const rightKey = enabledKeys [ right ] ;
const pose = poseWithDelta (
poseWithDelta ( base . pose , leftKey , stepForKey ( leftKey , 0.65 ) * leftSign ) ,
rightKey ,
stepForKey ( rightKey , 0.65 ) * rightSign ,
) ;
addCandidate ( pose , ` ${ base . mode } · ${ leftKey } / ${ rightKey } 联合 ` ) ;
} ) ;
} ) ;
} ) ;
}
}
}
for ( const candidate of candidates . slice ( 0 , settings . candidates ) ) {
} ) ;
const candidateLimit = Math . max ( settings . candidates , settings . candidates * searchBases . length ) ;
const candidatesToEvaluate = candidates . slice ( 0 , candidateLimit ) ;
for ( const candidate of candidatesToEvaluate ) {
const pose = candidate . pose ;
const score = candidateScore ( pose , settings ) ;
evaluated += 1 ;
@@ -2925,12 +3329,12 @@ async function runAutoFine(options = {}) {
}
const progress = 40 + ( ( index + 1 ) / Math . max ( rounds . length , 1 ) ) * 48 ;
const scoreText = Number . isFinite ( best . score ) ? best . score . toFixed ( 4 ) : "不可用" ;
setAutoProgress ( true , progress , ` 候选评分 ${ index + 1 } / ${ rounds . length } ` , ` 本轮评估 ${ candidates . length } 个候选,累计 ${ evaluated } 个;当前最佳 ${ best . mode } , score ${ scoreText } 。 ` , "score" ) ;
setAutoProgress ( true , progress , ` 候选评分 ${ index + 1 } / ${ rounds . length } ` , ` 本轮评估 ${ candidatesToEvaluate . length } / ${ candidates. length } 个候选,累计 ${ evaluated } 个;当前最高分候选 ${ best . mode } , score ${ scoreText } 。 ` , "score" ) ;
await waitForUiFrame ( ) ;
}
setAutoProgress ( true , 94 , "应用最佳位姿" , "正在把本轮最高分候选写回位姿控件和融合视图。" , "apply" ) ;
state . pose = { ... best . pose } ;
state . autoResult = { ... best , evaluated , settings : { ... autoSettingsForStorage ( settings ) , scoring : settings . imageContext ? "bone-window-stl-samples" : "reference-stl-box-overlap" , window : "bone" } } ;
state . autoResult = { ... best , evaluated , settings : { ... autoSettingsForStorage ( settings ) , scoring : settings . imageContext ? "fixed-bright- bone-window-stl-samples" : "reference-stl-box-overlap" , window : "bone" , boneThreshold : BONE _BRIGHT _THRESHOLD , boneReward : BONE _FIXED _REWARD } } ;
state . lastAutoPose = { before , after : { ... state . pose } } ;
state . autoDeltaBaseline = { ... before } ;
renderPoseControls ( ) ;
@@ -2939,11 +3343,12 @@ async function runAutoFine(options = {}) {
const changed = poseSignature ( best . pose ) !== poseSignature ( before ) ;
setAutoState ( changed ? "微调完成" : "微调无变化" , changed ? "ok" : "warn" ) ;
const scoreText = Number . isFinite ( best . score ) ? best . score . toFixed ( 4 ) : "不可用" ;
const referenceText = describeAutoReference ( settings ) ;
$ ( "autoResult" ) . textContent = changed
? ` 最佳 候选:${ best . mode } ; score ${ scoreText } ;评估 ${ evaluated } 个候选; ${ settings . imageContext ? "骨窗像素采样 + STL 参考评分" : "参考 STL 盒体评分" } 。 `
: ` 已完成评估但最佳仍为初始位姿 ; score ${ scoreText } ;评估 ${ evaluated } 个候选。说明本轮候选没有比当前位姿更高分;若图像上仍错位,通常需要先调整大角度旋转或粗配准后再微调 。 ` ;
? ` 本轮最高分 候选:${ best . mode } ; score ${ scoreText } ;评估 ${ evaluated } 个候选; ${ settings . imageContext ? ` 骨窗固定命中奖励,亮度 ≥ ${ BONE _BRIGHT _THRESHOLD } 统一计为骨骼命中 ` : "参考 STL 盒体评分" } ; ${ referenceText } 。 `
: ` 本轮未找到比当前位姿更高分的候选 ; score ${ scoreText } ;评估 ${ evaluated } 个候选。这不代表已配准正确,只说明当前搜索范围和参考 STL 下没有更高分; ${ referenceText } ;自动微调目前只调整平移/缩放,明显旋转错位需要先手动校正 。 ` ;
updateAutoPoseResult ( before , state . pose ) ;
setAutoProgress ( true , 100 , changed ? "微调完成" : "未找到更优候选 " , changed ? ` 已应用 ${ best . mode } 。 ` : "候选均未超过当前位姿,因此 没有强行移动模型。" , "apply" ) ;
setAutoProgress ( true , 100 , changed ? "微调完成" : "本轮未改动 " , changed ? ` 已应用 ${ best . mode } 。 ` : "没有强行移动模型;请检查参考 STL 和大角度旋转 。" , "apply" ) ;
markDirty ( ) ;
} catch ( error ) {
setAutoState ( "微调失败" , "error" ) ;
@@ -2999,7 +3404,15 @@ async function saveRegistration(nextStatus = null) {
algorithm _model : file . algorithm _model ,
} ) ) ,
transform : state . pose ,
module _styles : Object . fromEntries ( selectedFiles . map ( ( file , index ) => [ file . file _name , { color : cssColor ( index ) , opacity : modelDetailSettings ( ) . opacity , detail : state . modelDetail } ] ) ) ,
module _styles : Object . fromEntries ( selectedFiles . map ( ( file , index ) => {
const mode = stlMode ( file ) ;
return [ file . file _name , {
color : stlColor ( file , index ) ,
opacity : mode === "solid" ? 1 : 0.52 ,
detail : state . modelDetail ,
mode ,
} ] ;
} ) ) ,
dicom _reference : {
series _uid : selectedSeries . series _uid ,
series _number : selectedSeries . series _number ,
@@ -3128,17 +3541,22 @@ function wireEvents() {
$ ( "saveBtn" ) . addEventListener ( "click" , ( ) => saveRegistration ( ) ) ;
$ ( "statusBtn" ) . addEventListener ( "click" , ( ) => saveRegistration ( state . registrationStatus === "registered" ? "unregistered" : "registered" ) ) ;
$ ( "notes" ) . addEventListener ( "input" , ( ) => markDirty ( "notes" ) ) ;
$ ( "select AllStlBtn" ) . addEventListener ( "click" , select AllStl) ;
$ ( "invertStlBtn" ) . addEventListener ( "click" , invertStlSelection ) ;
$ ( "cycle AllStlMode Btn" ) . addEventListener ( "click" , cycle AllStlMode ) ;
$ ( "resetRotationBtn" ) . addEventListener ( "click" , ( ) => resetPose ( "rotation" ) ) ;
$ ( "resetTransformBtn" ) . addEventListener ( "click" , ( ) => resetPose ( "transform" ) ) ;
$ ( "resetFlipBtn" ) . addEventListener ( "click" , ( ) => resetPose ( "flip" ) ) ;
$ ( "poseManualToggle" ) . addEventListener ( "click" , ( ) => {
state . collapsedPoseSections . manual = ! state . collapsedPoseSections . manual ;
renderPoseSectionCollapse ( ) ;
} ) ;
$ ( "poseAutoToggle" ) . addEventListener ( "click" , ( ) => {
state . collapsedPoseSections . auto = ! state . collapsedPoseSections . auto ;
renderPoseSectionCollapse ( ) ;
} ) ;
$ ( "stretchXBtn" ) . addEventListener ( "click" , ( ) => stretchAxis ( "x" ) ) ;
$ ( "stretchYBtn" ) . addEventListener ( "click" , ( ) => stretchAxis ( "y" ) ) ;
$ ( "stretchZBtn" ) . addEventListener ( "click" , ( ) => stretchAxis ( "z" ) ) ;
$ ( "openAutoModalBtn" ) . addEventListener ( "click" , ( ) => {
$ ( "autoSettingsPanel" ) . scrollIntoView ( { block : "nearest" , behavior : "smooth" } ) ;
} ) ;
$ ( "openAutoModalBtn" ) . addEventListener ( "click" , resetAutoSettings ) ;
$ ( "suggestBoneBtn" ) . addEventListener ( "click" , suggestBoneSelection ) ;
$ ( "autoCoarseBtn" ) ? . addEventListener ( "click" , runAutoCoarse ) ;
$ ( "autoFineBtn" ) ? . addEventListener ( "click" , runAutoFine ) ;
@@ -3308,6 +3726,7 @@ function wireEvents() {
}
wireEvents ( ) ;
renderPoseSectionCollapse ( ) ;
if ( state . token ) {
bootstrap ( ) ;