這工作要取卵巢組織以組織固定液固定 ,三天後換成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()