Monday, August 03, 2026

Kernel Density Estimation (KDE)of Japanese Eel Egg Diameters







 
在從前,量化鰻魚卵巢發育狀態主要以測定卵徑大小為主。
這工作要取卵巢組織以組織固定液固定 ,三天後換成75%酒精,要連續換三次。
在組織穩定後取小塊卵巢組織將卵細胞從組織中分離,要用votex,甚至要用小鑷子慢慢剝。
之後剝下來的卵要照相,以血球計數盤為標準並合併 ImageJ 建立圓型直徑和像素(pixel)的直線關係式。再用ImageJ量測相片上的每一個卵細胞的像素回推其直徑,累積所有結果畫出Kernel Density Estimation (KDE)就可觀察卵巢的發育狀態,後面的工作既無聊又費眼!!!

現在既無聊又費眼可交給AI以前可能要一週的時間現在彈指就完成了....


















"""

批次分析:資料夾內所有 .jpg 圖檔的黑色圓形斑點(含重疊估計)直徑分布

======================================================================

【本版新增:標準尺 (Calibration Ruler) 校正】

------------------------------------------------------------------------

顯微鏡照片量到的直徑,原始單位是「像素 (pixel)」,並非實際物理尺寸。

要換算成真實尺寸(例如 µm、mm),需要一把「標準尺」做校正:


    做法:在同一台顯微鏡、同一倍率下,拍一張已知刻度的標準尺(載物台微尺,

    stage micrometer)或已知邊長的校正物體,量出「已知實際長度」對應了

    「多少像素」,即可得到換算比例:


        PIXELS_PER_UNIT = 量測到的像素長度 / 已知的實際長度


    之後所有偵測到的像素直徑,都可以用這個比例換算回真實尺寸:


        real_diameter = pixel_diameter / PIXELS_PER_UNIT

        real_area     = (π / 4) * real_diameter ** 2      # 面積與直徑的關係


本程式將這個「標準尺換算」整合進批次分析流程:

1. 掃描資料夾內所有 .jpg / .JPG 圖檔

2. 對每張圖:灰階化 -> Otsu 二值化 -> 形態學處理 -> 距離轉換 + Watershed

   分離重疊圓形 -> 計算每個圓形斑點的等效直徑 (pixel)

3. 用標準尺比例,將 pixel 直徑/面積換算成實際尺寸 (real_diameter, real_area)

4. 輸出:

     - 每張圖的三合一報告圖(原圖 / 分割結果 / 直徑密度圖,換算後單位)

     - all_diameters.csv:每顆斑點的 pixel 與換算後實際尺寸明細

     - summary_per_image.csv:每張圖的統計摘要(pixel 與實際尺寸)

     - individual_probability_distributions.png:「分別」每張圖片各自的直徑機率分布圖(Grid 排版)

     - total_probability_distribution.png:「總」全部圖片合併後的直徑機率分布圖

     - ruler_calibration_check.png:標準尺換算的「面積 vs 直徑」驗證圖與倍率對照表


依賴套件:opencv-python, numpy, scipy, scikit-image, matplotlib, pandas

"""


"""

批次分析:資料夾內所有 .jpg 圖檔的黑色圓形斑點(含重疊估計)直徑分布

======================================================================

【本版新增:全自動標準尺 (Calibration Ruler) 校正與整合】

------------------------------------------------------------------------

程式啟動時,會先讀取指定的校正圖片 (image_8fb5f9.jpg),利用 HSV 色彩過濾

抓取藍色參考圓,自動計算出它們的等效像素直徑,並與已知的實際直徑

(0.05, 0.1, 0.2, 0.4 mm) 做比對,求出精準的換算比例 (PIXELS_PER_UNIT)。


接著,將此比例自動套用於資料夾內所有的影像分析,將像素轉換為實際的物理單位 (mm)。


輸出:

     - calibration_curve.png:校正圖的「面積 vs 直徑」及「面積 vs 直徑平方」關係圖

     - 每張圖的三合一報告圖(原圖 / 分割結果 / 直徑密度圖)

     - all_diameters.csv:每顆斑點明細

     - summary_per_image.csv:每張圖的統計摘要

     - individual_probability_distributions.png

     - total_probability_distribution.png

"""


"""

批次分析:資料夾內所有 .jpg 圖檔的黑色圓形斑點(含重疊估計)直徑分布

======================================================================

【本版新增:全自動標準尺 (Calibration Ruler) 校正與整合】

------------------------------------------------------------------------

程式啟動時,會先讀取指定的校正圖片 (image_8fb5f9.jpg),利用 HSV 色彩過濾

抓取藍色參考圓,自動計算出它們的等效像素直徑,並與已知的實際直徑

(0.05, 0.1, 0.2, 0.4 mm) 做比對,求出精準的換算比例 (PIXELS_PER_UNIT)。


接著,將此比例自動套用於資料夾內所有的影像分析,將像素轉換為實際的物理單位 (um)。


【本次修改重點】

原本的程式在文件說明中提到會輸出:

    - individual_probability_distributions.png

    - total_probability_distribution.png

但主流程 main() 其實從未真正呼叫任何函式去產生這兩張圖。

本版新增了兩個繪圖函式,並在 main() 最後呼叫它們,讓程式真正輸出:

    1. individual_probability_distributions.png

       -> 每張圖片「各自」的直徑機率密度分布圖 (Grid 排版),

          並在圖上標示 n(顆數)、平均值、中位數、標準差等數值。

    2. total_probability_distribution.png

       -> 將「所有圖片」偵測到的直徑合併後,畫出總機率密度分布圖,

          同時用垂直線標出平均值 / 中位數位置,並在圖上以文字方塊

          列出:總顆數、平均值、中位數、標準差、最小值、最大值。

       -> 同時輸出對應的 total_probability_distribution_stats.csv,

          方便後續報告直接引用數值。


輸出:

     - calibration_curve.png:校正圖的「面積 vs 直徑」及「面積 vs 直徑平方」關係圖

     - 每張圖的三合一報告圖(原圖 / 分割結果 / 直徑密度圖)

     - all_diameters.csv:每顆斑點明細

     - summary_per_image.csv:每張圖的統計摘要

     - individual_probability_distributions.png(新增:含統計數值標示)

     - total_probability_distribution.png(新增:累積總分布圖,含統計數值標示)

     - total_probability_distribution_stats.csv(新增:總分布統計數值表)


依賴套件:opencv-python, numpy, scipy, scikit-image, matplotlib, pandas

"""

import os

import glob

import cv2

import numpy as np

import pandas as pd

import matplotlib.pyplot as plt

import matplotlib

from scipy import ndimage as ndi

from scipy.stats import gaussian_kde

from skimage.feature import peak_local_max

from skimage.segmentation import watershed

from skimage.measure import regionprops

import matplotlib.font_manager as fm


# ----------------------------------------------------------------------

# 0. 參數設定

# ----------------------------------------------------------------------

# <-- 請確認這是你要分析的資料夾路徑

FOLDER_PATH  = "/Users/yshuang/Documents/Python/2025.2026/2026.2347"

OUTPUT_DIR   = os.path.join(FOLDER_PATH, "analysis_output")


# 【標準尺自動校正參數】

CALIBRATION_IMG_NAME = "/Users/yshuang/Documents/Python/2025.2026/EggDiameter.jpg"  # 校正圖片的檔名 (需放在與程式同目錄或資料夾內)

KNOWN_DIAMETERS      = [50, 100, 200, 400]  # 圖片中藍色圓形的已知直徑

UNIT_LABEL           = "um"                  # 實際單位名稱(輸出圖中會直接顯示此英文/符號單位)


# 【形態學過濾參數】

MIN_AREA_PX  = 60      # 面積小於此值視為雜訊 / 碎屑,過濾掉

MIN_CIRC     = 0.45    # 圓形度門檻,小於此值視為纖維/不規則碎屑,過濾掉

MIN_DISTANCE = 8       # watershed 種子點之間的最小距離(像素)


# ----------------------------------------------------------------------

# 字型設定

# ----------------------------------------------------------------------

# 由於所有輸出圖片文字皆已改為英文,這裡僅保留基本設定,

# 不再需要特別偵測中文字型(若終端機列印仍含中文,不影響此設定)。

matplotlib.rcParams["axes.unicode_minus"] = False



# ----------------------------------------------------------------------

# 【功能 1】全自動標準尺校正與繪圖

# ----------------------------------------------------------------------

def auto_calibrate_and_plot(calib_img_path, known_diameters, out_plot_path):

    """

    讀取校正圖,擷取藍色圓形,計算像素直徑與實際直徑的比例,並輸出關係圖。

    回傳:平均的 PIXELS_PER_UNIT (pixel / unit)

    """

    img = cv2.imread(calib_img_path)

    if img is None:

        raise FileNotFoundError(f"找不到校正影像檔案: {calib_img_path}")


    # 抓取藍色圓形

    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)

    lower_blue, upper_blue = np.array([100, 80, 50]), np.array([130, 255, 255])

    mask = cv2.inRange(hsv, lower_blue, upper_blue)


    contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    contours = sorted(contours, key=cv2.contourArea, reverse=True)[:len(known_diameters)]


    if len(contours) != len(known_diameters):

        print(f"[警告] 找到的藍點數量({len(contours)})與設定的直徑數量({len(known_diameters)})不符!")


    pixel_areas = sorted([cv2.contourArea(c) for c in contours])

    known_diameters = sorted(known_diameters)


    print("\n=== [1] 自動標準尺校正程序 ===")

    ratios = []

    for d_real, a_px in zip(known_diameters, pixel_areas):

        # 從像素面積回推等效像素直徑: A = (pi/4) * d^2  =>  d = 2 * sqrt(A/pi)

        d_px = 2 * np.sqrt(a_px / np.pi)

        ratio = d_px / d_real

        ratios.append(ratio)

        print(f"已知直徑: {d_real:4.2f} {UNIT_LABEL} -> 像素面積: {a_px:7.1f} px, 等效直徑: {d_px:7.1f} px, 換算: {ratio:7.2f} px/{UNIT_LABEL}")


    mean_ratio = np.mean(ratios)

    print(f"-> 最終採用平均換算比例: 1 {UNIT_LABEL} = {mean_ratio:.2f} 像素 (px)\n")


    # 繪製與儲存關係圖(圖內文字:英文)

    x_diameters = np.array(known_diameters)

    y_areas = np.array(pixel_areas)


    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

    ax1.plot(x_diameters, y_areas, marker='o', linestyle='-', color='b',

             markersize=8, label="Measured points")

    ax1.set_title("Pixel Area vs. Diameter (Quadratic)", fontsize=12)

    ax1.set_xlabel(f"Diameter ({UNIT_LABEL})")

    ax1.set_ylabel("Area (pixels)")

    ax1.legend(loc="best")

    ax1.grid(True, linestyle='--', alpha=0.7)


    ax2.plot(x_diameters ** 2, y_areas, marker='s', linestyle='-', color='r',

             markersize=8, label="Measured points")

    ax2.set_title("Pixel Area vs. Diameter Squared (Linear)", fontsize=12)

    ax2.set_xlabel(f"Diameter Squared ({UNIT_LABEL}\u00b2)")

    ax2.set_ylabel("Area (pixels)")

    ax2.legend(loc="best")

    ax2.grid(True, linestyle='--', alpha=0.7)


    plt.tight_layout()

    plt.savefig(out_plot_path, dpi=150)

    plt.show(fig)

    print(f"已輸出校正關係圖: {out_plot_path}\n")


    return mean_ratio



# ----------------------------------------------------------------------

# 輔助函式

# ----------------------------------------------------------------------

def pixel_diameter_to_real(diameter_px, pixels_per_unit):

    return diameter_px / pixels_per_unit



def calculate_circle_area(diameter):

    return (np.pi / 4) * (diameter ** 2)



# ----------------------------------------------------------------------

# 【功能 2】核心函式:對單一影像執行「偵測 + 重疊分離 + 直徑估計」

# ----------------------------------------------------------------------

def analyze_single_image(image_path, pixels_per_unit):

    img_bgr = cv2.imread(image_path)

    if img_bgr is None:

        raise FileNotFoundError(f"無法讀取影像:{image_path}")


    img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)

    gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)


    blur = cv2.GaussianBlur(gray, (5, 5), 0)

    _, binary = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)


    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))

    opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=2)

    closed = cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel, iterations=2)


    distance = ndi.distance_transform_edt(closed)

    coords = peak_local_max(distance, min_distance=MIN_DISTANCE, labels=closed.astype(bool))

    mask_peaks = np.zeros(distance.shape, dtype=bool)

    mask_peaks[tuple(coords.T)] = True

    markers, _ = ndi.label(mask_peaks)


    labels_ws = watershed(-distance, markers, mask=closed.astype(bool))


    diameters_px, kept_labels = [], []

    for p in regionprops(labels_ws):

        area = p.area

        if area < MIN_AREA_PX:

            continue

        perimeter = p.perimeter if p.perimeter > 0 else 1e-6

        if (4 * np.pi * area / (perimeter ** 2)) < MIN_CIRC:

            continue


        diameters_px.append(2 * np.sqrt(area / np.pi))

        kept_labels.append(p.label)


    diameters_px = np.array(diameters_px)

    diameters_real = pixel_diameter_to_real(diameters_px, pixels_per_unit)  # 換算實際尺寸


    overlay = img_rgb.copy()

    mask_keep = np.isin(labels_ws, kept_labels)

    overlay_mask = np.zeros_like(gray)

    overlay_mask[mask_keep] = 255

    contours, _ = cv2.findContours(overlay_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    cv2.drawContours(overlay, contours, -1, (255, 0, 0), 2)


    grad = cv2.morphologyEx(labels_ws.astype(np.int32).astype(np.uint8), cv2.MORPH_GRADIENT, np.ones((3, 3), np.uint8))

    overlay[grad > 0] = [255, 255, 0]


    return img_rgb, overlay, diameters_px, diameters_real



def save_single_image_report(filename, img_rgb, overlay, diameters_real, out_path):

    fig, axes = plt.subplots(1, 3, figsize=(20, 6))

    axes[0].imshow(img_rgb)

    axes[0].set_title(f"Original Image\n{filename}")

    axes[0].axis("off")


    axes[1].imshow(overlay)

    axes[1].set_title(f"Segmentation Result\nDetected count = {len(diameters_real)}")

    axes[1].axis("off")


    axes[2].hist(diameters_real, bins=20, density=True, alpha=0.55,

                 color="steelblue", edgecolor="black", label="Histogram (density)")

    if len(diameters_real) > 1:

        kde = gaussian_kde(diameters_real)

        x_range = np.linspace(diameters_real.min(), diameters_real.max(), 300)

        axes[2].plot(x_range, kde(x_range), color="crimson", lw=2, label="KDE")

    axes[2].set_xlabel(f"Diameter ({UNIT_LABEL})")

    axes[2].set_ylabel("Density")

    axes[2].set_title(f"Diameter Distribution (unit: {UNIT_LABEL})")

    axes[2].legend(loc="best")


    plt.tight_layout()

    plt.savefig(out_path, dpi=150)

    plt.show(fig)



# ----------------------------------------------------------------------

# 【功能 3】新增:各別圖片的機率密度分布圖 (Grid 排版) + 統計數值標示(英文)

# ----------------------------------------------------------------------

def plot_individual_distributions(diameters_by_file_real, out_path):

    """

    針對每張圖片各自的直徑資料,畫出直方圖 + KDE 密度曲線,

    並在每張子圖右上角標示:顆數 n、平均值、中位數、標準差(英文標示)。

    """

    filenames = list(diameters_by_file_real.keys())

    n_files = len(filenames)

    if n_files == 0:

        print("[警告] 沒有可用資料,略過 individual_probability_distributions.png")

        return


    ncols = min(4, n_files)

    nrows = int(np.ceil(n_files / ncols))

    fig, axes = plt.subplots(nrows, ncols, figsize=(5 * ncols, 4 * nrows), squeeze=False)

    axes = axes.flatten()


    for i, filename in enumerate(filenames):

        ax = axes[i]

        d = np.asarray(diameters_by_file_real[filename])


        if len(d) == 0:

            ax.set_title(f"{filename}\n(No spots detected)")

            ax.axis("off")

            continue


        ax.hist(d, bins=15, density=True, alpha=0.55, color="steelblue",

                edgecolor="black", label="Histogram (density)")

        if len(d) > 1:

            kde = gaussian_kde(d)

            x_range = np.linspace(d.min(), d.max(), 300)

            ax.plot(x_range, kde(x_range), color="crimson", lw=2, label="KDE")


        stats_text = (

            f"n = {len(d)}\n"

            f"Mean = {d.mean():.2f} {UNIT_LABEL}\n"

            f"Median = {np.median(d):.2f} {UNIT_LABEL}\n"

            f"Std = {d.std():.2f} {UNIT_LABEL}"

        )

        ax.text(

            0.97, 0.97, stats_text, transform=ax.transAxes,

            fontsize=9, va="top", ha="right",

            bbox=dict(boxstyle="round", facecolor="white", alpha=0.85),

        )

        ax.set_xlabel(f"Diameter ({UNIT_LABEL})")

        ax.set_ylabel("Density")

        ax.set_title(filename, fontsize=10)

        ax.legend(loc="upper left", fontsize=8)


    # 關掉多餘的空白子圖

    for j in range(n_files, len(axes)):

        axes[j].axis("off")


    plt.tight_layout()

    plt.savefig(out_path, dpi=150)

    plt.show(fig)

    print(f"已輸出各別機率分布圖: {out_path}")



# ----------------------------------------------------------------------

# 【功能 4】新增:累積(總) 機率密度分布圖 + 統計數值標示(英文) + 統計 CSV

# ----------------------------------------------------------------------

def plot_total_distribution(diameters_by_file_real, out_path):

    """

    將所有圖片偵測到的直徑合併,畫出「總」機率密度分布圖,

    並用垂直虛線標出平均值/中位數位置,圖上以文字方塊列出完整統計數值(英文),

    同時輸出對應的統計數值 CSV。

    """

    valid_arrays = [np.asarray(v) for v in diameters_by_file_real.values() if len(v) > 0]

    if len(valid_arrays) == 0:

        print("[警告] 沒有可用資料,略過 total_probability_distribution.png")

        return


    all_d = np.concatenate(valid_arrays)

    n = len(all_d)

    mean_v = all_d.mean()

    median_v = np.median(all_d)

    std_v = all_d.std()

    min_v = all_d.min()

    max_v = all_d.max()


    fig, ax = plt.subplots(figsize=(10, 7))

    ax.hist(all_d, bins=30, density=True, alpha=0.55, color="steelblue",

            edgecolor="black", label="Histogram (probability density)")


    if n > 1:

        kde = gaussian_kde(all_d)

        x_range = np.linspace(min_v, max_v, 400)

        ax.plot(x_range, kde(x_range), color="crimson", lw=2.5, label="KDE curve")


    ax.axvline(mean_v, color="darkgreen", linestyle="--", lw=1.8,

               label=f"Mean = {mean_v:.2f} {UNIT_LABEL}")

    ax.axvline(median_v, color="orange", linestyle=":", lw=1.8,

               label=f"Median = {median_v:.2f} {UNIT_LABEL}")


    stats_text = (

        f"Total count n = {n}\n"

        f"Mean = {mean_v:.2f} {UNIT_LABEL}\n"

        f"Median = {median_v:.2f} {UNIT_LABEL}\n"

        f"Std = {std_v:.2f} {UNIT_LABEL}\n"

        f"Min = {min_v:.2f} {UNIT_LABEL}\n"

        f"Max = {max_v:.2f} {UNIT_LABEL}"

    )

    ax.text(

        0.98, 0.98, stats_text, transform=ax.transAxes,

        fontsize=11, va="top", ha="right",

        bbox=dict(boxstyle="round", facecolor="white", alpha=0.9),

    )

    # ✅ 新增這行:將 X 軸範圍限定在 0 到 100 (例如 0~100 µm)

    ax.set_xlim(0, 200)

    ax.set_xlabel(f"Diameter ({UNIT_LABEL})")

    ax.set_ylabel("Probability Density")

    ax.set_title(f"Cumulative Diameter Distribution ({len(diameters_by_file_real)} images, {n} spots)")

    ax.legend(loc="lower right", bbox_to_anchor=(1.0, 0.35)) # # 以右下角為基準,向向上移(Y軸方向調整,0 是最底部,1 是最頂部)

    ax.grid(True, linestyle="--", alpha=0.4)


    plt.tight_layout()

    plt.savefig(out_path, dpi=150)

    plt.show(fig)

    print(f"已輸出累積(總)機率分布圖: {out_path}")


    # 同步輸出統計數值 CSV,方便報告直接引用

    stats_df = pd.DataFrame([{

        "count": n,

        f"mean_{UNIT_LABEL}": mean_v,

        f"median_{UNIT_LABEL}": median_v,

        f"std_{UNIT_LABEL}": std_v,

        f"min_{UNIT_LABEL}": min_v,

        f"max_{UNIT_LABEL}": max_v,

    }])

    stats_csv_path = os.path.splitext(out_path)[0] + "_stats.csv"

    stats_df.to_csv(stats_csv_path, index=False, encoding="utf-8-sig")

    print(f"已輸出累積(總)統計數值表: {stats_csv_path}")



# ----------------------------------------------------------------------

# 主流程:批次掃描

# ----------------------------------------------------------------------

def main():

    os.makedirs(OUTPUT_DIR, exist_ok=True)


    # 1. 執行標準尺校正 (取得自動化 PIXELS_PER_UNIT)

    calib_path = CALIBRATION_IMG_NAME if os.path.exists(CALIBRATION_IMG_NAME) else os.path.join(FOLDER_PATH, CALIBRATION_IMG_NAME)


    if os.path.exists(calib_path):

        out_calib_plot = os.path.join(OUTPUT_DIR, "calibration_curve.png")

        pixels_per_unit = auto_calibrate_and_plot(calib_path, KNOWN_DIAMETERS, out_calib_plot)

    else:

        print(f"[警告] 找不到校正影像 '{CALIBRATION_IMG_NAME}',將強制使用比例: 1.0")

        pixels_per_unit = 1.0


    # 2. 獲取要批次分析的圖片 (並排除校正圖片本身以免干擾數據)

    jpg_files = sorted(set(glob.glob(os.path.join(FOLDER_PATH, "*.[jJ][pP][gG]"))))

    jpg_files = [f for f in jpg_files if os.path.basename(f) != CALIBRATION_IMG_NAME]


    if not jpg_files:

        print(f"[警告] 資料夾 {FOLDER_PATH} 中沒有需要分析的圖檔。")

        return


    print(f"=== [2] 開始批次分析 ({len(jpg_files)} 張圖檔) ===")

    all_records, summary_records, diameters_by_file_real = [], [], {}


    for idx, filepath in enumerate(jpg_files, start=1):

        filename = os.path.basename(filepath)

        print(f"[{idx}/{len(jpg_files)}] 處理:{filename}")


        try:

            # 將自動算出的 pixels_per_unit 傳入運算

            img_rgb, overlay, diameters_px, diameters_real = analyze_single_image(filepath, pixels_per_unit)

        except Exception as e:

            print(f"    -> 發生錯誤,略過:{e}")

            continue


        diameters_by_file_real[filename] = diameters_real


        # 輸出單張報告

        out_img_path = os.path.join(OUTPUT_DIR, f"{os.path.splitext(filename)[0]}_analysis.png")

        save_single_image_report(filename, img_rgb, overlay, diameters_real, out_img_path)


        # 紀錄明細

        areas_real = calculate_circle_area(diameters_real)

        for d_px, d_real, a_real in zip(diameters_px, diameters_real, areas_real):

            all_records.append({

                "filename": filename, "diameter_px": d_px,

                f"diameter_{UNIT_LABEL}": d_real, f"area_{UNIT_LABEL}2": a_real,

            })


        # 紀錄摘要

        if len(diameters_real) > 0:

            summary_records.append({

                "filename": filename, "count": len(diameters_real),

                f"mean_{UNIT_LABEL}": diameters_real.mean(),

                f"median_{UNIT_LABEL}": np.median(diameters_real),

            })

            print(f"    -> 找到 {len(diameters_real)} 顆斑點,平均 {diameters_real.mean():.2f}{UNIT_LABEL}")

        else:

            print("    -> 無斑點")


    # 3. 輸出總表與統計

    if all_records:

        pd.DataFrame(all_records).to_csv(os.path.join(OUTPUT_DIR, "all_diameters.csv"), index=False, encoding="utf-8-sig")

        pd.DataFrame(summary_records).to_csv(os.path.join(OUTPUT_DIR, "summary_per_image.csv"), index=False, encoding="utf-8-sig")


    # 4. 輸出「各別」與「累積(總)」機率密度分布圖(圖內文字皆為英文)

    out_individual = os.path.join(OUTPUT_DIR, "individual_probability_distributions.png")

    plot_individual_distributions(diameters_by_file_real, out_individual)


    out_total = os.path.join(OUTPUT_DIR, "total_probability_distribution.png")

    plot_total_distribution(diameters_by_file_real, out_total)


    print("\n=== [3] 分析完成!===")

    print(f"所有 CSV 報告與圖表已存入: {OUTPUT_DIR}")



if __name__ == "__main__":

    main()


Monday, July 27, 2026

鰻魚「過剩」價崩! 遇「土用丑日」搶排買1送1

https://www.youtube.com/watch?v=e7OOUPIiEqY

26是日本的土用丑日!習俗上要吃「鰻魚飯」補身,這風氣也吹進台灣,台灣也有不少鰻魚店,推出限量買一送一,吸引大批老饕,頂著烈陽搶排,頭香甚至早上7點就來等。而今年的鰻魚,因為對日外銷量大減,鰻苗產量過剩,導致價格雪崩,但台灣人喜歡秋天吃,肉比較多肥美,預估中秋後又會漲一波。

<iframe width="925" height="520" src="https://www.youtube.com/embed/e7OOUPIiEqY" title="鰻魚「過剩」價崩! 遇「土用丑日」搶排買1送1@東森新聞 CH51" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>




Sunday, July 26, 2026

刺激足三里能抑制發炎

Nature Biotechnology 
Article https://doi.org/10.1038/s41587-026-03231-z  
High numerical aperture confocal volumetric mesoscope reveals mesoscale subcellular dynamics in vivo

2021 年 10 月在國際頂尖期刊《Nature》發表了題為 "A neuroanatomical basis for electroacupuncture to drive the vagal–adrenal axis" 的重磅論文。這項研究首次解開了傳統針灸數千年來的核心謎團——「穴位特異性(Acupoint Specificity)」與「刺激深淺度/強度」的解剖神經學本質

以下為該研究的五大核心細節與突破:

1. 關鍵主角:$PROKR2^{Cre}$ 體感神經元

過去已知,低強度電針刺激小鼠後肢的「足三里(ST36)」能驅動「迷走神經-腎上腺抗炎軸(Vagal-Adrenal Axis)」,抑制全身性發炎;但刺激腹部的「天樞(ST25)」卻無法達到相同效果。

馬秋富團隊利用基因標記技術發現,背根神經節(DRG)中有一群表達 $PROKR2$(Prokineticin Receptor 2,前動力素受體 2) 的體感神經元是此現象的關鍵:

  • 分佈特異性$PROKR2^{Cre}$ 神經元的末梢纖維高度集中分佈於後肢深層筋膜組織(如脛骨骨膜、骨間膜與深層肌肉)。

  • 位置差異:在「足三里(ST36)」與前肢「手三里(LI10)」的深層筋膜中極為豐富,但在腹部「天樞(ST25)」或小鼠小腿後側「承筋(BL56)」的深層組織以及淺層皮膚中則幾乎沒有分佈。

解剖學突破:這證明了所謂的「穴位特異性」,本質上取決於該部位深層組織中特定神經元末梢的分佈密度,而非神秘的未知結構。

2. 刺激強度決定的「雙重神經反射環路」

研究團隊發現,電針的刺激強度(Intensity)會激活截然不同的神經通路:




  • 低強度刺激($0.5\text{ mA}$,低於痛覺閾值)

    • 唯一依賴 $PROKR2^{Cre}$ 神經元。

    • 訊號經由脊髓上行至腦幹孤束核(NTS)與迷走神經背核(DMV),觸發迷走神經,促使腎上腺髓質釋放兒茶酚胺(如多巴胺、腎上腺素),產生全身抗炎作用。

  • 高強度刺激($1.0\text{--}3.0\text{ mA}$,高於痛覺閾值)

    • 激活的是脊髓交感神經反射

    • 無論在足三里還是天樞,都能觸發抗炎,且不需要 $PROKR2^{Cre}$ 神經元參與。

3. 功能驗證:光基因學與基因剔除實驗

為了嚴謹證實 $PROKR2^{Cre}$ 神經元的因果關係,團隊進行了雙向驗證:

  • 基因剔除(Loss of Function)

    特異性清除小鼠的 $PROKR2^{Cre}$ 神經元後,低強度電針刺激足三里(ST36)完全無法再激活腦幹迷走神經,也無法促使腎上腺釋放兒茶酚胺,LPS 誘發的敗血症全身發炎反應不再被抑制。

  • 光基因學激活(Gain of Function)

    $PROKR2^{Cre}$ 神經元中表達光敏蛋白(Catch),並在小鼠足三里局部照射藍光(直接光照刺激神經末梢)。結果單純用光照刺激足三里,就足以完美重現低強度電針的迷走-腎上腺抗炎效應

4. 解剖圖譜的「回溯性預測力」

團隊進一步繪製了全身體感神經末梢的分佈圖譜,並提出一個假說:只要某個部位的深層組織富含 $PROKR2^{Cre}$ 神經末梢,低強度電針就能奏效

為了檢驗這個假說,他們選擇了過去未曾嘗試的其他部位進行測試:

  • 前肢手三里(LI10)圖譜顯示深層組織富含 $PROKR2^{Cre}$,測試發現低強度電針成功激活迷走-腎上腺軸。

  • 後腿承筋穴(BL56)圖譜顯示深層組織缺乏 $PROKR2^{Cre}$,測試發現低強度電針無效

這證明了研究團隊找到的解剖學規則具有極強的跨部位預測能力


傳統針灸概念馬秋富團隊的現代科學解析
穴位特異性特定神經亞型(如 $PROKR2$)在深層筋膜/骨膜中的高密度分佈
得氣與針刺深度手術或電針必須穿透皮膚、達深層筋膜/肌肉,才能觸及神經末梢
刺激手法與強度區分了低強度(迷走-腎上腺全身軸)與高強度(脊髓交感局域軸)的不同神經路徑

電針刺激足三里(ST36)能顯著抑制脾臟中嗜中性白血球的「湧現蜂群行為(Swarming)」,其背後的核心機制建立在近年已被廣泛證實的「體感-自主神經反射軸(Somatosensory-Autonomic Reflex)」「迷走神經-抗炎通路(Cholinergic Anti-inflammatory Pathway, CAP)」上。

RUSH3D-HR 顯微鏡第一次在活體器官(脾臟)中以中觀次細胞級別,捕捉到了神經調控對細胞群體行為的即時抑制效應。這個神經-免疫環路可以拆解為以下四個關鍵階段:

1. 神經-免疫環路完整路徑(從穴位到脾臟)


















2. 環路各階段機制拆解

① 外周感知的特異性(足三里敏化)

  • 特定神經元特異性:足三里(ST36)位於前脛骨肌(Tibialis anterior),其深層筋膜與肌肉組織中高密度分布著標記為 $PROKR2^{Adv}$ 的低閾值機械感受神經元

  • 電壓與頻率選擇性:低強度電針(約 $0.5\text{ mA}$)能精準激活這群感受神經,而不會引起劇烈痛覺,將物理電訊號轉化為向心神經衝動。

② 中樞整合與迷走神經傳導

  • 神經訊號經由脊髓後角上行至腦幹的孤束核(NTS)迷走神經背核(DMV)

  • DMV 發出副交感指令,沿著迷走神經(Vagus Nerve)下傳,同時觸發兩條抗炎下行通路:

    1. 迷走神經-腎上腺軸(Vagus-Adrenal Axis):刺激腎上腺髓質釋放兒茶酚胺(Catecholamines,如多巴胺與腎上腺素)進入血液循環,產生全身性全身抗炎作用。

    2. 迷走神經-脾神經軸(Vagus-Splenic Axis):訊號傳至腹腔神經節,換神經元後經由脾神經(Splenic Nerve)進入脾臟。

③ 脾臟微環境的膽鹼能抗炎效應(CAP)

  • 脾神經末梢在脾臟紅髓與白髓交界區釋放去甲腎上腺素(NE)

  • NE 結合脾臟內 $CD4^+ T$ 細胞表面的 $\beta_2$-腎上腺素受體($\beta_2$-AR),促使其釋放乙醯膽鹼(ACh)

  • ACh 進一步作用於巨噬細胞與嗜中性白血球表面的 $\alpha 7$ 菸鹼型乙醯膽鹼受體($\alpha 7 nAChR$,強效抑制促炎因子(如 TNF-$\alpha$, IL-1$\beta$, IL-6)的轉錄與釋放(主要經由抑制 NF-$\kappa$B 通路)。

3. 對嗜中性白血球「蜂群行為」的分子抑制機制

嗜中性白血球的蜂群行為(Swarming)是發炎反應中的正回饋爆發現象:當少數「先鋒細胞」感知發炎(如 LPS)後,會釋放白三烯 $B_4$$LTB_4$)與 CXC 趨化因子(如 CXCL1/CXCL2),吸引周圍數千個嗜中性白血球在數十分鐘內呈「蜂群狀」向發炎中心集結,常導致嚴重的組織自體損傷。

電針刺激 ST36 抑制蜂群的具體作用:

調控層面未刺激狀態(LPS 誘發發炎)電針 ST36 刺激後
趨化訊號源頭巨噬細胞與先鋒細胞大量釋放 $LTB_4$ 及 CXCL1/2神經神經遞質抑制 NF-$\kappa$B,大幅減少趨化因子合成
細胞表面整合素$\beta_2$-整合素(如 Mac-1/CD11b)高表達,細胞強效黏附止動訊號被打斷,嗜中性白血球黏附力降低
群體動態現象>6,000 個細胞發生集體導向遷移,形成高密度蜂群蜂群聚集現象被中斷/消退,細胞恢復隨機巡邏狀態