讀不到當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:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user