讀不到當0 + 起步門檻8秒 + 剪輯log + 轉場改1秒

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 21:07:03 +08:00
parent 44b52a4cf9
commit 4c0f973841
3 changed files with 90 additions and 46 deletions
+9 -5
View File
@@ -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` 剪輯紀錄,記下哪些片段被剪、原因
---
+79 -39
View File
@@ -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) ----------
+2 -2
View File
@@ -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,