3D Image Viewer: Reconstruction and Synchronization Mechanism

目標
3D CT 影像瀏覽器在多視角(coronal, sagittal, axial)下,如何透過滑桿 (slider) 操作與影像點擊,同步更新影像顯示與輔助線 (crosshair) 位置。
核心步驟
flowchart TD
A[使用者互動:點擊或滑桿操作]
B[判斷互動類型]
C[若為滑桿:直接取得滑桿數值作為基礎索引]
D[若為點擊:取得點擊位置於影像內的相對位置]
E[計算點擊位置的比例(normalized 值)]
F[根據設定決定是否反轉該比例]
G[利用預設範圍(bounds)將比例映射到物理座標範圍]
H[限制並捨入結果,得到有效影像索引]
I[更新 globalCoord 中對應軸的數值]
J[呼叫 updateAllViews 進行視圖更新]
K[對每個視圖:更新滑桿數值同步 globalCoord]
L[對每個視圖:依據 globalCoord 與 bounds 計算輔助線位置]
M[更新輔助線的 CSS 屬性,顯示正確位置]
N[對每個視圖:依據 imageIndexSource 選擇正確影像索引]
O[更新影像的 src 屬性以顯示正確圖片]
A --> B
B -- 滑桿操作 --> C
B -- 點擊操作 --> D
D --> E
E --> F
F --> G
G --> H
C --> I
H --> I
I --> J
J --> K
J --> L
L --> M
J --> N
N --> O
建立全域座標系統: 定義
globalCoord
物件儲存三個軸向 (x, y, z) 的影像索引,並根據影像數量初始化。配置視圖參數: 定義每個視圖 (view) 的設定,包含影像來源、路徑、軸向設定 (對應的全域軸、物理範圍
bounds
、是否反轉reverse
、滑桿 ID、輔助線 ID)。綁定滑桿事件: 初始化滑桿範圍與初始值,並綁定
input
事件,使滑桿數值變更時更新globalCoord
並觸發視圖更新。綁定點擊事件: 綁定影像
click
事件,計算點擊位置相對於影像尺寸的比例,經過反轉 (若有)、映射至物理範圍、限制與捨入後,更新globalCoord
並觸發視圖更新。同步更新視圖: 建立
updateAllViews()
函數,遍歷所有視圖並執行其update()
方法,同步更新滑桿值、輔助線位置與顯示影像。
步驟實現
環境設置:
準備 HTML 結構,包含三個視角的
<div>
(內含<img>
與輔助線<div>
) 以及對應的滑桿<input type="range">
。準備 CSS 樣式,設定影像、滑桿與輔助線的基本樣式與佈局。
準備 JavaScript 程式碼,包含影像資料陣列、全域座標變數、視圖設定物件與
CTView
類別。
全域座標系統
globalCoord
:
// 影像資料 (檔名依實際情況修改)
let coronalImages = [...];
let sagittalImages = [...];
let axialImages = [...];
// 全域軸範圍
const NX = sagittalImages.length;
const NY = coronalImages.length;
const NZ = axialImages.length;
// 全域座標 (儲存影像索引),初始置中
let globalCoord = {
x: Math.floor(NX / 2),
y: Math.floor(NY / 2),
z: Math.floor(NZ / 2)
};
- 視圖設定
viewConfigs
:
const viewConfigs = [
{
name: "coronal",
containerId: "coronal-view",
imageElementId: "coronal-image",
imageIndexSource: "y", // 以 global.y 選圖
path: "images/coronal/",
dataset: coronalImages,
axes: {
horizontal: {
globalAxis: "x",
bounds: [-40, 125], // 依實際資料調整
reverse: false,
sliderId: "coronal-slider-horizontal",
crosshairId: "coronal-crosshair-v"
},
vertical: {
globalAxis: "z",
bounds: [-30, 90], // 依實際資料調整
reverse: true,
sliderId: "coronal-slider-vertical",
crosshairId: "coronal-crosshair-h",
sliderDirection: "rtl" // 預設 rtl,可個別覆寫
}
}
},
// sagittal, axial 視圖設定 (類似 coronal)
];
CTView
類別:
class CTView {
constructor(config) {
// ... (初始化程式碼,參考完整對話) ...
this.initSliders();
this.addEvents();
}
initSliders() {
for (const key in this.axes) {
const axis = this.axes[key];
let range = (axis.globalAxis === 'x') ? NX : ((axis.globalAxis === 'y') ? NY : NZ);
axis.sliderElement.min = 0;
axis.sliderElement.max = range - 1;
axis.sliderElement.value = globalCoord[axis.globalAxis];
}
}
addEvents() {
for (const key in this.axes) {
const axis = this.axes[key];
axis.sliderElement.addEventListener('input', (e) => {
globalCoord[axis.globalAxis] = parseInt(e.target.value);
updateAllViews();
});
}
this.container.addEventListener('click', (e) => {
// ... (點擊事件處理,參考完整對話) ...
updateAllViews();
});
}
update() {
// ... (更新輔助線與影像,參考完整對話) ...
}
}
- 滑桿事件處理:在
CTView
的addEvents()
方法中,為每個滑桿綁定input
事件:
axis.sliderElement.addEventListener('input', (e) => {
globalCoord[axis.globalAxis] = parseInt(e.target.value);
updateAllViews();
});
- 點擊事件處理:在
CTView
的addEvents()
方法中,為影像容器綁定click
事件:
this.container.addEventListener('click', (e) => {
const rect = this.imageElement.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
for (const key in this.axes) {
const axis = this.axes[key];
let norm = (key === 'horizontal') ? (clickX / rect.width) : (clickY / rect.height);
if (axis.reverse) norm = 1 - norm;
let range = (axis.globalAxis === 'x') ? NX : ((axis.globalAxis === 'y') ? NY : NZ);
let newVal = axis.bounds[0] + norm * (axis.bounds[1] - axis.bounds[0]);
globalCoord[axis.globalAxis] = Math.max(0, Math.min(Math.round(newVal), range - 1));
}
updateAllViews();
});
可能錯誤:
rect
計算錯誤:確保使用getBoundingClientRect()
取得相對於視窗的位置,而非相對於父元素。- 比例計算錯誤:
norm
應為點擊位置與影像寬度或高度的比例。 bounds
映射錯誤:使用正確的公式newVal = bounds[0] + norm * (bounds[1] - bounds[0])
。範圍限制錯誤:使用
Math.max(0, Math.min(Math.round(newVal), range - 1))
確保索引在有效範圍內。視圖更新
update()
方法:
update() {
const rect = this.imageElement.getBoundingClientRect();
for (const key in this.axes) {
const axis = this.axes[key];
let norm = (globalCoord[axis.globalAxis] - axis.bounds[0]) / (axis.bounds[1] - axis.bounds[0]);
if (axis.reverse) norm = 1 - norm;
if (key === 'horizontal') {
axis.crosshairElement.style.left = (norm * rect.width) + 'px';
} else {
axis.crosshairElement.style.top = (norm * rect.height) + 'px';
}
axis.sliderElement.value = globalCoord[axis.globalAxis];
}
const idx = globalCoord[this.config.imageIndexSource];
this.imageElement.src = this.config.path + this.config.dataset[idx];
}
updateAllViews()
函數:
const views = viewConfigs.map(config => new CTView(config));
function updateAllViews() {
views.forEach(view => view.update());
}
- 完整實現
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>3D CT Viewer - Refactored & Unified with New Vertical Slider</title>
<style>
/* 全域樣式與 slider 樣式 */
body {
display: flex;
flex-wrap: wrap;
font-family: sans-serif;
margin: 0;
}
.view-container {
margin: 10px;
display: inline-block;
}
.view-content {
display: flex;
align-items: flex-start;
justify-content: flex-start;
position: relative;
}
.image-wrapper {
position: relative;
}
.view-image {
max-width: 300px;
display: block;
}
:root {
--crosshair-thickness: 0.5px;
--crosshair-color: red;
--crosshair-opacity: 0.7;
}
.crosshair-line {
position: absolute;
background-color: var(--crosshair-color);
opacity: var(--crosshair-opacity);
}
.crosshair-h {
height: var(--crosshair-thickness);
}
.crosshair-v {
width: var(--crosshair-thickness);
}
.slider-container {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 0;
}
.slider-horizontal {
width: 300px;
}
.slider-container-vertical {
display: flex;
flex-direction: column;
align-items: center;
margin-left: 10px;
}
/* 新 vertical slider 樣式:使用 writing-mode 與 direction CSS 變數
預設 direction 為 rtl,但可透過 --slider-direction 個別覆寫 */
.slider-vertical {
writing-mode: vertical-lr;
direction: var(--slider-direction, rtl);
width: 8px;
margin: 0;
}
</style>
</head>
<body>
<!-- coronal view -->
<div class="view-container" id="coronal-container">
<div class="view-content">
<div class="image-wrapper" id="coronal-wrapper">
<img id="coronal-img" class="view-image" src="" alt="Coronal View">
<!-- 輔助線:crosshair-h 決定垂直位置,crosshair-v 決定水平位置 -->
<div id="coronal-crosshair-h" class="crosshair-line crosshair-h"></div>
<div id="coronal-crosshair-v" class="crosshair-line crosshair-v"></div>
</div>
<div class="slider-container-vertical">
<input type="range" id="coronal-slider-v" class="slider-vertical" step="1">
</div>
</div>
<div class="slider-container">
<input type="range" id="coronal-slider-h" class="slider-horizontal" step="1">
</div>
</div>
<!-- sagittal view -->
<div class="view-container" id="sagittal-container">
<div class="view-content">
<div class="image-wrapper" id="sagittal-wrapper">
<img id="sagittal-img" class="view-image" src="" alt="Sagittal View">
<div id="sagittal-crosshair-h" class="crosshair-line crosshair-h"></div>
<div id="sagittal-crosshair-v" class="crosshair-line crosshair-v"></div>
</div>
<div class="slider-container-vertical">
<input type="range" id="sagittal-slider-v" class="slider-vertical" step="1">
</div>
</div>
<div class="slider-container">
<input type="range" id="sagittal-slider-h" class="slider-horizontal" step="1">
</div>
</div>
<!-- axial view -->
<div class="view-container" id="axial-container">
<div class="view-content">
<div class="image-wrapper" id="axial-wrapper">
<img id="axial-img" class="view-image" src="" alt="Axial View">
<div id="axial-crosshair-h" class="crosshair-line crosshair-h"></div>
<div id="axial-crosshair-v" class="crosshair-line crosshair-v"></div>
</div>
<div class="slider-container-vertical">
<input type="range" id="axial-slider-v" class="slider-vertical" step="1">
</div>
</div>
<div class="slider-container">
<input type="range" id="axial-slider-h" class="slider-horizontal" step="1">
</div>
</div>
<script>
/**********************
* 影像資料與全域軸設定
**********************/
// 影像資料(請依實際檔名修改)
let coronalImages = [
"Screenshot 2025-03-17 at 9.27.35.png",
"Screenshot 2025-03-17 at 9.27.45.png",
"Screenshot 2025-03-17 at 9.27.48.png",
];
let sagittalImages = [
"Screenshot 2025-03-17 at 9.26.23.png",
"Screenshot 2025-03-17 at 9.26.16.png",
"Screenshot 2025-03-17 at 9.26.14.png",
];
let axialImages = [
"Screenshot 2025-03-17 at 9.20.44.png",
"Screenshot 2025-03-17 at 9.20.41.png",
"Screenshot 2025-03-17 at 9.20.39.png",
];
// 全域軸範圍依影像數量決定
const NX = sagittalImages.length; // 用於 global.x
const NY = coronalImages.length; // 用於 global.y
const NZ = axialImages.length; // 用於 global.z
// 全域座標(儲存影像索引),初始置中
let globalCoord = {
x: Math.floor(NX / 2),
y: Math.floor(NY / 2),
z: Math.floor(NZ / 2)
};
/**********************
* 統一配置設定
*
* 每個 view 的設定分為兩部分:
* 1. 影像選擇設定: imageIndexSource 指定用哪個全域軸決定顯示哪張影像,
* path 與 dataset 分別為影像路徑與檔案名稱陣列。
* 2. 軸向操作設定:每個 view 的 axes 為一物件,包含 horizontal 與 vertical,
* 每個軸定義:
* - globalAxis:對應全域座標 ("x", "y", "z")
* - bounds:該軸在物理座標下的上下界
* - reverse:是否反轉 normalized 值(true 表示要 1 - normalized)
* - sliderId:該軸對應的 slider 元素 ID
* - crosshairId:該軸對應的輔助線元素 ID
* - (vertical 軸可選) sliderDirection:控制 slider 的方向,預設為 "rtl"
**********************/
const viewConfigs = [
{
name: "coronal",
containerId: "coronal-wrapper",
imageElementId: "coronal-img",
imageIndexSource: "y", // 以 global.y 選圖
path: "coronal/",
dataset: coronalImages,
axes: {
horizontal: {
globalAxis: "x",
bounds: [-40, 125],
reverse: false,
sliderId: "coronal-slider-h",
crosshairId: "coronal-crosshair-v"
},
vertical: {
globalAxis: "z",
bounds: [-15.5, 81],
reverse: true,
sliderId: "coronal-slider-v",
crosshairId: "coronal-crosshair-h",
sliderDirection: "rtl" // 預設 rtl,可個別覆寫
}
}
},
{
name: "sagittal",
containerId: "sagittal-wrapper",
imageElementId: "sagittal-img",
imageIndexSource: "x", // 以 global.x 選圖
path: "sagittal/",
dataset: sagittalImages,
axes: {
horizontal: {
globalAxis: "y",
bounds: [-33, 94],
reverse: false,
sliderId: "sagittal-slider-h",
crosshairId: "sagittal-crosshair-v"
},
vertical: {
globalAxis: "z",
bounds: [-6.3, 73],
reverse: true,
sliderId: "sagittal-slider-v",
crosshairId: "sagittal-crosshair-h",
sliderDirection: "rtl" // 此處設定為 ltr
}
}
},
{
name: "axial",
containerId: "axial-wrapper",
imageElementId: "axial-img",
imageIndexSource: "z", // 以 global.z 選圖
path: "axial/",
dataset: axialImages,
axes: {
horizontal: {
globalAxis: "x",
bounds: [-40, 126],
reverse: false,
sliderId: "axial-slider-h",
crosshairId: "axial-crosshair-v"
},
vertical: {
globalAxis: "y",
bounds: [-12, 78.6],
reverse: false,
sliderId: "axial-slider-v",
crosshairId: "axial-crosshair-h",
sliderDirection: "ltr"
}
}
}
];
/**********************
* CTView 類別:根據統一設定初始化、綁定事件與更新畫面
**********************/
class CTView {
constructor(config) {
this.config = config;
this.container = document.getElementById(config.containerId);
this.imageElement = document.getElementById(config.imageElementId);
this.axes = {};
for (const key in config.axes) {
const axisConfig = config.axes[key];
axisConfig.sliderElement = document.getElementById(axisConfig.sliderId);
axisConfig.crosshairElement = document.getElementById(axisConfig.crosshairId);
// 若為 vertical 軸,設定 slider 的 CSS 變數 --slider-direction
if (key === "vertical") {
axisConfig.sliderElement.style.setProperty("--slider-direction", axisConfig.sliderDirection || "rtl");
}
this.axes[key] = axisConfig;
}
this.initSliders();
this.addEvents();
}
initSliders() {
// 為每個軸設定 slider 範圍與初始值
for (const key in this.axes) {
const axis = this.axes[key];
let range = (axis.globalAxis === "x") ? NX :
(axis.globalAxis === "y") ? NY :
(axis.globalAxis === "z") ? NZ : 0;
axis.sliderElement.min = 0;
axis.sliderElement.max = range - 1;
axis.sliderElement.value = globalCoord[axis.globalAxis];
}
}
addEvents() {
// 為每個軸的 slider 綁定事件
for (const key in this.axes) {
const axis = this.axes[key];
axis.sliderElement.addEventListener("input", (e) => {
globalCoord[axis.globalAxis] = parseInt(e.target.value);
updateAllViews();
});
}
// 點擊事件:根據點擊位置更新每個軸的值
this.container.addEventListener("click", (e) => {
const rect = this.imageElement.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
["horizontal", "vertical"].forEach((axisKey) => {
const axis = this.axes[axisKey];
let norm = (axisKey === "horizontal") ? clickX / rect.width : clickY / rect.height;
if (axis.reverse) norm = 1 - norm;
const newVal = axis.bounds[0] + norm * (axis.bounds[1] - axis.bounds[0]);
let range = (axis.globalAxis === "x") ? NX :
(axis.globalAxis === "y") ? NY :
(axis.globalAxis === "z") ? NZ : 0;
globalCoord[axis.globalAxis] = Math.max(0, Math.min(Math.round(newVal), range - 1));
});
updateAllViews();
});
}
update() {
const rect = this.imageElement.getBoundingClientRect();
// 更新每個軸的輔助線與 slider 顯示
for (const key in this.axes) {
const axis = this.axes[key];
let norm = (globalCoord[axis.globalAxis] - axis.bounds[0]) / (axis.bounds[1] - axis.bounds[0]);
if (axis.reverse) norm = 1 - norm;
if (key === "horizontal") {
const pos = norm * rect.width;
axis.crosshairElement.style.left = pos + "px";
axis.crosshairElement.style.top = "0px";
axis.crosshairElement.style.width = "var(--crosshair-thickness)";
axis.crosshairElement.style.height = rect.height + "px";
} else if (key === "vertical") {
const pos = norm * rect.height;
axis.crosshairElement.style.top = pos + "px";
axis.crosshairElement.style.left = "0px";
axis.crosshairElement.style.width = rect.width + "px";
axis.crosshairElement.style.height = "var(--crosshair-thickness)";
}
// 同步 slider 顯示
axis.sliderElement.value = globalCoord[axis.globalAxis];
}
// 依據 imageIndexSource 從 globalCoord 選圖
const idx = globalCoord[this.config.imageIndexSource];
this.imageElement.src = this.config.path + this.config.dataset[idx];
}
}
// 建立所有 view 實例
const views = viewConfigs.map(config => new CTView(config));
function adjustSliderSizes() {
views.forEach(view => {
const rect = view.imageElement.getBoundingClientRect();
view.axes.horizontal.sliderElement.style.width = rect.width + "px";
view.axes.vertical.sliderElement.style.height = rect.height + "px";
});
}
function updateAllViews() {
views.forEach(view => view.update());
adjustSliderSizes();
}
window.addEventListener("load", updateAllViews);
window.addEventListener("resize", updateAllViews);
</script>
</body>
</html>
結論
簡易的 3D 影像網頁瀏覽器實現,透過全域座標系統同步多個視角的影像顯示、滑桿操作與輔助線定位。
全域座標: 使用
globalCoord
物件統一管理各個軸向的影像索引。配置驅動: 透過
viewConfigs
物件集中管理每個視圖的設定,提高程式碼的可維護性。事件驅動: 利用滑桿的
input
事件與影像的click
事件觸發全域座標更新與視圖同步。比例映射: 將點擊位置轉換為比例,並利用
bounds
映射到實際座標範圍,確保索引的準確性。
Subscribe to my newsletter
Read articles from Aldo Yang directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
