3D Image Viewer: Reconstruction and Synchronization Mechanism

Aldo YangAldo Yang
8 min read

目標

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
  1. 建立全域座標系統: 定義 globalCoord 物件儲存三個軸向 (x, y, z) 的影像索引,並根據影像數量初始化。

  2. 配置視圖參數: 定義每個視圖 (view) 的設定,包含影像來源、路徑、軸向設定 (對應的全域軸、物理範圍 bounds、是否反轉 reverse、滑桿 ID、輔助線 ID)。

  3. 綁定滑桿事件: 初始化滑桿範圍與初始值,並綁定 input 事件,使滑桿數值變更時更新 globalCoord 並觸發視圖更新。

  4. 綁定點擊事件: 綁定影像 click 事件,計算點擊位置相對於影像尺寸的比例,經過反轉 (若有)、映射至物理範圍、限制與捨入後,更新 globalCoord 並觸發視圖更新。

  5. 同步更新視圖: 建立 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() {
        // ... (更新輔助線與影像,參考完整對話) ...
      }
    }
  • 滑桿事件處理:在 CTViewaddEvents() 方法中,為每個滑桿綁定 input 事件:
    axis.sliderElement.addEventListener('input', (e) => {
      globalCoord[axis.globalAxis] = parseInt(e.target.value);
      updateAllViews();
    });
  • 點擊事件處理:在 CTViewaddEvents() 方法中,為影像容器綁定 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 映射到實際座標範圍,確保索引的準確性。

0
Subscribe to my newsletter

Read articles from Aldo Yang directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Aldo Yang
Aldo Yang