From 4c0f973841605bd9a79e251089bcf554216e071b Mon Sep 17 00:00:00 2001 From: JianMiau Date: Wed, 3 Jun 2026 21:07:03 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=80=E4=B8=8D=E5=88=B0=E7=95=B60=20+=20?= =?UTF-8?q?=E8=B5=B7=E6=AD=A5=E9=96=80=E6=AA=BB8=E7=A7=92=20+=20=E5=89=AA?= =?UTF-8?q?=E8=BC=AFlog=20+=20=E8=BD=89=E5=A0=B4=E6=94=B91=E7=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - smooth_speeds 改為「讀不到(None/??)一律當 GPS=0」,不再前向補值。 停車時 OCR 常讀不到或把 0 誤讀成小數字,當 0 後孤立誤讀會被中位數吃掉, 根本解決假性『起步』把一整段紅燈切兩半的問題(實測 02 的 05:18~07:45 已合為一段) - depart_seconds 5 -> 8(起步要持續超過 8 秒才算真起步) - transition_duration 0.5 -> 1.0(淡出淡入改 1 秒) - 新增剪輯紀錄 <檔名>_cut_log.txt: 列出每段被剪掉的時間範圍與原因、保留片段 - find_keep_intervals 的 removals 改帶原因(dict),process_one 同步更新 - 移除先前嘗試的 depart_min_speed(改用讀不到當0更乾淨) 本次調整由 Claude Opus 4.8 (1M context) 協助開發與處理。 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 14 +++-- auto_remove_redlight.py | 118 +++++++++++++++++++++++++++------------- config.json | 4 +- 3 files changed, 90 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 571ac58..c31f3f3 100644 --- a/README.md +++ b/README.md @@ -87,14 +87,14 @@ python auto_remove_redlight.py 01.MOV 03.MOV 05.MOV | `speed_threshold` | `0` | 時速 ≤ 此值視為停止 | | `min_confidence` | `0.5` | OCR 信心低於此值視為雜訊 | | `smooth_window` | `5` | 中位數濾波視窗(取樣點數) | -| `depart_seconds` | `5.0` | 起步門檻(秒):兩段停車間「持續行駛」需超過此秒數才算真正起步而保留;否則(起步 N 秒內又停)視為仍在等待,連同短暫蠕動一起剪掉(處理車陣走走停停),也順便吸收 OCR 雜訊斷點 | +| `depart_seconds` | `8.0` | 起步門檻(秒):兩段停車間「持續行駛」需超過此秒數才算真正起步而保留;否則(起步 N 秒內又停)視為仍在等待,連同短暫蠕動一起剪掉(處理車陣走走停停) | | `cut_before_stop` | `2.0` | 進入紅燈端(速度到 0)移除起點再往前幾秒,連減速進站一起砍 | | `keep_after_stop` | `2.0` | 綠燈起步端(速度從 0 開始跑)移除終點提早幾秒,多留卡達起步畫面 | | `min_keep` | `0.8` | 保留片段短於此秒數即丟棄 | | `use_gpu` | `true` | 是否使用 GPU(無 CUDA 時自動退回 CPU) | | `reencode` | `false` | `false` = 無損快剪;`true` = 重新編碼,切點精準到幀但較慢 | | `transition` | `true` | 剪接點是否加轉場(淡出淡入 + 恆定功率交叉淡化)。**開啟會強制重新編碼**,有 NVIDIA 顯卡時自動用 NVENC 加速 | -| `transition_duration` | `0.5` | 轉場長度(秒),畫面與聲音共用以維持同步 | +| `transition_duration` | `1.0` | 轉場長度(秒),畫面與聲音共用以維持同步 | | `video_transition` | `fadeblack` | 畫面轉場類型:`fadeblack`=淡出到黑再淡入;`fade`=交叉溶接;其餘見 FFmpeg `xfade` 文件 | | `video_quality` | `30` | 重新編碼畫質(NVENC `-rc vbr -cq`/x264 `-crf`)。數字越大檔案越小、畫質越低。參考(4K 來源約 54Mbps):`28`≈高畫質(檔大)/`30`≈接近原檔/`33`≈省空間/`36`≈更小 | | `volume_boost` | `true` | **音量增益**(Adobe PR「增益/音量」):是否放大音量 | @@ -126,9 +126,12 @@ python auto_remove_redlight.py 01.MOV 03.MOV 05.MOV <影片資料夾>\no_redlight\ ├─ 01_no_redlight.MOV ← 去紅燈後的成品 ├─ 01_speeds.csv ← 每秒辨識時速紀錄(檢查用) + ├─ 01_cut_log.txt ← 剪輯紀錄:哪些片段被剪掉、各自原因 └─ ... ``` +> `*_cut_log.txt` 會列出每段被剪掉的時間範圍與原因(例如「等紅燈/停車約 42 秒」「停車至影片結尾」),以及保留下來的行駛片段,方便核對。 + --- ## 運作原理 @@ -136,9 +139,10 @@ python auto_remove_redlight.py 01.MOV 03.MOV 05.MOV 1. 用 OpenCV 每隔 N 秒抽一幀 2. 裁切出畫面上時速表的固定像素區域(ROI) 3. 用 EasyOCR 只辨識數字,讀出當下時速 -4. 對逐秒時速做平滑濾波(補空值 + 中位數),壓掉跳動雜訊 -5. 時速持續為 0 超過門檻 → 判定為等紅燈,標記移除 -6. 用 FFmpeg 無損切割保留片段並拼接,輸出去紅燈影片 +4. 對逐秒時速做平滑濾波(**讀不到一律當 0** + 中位數),壓掉停車時的跳動雜訊與誤讀 +5. 時速持續為 0 超過門檻 → 判定為等紅燈,標記移除(走走停停會合併、結尾停車砍到底) +6. 用 FFmpeg 切割保留片段並拼接(可選淡出淡入轉場 + 音量處理),輸出去紅燈影片 +7. 另輸出 `*_cut_log.txt` 剪輯紀錄,記下哪些片段被剪、原因 --- diff --git a/auto_remove_redlight.py b/auto_remove_redlight.py index 4378a6f..34e6dda 100644 --- a/auto_remove_redlight.py +++ b/auto_remove_redlight.py @@ -101,10 +101,9 @@ MAX_SPEED = 300 # 合理時速上限,超過視為誤判(雜 # - 孤立的雜訊讀數會被周圍的 0 吃掉(中位數) # - 辨識失敗的空值會用前後最近的有效讀數補上 SMOOTH_WINDOW = 5 # 中位數濾波視窗(取樣點數,奇數)。5 = 看前後各 2 秒 -DEPART_SECONDS = 5.0 # 起步門檻(秒): 兩段停車中間「持續行駛(GPS>0)」需超過此 - # 秒數,才算真正起步而保留;否則(起步 N 秒內又停)視為 - # 仍在等待,連同那段短暫蠕動一起剪掉(處理車陣走走停停)。 - # 也順便吸收 OCR 雜訊造成的短暫斷點。 +DEPART_SECONDS = 8.0 # 起步門檻(秒): 兩段停車中間「持續行駛」需超過此秒數, + # 才算真正起步而保留;否則(起步 N 秒內又停)視為仍在等待, + # 連同那段短暫蠕動一起剪掉(處理車陣走走停停)。 # --- 剪輯 / 輸出參數 ---------------------------------------- # 紅燈移除段的頭尾「不對稱」微調: @@ -128,7 +127,7 @@ PROGRESS_EVERY = 60 # 進度回報: 每處理幾秒影片印一 # 注意: 一旦開啟轉場,就「必須重新編碼」(濾鏡無法用 -c copy),非無損且較慢。 # 有 NVIDIA 顯卡時會自動用 NVENC 硬體編碼加速 4K。 TRANSITION = True # 是否在每個剪接點加轉場。False = 維持無損快剪 -TRANSITION_DURATION = 0.5 # 轉場長度 (秒)。畫面與聲音共用同一長度,確保同步 +TRANSITION_DURATION = 1.0 # 轉場長度 (秒)。畫面與聲音共用同一長度,確保同步 VIDEO_TRANSITION = "fadeblack" # 畫面轉場 (FFmpeg xfade 類型): # fadeblack = 淡出到黑再淡入(較接近「淡出淡入」) # fade = 交叉溶接(兩段直接互溶) @@ -305,31 +304,18 @@ def is_stopped(s: Sample) -> bool: def smooth_speeds(samples: List[Sample], window: int) -> List[int]: """ - 對逐秒速度做「補空值 + 中位數濾波」,壓掉停車時的跳動雜訊。 - 1. 補空值: 辨識失敗(None)的點,用前一個有效讀數延續(找不到再用後一個) - 2. 中位數濾波: 每個點取前後 window//2 範圍的中位數,孤立的雜訊(單格 6/60)會被吃掉 + 對逐秒速度做「讀不到當 0 + 中位數濾波」,壓掉停車時的跳動雜訊。 + 1. 讀不到(None/??)一律當作 GPS=0(停止)。停車時 OCR 常讀不到或誤讀成小數字, + 當 0 處理後,孤立的誤讀(如把 0 看成 70)會被中位數吃掉,避免假性「起步」。 + 2. 中位數濾波: 每個點取前後 window//2 範圍的中位數,孤立雜訊(單格 6/60/70)會被吃掉。 回傳與 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] + # --- 1) 讀不到(None)一律當 0 --- + filled: List[int] = [s.speed if s.speed is not None else 0 for s in samples] # --- 2) 中位數濾波 --- half = max(0, window // 2) @@ -369,7 +355,7 @@ def find_keep_intervals(samples: List[Sample], duration: float, interval: float else: i += 1 - # 2) 合併「中間行駛(GPS>0)不超過 DEPART_SECONDS 秒」的相鄰停止段: + # 2) 合併「中間行駛不超過 DEPART_SECONDS 秒」的相鄰停止段: # 起步後 N 秒內又停 → 視為仍在等待(車陣走走停停),連那段短暫蠕動一起算停止。 # 只有行駛「超過」DEPART_SECONDS 才算真正起步,該行駛段才會被保留。 merged_runs: List[List[float]] = [] @@ -379,8 +365,8 @@ def find_keep_intervals(samples: List[Sample], duration: float, interval: float else: merged_runs.append(list(run)) - # 3) 套用門檻與頭尾偏移 → 產生移除區段 - removals: List[Tuple[float, float]] = [] + # 3) 套用門檻與頭尾偏移 → 產生移除區段(每段附帶移除原因,供 log 使用) + removals: List[dict] = [] for (run_start, run_end) in merged_runs: run_dur = run_end - run_start at_end = run_end >= duration - 1e-3 # 是否一路停到影片結尾 @@ -392,26 +378,34 @@ def find_keep_intervals(samples: List[Sample], duration: float, interval: float if at_end: # 停到影片結尾: 沒有起步要留 → 砍到底,不保留 KEEP_AFTER_STOP rend = run_end + reason = f"停車至影片結尾(停止約 {run_dur:.0f} 秒)" else: # 一般紅燈: 綠燈起步端提早 KEEP_AFTER_STOP 秒結束(多留卡達起步) rend = run_end - KEEP_AFTER_STOP + reason = f"等紅燈/停車約 {run_dur:.0f} 秒" if rend > rstart: - removals.append((rstart, rend)) + removals.append({"start": rstart, "end": rend, + "stop_dur": run_dur, "at_end": at_end, + "reason": reason}) # 4) 頭尾偏移後若仍有相鄰移除段重疊/相接,合併之 if removals: - merged: List[List[float]] = [list(removals[0])] - for (rs, re) in removals[1:]: - if rs <= merged[-1][1]: - merged[-1][1] = max(merged[-1][1], re) + merged: List[dict] = [dict(removals[0])] + for r in removals[1:]: + if r["start"] <= merged[-1]["end"]: + merged[-1]["end"] = max(merged[-1]["end"], r["end"]) + merged[-1]["stop_dur"] += r["stop_dur"] + merged[-1]["at_end"] = merged[-1]["at_end"] or r["at_end"] + merged[-1]["reason"] = "等紅燈/停車(多段相連合併)" else: - merged.append([rs, re]) - removals = [(a, b) for a, b in merged] + merged.append(dict(r)) + removals = merged # keeps = 全片 [0, duration] 扣掉所有 removals keeps: List[Tuple[float, float]] = [] cursor = 0.0 - for (rs, re) in removals: + for r in removals: + rs, re = r["start"], r["end"] if rs > cursor: keeps.append((cursor, rs)) cursor = max(cursor, re) @@ -929,12 +923,13 @@ def process_one(video_path: str, roi: Tuple[int, int, int, int], # 3. 計算保留/移除區段 keeps, removals = find_keep_intervals(samples, duration, args.interval) - removed_total = sum(e - s for s, e in removals) + removed_total = sum(r["end"] - r["start"] for r 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)") + for idx, r in enumerate(removals, 1): + print(f" 移除#{idx}: {fmt_ts(r['start'])} ~ {fmt_ts(r['end'])} " + f"(剪掉 {r['end'] - r['start']:.0f}s) 原因: {r['reason']}") if not keeps: print("[WARN] 沒有可保留的片段,跳過此檔(請檢查 ROI 與 speeds.csv)。") @@ -954,8 +949,53 @@ def process_one(video_path: str, roi: Tuple[int, int, int, int], # 無轉場: 維持無損快剪 ffmpeg_cut_concat(video_path, keeps, out_path, args.reencode, tmp_parent=out_dir) + + # 5. 輸出剪輯 log: 哪些片段被剪掉、各自原因 + log_path = os.path.join(out_dir, f"{stem}_cut_log.txt") + write_cut_log(log_path, video_path, out_path, duration, + removals, keeps, removed_total, kept_total) + return {"file": stem, "status": "ok", "output": out_path, - "removed": removed_total, "redlights": len(removals)} + "removed": removed_total, "redlights": len(removals), + "log": log_path} + + +def write_cut_log(path: str, src: str, out: str, duration: float, + removals: List[dict], keeps: List[Tuple[float, float]], + removed_total: float, kept_total: float) -> None: + """輸出一份人看得懂的剪輯紀錄: 列出每段被剪掉的時間範圍與原因。""" + lines = [] + lines.append("=" * 56) + lines.append("去紅燈剪輯紀錄") + lines.append("=" * 56) + lines.append(f"來源影片 : {src}") + lines.append(f"輸出影片 : {out}") + lines.append(f"原始總長 : {fmt_ts(duration)} ({duration:.1f}s)") + lines.append(f"剪掉 : {len(removals)} 段,共 {fmt_ts(removed_total)} " + f"({removed_total:.1f}s)") + lines.append(f"保留 : {len(keeps)} 段,共 {fmt_ts(kept_total)} " + f"({kept_total:.1f}s)") + lines.append("") + lines.append("【被剪掉的片段與原因】") + if removals: + for idx, r in enumerate(removals, 1): + lines.append( + f" #{idx:>2} {fmt_ts(r['start'])} ~ {fmt_ts(r['end'])} " + f"(剪掉 {r['end'] - r['start']:.0f} 秒) → {r['reason']}") + else: + lines.append(" (無;整支影片皆保留)") + lines.append("") + lines.append("【保留下來的片段(行駛畫面)】") + for idx, (s, e) in enumerate(keeps, 1): + lines.append(f" #{idx:>2} {fmt_ts(s)} ~ {fmt_ts(e)} ({e - s:.0f} 秒)") + lines.append("") + lines.append("判定規則: 連續停止 >= 紅燈門檻,或停到影片結尾即剪掉;" + "兩段停車間行駛未超過起步門檻會併入一起剪;讀不到時速一律當 0。") + + text = "\n".join(lines) + "\n" + with open(path, "w", encoding="utf-8-sig") as f: + f.write(text) + print(f"[INFO] 已輸出剪輯紀錄: {path}") # ---------- 設定檔 (config.json) ---------- diff --git a/config.json b/config.json index 9dff581..ebd76fc 100644 --- a/config.json +++ b/config.json @@ -11,14 +11,14 @@ "speed_threshold": 0, "min_confidence": 0.5, "smooth_window": 5, - "depart_seconds": 5.0, + "depart_seconds": 8.0, "cut_before_stop": 2.0, "keep_after_stop": 2.0, "min_keep": 0.8, "use_gpu": true, "reencode": false, "transition": true, - "transition_duration": 0.5, + "transition_duration": 1.0, "video_transition": "fadeblack", "video_quality": 30, "volume_boost": true,