跳轉到

影像品質自動化分析系統:基於 CNN 與特徵工程的 NOISE 和 SNR 分析工具

本工具旨在提供一套標準化的影像品質分析流程,結合 OpenCV 影像處理技術與 PyTorch 迴歸模型,針對影像的銳利度 (Sharpness)信噪比 (SNR) 進行量化。系統利用 ArUco Marker 進行自動位移補償與透視校正,並透過基準格對齊邏輯降低環境光影對分析結果的影響。

  • 格式處理:支援將影片自動截幀並轉存為 PNG 無損格式,以減少 JPG 壓縮對高頻資訊與噪訊分析的干擾。
  • 空間校正:利用 ArUco Marker 定位,將受測區域校正為標準正方形,並以特定 ROI(第七格)作為特徵歸一化的基準。
  • 多維度量化:提取包含 Laplacian 變異數、FFT 能量分佈與資訊熵等 64 維特徵,分別針對綜合品質、銳利度與信噪比進行建模。
  • 訓練與驗證:內建驗證集損失監控與早停機制(Early Stopping),自動保留驗證損失最低的模型權重。
  • 數據輸出:提供批次影片處理能力,產出包含逐幀預測數值的 CSV 文件與平均值摘要報告。

整體流程

  1. 數據準備:利用 preprocess.py 將影片轉換為具備標籤的 PNG 影像資料集。
  2. 模型訓練:執行 train.py 進行特徵提取與多維度 CNN 迴歸訓練,產出最佳權重檔案。
  3. 效能預測:運行 predict.py 對未知影片進行自動化校正與品質分級,獲取最終量化報告。

影像品質自動化分析系統:基於 CNN 與特徵工程的 NOISE 和 SNR 分析工具

🐻環境安裝

  1. python (建議 3.8 或以上)
  2. python plugins
    pip install opencv-contrib-python numpy pandas torch joblib tqdm matplotlib scikit-learn
    
  3. 以下程式碼,若電腦有 NVIDIA 顯卡者,可以額外設定 CUDA 版本的 PyTorch 以加速(程式碼需要微調),但因為本程式的方法是將圖片先做特徵提取,而不是將圖片進行 RNN 處理,所以理論上 CPU 應該就夠力了。
  4. 本訓練程式有 UI 版,但整體程式碼會變得過於複雜難以閱讀,因此建議先用此版本的做法。

🐻素材準備

  • 測試 Chart。
  • 拍攝不同色溫下 Q 的數值的影片,在此以 Q = 25, 50, 75, ..., 250 為例。
  • 請務必拍攝景深內 (清晰的意思),有小角度差異的影片 (例如稍微傾左、傾右、傾上、傾下...),無變化的訓練素材會導致過擬合。
  • 請確保拍攝的圖片或影片中包含 ID 為 1, 2, 3, 4 的 ArUco 碼分別在左上、右上、右下、左下。

🐻‍❄️影片自動截幀工具 preprocess.py

  • 自動掃描 video/ 中的影片(例如 25.mp4),建立對應名稱的資料夾(如 dataset/25/),並按設定的間隔(例如每 10 幀)儲存一張圖片。
  • 儲存的檔案可以選擇無損壓縮的 .png 會比 .jpg 更好,尤其在傅里葉轉換 (FFT) 與 拉普拉斯 (Laplacian) 提取銳度特徵時, .jpg 產生的偽影會導致訓練結果錯誤。
import cv2
import os
from tqdm import tqdm

def extract_frames(video_path, output_root, frame_interval=10):
    """
    video_path: 影片路徑 (e.g., 'video/25.mp4')
    output_root: 輸出的 dataset 根目錄 (e.g., 'dataset')
    frame_interval: 每隔幾幀截一張圖 (調整此數值可控制資料量)
    """
    # 取得影片檔名作為標籤 (e.g., '25.mp4' -> '25')
    video_name = os.path.splitext(os.path.basename(video_path))[0]

    # 建立對應標籤的資料夾
    save_dir = os.path.join(output_root, video_name)
    os.makedirs(save_dir, exist_ok=True)

    cap = cv2.VideoCapture(video_path)
    if not cap.isOpened():
        print(f"無法開啟影片: {video_path}")
        return

    frame_count = 0
    saved_count = 0
    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))

    with tqdm(total=total_frames, desc=f"處理影片 {video_name}") as pbar:
        while True:
            ret, frame = cap.read()
            if not ret:
                break

            # 按間隔截圖
            if frame_count % frame_interval == 0:
                img_name = f"{video_name}_f{frame_count:05d}.png"
                cv2.imwrite(os.path.join(save_dir, img_name), frame)
                saved_count += 1

            frame_count += 1
            pbar.update(1)

    cap.release()
    print(f"完成!影片 {video_name} 共截出 {saved_count} 張圖片存至 {save_dir}")

if __name__ == "__main__":
    # 設定路徑
    VIDEO_DIR = 'video'    # 原始影片存放處
    DATASET_DIR = 'dataset' # 預計生成的訓練資料夾路徑

    # 自動處理 video 資料夾內所有 mp4
    if not os.path.exists(VIDEO_DIR):
        print(f"錯誤:找不到 {VIDEO_DIR} 資料夾")
    else:
        video_files = [f for f in os.listdir(VIDEO_DIR) if f.lower().endswith(('.mp4', '.avi', '.mov'))]

        for v_file in video_files:
            # 排除非數字命名的影片 (如果你的預測影片如 Detect.mp4 也在這,會被跳過或一起處理)
            # 這裡我們假設你要處理的是 25.mp4, 50.mp4 這種純數字命名的訓練用影片
            name_part = os.path.splitext(v_file)[0]
            if name_part.isdigit():
                full_path = os.path.join(VIDEO_DIR, v_file)
                extract_frames(full_path, DATASET_DIR, frame_interval=15) # 建議間隔 15-30 幀避免資料過度重複

🐻‍❄️資料夾結構

project/
├── preprocess.py       <-- [手動建立] 剛寫好的截幀程式
├── video/              <-- [手動建立] 存放原始影片
│   ├── 25.mp4
│   ├── 50.mp4
│   ├── 100.mp4
│   └── Detect.mp4      <-- 非數字檔名,程式會自動跳過(不納入訓練集)
└── dataset/            <-- [手動建立] 空資料夾

🐻訓練模型

🐻‍❄️初始資料夾結構

project/                    <-- [手動建立]
├── preprocess.py
├── train.py                <-- [手動建立] 複製以下程式碼貼入
├── predict.py              <-- [手動建立] 複製以下程式碼貼入
├── dataset/                
│   ├── 25/                 
│   │   ├── img1.png        
│   │   ├── img2.png        
│   │   └── ...             
│   ├── 50/                 
│   │   └── ...             
│   │   ...                 
│   └── 250/                
│       └── ...             
└── video/                  
    ├── 25.mp4              
    ├── 50.mp4              
    └── Detect.mp4          

🐻‍❄️train.py

  • 資料初始化與環境檢查:程式啟動後會檢查 dataset/ 資料夾,並根據當前時間建立一個獨立的成果資料夾(例如 v7_final_2026...),確保每次訓練的模型與報告都不會互相覆蓋。
  • 影像讀取與前處理:逐一讀取 dataset/ 中各個數值資料夾下的圖片,將圖片旋轉 90 度後,利用 ArUco 標記 (ID 1-4) 進行定位,確保受測區域被校正為標準的 \(300 \times 300\) 像素正方形。
  • 物理基準選取:在校正後的影像中,選取左下方的「第 7 格」區域作為光影基準,計算該區域的原始統計數值,用以對抗環境光線或不同攝像頭產生的偏差。 --> 避免整體環境光線差異過大,對比度過高造成數據爆炸
  • 多維特徵提取:針對受測區域中的 8 個 ROI 區塊,分別計算空域特徵(如變異數、標準差、均值、中位數)、頻域特徵(FFT 能量分佈)以及資訊熵(直方圖複雜度),總計產生 64 維特徵。
  • 特徵歸一化與 CSV 存檔:將提取出的特徵除以第 7 格的基準值進行物理對齊,隨後將所有特徵與對應的資料夾標籤(目標數值)整合,存成 raw_all_features.csv
  • 子模型切片與訓練啟動:程式會依序針對「全特徵 (All)」、「銳利度特徵 (Sharpness)」、「信噪比特徵 (SNR)」三種模式進行訓練,每種模式會自動切分對應的特徵維度。
  • 標準化與模型優化:利用 StandardScaler 將數據縮放至常態分佈,並使用 RegressorCNNv7 架構進行擬合。訓練過程中採用 MSE 損失函數與 Adam 優化器,並設有「早停機制」,若 50 個 Epoch 內驗證集損失未改善則停止訓練。
  • 自動產出訓練報告:訓練完成後,程式會載入表現最好的權重檔 (best_model.pth),計算 \(R^2\) 判定係數,並繪製包含 Loss 下降曲線與預測散佈圖的 training_report.png。 這是一個非常重要的實務操作建議。在深度學習的流程中,訓練(Train)預測(Predict)通常是拆開的,而「驗證分數」就是決定模型是否能進入生產環境的門檻。
  • 自動篩選最佳權重:程式在訓練過程中會不斷監控驗證集損失(Val Loss),只有當該 Epoch 的分數優於歷史紀錄時,才會更新並保留 best_model.pth,這確保了最終留下來的是具備最強泛化能力的模型。
  • 驗證水準評估:訓練結束後,應優先檢查 training_report.png 中的 \(R^2\) 分數;通常在迴歸任務中,若 \(R^2\) 達到 0.90 以上,代表模型已能精準掌握物理特徵與標籤之間的線性與非線性關係。
  • 模型鎖定與銜接:一旦確認驗證水準達標,該時間戳記資料夾(如 v7_final_...)內的所有檔案即視為「正式版」;此時無需再變動訓練程式,直接進入預測階段。
import cv2
import cv2.aruco as aruco
import numpy as np
import os
import pandas as pd
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
import joblib
from tqdm import tqdm
from datetime import datetime
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import r2_score
from sklearn.model_selection import train_test_split

# ==========================================
# 1. 模型架構 (CNN 穩定版)
# ==========================================
class RegressorCNNv7(nn.Module):
    def __init__(self, input_dim, dropout_rate=0.4):
        super().__init__()
        self.conv = nn.Sequential(
            nn.Conv1d(1, 32, 3, padding=1), nn.BatchNorm1d(32), nn.ReLU(),
            nn.Conv1d(32, 64, 3, padding=1), nn.BatchNorm1d(64), nn.ReLU(),
            nn.Flatten()
        )
        mock_input = torch.zeros(1, 1, input_dim)
        with torch.no_grad():
            flatten_dim = self.conv(mock_input).shape[1]

        self.fc = nn.Sequential(
            nn.Linear(flatten_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate),
            nn.Linear(256, 128), nn.ReLU(),
            nn.Linear(128, 1) 
        )
    def forward(self, x):
        return self.fc(self.conv(x.unsqueeze(1)))

# ==========================================
# 2. 特徵提取
# ==========================================
def extract_features_v7(roi_img):
    if roi_img is None or roi_img.size == 0: return np.zeros(8)
    gray = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
    f = [cv2.Laplacian(gray, cv2.CV_64F).var(), np.std(gray), np.std(gray)/(np.mean(gray)+1e-6)]
    f_fft = np.fft.fftshift(np.fft.fft2(gray))
    mag = 20 * np.log(np.abs(f_fft) + 1)
    f.extend([np.mean(mag), np.max(mag), np.mean(gray), np.median(gray)])
    hist = cv2.calcHist([gray], [0], None, [256], [0, 256])
    hist = hist / (hist.sum() + 1e-7)
    f.append(-np.sum(hist * np.log2(hist + 1e-7)))
    return np.array(f)[:8]

def collect_data_v7(root_dir):
    data_list = []
    detector = aruco.ArucoDetector(aruco.getPredefinedDictionary(aruco.DICT_6X6_250), aruco.DetectorParameters())
    folders = sorted([f for f in os.listdir(root_dir) if os.path.isdir(os.path.join(root_dir, f))], key=lambda x: float(x))

    for label in folders:
        path = os.path.join(root_dir, label); y = float(label)
        img_files = [f for f in os.listdir(path) if f.lower().endswith(('.png', '.jpg', '.jpeg'))]
        for img_name in tqdm(img_files, desc=f"V7 Processing {label}"):
            img = cv2.imread(os.path.join(path, img_name))
            if img is None: continue
            img = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)
            corners, ids, _ = detector.detectMarkers(img)
            if ids is not None and len(ids) >= 4:
                id_map = {ids[i][0]: corners[i][0] for i in range(len(ids))}
                if all(rid in id_map for rid in [1, 2, 3, 4]):
                    try:
                        p00, p30, p33, p03 = id_map[1][1], id_map[2][0], id_map[3][3], id_map[4][2]
                        M = cv2.getPerspectiveTransform(np.array([p00, p30, p33, p03], dtype="float32"),
                                                    np.array([[0,0],[299,0],[299,299],[0,299]], dtype="float32"))
                        warped = cv2.warpPerspective(img, M, (300, 300))
                        ref_f = extract_features_v7(warped[200:300, 0:100]) + 1e-6
                        full_feats = []
                        for r in range(3):
                            for c in range(3):
                                if r==2 and c==2: continue 
                                roi = warped[r*100:(r+1)*100, c*100:(c+1)*100]
                                f8 = extract_features_v7(roi[18:82, 18:82]) / ref_f
                                full_feats.extend(f8)
                        data_list.append(full_feats + [y])
                    except: pass
    return pd.DataFrame(data_list)

# ==========================================
# 3. 訓練子模型 (增加存圖與 CSV 邏輯)
# ==========================================
def train_sub_model(df_all, main_dir, config, mode='all'):
    print(f"\n>>> [訓練啟動] 模式: {mode}")
    sub_dir = os.path.join(main_dir, mode); os.makedirs(sub_dir, exist_ok=True)

    if mode == 'all': feat_slice, dim = slice(0, 64), 64
    elif mode == 'sharpness': feat_slice, dim = slice(0, 40), 40
    else: feat_slice, dim = slice(40, 64), 24

    X = df_all.iloc[:, :-1].values[:, feat_slice].astype(np.float32)
    y = df_all.iloc[:, -1].values.reshape(-1, 1).astype(np.float32)

    scaler = StandardScaler(); X_s = scaler.fit_transform(X)
    joblib.dump(scaler, f'{sub_dir}/scaler.pkl')

    X_train, X_val, y_train, y_val = train_test_split(X_s, y, test_size=0.2, random_state=42)
    train_loader = DataLoader(TensorDataset(torch.from_numpy(X_train), torch.from_numpy(y_train)), batch_size=config['batch_size'], shuffle=True)
    val_loader = DataLoader(TensorDataset(torch.from_numpy(X_val), torch.from_numpy(y_val)), batch_size=config['batch_size'])

    model = RegressorCNNv7(input_dim=dim)
    optimizer = optim.Adam(model.parameters(), lr=config['lr']); criterion = nn.MSELoss()

    history = {'t': [], 'v': []}
    best_loss = float('inf')
    patience, trigger = 50, 0

    for epoch in range(1, config['epochs'] + 1):
        model.train(); t_l = 0
        for bx, by in train_loader:
            optimizer.zero_grad(); loss = criterion(model(bx), by); loss.backward(); optimizer.step()
            t_l += loss.item()

        model.eval(); v_l = 0
        with torch.no_grad():
            for bx, by in val_loader: v_l += criterion(model(bx), by).item()

        avg_t, avg_v = t_l/len(train_loader), v_l/len(val_loader)
        history['t'].append(avg_t); history['v'].append(avg_v)

        if avg_v < best_loss:
            best_loss = avg_v
            torch.save(model.state_dict(), f'{sub_dir}/best_model.pth')
            trigger = 0
        else:
            trigger += 1
            if trigger >= patience: break

        if epoch % 10 == 0:
            print(f"  Epoch {epoch:3d} | Train Loss: {avg_t:.6f} | Val Loss: {avg_v:.6f}")

    # --- 關鍵修正:存出訓練報告圖 ---
    print(f">>> 正在存出 {mode} 報告圖表...")
    # model.load_state_dict(torch.load(f'{sub_dir}/best_model.pth'))
    model.load_state_dict(torch.load(f'{sub_dir}/best_model.pth', map_location='cpu', weights_only=True))
    model.eval()
    with torch.no_grad():
        pred = model(torch.from_numpy(X_s)).numpy()

    r2 = r2_score(y, pred)
    plt.figure(figsize=(12, 5))
    plt.subplot(1, 2, 1); plt.plot(history['t'], label='Train'); plt.plot(history['v'], label='Val'); plt.title(f"{mode} Learning Curve"); plt.legend()
    plt.subplot(1, 2, 2); plt.scatter(y, pred, alpha=0.5); plt.plot([y.min(), y.max()],[y.min(), y.max()], 'r--'); plt.title(f"{mode} R2: {r2:.4f}")
    plt.tight_layout(); plt.savefig(f'{sub_dir}/training_report.png'); plt.close()

if __name__ == "__main__":
    cfg = {'dataset_path': 'dataset', 'lr': 0.001, 'epochs': 500, 'batch_size': 16}
    main_folder = f"v7_final_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
    os.makedirs(main_folder, exist_ok=True)

    print(">>> [Step 1] 數據採集與物理校正...")
    df_full = collect_data_v7(cfg['dataset_path'])

    # --- 關鍵修正:存出原始特徵 CSV ---
    df_full.to_csv(f"{main_folder}/raw_all_features.csv", index=False)
    print(f">>> 原始特徵已存至: {main_folder}/raw_all_features.csv")

    for m in ['all', 'sharpness', 'snr']:
        train_sub_model(df_full, main_folder, cfg, mode=m)

    print(f"\n>>> 訓練全數完成!路徑: {main_folder}")

🐻‍❄️執行後的資料夾結構

project/
├── v7_final_20260513_212500/      <-- [自動建立] 預測版本紀錄
│   ├── raw_all_features.csv       <-- [自動建立] 所有從 dataset 提取出的原始特徵
│   ├── all/                       <-- [自動建立] 模式 1:全特徵模型
│   │   ├── scaler.pkl             <-- [自動建立] 標準化權重
│   │   ├── best_model.pth         <-- [自動建立] 訓練好的模型
│   │   └── training_report.png    <-- [自動建立] 訓練報告:學習曲線與 R2 散佈圖
│   ├── sharpness/                 <-- [自動建立] 模式 2:銳利度模型
│   │   ├── scaler.pkl             <-- [自動建立] 標準化權重
│   │   ├── best_model.pth         <-- [自動建立] 模型檔案
│   │   └── training_report.png    <-- [自動建立] 訓練報告:學習曲線與 R2 散佈圖
│   └── snr/                       <-- [自動建立] 模式 3:信噪比模型
│       ├── scaler.pkl             <-- [自動建立] 標準化權重
│       ├── best_model.pth         <-- [自動建立] 模型檔案
│       └── training_report.png    <-- [自動建立] 訓練報告:學習曲線與 R2 散佈圖
├── train.py
├── predict.py
├── dataset/
│   ├── 25/                 
│   │   ├── img1.jpg
│   │   ├── img2.jpg
│   │   └── ...
│   ├── 50/                 
│   │   └── ...
│   │   ...  
│   └── 250/
│       └── ...
└── video/                  
    ├── 25.mp4
    ├── 50.mp4
    └── Detect.mp4

🐻‍❄️訓練報告 training_report.png

在進行預測之前,你必須先確認模型是否「訓練成功」。請進入 v7_final_... 目錄下的各個子資料夾(all, sharpness, snr)查看這張圖。

  • 左圖 (Learning Curve):觀察 Train 與 Val 兩條線是否都有平滑下降。如果 Val 線突然往上彈,代表發生了過擬合。
  • 右圖 (R2 Score):這是最重要的指標。\(R^2\) 分數建議要達到 0.90 以上。這代表你的物理特徵(如 Laplacian, FFT)與標籤數值之間有強烈的相關性,模型預測才具有參考價值。

🐻‍❄️進階分析 raw_data_xxx.csv

如果你發現某部影片的平均得分(Summary)不如預期,或者預測結果波動很大,請開啟預測目錄下的 CSV 檔。

  • 檔案功能:CSV 記錄了影片中每一幀的預測值。
  • 異常排除:你可以將 CSV 數據拉成折線圖。如果數值在某個區段突然暴跌,可能是影片在那幾秒發生了失焦、劇烈震動或 ArUco 標記被遮擋的情況。

🐻預測檔案

  • 更新預測路徑:將 predict.py 末端的 MODEL_FOLDER 變數手動更新為該次訓練的資料夾名稱(例如剛剛訓練時產生的 v7_final_20260513_212500),這是將「訓練成果」轉化為「實際應用」的關鍵手動步驟。
  • 選擇預測模式
    • 執行批次預測 (預設):確認模型路徑正確後,執行 predict.py。程式會自動載入剛剛驗證過關的 .pth 權重與 scaler.pkl 參數,對 video/ 資料夾下的待測影片進行自動化分析並產出最終報告。
    • 執行單檔案預測: 手動取消 run_v7_prediction('video/Detect.mp4', MODEL_FOLDER) 的註解。
  • 模型與環境初始化:程式啟動後,會根據你指定的 MODEL_FOLDER 路徑,同時載入「全特徵 (All)」、「銳利度 (Sharpness)」、「信噪比 (SNR)」三組訓練好的 .pth 模型權重,以及對應的 scaler.pkl 標準化參數。
  • 影像讀取與校正迴圈:開啟影片檔案,逐幀進行處理。每一幀都會旋轉 90 度,並利用 ArUco 標記 (ID 1-4) 進行空間定位。
  • 透視變換與基準校正:將影片中的受測目標透過矩陣運算校正為標準的 \(300 \times 300\) 像素影像,並同樣提取「第 7 格」區域作為當前幀的物理基準,用以消除光影波動。
  • 即時特徵提取與預測:針對每一幀影像提取 64 維特徵,分別餵入三組模型進行迴歸運算,即時得到該幀的 All、Sharpness 與 SNR 預測值。
  • 數據統計與整合:將整段影片所有幀的預測結果匯總,計算平均值,以代表該影片的整體品質水準。
  • 自動化報告產出:程式會在模型資料夾下自動建立 predictions 目錄,並針對每部影片生成逐幀數據的 .csv 檔以及簡潔的 summary_report.txt 摘要報告。

🐻‍❄️predict.py

import cv2
import cv2.aruco as aruco
import numpy as np
import torch
import torch.nn as nn
import os
import joblib
import pandas as pd
from datetime import datetime

# ==========================================
# 1. 模型架構
# ==========================================
class RegressorCNNv7(nn.Module):
    def __init__(self, input_dim, dropout_rate=0.4):
        super().__init__()
        self.conv = nn.Sequential(
            nn.Conv1d(1, 32, 3, padding=1), nn.BatchNorm1d(32), nn.ReLU(),
            nn.Conv1d(32, 64, 3, padding=1), nn.BatchNorm1d(64), nn.ReLU(),
            nn.Flatten()
        )
        mock_input = torch.zeros(1, 1, input_dim)
        with torch.no_grad():
            flatten_dim = self.conv(mock_input).shape[1]
        self.fc = nn.Sequential(
            nn.Linear(flatten_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate),
            nn.Linear(256, 128), nn.ReLU(),
            nn.Linear(128, 1)
        )
    def forward(self, x):
        return self.fc(self.conv(x.unsqueeze(1)))

def extract_features_v7(roi_img):
    if roi_img is None or roi_img.size == 0: return np.zeros(8)
    gray = cv2.cvtColor(roi_img, cv2.COLOR_BGR2GRAY)
    f = [cv2.Laplacian(gray, cv2.CV_64F).var(), np.std(gray), np.std(gray)/(np.mean(gray)+1e-6)]
    f_fft = np.fft.fftshift(np.fft.fft2(gray))
    mag = 20 * np.log(np.abs(f_fft) + 1)
    f.extend([np.mean(mag), np.max(mag), np.mean(gray), np.median(gray)])
    hist = cv2.calcHist([gray], [0], None, [256], [0, 256])
    hist = hist / (hist.sum() + 1e-7)
    f.append(-np.sum(hist * np.log2(hist + 1e-7)))
    return np.array(f)[:8]

# ==========================================
# 2. 預測與自動整理路徑
# ==========================================
def run_v7_prediction(video_path, model_dir):
    if not os.path.exists(video_path):
        print(f"錯誤: 找不到影片檔案 {video_path}")
        return

    modes = ['all', 'sharpness', 'snr']
    models = {}; scalers = {}; dims = {'all': 64, 'sharpness': 40, 'snr': 24}

    # 建立輸出路徑: MODEL_DIR/predictions/result_{檔名}/
    base_name = os.path.splitext(os.path.basename(video_path))[0]
    output_dir = os.path.join(model_dir, "predictions", f"result_{base_name}")
    os.makedirs(output_dir, exist_ok=True)

    # 載入模型與 Scaler
    for m in modes:
        path = os.path.join(model_dir, m)
        scalers[m] = joblib.load(f"{path}/scaler.pkl")
        models[m] = RegressorCNNv7(input_dim=dims[m])
        # 使用 weights_only=True 消除警告
        models[m].load_state_dict(torch.load(f"{path}/best_model.pth", map_location='cpu', weights_only=True))
        models[m].eval()

    cap = cv2.VideoCapture(video_path)
    detector = aruco.ArucoDetector(aruco.getPredefinedDictionary(aruco.DICT_6X6_250), aruco.DetectorParameters())
    results = []

    print(f"\n>>> 正在分析影片: {video_path}")
    print(f">>> 輸出目錄: {output_dir}")

    while True:
        ret, frame = cap.read()
        if not ret: break

        frame_r = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
        corners, ids, _ = detector.detectMarkers(frame_r)

        if ids is not None and len(ids) >= 4:
            id_map = {ids[i][0]: corners[i][0] for i in range(len(ids))}
            if all(rid in id_map for rid in [1, 2, 3, 4]):
                try:
                    p00, p30, p33, p03 = id_map[1][1], id_map[2][0], id_map[3][3], id_map[4][2]
                    M = cv2.getPerspectiveTransform(np.array([p00, p30, p33, p03], dtype="float32"),
                                                np.array([[0,0],[299,0],[299,299],[0,299]], dtype="float32"))
                    warped = cv2.warpPerspective(frame_r, M, (300, 300))
                    ref_f = extract_features_v7(warped[200:300, 0:100]) + 1e-6

                    full_feats = []
                    for r in range(3):
                        for c in range(3):
                            if r == 2 and c == 2: continue
                            roi = warped[r*100:(r+1)*100, c*100:(c+1)*100]
                            full_feats.extend(extract_features_v7(roi[18:82, 18:82]) / ref_f)

                    f_np = np.array(full_feats).reshape(1, -1)
                    with torch.no_grad():
                        v_all = models['all'](torch.FloatTensor(scalers['all'].transform(f_np))).item()
                        v_sha = models['sharpness'](torch.FloatTensor(scalers['sharpness'].transform(f_np[:, 0:40]))).item()
                        v_snr = models['snr'](torch.FloatTensor(scalers['snr'].transform(f_np[:, 40:64]))).item()
                    results.append({'All': v_all, 'Sharpness': v_sha, 'SNR': v_snr})
                except: pass

    cap.release()

    if results:
        df = pd.DataFrame(results)
        means = df.mean()

        # 1. 存出完整的逐幀數據 CSV
        csv_path = os.path.join(output_dir, f"raw_data_{base_name}.csv")
        df.to_csv(csv_path, index=False)

        # 2. 存出摘要報告 TXT (您最需要的結果)
        report_path = os.path.join(output_dir, f"summary_report.txt")
        with open(report_path, "w", encoding="utf-8") as f:
            f.write(f"V7 預測分析報告\n")
            f.write(f"分析時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
            f.write(f"影片名稱: {video_path}\n")
            f.write("-" * 30 + "\n")
            f.write(f"All        {means['All']:.6f}\n")
            f.write(f"Sharpness  {means['Sharpness']:.6f}\n")
            f.write(f"SNR        {means['SNR']:.6f}\n")
            f.write("-" * 30 + "\n")
            f.write(f"註: 數據已基於第七格進行物理對齊校正\n")

        print(f"\n[V7 預測分析完成]")
        print(means.to_string()) 

        # 1. 存出逐幀 CSV
        csv_path = os.path.join(output_dir, f"raw_data_{base_name}.csv")
        df.to_csv(csv_path, index=False)

        # 2. 存出簡潔的摘要報告 TXT
        report_path = os.path.join(output_dir, f"summary_report.txt")
        with open(report_path, "w", encoding="utf-8") as f:
            f.write(f"V7 預測分析報告\n")
            f.write(f"分析時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
            f.write(f"影片名稱: {os.path.basename(video_path)}\n")
            f.write("-" * 30 + "\n")
            # 這裡也會自動去掉 dtype
            f.write(means.to_string()) 
            f.write("\n" + "-" * 30 + "\n")
            f.write(f"註: 數據已基於第7格進行物理校正\n")

        print(f"\n摘要報告已存至: {report_path}")

if __name__ == "__main__":
    # 請替換為您正確的訓練模型資料夾
    MODEL_FOLDER = 'v7_final_20260105_183231' 

    # 執行單檔預測
    # run_v7_prediction('video/Detect.mp4', MODEL_FOLDER)

    # 若要批次處理 25-250 的影片,可以取消下方註解
    for num in range(25, 251, 25):
        run_v7_prediction(f'video/{num}.mp4', MODEL_FOLDER)

🐻‍❄️執行後的資料夾結構

project/
├── v7_final_20260513_212500/      
│   ├── raw_all_features.csv       
│   ├── all/                       
│   │   ├── scaler.pkl             
│   │   ├── best_model.pth         
│   │   └── training_report.png    
│   ├── sharpness/                 
│   │   ├── scaler.pkl
│   │   ├── best_model.pth
│   │   └── training_report.png
│   ├── snr/                       
│   │   ├── scaler.pkl
│   │   ├── best_model.pth
│   │   └── training_report.png
│   └── predictions/                   <-- [自動建立] 自動生成的預測目錄
│       ├── result_25/                 <-- [自動建立] 針對 25.mp4 的分析結果
│       │   ├── raw_data_25.csv        <-- [自動建立] 每一幀的預測數值
│       │   └── summary_report.txt     <-- [自動建立] 最終平均值的統計報告 (你最需要的)
│       ├── result_50/                 <-- [自動建立] 針對 50.mp4 的分析結果
│       │   ├── raw_data_50.csv        <-- [自動建立] 每一幀的預測數值
│       │   ├── summary_report.txt     <-- [自動建立] 預測報告
│       │   └── ...                    <-- [自動建立] ...
│       └── result_Detect/             <-- [自動建立] 針對 Detect.mp4 的分析結果
│           ├── raw_data_Detect.cs     <-- [自動建立] 每一幀的預測數值
│           └── summary_report.txt     <-- [自動建立] 預測報告
├── train.py
├── predict.py
├── dataset/
│   ├── 25/                 
│   │   ├── img1.png
│   │   ├── img2.png
│   │   └── ...
│   ├── 50/                 
│   │   └── ...
│   │   ...  
│   └── 250/
│       └── ...
└── video/                  
    ├── 25.mp4
    ├── 50.mp4
    └── Detect.mp4

🐻‍❄️預測報告 summary_report.txt

當你執行完 predict.py,你最需要關注的結果就在 predictions/result_xxx/summary_report.txt 中。這份報告提供了該影片的平均品質水準,建議觀察以下指標:

  • Sharpness:數值越高,代表邊緣特徵越鮮明(影像越清晰)。
  • SNR:數值反映了影像的純淨度。通常在低光源測試中,此數值的變化能幫你量化噪訊的嚴重程度。
  • All:影像整體的綜合預測分數。