@@ -57,6 +57,7 @@ const state = {
activeCase : null ,
series : [ ] ,
selectedSeriesUid : "" ,
registrationSeriesUid : "" ,
stlFiles : [ ] ,
selectedStlIds : new Set ( ) ,
algorithmModel : "" ,
@@ -94,6 +95,8 @@ const state = {
modelMeshes : [ ] ,
stlSignature : "" ,
baseModelScale : 1 ,
modelBoundsFrame : null ,
modelLocalBounds : null ,
dicomSceneBox : null ,
mappingViewport : { scale : 1 , offsetX : 0 , offsetY : 0 , rotation : 0 } ,
mappingDrag : null ,
@@ -458,6 +461,7 @@ function clearDetail() {
state . stlFiles = [ ] ;
state . selectedStlIds = new Set ( ) ;
state . selectedSeriesUid = "" ;
state . registrationSeriesUid = "" ;
$ ( "activeTitle" ) . textContent = "未选择 CT" ;
$ ( "activeMeta" ) . textContent = "从左侧选择一个完整关联检查" ;
$ ( "activeTags" ) . innerHTML = "" ;
@@ -511,6 +515,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
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 ;
state . registrationSeriesUid = savedSeries ? . series _uid || "" ;
state . selectedSeriesUid = shouldUseSavedSeries ? savedSeries . series _uid : defaultSeries ? . series _uid || "" ;
const selected = currentSeries ( ) ;
state . sliceIndex = selected ? Math . max ( 0 , Math . floor ( ( Number ( selected . count ) || 1 ) / 2 ) ) : 0 ;
@@ -581,14 +586,15 @@ function renderActiveHeader() {
const row = state . activeCase ;
if ( ! row ) return ;
const selected = currentSeries ( ) ;
const registration = registrationSeries ( ) ;
const bodyTags = bodyPartTags ( row ) ;
const selectedLabels = selected ? . annotation _labels ? . length ? selected . annotation _labels . filter ( ( label ) => ! String ( label ) . includes ( "略过" ) && ! String ( label ) . includes ( "不采用" ) ) : [ ] ;
const selectedLabels = registration ? . annotation _labels ? . length ? registration . annotation _labels . filter ( ( label ) => ! String ( label ) . includes ( "略过" ) && ! String ( label ) . includes ( "不采用" ) ) : [ ] ;
$ ( "activeTitle" ) . textContent = ` ${ row . ct _number } · ${ row . algorithm _model || "未指定模型" } ` ;
$ ( "activeMeta" ) . textContent = ` ${ row . patient _name || row . upp _patient _name || "-" } · ${ row . patient _id || "-" } · ${ fmtDateTime ( row . study _date , row . study _time ) || "检查时间未知" } · ${ bodyTags . join ( "、" ) || row . study _description || "部位未知" } · ${ Number ( row . series _count || 0 ) } 个 DICOM 序列 · STL ${ Number ( row . stl _file _count || 0 ) } 个 ` ;
const tags = [
` <span class="tag ${ state . registrationStatus === "registered" ? "green" : "amber" } "> ${ statusText ( state . registrationStatus ) } </span> ` ,
` <span class="tag"> ${ escapeHtml ( state . algorithmModel || row . algorithm _model || "未指定模型" ) } </span> ` ,
... ( selected ? [ ` <span class="tag"> ${ escapeHtml ( selected . description || "当前序列" ) } </span> ` ] : [ ] ) ,
... ( registration ? [ ` <span class="tag">配准 ${ escapeHtml ( registration . description || "当前序列" ) } </span> ` ] : [ ] ) ,
... selectedLabels . map ( ( tag ) => ` <span class="tag"> ${ escapeHtml ( tag ) } </span> ` ) ,
... bodyTags . map ( ( tag ) => ` <span class="tag"> ${ escapeHtml ( tag ) } </span> ` ) ,
] ;
@@ -601,23 +607,37 @@ function renderSeries() {
$ ( "seriesCount" ) . textContent = state . series . length ;
$ ( "seriesList" ) . innerHTML = state . series . length
? sortedSeriesForDisplay ( state . series )
. map ( ( item ) => seriesCardHtml ( item , item . series _uid === state . selectedSeriesUid ) )
. map ( ( item ) => seriesCardHtml ( item , item . series _uid === state . selectedSeriesUid , item . series _uid === state . registrationSeriesUid ))
. join ( "" )
: ` <div class="empty-state">未读取到 DICOM 序列</div> ` ;
document . querySelectorAll ( ".series-card[data-series]" ) . forEach ( ( button ) => {
button . addEventListener ( "click" , ( ) => selectSeries ( button . dataset . series ) ) ;
document . querySelectorAll ( ".series-card[data-series]" ) . forEach ( ( card ) => {
card . addEventListener ( "click" , ( event ) => {
if ( event . target . closest ( ".series-check" ) ) return ;
selectSeries ( card . dataset . series ) ;
} ) ;
card . addEventListener ( "keydown" , ( event ) => {
if ( ! [ "Enter" , " " ] . includes ( event . key ) ) return ;
event . preventDefault ( ) ;
selectSeries ( card . dataset . series ) ;
} ) ;
} ) ;
document . querySelectorAll ( ".series-check[data-registration-series]" ) . forEach ( ( button ) => {
button . addEventListener ( "click" , async ( event ) => {
event . stopPropagation ( ) ;
await setRegistrationSeries ( button . dataset . registrationSeries ) ;
} ) ;
} ) ;
updateSliceControl ( ) ;
renderDicomAnnotation ( ) ;
}
function seriesCardHtml ( item , active = false ) {
function seriesCardHtml ( item , active = false , registrationSelected = false ) {
const labels = ( item . annotation _labels || [ ] ) . map ( ( label ) => ` <i> ${ escapeHtml ( label ) } </i> ` ) . join ( "" ) ;
const time = [ fmtTime ( item . first _time || item . series _time ) , fmtTime ( item . last _time ) ] . filter ( Boolean ) ;
const skipped = isSkippedSeries ( item ) ;
return `
<button class="series-card ${ active ? "active" : "" } ${ skipped ? "skipped" : "" } " data-series=" ${ escapeHtml ( item . series _uid ) } " title=" ${ escapeHtml ( item . description ) } ">
<spa n class="series-check" aria-hidden="true"> ${ active ? "✓" : "" } </spa n>
<div class="series-card ${ active ? "active" : "" } ${ registrationSelected ? "registration-selected" : "" } ${ skipped ? "skipped" : "" } " data-series=" ${ escapeHtml ( item . series _uid ) } " title=" ${ escapeHtml ( item . description ) } " role="button" tabindex="0" >
<butto n class="series-check" data-registration-series=" ${ escapeHtml ( item . series _uid ) } " type="button" aria-label="设为配准 DICOM"> ${ registrationSelected ? "✓" : "" } </butto n>
<strong> ${ escapeHtml ( item . description || "未命名序列" ) } </strong>
<span>拍摄 ${ escapeHtml ( time . length > 1 ? ` ${ time [ 0 ] } - ${ time [ 1 ] } ` : time [ 0 ] || "-" ) } </span>
<small> ${ escapeHtml ( item . slice _thickness || "厚度未知" ) } · 序列 ${ escapeHtml ( item . series _number || "-" ) } · ${ escapeHtml ( item . rows || "-" ) } × ${ escapeHtml ( item . columns || "-" ) } · ${ escapeHtml ( item . modality || "CT" ) } · ${ Number ( item . count || 0 ) } 张</small>
@@ -625,20 +645,39 @@ function seriesCardHtml(item, active = false) {
${ item . body _part _dicom ? ` <i> ${ escapeHtml ( item . body _part _dicom ) } </i> ` : "" }
${ labels || ` <i>未标注</i> ` }
</div>
</button >
</div >
` ;
}
async function select Series( uid ) {
function registration Series( ) {
return state . series . find ( ( item ) => item . series _uid === state . registrationSeriesUid ) || null ;
}
async function setRegistrationSeries ( uid ) {
if ( ! uid ) return ;
if ( uid !== state . selectedSeriesUid ) {
await selectSeries ( uid , { markRegistration : true } ) ;
return ;
}
state . registrationSeriesUid = uid ;
markDirty ( "series" ) ;
renderSeries ( ) ;
renderActiveHeader ( ) ;
}
async function selectSeries ( uid , options = { } ) {
if ( ! uid || uid === state . selectedSeriesUid ) return ;
if ( ! ( await confirmChange ( ) ) ) return ;
state . selectedSeriesUid = uid ;
if ( options . markRegistration ) state . registrationSeriesUid = uid ;
const selected = currentSeries ( ) ;
state . sliceIndex = selected ? Math . max ( 0 , Math . floor ( ( Number ( selected . count ) || 1 ) / 2 ) ) : 0 ;
state . sliceRangeStart = 0 ;
state . sliceRangeEnd = selected ? Math . max ( 0 , Number ( selected . count || 1 ) - 1 ) : 0 ;
setSaveState ( "序列未保存" , "warn ") ;
if ( options . markRegistration ) markDirty ( "series ") ;
else setSaveState ( "正在浏览序列" , "" ) ;
renderSeries ( ) ;
renderActiveHeader ( ) ;
renderDicomAnnotation ( ) ;
await loadFusion ( ) ;
}
@@ -669,7 +708,7 @@ function renderStl() {
const id = Number ( input . dataset . stl ) ;
if ( input . checked ) state . selectedStlIds . add ( id ) ;
else state . selectedStlIds . delete ( id ) ;
setSaveState ( "模型未保存" , "warn ") ;
markDirty ( "stl ") ;
renderStl ( ) ;
await loadFusion ( ) ;
} ) ;
@@ -677,6 +716,27 @@ function renderStl() {
renderAutoBoneOptions ( ) ;
}
async function selectAllStl ( ) {
if ( ! state . stlFiles . length ) return ;
state . selectedStlIds = new Set ( state . stlFiles . map ( ( file ) => Number ( file . id ) ) . filter ( Number . isFinite ) ) ;
markDirty ( "stl" ) ;
renderStl ( ) ;
await loadFusion ( ) ;
}
async function invertStlSelection ( ) {
if ( ! state . stlFiles . length ) return ;
const next = new Set ( ) ;
state . stlFiles . forEach ( ( file ) => {
const id = Number ( file . id ) ;
if ( Number . isFinite ( id ) && ! state . selectedStlIds . has ( id ) ) next . add ( id ) ;
} ) ;
state . selectedStlIds = next ;
markDirty ( "stl" ) ;
renderStl ( ) ;
await loadFusion ( ) ;
}
function stlLikelyScore ( file ) {
const textValue = ` ${ file . family || "" } ${ file . segment _name || "" } ${ file . file _name || "" } ${ file . category || "" } ` . toLowerCase ( ) ;
const priority = [
@@ -1012,12 +1072,19 @@ function clearGroup(group) {
function clearFusion ( ) {
clearGroup ( state . dicomGroup ) ;
clearGroup ( state . rawModelGroup ) ;
if ( state . rawModelGroup ) {
state . rawModelGroup . position . set ( 0 , 0 , 0 ) ;
state . rawModelGroup . rotation . set ( 0 , 0 , 0 ) ;
state . rawModelGroup . scale . set ( 1 , 1 , 1 ) ;
}
state . volumeMeta = null ;
state . volumeScene = null ;
state . modelMeshes = [ ] ;
state . dicomPlane = null ;
state . dicomPoints = null ;
state . dicomSlicePlanes = [ ] ;
state . modelBoundsFrame = null ;
state . modelLocalBounds = null ;
$ ( "fusionMeta" ) . textContent = "DICOM - · STL -" ;
$ ( "fusionStatus" ) . textContent = "等待选择 DICOM 与 STL" ;
updateSliceControl ( ) ;
@@ -1449,6 +1516,7 @@ async function buildDicomVolume(volume) {
const centerIndex = Number ( volume . center || 0 ) ;
const total = Math . max ( 1 , Number ( volume . total || 1 ) ) ;
const indices = volume . indices || [ ] ;
const frames = volume . frames || [ ] ;
const centerPosition = indices . length ? Math . floor ( indices . length / 2 ) : 0 ;
state . volumeScene = {
width ,
@@ -1477,7 +1545,7 @@ async function buildDicomVolume(volume) {
const rangeDepth = Math . max ( 0.015 , Math . abs ( sliceToSceneZ ( localEnd ) - sliceToSceneZ ( localStart ) ) ) ;
const rangeBox = new THREE . LineSegments (
new THREE . EdgesGeometry ( new THREE . BoxGeometry ( width * 0.94 , height * 0.94 , rangeDepth ) ) ,
new THREE . LineBasicMaterial ( { color : 0xfacc15 , transparent : true , opacity : 0.56 } ) ,
new THREE . LineBasicMaterial ( { color : 0x38bdf8 , transparent : true , opacity : 0.24 } ) ,
) ;
rangeBox . position . z = ( sliceToSceneZ ( localStart ) + sliceToSceneZ ( localEnd ) ) / 2 ;
state . dicomGroup . add ( rangeBox ) ;
@@ -1527,7 +1595,6 @@ async function buildDicomVolume(volume) {
const pointPositions = [ ] ;
const pointColors = [ ] ;
const frames = volume . frames || [ ] ;
for ( const [ index , frame ] of frames . entries ( ) ) {
const image = await loadImageData ( frame , 180 ) ;
const stride = Math . max ( 2 , Math . round ( Math . max ( image . width , image . height ) / fusionDetail . pointGrid ) ) ;
@@ -1567,17 +1634,53 @@ async function buildDicomVolume(volume) {
state . dicomSceneBox = new THREE . Box3 ( ) . setFromObject ( state . dicomGroup ) ;
}
function updateBaseModelScaleFromBounds ( ) {
const size = state . modelLocalBounds ? . size ;
if ( ! size ) return ;
const maxModelSize = Math . max ( Number ( size . x ) || 0 , Number ( size . y ) || 0 , Number ( size . z ) || 0 , 1 ) ;
const volumeSize = state . volumeScene
? Math . max ( state . volumeScene . width , state . volumeScene . height , state . volumeScene . depth , 1 )
: 4.8 ;
state . baseModelScale = ( volumeSize / maxModelSize ) * 0.92 ;
}
function addModelBoundsFrame ( size ) {
if ( ! state . rawModelGroup || ! size ) return ;
const frame = new THREE . LineSegments (
new THREE . EdgesGeometry ( new THREE . BoxGeometry ( size . x , size . y , size . z ) ) ,
new THREE . LineBasicMaterial ( {
color : 0xfacc15 ,
transparent : true ,
opacity : 0.96 ,
depthTest : false ,
depthWrite : false ,
toneMapped : false ,
} ) ,
) ;
frame . name = "model-bounds" ;
frame . renderOrder = 1000 ;
state . rawModelGroup . add ( frame ) ;
state . modelBoundsFrame = frame ;
}
async function buildStlModels ( volume , onProgress = null ) {
const signature = stlSelectionSignature ( ) ;
if ( signature && signature === state . stlSignature && state . rawModelGroup ? . children . length ) {
updateBaseModelScaleFromBounds ( ) ;
applyPose ( ) ;
applyModelDetail ( ) ;
if ( ! state . modelBoundsFrame && state . modelLocalBounds ? . size ) addModelBoundsFrame ( state . modelLocalBounds . size ) ;
scheduleMappingDraw ( ) ;
onProgress ? . ( 100 ) ;
return ;
}
state . rawModelGroup . position . set ( 0 , 0 , 0 ) ;
state . rawModelGroup . rotation . set ( 0 , 0 , 0 ) ;
state . rawModelGroup . scale . set ( 1 , 1 , 1 ) ;
clearGroup ( state . rawModelGroup ) ;
state . modelMeshes = [ ] ;
state . modelBoundsFrame = null ;
state . modelLocalBounds = null ;
state . stlSignature = signature ;
const files = state . stlFiles . filter ( ( file ) => state . selectedStlIds . has ( Number ( file . id ) ) ) ;
if ( ! files . length ) {
@@ -1616,10 +1719,28 @@ async function buildStlModels(volume, onProgress = null) {
state . modelMeshes . push ( { mesh , file , color : cssColor ( index ) , index } ) ;
onProgress ? . ( ( ( index + 1 ) / Math . max ( files . length , 1 ) ) * 100 ) ;
}
const bbox = new THREE . Box3 ( ) . setFromObject ( state . rawModelGroup ) ;
const bbox = new THREE . Box3 ( ) ;
state . modelMeshes . forEach ( ( record ) => {
record . mesh . geometry . computeBoundingBox ? . ( ) ;
if ( record . mesh . geometry . boundingBox ) bbox . union ( record . mesh . geometry . boundingBox ) ;
} ) ;
const center = new THREE . Vector3 ( ) ;
const size = new THREE . Vector3 ( ) ;
bbox . getCenter ( center ) ;
state . rawModelGroup . position . set ( - center . x , - center . y , - center . z ) ;
bbox . getSize ( size ) ;
state . modelMeshes . forEach ( ( record ) => {
const geometry = record . mesh . geometry ;
geometry . translate ( - center . x , - center . y , - center . z ) ;
geometry . computeBoundingBox ? . ( ) ;
geometry . computeBoundingSphere ? . ( ) ;
geometry . computeVertexNormals ? . ( ) ;
} ) ;
state . modelLocalBounds = {
center : { x : center . x , y : center . y , z : center . z } ,
size : { x : size . x , y : size . y , z : size . z } ,
} ;
updateBaseModelScaleFromBounds ( ) ;
addModelBoundsFrame ( size ) ;
applyPose ( ) ;
scheduleMappingDraw ( ) ;
onProgress ? . ( 100 ) ;
@@ -1917,11 +2038,21 @@ async function saveRegistration(nextStatus = null) {
} ) ,
} ) ;
state . registrationStatus = status ;
if ( state . activeCase ) state . activeCase . registration _status = status ;
state . registrationSeriesUid = selectedSeries . series _uid ;
if ( state . activeCase ) {
state . activeCase . registration _status = status ;
state . activeCase . series _instance _uid = selectedSeries . series _uid ;
state . activeCase . series _description = selectedSeries . description || "" ;
}
const existing = state . cases . find ( ( row ) => sameCase ( row , state . activeCase ) ) ;
if ( existing ) existing . registration _status = status ;
if ( existing ) {
existing . registration _status = status ;
existing . series _instance _uid = selectedSeries . series _uid ;
existing . series _description = selectedSeries . description || "" ;
}
resetDirty ( ) ;
renderCases ( ) ;
renderSeries ( ) ;
renderActiveHeader ( ) ;
} catch ( error ) {
setSaveState ( error . message , "error" ) ;
@@ -2007,6 +2138,8 @@ function wireEvents() {
$ ( "saveBtn" ) . addEventListener ( "click" , ( ) => saveRegistration ( ) ) ;
$ ( "statusBtn" ) . addEventListener ( "click" , ( ) => saveRegistration ( state . registrationStatus === "registered" ? "unregistered" : "registered" ) ) ;
$ ( "notes" ) . addEventListener ( "input" , ( ) => markDirty ( "notes" ) ) ;
$ ( "selectAllStlBtn" ) . addEventListener ( "click" , selectAllStl ) ;
$ ( "invertStlBtn" ) . addEventListener ( "click" , invertStlSelection ) ;
$ ( "resetRotationBtn" ) . addEventListener ( "click" , ( ) => resetPose ( "rotation" ) ) ;
$ ( "resetTransformBtn" ) . addEventListener ( "click" , ( ) => resetPose ( "transform" ) ) ;
$ ( "resetFlipBtn" ) . addEventListener ( "click" , ( ) => resetPose ( "flip" ) ) ;