commit 4c38eb68ec5b4bb8464f101a59fdecbdbaaa7a32 Author: JianMiau Date: Tue Jun 2 17:32:27 2026 +0800 初版: 機車第一人稱影片自動去紅燈工具 - OCR 辨識畫面上的 GPS 時速表,連續停車超過門檻即判定為紅燈並無損剪除 - 中位數平滑濾波 + 補空值 + 相鄰段合併,抗 OCR 在 0 與雜訊間跳動的問題 - 支援 GPU(CUDA) 加速、互動式選單(空白鍵多選)、config.json 設定檔 - 附中文 README 與互動啟動批次檔 本工具由 Claude Opus 4.8 (1M context) 協助開發與處理。 Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c43ac0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# 測試暫存(內含大型測試影片,切勿上傳) +_test_tmp/ + +# Python 快取 +__pycache__/ +*.pyc + +# 預覽圖 +preview_*.png + +# 輸出成品與紀錄 +no_redlight/ +*_speeds.csv + +# 影片檔 +*.MOV +*.mov +*.mp4 +*.MP4 +*.wav +*.WAV diff --git a/README.md b/README.md new file mode 100644 index 0000000..311376a --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# Redlight Remover — 機車第一人稱影片自動去紅燈工具 + +針對「安全帽第一人稱機車影片」,自動偵測等紅燈的停車片段並剪掉,輸出無損的去紅燈影片。 +程式用 OCR 讀取畫面上的 GPS 時速表,當時速持續為 0 超過設定秒數即判定為紅燈,再用 FFmpeg 無損切割拼接。 + +--- + +## 功能特色 + +- 🎯 **自動偵測紅燈**:逐秒 OCR 辨識時速,連續停車超過門檻即視為等紅燈 +- ✂️ **無損剪輯**:用 FFmpeg `-c copy` 串流複製,畫質零損失、速度快 +- 🧹 **抗雜訊平滑**:中位數濾波 + 補空值 + 相鄰段合併,解決停車時 OCR 在 0 與雜訊間跳動的問題 +- ⚡ **GPU 加速**:支援 NVIDIA CUDA,OCR 大幅加速 +- 🖱️ **互動選單**:批次檔啟動,↑↓ 選資料夾、空白鍵勾選影片 +- ⚙️ **設定檔**:所有參數集中在 `config.json`,免改程式碼 +- 📊 **辨識紀錄**:每支影片輸出 `*_speeds.csv`,方便檢查 OCR 結果 + +--- + +## 環境需求 + +- **Python 3.x** +- **FFmpeg / ffprobe**(需加入系統 PATH) +- Python 套件:`easyocr`、`opencv-python`、`numpy` +- (選用)NVIDIA 顯卡 + CUDA 版 PyTorch 以啟用 GPU + +### 安裝 + +```bat +pip install -r requirements.txt +``` + +FFmpeg 可用 winget 安裝: + +```bat +winget install Gyan.FFmpeg +``` + +啟用 GPU(需 NVIDIA 顯卡),安裝對應 CUDA 版 PyTorch,例如: + +```bat +pip install --force-reinstall torch torchvision --index-url https://download.pytorch.org/whl/cu128 +``` + +--- + +## 使用方法 + +### 方式一:互動批次檔(推薦) + +雙擊 `啟動.bat`,依選單操作: + +1. 選 `[1]` 預覽 ROI:先確認時速表框選位置(第一次使用建議先做) +2. 選 `[2]` 開始處理: + - ↑↓ 移動、Enter 選擇要處理的資料夾 + - ↑↓ 移動、**空白鍵勾選**要去紅燈的影片、Enter 開始 + - 處理完成,成品輸出到該資料夾的 `no_redlight\` 子資料夾 + +### 方式二:命令列 + +```bat +:: 預覽 ROI(確認時速表位置) +python auto_remove_redlight.py 20260516 --preview + +:: 處理整個資料夾 +python auto_remove_redlight.py 20260516 + +:: 處理單一影片 +python auto_remove_redlight.py E:\videos\20260516\01.MOV + +:: 處理多支指定影片 +python auto_remove_redlight.py 01.MOV 03.MOV 05.MOV +``` + +--- + +## 設定檔 `config.json` + +所有常用參數集中於此,每次執行會自動讀取(檔案不存在時會自動產生預設值)。 + +| 參數 | 預設值 | 說明 | +|------|--------|------| +| `roi` | `[3300, 1770, 300, 140]` | 時速表在畫面上的位置 `[x, y, 寬, 高]` | +| `sample_interval` | `1.0` | 取樣間隔(秒),每幾秒辨識一次;越小越精準但越慢 | +| `progress_every` | `60` | 進度回報間隔(秒),純顯示用,不影響效能 | +| `stop_seconds` | `4.0` | 連續停車超過幾秒判定為紅燈 | +| `speed_threshold` | `0` | 時速 ≤ 此值視為停止 | +| `min_confidence` | `0.5` | OCR 信心低於此值視為雜訊 | +| `smooth_window` | `5` | 中位數濾波視窗(取樣點數) | +| `merge_gap` | `3.0` | 相鄰紅燈段間隔小於此秒數即合併 | +| `stop_padding` | `0.0` | 紅燈段頭尾各保留幾秒(讓剪接柔和) | +| `min_keep` | `0.8` | 保留片段短於此秒數即丟棄 | +| `use_gpu` | `true` | 是否使用 GPU(無 CUDA 時自動退回 CPU) | +| `reencode` | `false` | `false` = 無損快剪;`true` = 重新編碼,切點精準到幀但較慢 | +| `base_dir` | `E:\\videos` | 影片根目錄 | + +> 參數優先順序:**命令列參數 > config.json > 程式內建預設** + +--- + +## 輸出結果 + +成品放在來源資料夾的 `no_redlight\` 子資料夾,原始影片不會被改動: + +``` +<影片資料夾>\no_redlight\ + ├─ 01_no_redlight.MOV ← 去紅燈後的成品 + ├─ 01_speeds.csv ← 每秒辨識時速紀錄(檢查用) + └─ ... +``` + +--- + +## 運作原理 + +1. 用 OpenCV 每隔 N 秒抽一幀 +2. 裁切出畫面上時速表的固定像素區域(ROI) +3. 用 EasyOCR 只辨識數字,讀出當下時速 +4. 對逐秒時速做平滑濾波(補空值 + 中位數),壓掉跳動雜訊 +5. 時速持續為 0 超過門檻 → 判定為等紅燈,標記移除 +6. 用 FFmpeg 無損切割保留片段並拼接,輸出去紅燈影片 + +--- + +## 注意事項 + +- 不同影片的儀表疊加位置可能不同,換來源時請先用 `--preview` 確認並調整 `roi` +- 無損模式切點會對齊最近的關鍵幀,實際剪接點可能 ±1~2 秒(對去紅燈用途通常足夠) +- 程式只處理資料夾第一層的影片,不會遞迴子資料夾 + +--- + +## 檔案說明 + +| 檔案 | 用途 | +|------|------| +| `auto_remove_redlight.py` | 主程式 | +| `啟動.bat` | 互動式啟動器(Big5 編碼) | +| `_bat_src.txt` | 啟動器的 UTF-8 原始碼 | +| `_convert.ps1` | 把 `_bat_src.txt` 轉成 Big5 的 `啟動.bat` | +| `config.json` | 參數設定檔 | +| `requirements.txt` | Python 套件清單 | + +--- + +> 本工具由 **Claude Opus 4.8 (1M context)** 協助開發與處理。 diff --git a/_bat_src.txt b/_bat_src.txt new file mode 100644 index 0000000..6830b42 --- /dev/null +++ b/_bat_src.txt @@ -0,0 +1,134 @@ +@echo off +chcp 950 >nul +title 機車影片 自動去紅燈工具 +cd /d "%~dp0" +setlocal enabledelayedexpansion + +rem === 偵測 Python(優先用已知安裝路徑,避開 Microsoft Store 假捷徑)=== +set "PYEXE=" +if exist "C:\Python314\python.exe" set "PYEXE=C:\Python314\python.exe" +if not defined PYEXE ( + where py >nul 2>nul && set "PYEXE=py" +) +if not defined PYEXE ( + where python >nul 2>nul && set "PYEXE=python" +) +if not defined PYEXE goto no_python +"%PYEXE%" --version >nul 2>nul +if errorlevel 1 goto no_python + + +:menu +cls +echo ============================================================ +echo 機車影片 自動去紅燈工具 +echo ============================================================ +echo. +echo 影片根目錄(BASE_DIR): E:\videos +echo 只要輸入資料夾名稱(例如 20260502),程式會自動到上面找。 +echo. +echo [1] 預覽 ROI 先確認時速表的框選位置(第一次建議先做) +echo [2] 開始處理 批次去紅燈(資料夾內每支影片各自輸出一支) +echo [3] 離開 +echo. +set "mode=" +set /p mode=請輸入選項並按 Enter (1/2/3): +if "%mode%"=="1" goto preview +if "%mode%"=="2" goto process +if "%mode%"=="3" goto end +goto menu + + +:preview +echo. +echo ------------------------------------------------------------ +echo 預覽 ROI:抽一張畫面、畫紅框,確認有沒有框住時速表 +echo ------------------------------------------------------------ +set "folder=" +set /p folder=請輸入資料夾名稱或路徑(例如 20260502): +if "%folder%"=="" goto preview +set "ROIARG=" +set "roi=" +set /p roi=自訂時速表座標 x y w h(用空格分隔;直接 Enter 用程式預設): +if not "%roi%"=="" set "ROIARG=--roi %roi%" +set "ATARG=" +set "at=" +set /p at=要抽第幾秒的畫面當預覽(直接 Enter 用第 0 秒): +if not "%at%"=="" set "ATARG=--preview-at %at%" +echo. +echo 執行預覽中,請稍候... +"%PYEXE%" "%~dp0auto_remove_redlight.py" "%folder%" --preview %ROIARG% %ATARG% +echo. +echo ------------------------------------------------------------ +echo 預覽圖已輸出到本資料夾: +echo preview_roi.png 開這張看紅框有沒有框住時速表 +echo preview_crop.png 裁切出來的時速表區域 +echo preview_crop_processed.png 送進 OCR 前的影像 +echo. +echo 若框得不準: 記下正確的 x y w h,下次預覽輸入即可重試; +echo 確定後建議把座標填進 auto_remove_redlight.py 最上面的 ROI。 +echo ------------------------------------------------------------ +echo. +pause +goto menu + + +:process +echo. +echo ------------------------------------------------------------ +echo 開始處理:選資料夾 - 勾選影片 - 自動去紅燈 +echo ------------------------------------------------------------ +set "LIST=%TEMP%\redlight_sel.txt" +del "%LIST%" >nul 2>nul +rem (1) 互動選擇器:選資料夾 + 空白鍵勾選影片,選好寫出清單檔 +"%PYEXE%" "%~dp0auto_remove_redlight.py" --select --base-dir "E:\videos" --out-list "%LIST%" +if not exist "%LIST%" ( + echo. + echo 已取消或未選擇任何影片,返回選單。 + pause + goto menu +) +rem (2) 讀清單,組成帶引號的參數字串 +set "FILES=" +for /f "usebackq delims=" %%L in ("%LIST%") do set "FILES=!FILES! "%%L"" +del "%LIST%" >nul 2>nul +if not defined FILES ( + echo 清單為空,返回選單。 + pause + goto menu +) +echo. +echo ============================================================ +echo 即將開始去紅燈(4K 逐秒 OCR + 無損切割,請勿關閉視窗)。 +echo 輸出會放在影片所在資料夾的 no_redlight 子資料夾。 +echo ============================================================ +echo. +rem (3) 帶著勾選好的影片檔當參數,呼叫主程式處理 +"%PYEXE%" "%~dp0auto_remove_redlight.py"!FILES! +echo. +echo ============================================================ +echo 處理結束。請到該資料夾的 no_redlight 子資料夾查看成果, +echo 並可開啟各支的 *_speeds.csv 檢查 OCR 是否正確。 +echo ============================================================ +echo. +pause +goto menu + + +:no_python +echo. +echo [錯誤] 找不到可用的 Python。 +echo. +echo 這台電腦目前只有 Microsoft Store 假捷徑,不是真的 Python。 +echo 請到 https://www.python.org/downloads/ 安裝 Python 3, +echo 安裝時務必勾選 Add python.exe to PATH。 +echo. +echo 裝好後在命令列執行: pip install -r "%~dp0requirements.txt" +echo 並安裝 FFmpeg(https://ffmpeg.org/download.html)且加入 PATH。 +echo. +pause +goto end + + +:end +endlocal diff --git a/_convert.ps1 b/_convert.ps1 new file mode 100644 index 0000000..1af29cd --- /dev/null +++ b/_convert.ps1 @@ -0,0 +1,19 @@ +# 把 UTF-8 的 bat 內容轉成 Big5(cp950)+ CRLF,輸出成「啟動.bat」 +# 啟=U+555F 動=U+52D5(用碼位組檔名,避免中文在命令列被破壞) +$dir = 'E:\videos\redlight_remover' +$src = Join-Path $dir '_bat_src.txt' +$dst = Join-Path $dir ([string][char]0x555F + [string][char]0x52D5 + '.bat') + +$lines = Get-Content -Encoding UTF8 $src +$t = [string]::Join([char]13 + [char]10, $lines) + [char]13 + [char]10 + +$enc = [Text.Encoding]::GetEncoding(950) +[IO.File]::WriteAllText($dst, $t, $enc) + +# 回讀驗證:若有字元無法以 Big5 表示會變成 '?',回讀就不會相等 +$b = [IO.File]::ReadAllText($dst, $enc) +$bytes = [IO.File]::ReadAllBytes($dst).Length + +Write-Output ("DST=" + $dst) +Write-Output ("BYTES=" + $bytes) +if ($t -eq $b) { Write-Output 'ROUNDTRIP_OK' } else { Write-Output 'ROUNDTRIP_MISMATCH' } diff --git a/auto_remove_redlight.py b/auto_remove_redlight.py new file mode 100644 index 0000000..8f25a82 --- /dev/null +++ b/auto_remove_redlight.py @@ -0,0 +1,1025 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +auto_remove_redlight.py +================================================================ +安全帽第一人稱機車影片 — 自動去紅燈剪輯工具(支援整個資料夾批次處理) + +功能流程(對「每一支」影片): + 1. 用 OpenCV 每隔 N 秒(預設 1 秒)抽一幀 + 2. 裁切出畫面上「GPS 時速表」的固定像素區域 (ROI) + 3. 用 EasyOCR 只辨識數字,讀出當下時速 + 4. 當時速 = 0 且「連續超過 4 秒」→ 判定為等紅燈,標記為移除區段 + 5. 其餘「時速 > 0 的行駛片段」全部保留 + 6. 用 FFmpeg 進行【無損】切割 (-c copy) 與拼接,輸出去紅燈影片 + +批次模式: + - 輸入可以直接打「資料夾名稱」(例如 20260502),程式會到 BASE_DIR(預設 E:\\videos) + 底下找該資料夾,並把裡面所有影片(01.MOV、02.MOV…)「各自獨立」去紅燈、各輸出一支。 + - 也可以直接給單一影片檔或完整資料夾路徑。 + +設計重點: + - 偵測座標 (ROI) 與所有門檻參數都集中在最上方的「使用者設定區」,方便修改 + - 提供 --preview 模式: 先輸出一張畫了紅框的預覽圖,確認 ROI 框對位置再正式跑 + - 每支影片都會輸出一份 *_speeds.csv: 紀錄每秒辨識結果,方便檢查 OCR、微調參數 + +需要安裝(測試前請先裝好): + 1. Python 3.x 本體(目前這台電腦的 python 只是 Microsoft Store 捷徑,不是真的 Python) + 2. pip install easyocr opencv-python numpy + 3. FFmpeg / ffprobe,並加入系統 PATH(目前尚未安裝) + +基本用法: + # 1) 先確認框選位置(會抽資料夾第一支影片的一幀,畫紅框) + python auto_remove_redlight.py 20260502 --preview + + # 2) 確認 ROI 沒問題後,批次處理整個資料夾(每支各自輸出) + python auto_remove_redlight.py 20260502 + + # 也可以只處理單一檔案 + python auto_remove_redlight.py E:\\videos\\20260502\\01.MOV -o 01_clean.MOV + + ※ 一般使用建議直接執行同資料夾的「啟動.bat」,用問答方式輸入即可,免記指令。 +================================================================ +""" + +import argparse +import csv +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional, Tuple + +import cv2 +import numpy as np + +# 讓 print 在任何主控台編碼(cp950 / utf-8)下都不會因無法顯示的字元而崩潰 +try: + sys.stdout.reconfigure(errors="replace") + sys.stderr.reconfigure(errors="replace") +except Exception: + pass + +# ============================================================ +# 【使用者設定區】 — 最常修改的參數都集中在這裡 +# ============================================================ + +# --- 影片根目錄 --------------------------------------------- +# 當你只輸入資料夾名稱(例如 20260502)時,程式會到這個目錄底下找。 +BASE_DIR = r"E:\videos" + +# --- GPS 時速表在畫面上的像素區域 (ROI) ---------------------- +# 格式: (x, y, w, h) +# x, y = 區域「左上角」座標 (像素) +# w, h = 區域的寬、高 (像素) +# 4K 影片解析度通常為 3840 x 2160。 +# ⚠️ 下面是「範例佔位值」,請務必改成你影片中時速表的實際位置! +# 小技巧: 先跑 preview(啟動.bat 選 1,或加 --preview), +# 它會輸出 preview_roi.png(整張畫面+紅框)和 preview_crop.png(裁切結果), +# 開圖確認紅框剛好框住時速數字,再回來微調這四個數字即可。 +ROI = (3300, 1770, 300, 140) # <--改這裡!(x, y, w, h) + # 目前值對應 20260516 那批:右下角圓形時速錶內的白色數字 + # (置中數字,避開左側會移動的指針三角形) + +# --- 偵測邏輯參數 ------------------------------------------- +SAMPLE_INTERVAL = 1.0 # 每隔幾秒辨識一次 (秒)。1.0 = 每秒一次 +STOP_SECONDS = 4.0 # 時速為 0 連續超過幾秒,才算等紅燈 (秒) +SPEED_THRESHOLD = 0 # 時速 <= 此值視為「停止」。0 = 只有讀到 0 才算停 + # 若想把「龜速 1~2 km/h」也當停止,可改成 1 或 2 +MIN_CONFIDENCE = 0.50 # OCR 信心低於此值的結果視為「未知」(交給平滑補值) + # 提高到 0.5 可濾掉停車時偶發的低信心雜訊(如 60@0.39) +MAX_SPEED = 300 # 合理時速上限,超過視為誤判(雜訊),當成未知 + +# --- 平滑濾波(關鍵!解決停車時 OCR 在 0 與雜訊間跳動的問題)---- +# 停車時 OCR 多數讀到 0,但偶爾蹦出單格雜訊(6、60…)或空值,會把「連續停車」 +# 打斷,導致紅燈段沒被完整移除。對每秒速度做「補空值 + 中位數濾波」後再判定: +# - 孤立的雜訊讀數會被周圍的 0 吃掉(中位數) +# - 辨識失敗的空值會用前後最近的有效讀數補上 +SMOOTH_WINDOW = 5 # 中位數濾波視窗(取樣點數,奇數)。5 = 看前後各 2 秒 +MERGE_GAP = 3.0 # 兩個紅燈移除段若間隔小於此秒數,視為同一段並合併 (秒) + +# --- 剪輯 / 輸出參數 ---------------------------------------- +STOP_PADDING = 0.0 # 移除紅燈區段時,頭尾各「保留」幾秒停止畫面 (秒) + # 0 = 完整移除整段停等;設成 0.5 可讓剪接點稍微柔和 +MIN_KEEP = 0.8 # 保留片段若短於此秒數就丟棄,避免產生超碎片段 (秒) +USE_GPU = True # EasyOCR 是否用 GPU。需 CUDA + GPU 版 PyTorch。 + # 設 True 時若沒有 CUDA,EasyOCR 會自動退回 CPU(不會壞) +REENCODE = False # False = 無損 -c copy(切點對齊關鍵幀,可能 ±1~2 秒) + # True = 重新編碼(切點精準到幀,但較慢、非無損) +PROGRESS_EVERY = 60 # 進度回報: 每處理幾秒影片印一行目前時速 (秒)。 + # 純顯示用,改小只是印多幾行,不影響速度與偵測精度 + +# --- 批次模式 ----------------------------------------------- +# 會被當成影片來處理的副檔名(小寫比較) +VIDEO_EXTS = {".mov", ".mp4", ".m4v", ".avi", ".mkv", ".mts", ".m2ts", ".insv"} +# 輸出檔名後綴與預設輸出子資料夾名稱 +OUTPUT_SUFFIX = "_no_redlight" # 輸出檔名 = <原檔名><此後綴>.<原副檔名> +OUTPUT_SUBDIR = "no_redlight" # 批次輸出會放到 <資料夾>\no_redlight\ 底下 + +# ============================================================ +# 以下為程式邏輯,一般情況不需更動 +# ============================================================ + + +@dataclass +class Sample: + """單一取樣點的辨識結果。""" + t: float # 時間戳 (秒) + speed: Optional[int] # 辨識到的時速;None 代表辨識失敗/信心不足 + conf: float # OCR 信心值 (0~1) + raw: str # OCR 原始文字 (除錯用) + + +# ---------- EasyOCR 讀取器 (延遲載入,避免 --preview 也要等模型) ---------- + +def build_reader(use_gpu: bool): + """建立 EasyOCR 讀取器。第一次執行會自動下載模型 (約 100MB)。""" + import easyocr # 延遲匯入: 載入較慢,且 preview 模式用不到 + print("[INFO] 正在載入 EasyOCR 模型 (第一次會下載,請稍候)...") + reader = easyocr.Reader(['en'], gpu=use_gpu) + print("[INFO] EasyOCR 載入完成。") + return reader + + +# ---------- 影像前處理 ---------- + +def preprocess_roi(roi_bgr: np.ndarray, upscale: int = 3) -> np.ndarray: + """ + 對 ROI 做前處理以利數字辨識。 + 依你的時速表樣式不同,可能需要調整這裡(這是最影響辨識率的地方): + - 放大: 小數字放大後辨識率較高 + - 二值化 / 反相: 視「亮底暗字」或「暗底亮字」決定是否啟用、是否反相 + """ + gray = cv2.cvtColor(roi_bgr, cv2.COLOR_BGR2GRAY) + + # 放大(對小字很有效) + gray = cv2.resize(gray, None, fx=upscale, fy=upscale, + interpolation=cv2.INTER_CUBIC) + + # ↓↓↓ 如果辨識率不佳,試著「取消註解」下面幾行做二值化 ↓↓↓ + # # Otsu 自動二值化: + # _, gray = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + # # 若你的時速表是「暗底亮字」,二值化後可能要反相(讓字變黑、底變白): + # gray = cv2.bitwise_not(gray) + + return gray + + +# ---------- 單張 ROI → 時速 ---------- + +def detect_speed(reader, roi_bgr: np.ndarray) -> Tuple[Optional[int], float, str]: + """ + 對一張 ROI 影像辨識時速。 + 回傳 (speed, confidence, raw_text);辨識不到數字時 speed = None。 + """ + proc = preprocess_roi(roi_bgr) + + # allowlist 限制只辨識 0~9,可大幅降低把背景雜訊誤認成文字的機率 + results = reader.readtext(proc, allowlist='0123456789', + detail=1, paragraph=False) + + # results 為 [(bbox, text, conf), ...];挑出「信心最高且能解析成數字」的那個 + best: Optional[Tuple[int, float, str]] = None + for (_bbox, text, conf) in results: + digits = ''.join(ch for ch in text if ch.isdigit()) + if not digits: + continue + value = int(digits) + if value > MAX_SPEED: # 超過合理上限,視為誤判 + continue + if best is None or conf > best[1]: + best = (value, float(conf), text) + + if best is None: + return None, 0.0, '' + return best + + +# ---------- 逐秒取樣整支影片 ---------- + +def sample_speeds(video_path: str, roi: Tuple[int, int, int, int], + interval: float, reader, + debug_dir: Optional[str] = None + ) -> Tuple[List[Sample], float, float]: + """ + 用 OpenCV 每隔 interval 秒抽一幀並辨識時速。 + 回傳 (samples, duration, fps)。 + """ + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + raise RuntimeError(f"無法開啟影片(OpenCV 可能不支援此編碼): {video_path}") + + fps = cap.get(cv2.CAP_PROP_FPS) or 60.0 + frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0 + duration = frame_count / fps if frame_count else 0.0 + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + x, y, w, h = roi + # 邊界保護: 避免 ROI 超出畫面導致裁切錯誤 + if x < 0 or y < 0 or x + w > width or y + h > height: + print(f"[WARN] ROI {roi} 超出畫面範圍 {width}x{height},請檢查座標!") + + print(f"[INFO] 影片資訊: {width}x{height}, {fps:.2f} fps, " + f"長度 {fmt_ts(duration)} ({duration:.1f}s)") + print(f"[INFO] 開始逐秒辨識 (每 {interval}s 一次,共約 " + f"{int(duration / interval) if duration else '?'} 個取樣點)...") + + samples: List[Sample] = [] + t = 0.0 + while duration == 0.0 or t < duration: + frame_idx = int(round(t * fps)) + cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) + ok, frame = cap.read() + if not ok: + break # 讀到結尾 + + roi_bgr = frame[y:y + h, x:x + w] + speed, conf, raw = detect_speed(reader, roi_bgr) + + # 信心不足 → 視為未知 (None),避免把不確定的結果當成「停止」而誤刪畫面 + if speed is not None and conf < MIN_CONFIDENCE: + speed = None + + samples.append(Sample(t=t, speed=speed, conf=conf, raw=raw)) + + # 除錯: 把每個取樣點的 ROI 存檔,方便回頭檢查 OCR 為何讀錯 + if debug_dir: + cv2.imwrite(os.path.join(debug_dir, f"t{int(t):05d}_{raw or 'NA'}.png"), + roi_bgr) + + # 進度列 (每 PROGRESS_EVERY 秒印一次;<=0 則不印) + if PROGRESS_EVERY > 0 and int(t) % PROGRESS_EVERY == 0: + shown = speed if speed is not None else '??' + print(f" [{fmt_ts(t)}] 時速={shown}", flush=True) + + t += interval + + cap.release() + print(f"[INFO] 辨識完成,共 {len(samples)} 個取樣點。") + return samples, (duration or t), fps + + +# ---------- 由取樣結果計算「要保留的片段」 ---------- + +def is_stopped(s: Sample) -> bool: + """判斷一個取樣點是否為『停止』(僅供 CSV 顯示用,未平滑)。未知(None)不算停止。""" + if s.speed is None: + return False + return s.speed <= SPEED_THRESHOLD + + +def smooth_speeds(samples: List[Sample], window: int) -> List[int]: + """ + 對逐秒速度做「補空值 + 中位數濾波」,壓掉停車時的跳動雜訊。 + 1. 補空值: 辨識失敗(None)的點,用前一個有效讀數延續(找不到再用後一個) + 2. 中位數濾波: 每個點取前後 window//2 範圍的中位數,孤立的雜訊(單格 6/60)會被吃掉 + 回傳與 samples 等長的整數速度陣列。 + """ + n = len(samples) + if n == 0: + return [] + + # --- 1) 前向補值: 把 None 用「上一個有效讀數」填滿 --- + filled: List[Optional[int]] = [None] * n + last: Optional[int] = None + for i in range(n): + if samples[i].speed is not None: + last = samples[i].speed + filled[i] = last + # --- 開頭若還是 None(前面沒有任何有效讀數),用「下一個有效讀數」回填 --- + nxt: Optional[int] = None + for i in range(n - 1, -1, -1): + if filled[i] is None: + filled[i] = nxt + else: + nxt = filled[i] + # 整支都讀不到時 → 視為 0(交給後續邏輯,通常代表 ROI 設錯) + filled = [0 if v is None else v for v in filled] + + # --- 2) 中位數濾波 --- + half = max(0, window // 2) + smoothed: List[int] = [0] * n + for i in range(n): + lo = max(0, i - half) + hi = min(n, i + half + 1) + win = sorted(filled[lo:hi]) + smoothed[i] = win[len(win) // 2] + return smoothed + + +def find_keep_intervals(samples: List[Sample], duration: float, interval: float + ) -> Tuple[List[Tuple[float, float]], List[Tuple[float, float]]]: + """ + 根據取樣結果,找出: + - removals: 需要移除的紅燈區段 [(start, end), ...] + - keeps : 需要保留的行駛區段 [(start, end), ...] (= 全片扣掉 removals) + """ + n = len(samples) + # 用平滑後的速度判定停止,避免停車時 OCR 跳動把連續紅燈打斷 + smoothed = smooth_speeds(samples, SMOOTH_WINDOW) + stopped = [v <= SPEED_THRESHOLD for v in smoothed] + + removals: List[Tuple[float, float]] = [] + i = 0 + while i < n: + if stopped[i]: + # 找出這一段連續停止的範圍 i..j + j = i + while j + 1 < n and stopped[j + 1]: + j += 1 + + run_start = samples[i].t + # 視為持續到「下一個取樣點」才結束,所以 +interval + run_end = min(samples[j].t + interval, duration) + run_dur = run_end - run_start + + # 連續停止超過門檻 → 列為移除(等紅燈) + if run_dur >= STOP_SECONDS: + rstart = run_start + STOP_PADDING + rend = run_end - STOP_PADDING + if rend > rstart: + removals.append((rstart, rend)) + i = j + 1 + else: + i += 1 + + # 合併「間隔很短」的相鄰移除段(平滑後仍可能殘留 1~2 格雜訊把一個紅燈切兩半) + if removals: + merged: List[Tuple[float, float]] = [removals[0]] + for (rs, re) in removals[1:]: + if rs - merged[-1][1] < MERGE_GAP: + merged[-1] = (merged[-1][0], re) # 接起來 + else: + merged.append((rs, re)) + removals = merged + + # keeps = 全片 [0, duration] 扣掉所有 removals + keeps: List[Tuple[float, float]] = [] + cursor = 0.0 + for (rs, re) in removals: + if rs > cursor: + keeps.append((cursor, rs)) + cursor = max(cursor, re) + if cursor < duration: + keeps.append((cursor, duration)) + + # 丟棄過短的保留片段 + keeps = [(s, e) for (s, e) in keeps if (e - s) >= MIN_KEEP] + return keeps, removals + + +# ---------- FFmpeg: 無損切割 + 拼接 ---------- + +def run_cmd(cmd: List[str]) -> None: + """執行外部指令,失敗時印出 stderr 並丟出例外。""" + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + sys.stderr.write("[ERROR] 指令執行失敗:\n " + " ".join(cmd) + "\n") + sys.stderr.write((proc.stderr or "")[-2000:] + "\n") # 只印最後一段錯誤訊息 + raise RuntimeError("FFmpeg 指令失敗") + + +def ffmpeg_cut_concat(input_path: str, keeps: List[Tuple[float, float]], + output_path: str, reencode: bool, + tmp_parent: Optional[str] = None) -> None: + """ + 將 keeps 中的每個片段切出來,再拼接成 output_path。 + + 無損模式 (reencode=False): + 使用 -c copy 串流複製,完全不重新編碼 → 畫質無損、速度快。 + 代價: 切點會對齊到最近的「關鍵幀(keyframe)」,實際起點可能比指定時間稍早, + 因此切點精度約 ±1~2 秒(取決於影片 GOP 長度)。對「去紅燈」用途通常足夠。 + + 精準模式 (reencode=True): + 重新編碼 → 切點精準到幀,但會耗時且非無損(此處用 CRF 18 接近視覺無損)。 + + tmp_parent: + 暫存切片要放的「父目錄」。建議放在與輸出同一顆硬碟(例如 E:), + 避免大量 4K 暫存檔塞爆 C: 系統碟。 + """ + # 暫存檔放在輸出同顆硬碟,避免塞爆系統碟(4K 切片可能很大) + tmpdir = tempfile.mkdtemp(prefix="redlight_", dir=tmp_parent) + seg_files: List[str] = [] + try: + # 1) 逐段切出 + for idx, (start, end) in enumerate(keeps): + dur = end - start + ext = os.path.splitext(output_path)[1] or ".mp4" + seg = os.path.join(tmpdir, f"seg_{idx:04d}{ext}") + + if reencode: + cmd = [ + "ffmpeg", "-y", + "-ss", f"{start:.3f}", "-i", input_path, + "-t", f"{dur:.3f}", + "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-c:a", "aac", "-b:a", "192k", + "-avoid_negative_ts", "make_zero", + seg, + ] + else: + cmd = [ + "ffmpeg", "-y", + "-ss", f"{start:.3f}", "-i", input_path, + "-t", f"{dur:.3f}", + "-c", "copy", + "-avoid_negative_ts", "make_zero", + seg, + ] + print(f" 切割片段 {idx + 1}/{len(keeps)}: " + f"{fmt_ts(start)} ~ {fmt_ts(end)} ({dur:.1f}s)") + run_cmd(cmd) + seg_files.append(seg) + + # 2) 寫 concat 清單 (concat demuxer 要求每行: file '路徑') + # Windows 路徑的反斜線在清單檔中容易出問題,統一改成正斜線 + listfile = os.path.join(tmpdir, "concat_list.txt") + with open(listfile, "w", encoding="utf-8") as f: + for seg in seg_files: + f.write(f"file '{seg.replace(os.sep, '/')}'\n") + + # 3) 無損拼接 (-c copy) + print(" 正在拼接所有片段...") + cmd = [ + "ffmpeg", "-y", + "-f", "concat", "-safe", "0", + "-i", listfile, + "-c", "copy", + output_path, + ] + run_cmd(cmd) + print(f"[INFO] 完成! 輸出檔案: {output_path}") + + finally: + shutil.rmtree(tmpdir, ignore_errors=True) # 清掉暫存檔 + + +# ---------- preview: 輸出 ROI 預覽圖 ---------- + +def save_preview(video_path: str, roi: Tuple[int, int, int, int], + at_seconds: float = 0.0, prefix: str = "preview") -> None: + """抽一幀,畫上 ROI 紅框並另存裁切結果,方便確認/微調座標。""" + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + raise RuntimeError(f"無法開啟影片: {video_path}") + fps = cap.get(cv2.CAP_PROP_FPS) or 60.0 + cap.set(cv2.CAP_PROP_POS_FRAMES, int(at_seconds * fps)) + ok, frame = cap.read() + cap.release() + if not ok: + raise RuntimeError("無法讀取預覽影格(試試把 --preview-at 設小一點)") + + x, y, w, h = roi + vis = frame.copy() + cv2.rectangle(vis, (x, y), (x + w, y + h), (0, 0, 255), 4) # 紅框 + cv2.imwrite(f"{prefix}_roi.png", vis) # 整張畫面+紅框 + + crop = frame[y:y + h, x:x + w] + cv2.imwrite(f"{prefix}_crop.png", crop) # 裁切結果 + cv2.imwrite(f"{prefix}_crop_processed.png", preprocess_roi(crop)) # 前處理後 + + print("[INFO] 已輸出預覽圖:") + print(f" {prefix}_roi.png <--整張畫面,確認紅框是否框住時速表") + print(f" {prefix}_crop.png <--裁切出的時速表區域") + print(f" {prefix}_crop_processed.png <--送進 OCR 前的前處理結果") + print(f" 來源影片 = {video_path}") + print(f" 目前 ROI = {roi} (x, y, w, h)") + print(" 若框得不準,請修改檔案最上方的 ROI 變數或用 --roi 參數覆寫。") + + +# ---------- 小工具 ---------- + +def fmt_ts(seconds: float) -> str: + """秒數 → HH:MM:SS 字串。""" + seconds = max(0, int(round(seconds))) + h, rem = divmod(seconds, 3600) + m, s = divmod(rem, 60) + return f"{h:02d}:{m:02d}:{s:02d}" + + +def natural_key(name: str): + """自然排序鍵: 讓 1,2,...,10 正確排序(雖然 01,02 補零時一般排序也對)。""" + return [int(t) if t.isdigit() else t.lower() + for t in re.split(r'(\d+)', name)] + + +def find_videos(folder: str) -> List[str]: + """找出資料夾內所有影片檔(只看第一層,不遞迴),依檔名自然排序。""" + items = [] + for name in os.listdir(folder): + full = os.path.join(folder, name) + if os.path.isfile(full) and os.path.splitext(name)[1].lower() in VIDEO_EXTS: + items.append(name) + items.sort(key=natural_key) + return [os.path.join(folder, n) for n in items] + + +def list_folders(base_dir: str) -> List[str]: + """列出 base_dir 底下第一層的子資料夾,依名稱自然排序。""" + items = [] + try: + for name in os.listdir(base_dir): + full = os.path.join(base_dir, name) + if os.path.isdir(full): + items.append(name) + except FileNotFoundError: + return [] + items.sort(key=natural_key) + return [os.path.join(base_dir, n) for n in items] + + +# ---------- 互動式鍵盤選單 (Windows;用內建 msvcrt,免裝套件) ---------- + +def _getkey() -> str: + """讀一個按鍵並回傳語意字串(UP/DOWN/ENTER/SPACE/ESC 或單一字元)。""" + import msvcrt + ch = msvcrt.getwch() + if ch in ("\x00", "\xe0"): # 方向鍵等特殊鍵的前導碼 + ch2 = msvcrt.getwch() + return {"H": "UP", "P": "DOWN", "K": "LEFT", "M": "RIGHT"}.get(ch2, "") + if ch in ("\r", "\n"): + return "ENTER" + if ch == " ": + return "SPACE" + if ch == "\x1b": + return "ESC" + if ch == "\x03": # Ctrl-C + raise KeyboardInterrupt + return ch + + +def menu_select_one(title: str, labels: List[str]) -> Optional[int]: + """單選選單。↑↓ 移動、Enter 或數字鍵選定、Esc 取消。回傳索引或 None。""" + idx = 0 + n = len(labels) + hint = "操作: ↑↓ 移動 | Enter 選定 | 數字鍵直接選 | Esc 取消" + while True: + os.system("cls") + print("=" * 60) + print(title) + print("=" * 60) + print(hint + "\n") + for i, lab in enumerate(labels): + cursor = ">" if i == idx else " " + print(f" {cursor} {i + 1:>2}. {lab}") + key = _getkey() + if key == "UP": + idx = (idx - 1) % n + elif key == "DOWN": + idx = (idx + 1) % n + elif key == "ENTER": + return idx + elif key == "ESC": + return None + elif key.isdigit() and 1 <= int(key) <= n: + return int(key) - 1 + + +def menu_select_many(title: str, labels: List[str]) -> Optional[List[int]]: + """ + 多選選單。↑↓ 移動、空白鍵勾選/取消、a 全選或全不選、Enter 開始、Esc 取消。 + 預設「全部勾選」(整批處理最常見)。回傳已選索引清單或 None。 + """ + idx = 0 + n = len(labels) + checked = set(range(n)) # 預設全選 + hint = ("操作: ↑↓ 移動 | 空白鍵 勾選/取消 | a 全選切換 | " + "數字鍵 勾選/取消 | Enter 開始處理 | Esc 取消") + while True: + os.system("cls") + print("=" * 60) + print(title) + print("=" * 60) + print(hint + "\n") + for i, lab in enumerate(labels): + cursor = ">" if i == idx else " " + box = "[v]" if i in checked else "[ ]" + print(f" {cursor} {box} {i + 1:>2}. {lab}") + print(f"\n 已勾選 {len(checked)} / {n} 支") + key = _getkey() + if key == "UP": + idx = (idx - 1) % n + elif key == "DOWN": + idx = (idx + 1) % n + elif key == "SPACE": + checked.discard(idx) if idx in checked else checked.add(idx) + elif key in ("a", "A"): + checked = set() if len(checked) == n else set(range(n)) + elif key.isdigit() and 1 <= int(key) <= n: + j = int(key) - 1 + checked.discard(j) if j in checked else checked.add(j) + elif key == "ENTER": + if checked: # 至少選一支才開始,避免誤按 + return sorted(checked) + elif key == "ESC": + return None + + +def run_selector(base_dir: str, out_list: str) -> int: + """ + 互動選擇器(給 啟動.bat 呼叫): + 1. 掃描 base_dir 列出資料夾 → 單選 + 2. 掃描該資料夾影片 → 空白鍵打勾多選 + 把選中的影片「絕對路徑」逐行寫到 out_list(UTF-8)。 + 取消或沒選 → 不產生 out_list,回傳非 0,bat 端據此中止。 + 回傳 0 表示成功寫出清單。 + """ + # 只列出「有影片」的資料夾(略過 redlight_remover 等沒有影片的) + folders = [(f, vs) for f in list_folders(base_dir) if (vs := find_videos(f))] + if not folders: + print(f"[ERROR] 在 {base_dir} 底下找不到含影片的資料夾。") + return 2 + + flabels = [f"{os.path.basename(f)} ({len(vs)} 支影片)" for f, vs in folders] + sel = menu_select_one(f"選擇要處理的資料夾 (根目錄: {base_dir})", flabels) + if sel is None: + print("已取消。") + return 1 + folder, allvids = folders[sel] # allvids 已在上面篩出,不必重抓 + + vlabels = [] + for v in allvids: + try: + gb = os.path.getsize(v) / (1024 ** 3) + vlabels.append(f"{os.path.basename(v)} ({gb:.1f} GB)") + except OSError: + vlabels.append(os.path.basename(v)) + picks = menu_select_many( + f"勾選要去紅燈的影片 (資料夾: {os.path.basename(folder)})", vlabels) + if picks is None: + print("已取消。") + return 1 + + chosen = [allvids[i] for i in picks] + with open(out_list, "w", encoding="utf-8") as f: + for p in chosen: + f.write(os.path.abspath(p) + "\n") + + os.system("cls") + print(f"已選擇 {len(chosen)} 支影片(資料夾 {os.path.basename(folder)}):") + for p in chosen: + print(f" - {os.path.basename(p)}") + return 0 + + +def resolve_input(arg: str, base_dir: str) -> str: + """ + 把使用者輸入解析成實際路徑: + 1. 若 arg 本身就是存在的檔案或資料夾 → 直接用 + 2. 否則嘗試 base_dir\\arg(例如只打 20260502) + 回傳實際存在的路徑;找不到則丟出錯誤。 + """ + if os.path.exists(arg): + return arg + candidate = os.path.join(base_dir, arg) + if os.path.exists(candidate): + return candidate + raise FileNotFoundError( + f"找不到輸入: '{arg}'(也試過 '{candidate}')") + + +def write_csv(samples: List[Sample], path: str) -> None: + """把每秒辨識結果寫成 CSV,方便檢查 OCR 準不準。""" + with open(path, "w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + writer.writerow(["time_s", "time_hms", "speed", "confidence", + "raw_text", "stopped"]) + for s in samples: + writer.writerow([ + f"{s.t:.1f}", fmt_ts(s.t), + "" if s.speed is None else s.speed, + f"{s.conf:.2f}", s.raw, int(is_stopped(s)), + ]) + print(f"[INFO] 已輸出逐秒辨識紀錄: {path}") + + +def check_ffmpeg() -> None: + """確認 ffmpeg 可用,否則提早給出清楚的錯誤訊息。""" + if shutil.which("ffmpeg") is None: + sys.exit("[ERROR] 找不到 ffmpeg。請先安裝 FFmpeg 並加入系統 PATH," + "再重新執行(可用 `ffmpeg -version` 測試是否裝好)。") + + +# ---------- 處理單一影片 ---------- + +def process_one(video_path: str, roi: Tuple[int, int, int, int], + out_dir: str, reader, args, + out_file: Optional[str] = None) -> dict: + """ + 對單一影片完整跑一次:辨識 → 算保留區段 → 切割拼接。 + out_file 若指定則用該完整檔名,否則自動命名放到 out_dir(no_redlight 資料夾)。 + 回傳一個結果摘要 dict。 + """ + stem = Path(video_path).stem + ext = Path(video_path).suffix or ".mp4" + if out_file: + out_path = out_file + out_dir = os.path.dirname(os.path.abspath(out_file)) or "." + else: + out_path = os.path.join(out_dir, f"{stem}{OUTPUT_SUFFIX}{ext}") + csv_path = os.path.join(out_dir, f"{stem}_speeds.csv") + + print("\n" + "=" * 60) + print(f"處理: {os.path.basename(video_path)}") + print("=" * 60) + + debug_dir = None + if args.debug_dir: + debug_dir = os.path.join(args.debug_dir, stem) + os.makedirs(debug_dir, exist_ok=True) + + # 1. 逐秒辨識 + samples, duration, fps = sample_speeds( + video_path, roi, args.interval, reader, debug_dir=debug_dir) + + # 2. 輸出逐秒紀錄 + write_csv(samples, csv_path) + + # 3. 計算保留/移除區段 + keeps, removals = find_keep_intervals(samples, duration, args.interval) + + removed_total = sum(e - s for s, e in removals) + kept_total = sum(e - s for s, e in keeps) + print(f"[結果] 總長 {fmt_ts(duration)} | 紅燈 {len(removals)} 段(移除 " + f"{fmt_ts(removed_total)})| 保留 {len(keeps)} 段({fmt_ts(kept_total)})") + for idx, (s, e) in enumerate(removals, 1): + print(f" 移除#{idx}: {fmt_ts(s)} ~ {fmt_ts(e)} (停 {e - s:.0f}s)") + + if not keeps: + print("[WARN] 沒有可保留的片段,跳過此檔(請檢查 ROI 與 speeds.csv)。") + return {"file": stem, "status": "skipped_no_keep", + "removed": removed_total} + + # 4. FFmpeg 無損切割 + 拼接(暫存放在輸出同顆硬碟) + ffmpeg_cut_concat(video_path, keeps, out_path, args.reencode, + tmp_parent=out_dir) + return {"file": stem, "status": "ok", "output": out_path, + "removed": removed_total, "redlights": len(removals)} + + +# ---------- 設定檔 (config.json) ---------- + +CONFIG_NAME = "config.json" + +# 設定檔的 key → 全域變數名稱。值的型別由 JSON 本身決定(數字/布林/陣列)。 +_CONFIG_KEYS = { + "roi": "ROI", # [x, y, w, h] 四個數字 + "sample_interval": "SAMPLE_INTERVAL", + "progress_every": "PROGRESS_EVERY", + "stop_seconds": "STOP_SECONDS", + "speed_threshold": "SPEED_THRESHOLD", + "min_confidence": "MIN_CONFIDENCE", + "smooth_window": "SMOOTH_WINDOW", + "merge_gap": "MERGE_GAP", + "stop_padding": "STOP_PADDING", + "min_keep": "MIN_KEEP", + "use_gpu": "USE_GPU", + "reencode": "REENCODE", + "base_dir": "BASE_DIR", +} + + +def _current_config() -> dict: + """把目前的全域參數打包成 dict,用來寫出預設設定檔。""" + cfg = {} + for key, gname in _CONFIG_KEYS.items(): + val = globals()[gname] + cfg[key] = list(val) if isinstance(val, tuple) else val + return cfg + + +def load_config(script_dir: str) -> Optional[str]: + """ + 讀取與程式同目錄的 config.json,把參數覆寫到全域變數。 + 若檔案不存在,會用目前內建預設「自動產生一份」,方便使用者直接編輯。 + 回傳設定檔路徑(讀取或產生失敗則回 None)。 + """ + path = os.path.join(script_dir, CONFIG_NAME) + + # 不存在 → 產生一份預設設定檔 + if not os.path.exists(path): + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(_current_config(), f, ensure_ascii=False, indent=2) + print(f"[INFO] 已產生預設設定檔: {path}") + except OSError as e: + print(f"[WARN] 無法產生設定檔({e}),改用內建預設。") + return path + + # 存在 → 讀取並套用 + try: + with open(path, encoding="utf-8-sig") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as e: + print(f"[WARN] 設定檔讀取/解析失敗({e}),改用內建預設: {path}") + return None + if not isinstance(data, dict): + print(f"[WARN] 設定檔格式不是物件(應為 {{...}}),改用內建預設: {path}") + return None + + applied = [] + for key, val in data.items(): + if key not in _CONFIG_KEYS: + print(f"[WARN] 設定檔: 不認識的項目『{key}』,略過") + continue + gname = _CONFIG_KEYS[key] + try: + if key == "roi": + nums = [int(x) for x in val] + if len(nums) != 4: + raise ValueError("roi 需要 4 個數字 [x, y, w, h]") + globals()["ROI"] = tuple(nums) + else: + globals()[gname] = val + applied.append(f"{key}={val}") + except (TypeError, ValueError) as e: + print(f"[WARN] 設定檔『{key}={val}』無效: {e}(沿用預設)") + + if applied: + print(f"[INFO] 已套用設定檔 ({CONFIG_NAME}): " + ", ".join(applied)) + return path + + +# ---------- 主程式 ---------- + +def main(): + # global 宣告必須在這兩個變數第一次被使用之前(argparse default 也算使用), + # 否則 Python 3.14 會報 "used prior to global declaration" + global STOP_SECONDS, SAMPLE_INTERVAL + + # 先讀設定檔(覆寫全域預設),再建 argparse —— 讓 CLI 參數仍可再覆寫設定檔 + load_config(os.path.dirname(os.path.abspath(__file__))) + + parser = argparse.ArgumentParser( + description="安全帽機車影片 — 自動去紅燈剪輯工具(支援資料夾批次)", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument("input", nargs="*", default=None, + help="影片檔(可多個)、資料夾路徑,或只打資料夾名稱(會到 BASE_DIR 底下找)," + "例如: 20260502。不給則進入互動選單") + parser.add_argument("--select", action="store_true", + help="啟動.bat 專用: 顯示資料夾/影片選單,把勾選的影片寫到 --out-list 後結束") + parser.add_argument("--out-list", default=None, + help="搭配 --select: 選好的影片絕對路徑會逐行寫到這個檔案") + parser.add_argument("-o", "--output", default=None, + help="單檔模式: 輸出檔路徑;資料夾模式: 輸出資料夾。" + "不指定則自動放到 <資料夾>\\no_redlight\\") + parser.add_argument("--base-dir", default=BASE_DIR, + help="只輸入資料夾名稱時的根目錄") + parser.add_argument("--roi", nargs=4, type=int, metavar=("X", "Y", "W", "H"), + help="覆寫 ROI 座標 (x y w h),不指定則用檔案內的 ROI 常數") + parser.add_argument("--interval", type=float, default=SAMPLE_INTERVAL, + help="每隔幾秒辨識一次") + parser.add_argument("--stop-seconds", type=float, default=STOP_SECONDS, + help="時速 0 連續超過幾秒判定為紅燈") + parser.add_argument("--gpu", action="store_true", default=USE_GPU, + help="EasyOCR 使用 GPU") + parser.add_argument("--reencode", action="store_true", default=REENCODE, + help="重新編碼(切點精準到幀,但較慢、非無損)") + parser.add_argument("--preview", action="store_true", + help="只輸出 ROI 預覽圖後結束(資料夾模式抽第一支影片)") + parser.add_argument("--preview-at", type=float, default=0.0, + help="預覽要抽第幾秒的畫面") + parser.add_argument("--preview-file", default=None, + help="資料夾模式下,指定要用哪支影片做預覽(檔名)") + parser.add_argument("--debug-dir", default=None, + help="把每個取樣點的 ROI 存到此資料夾(除錯用)") + args = parser.parse_args() + + # 套用全域參數覆寫 (讓 CLI 參數優先) + STOP_SECONDS = args.stop_seconds + SAMPLE_INTERVAL = args.interval + roi = tuple(args.roi) if args.roi else ROI + + # === --select: 啟動.bat 專用選擇器,選好寫出清單檔後結束 === + if args.select: + if not args.out_list: + sys.exit("[ERROR] --select 需搭配 --out-list <清單檔路徑>") + sys.exit(run_selector(args.base_dir, args.out_list)) + + inputs = list(args.input) if args.input else [] + out_dir = None + single_named_output = None + + if len(inputs) >= 2: + # === 多個影片檔: 啟動.bat 勾選好後帶參數呼叫(主要路徑)=== + videos = [] + for p in inputs: + try: + videos.append(resolve_input(p, args.base_dir)) + except FileNotFoundError as e: + sys.exit(f"[ERROR] {e}") + base_folder = os.path.dirname(os.path.abspath(videos[0])) + out_dir = args.output if args.output else os.path.join(base_folder, OUTPUT_SUBDIR) + print(f"[INFO] 已指定 {len(videos)} 支影片,準備處理:") + for v in videos: + print(f" - {os.path.basename(v)}") + + elif len(inputs) == 0: + # === 互動後備: 直接執行 py 沒帶參數時,自己跳選單 === + folders = [(f, vs) for f in list_folders(args.base_dir) + if (vs := find_videos(f))] + if not folders: + sys.exit(f"[ERROR] 在 {args.base_dir} 底下找不到含影片的資料夾。") + flabels = [f"{os.path.basename(f)} ({len(vs)} 支影片)" for f, vs in folders] + sel = menu_select_one(f"選擇要處理的資料夾 (根目錄: {args.base_dir})", flabels) + if sel is None: + print("已取消。") + return + in_path, allvids = folders[sel] + vlabels = [] + for v in allvids: + try: + gb = os.path.getsize(v) / (1024 ** 3) + vlabels.append(f"{os.path.basename(v)} ({gb:.1f} GB)") + except OSError: + vlabels.append(os.path.basename(v)) + picks = menu_select_many( + f"勾選要去紅燈的影片 (資料夾: {os.path.basename(in_path)})", vlabels) + if picks is None: + print("已取消。") + return + videos = [allvids[i] for i in picks] + out_dir = os.path.join(in_path, OUTPUT_SUBDIR) + os.system("cls") + print(f"[INFO] 資料夾: {in_path}") + print(f"[INFO] 已選 {len(videos)} 支影片,準備處理:") + for v in videos: + print(f" - {os.path.basename(v)}") + + else: + # === 單一輸入: 影片檔 / 資料夾 / 只給資料夾名稱 === + try: + in_path = resolve_input(inputs[0], args.base_dir) + except FileNotFoundError as e: + sys.exit(f"[ERROR] {e}") + if os.path.isdir(in_path): + videos = find_videos(in_path) + if not videos: + sys.exit(f"[ERROR] 資料夾內找不到影片檔: {in_path}\n" + f" 支援的副檔名: {sorted(VIDEO_EXTS)}") + print(f"[INFO] 資料夾: {in_path}") + print(f"[INFO] 找到 {len(videos)} 支影片:") + for v in videos: + print(f" - {os.path.basename(v)}") + out_dir = args.output if args.output else os.path.join(in_path, OUTPUT_SUBDIR) + else: + videos = [in_path] + if args.output and os.path.splitext(args.output)[1]: + single_named_output = args.output + out_dir = os.path.dirname(os.path.abspath(args.output)) or "." + else: + out_dir = args.output if args.output else os.path.dirname(os.path.abspath(in_path)) + + # --- preview 模式: 確認 ROI 後就結束(僅單一輸入支援)--- + if args.preview: + target = videos[0] + if args.preview_file: + p2 = [v for v in videos if os.path.basename(v) == args.preview_file] + if p2: + target = p2[0] + save_preview(target, roi, args.preview_at, prefix="preview") + return + + check_ffmpeg() + os.makedirs(out_dir, exist_ok=True) + if args.debug_dir: + os.makedirs(args.debug_dir, exist_ok=True) + + # 載入 OCR 模型(只載一次,所有影片共用) + reader = build_reader(args.gpu) + + # 逐支處理;單支失敗不影響其他支 + results = [] + for v in videos: + try: + res = process_one(v, roi, out_dir, reader, args, + out_file=single_named_output) + results.append(res) + except Exception as e: + print(f"[ERROR] 處理 {os.path.basename(v)} 失敗: {e}") + results.append({"file": Path(v).stem, "status": "error", + "error": str(e)}) + + # 總結 + print("\n" + "#" * 60) + print("# 全部處理完成 — 總結") + print("#" * 60) + for r in results: + if r["status"] == "ok": + print(f" [OK] {r['file']}: 去除 {r['redlights']} 個紅燈,共 " + f"{fmt_ts(r['removed'])} -> {r['output']}") + elif r["status"] == "skipped_no_keep": + print(f" [SKIP] {r['file']}: 略過(沒有可保留片段)") + else: + print(f" [FAIL] {r['file']}: {r.get('error', '')}") + + +if __name__ == "__main__": + main() diff --git a/config.json b/config.json new file mode 100644 index 0000000..735c01c --- /dev/null +++ b/config.json @@ -0,0 +1,20 @@ +{ + "roi": [ + 3300, + 1770, + 300, + 140 + ], + "sample_interval": 1.0, + "progress_every": 60, + "stop_seconds": 4.0, + "speed_threshold": 0, + "min_confidence": 0.5, + "smooth_window": 5, + "merge_gap": 3.0, + "stop_padding": 0.0, + "min_keep": 0.8, + "use_gpu": true, + "reencode": false, + "base_dir": "E:\\videos" +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9b7b38c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +# 安裝: pip install -r requirements.txt +# 另外需自行安裝 FFmpeg 並加入 PATH (https://ffmpeg.org/download.html) +easyocr +opencv-python +numpy diff --git a/啟動.bat b/啟動.bat new file mode 100644 index 0000000..3ce0f02 --- /dev/null +++ b/啟動.bat @@ -0,0 +1,134 @@ +@echo off +chcp 950 >nul +title v ۰ʥhOu +cd /d "%~dp0" +setlocal enabledelayedexpansion + +rem === Python(uΤww˸|,׶} Microsoft Store |)=== +set "PYEXE=" +if exist "C:\Python314\python.exe" set "PYEXE=C:\Python314\python.exe" +if not defined PYEXE ( + where py >nul 2>nul && set "PYEXE=py" +) +if not defined PYEXE ( + where python >nul 2>nul && set "PYEXE=python" +) +if not defined PYEXE goto no_python +"%PYEXE%" --version >nul 2>nul +if errorlevel 1 goto no_python + + +:menu +cls +echo ============================================================ +echo v ۰ʥhOu +echo ============================================================ +echo. +echo vڥؿ(BASE_DIR): E:\videos +echo unJƧW(Ҧp 20260502),{|۰ʨWC +echo. +echo [1] w ROI T{ɳtؿm(Ĥ@ij) +echo [2] }lBz 妸hO(ƧCvUۿX@) +echo [3] } +echo. +set "mode=" +set /p mode=пJﶵë Enter (1/2/3): +if "%mode%"=="1" goto preview +if "%mode%"=="2" goto process +if "%mode%"=="3" goto end +goto menu + + +:preview +echo. +echo ------------------------------------------------------------ +echo w ROI:@ieBe,T{Sئɳt +echo ------------------------------------------------------------ +set "folder=" +set /p folder=пJƧW٩θ|(Ҧp 20260502): +if "%folder%"=="" goto preview +set "ROIARG=" +set "roi=" +set /p roi=ۭqɳty x y w h(ΪŮj; Enter ε{w]): +if not "%roi%"=="" set "ROIARG=--roi %roi%" +set "ATARG=" +set "at=" +set /p at=nĴXew( Enter β 0 ): +if not "%at%"=="" set "ATARG=--preview-at %at%" +echo. +echo w,еy... +"%PYEXE%" "%~dp0auto_remove_redlight.py" "%folder%" --preview %ROIARG% %ATARG% +echo. +echo ------------------------------------------------------------ +echo wϤwX쥻Ƨ: +echo preview_roi.png }oiݬئSئɳt +echo preview_crop.png XӪɳtϰ +echo preview_crop_processed.png ei OCR ev +echo. +echo Yرo: OUT x y w h,UwJYi; +echo Twijyжi auto_remove_redlight.py ̤W ROIC +echo ------------------------------------------------------------ +echo. +pause +goto menu + + +:process +echo. +echo ------------------------------------------------------------ +echo }lBz:Ƨ - Ŀv - ۰ʥhO +echo ------------------------------------------------------------ +set "LIST=%TEMP%\redlight_sel.txt" +del "%LIST%" >nul 2>nul +rem (1) ʿܾ:Ƨ + ťĿv,ngXM +"%PYEXE%" "%~dp0auto_remove_redlight.py" --select --base-dir "E:\videos" --out-list "%LIST%" +if not exist "%LIST%" ( + echo. + echo wΥܥv,^C + pause + goto menu +) +rem (2) ŪM,զa޸ѼƦr +set "FILES=" +for /f "usebackq delims=" %%L in ("%LIST%") do set "FILES=!FILES! "%%L"" +del "%LIST%" >nul 2>nul +if not defined FILES ( + echo M欰,^C + pause + goto menu +) +echo. +echo ============================================================ +echo YN}lhO(4K v OCR + Ll,Ф)C +echo X|bvҦbƧ no_redlight lƧC +echo ============================================================ +echo. +rem (3) aۤĿnvɷѼ,IsD{Bz +"%PYEXE%" "%~dp0auto_remove_redlight.py"!FILES! +echo. +echo ============================================================ +echo BzCШӸƧ no_redlight lƧdݦG, +echo åi}ҦU䪺 *_speeds.csv ˬd OCR O_TC +echo ============================================================ +echo. +pause +goto menu + + +:no_python +echo. +echo [~] 䤣iΪ PythonC +echo. +echo oxqثeu Microsoft Store |,Ou PythonC +echo Ш https://www.python.org/downloads/ w Python 3, +echo wˮɰȥĿ Add python.exe to PATHC +echo. +echo ˦nbROC: pip install -r "%~dp0requirements.txt" +echo æw FFmpeg(https://ffmpeg.org/download.html)B[J PATHC +echo. +pause +goto end + + +:end +endlocal