From b26c32d410a924e88bfdde02953f84c3ad99152e Mon Sep 17 00:00:00 2001 From: JianMiau Date: Wed, 3 Jun 2026 23:33:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=85=A9=E6=AE=B5=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E5=8F=96=E4=BB=A3=E4=B8=AD=E4=BD=8D=E6=95=B8=E5=B9=B3?= =?UTF-8?q?=E6=BB=91:=20=E7=B2=BE=E6=BA=96=E5=AF=A6=E4=BD=9C=E7=A9=A9?= =?UTF-8?q?=E5=AE=9A=E8=B5=B7=E6=AD=A5=E8=A6=8F=E5=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依使用者定義: 穩定起步 = 一段移動連續 >=DEPART_SECONDS(8)秒、且其中沒有 >=STOP_SECONDS(4)秒的 GPS=0。作法: - smooth_speeds 改為只做『讀不到當0 + 物理過濾』,不再做中位數(避免視窗糊掉邊界) - find_keep_intervals 兩段: Pass A 短停(<4s)併入移動; Pass B 短移動(<8s)併入停止 - 移除 depart_min_speed(速度門檻)與中位數,改用零模式判定,更乾淨且邊界精準 - MIN_CONFIDENCE 0.7->0.5(門檻太高會濾掉真實 creep 讀數如 12@0.55) 實測 02: #11(原#9)精準剪到 19:15(符合使用者), #3 保留真起步, #4 排隊龜速全剪。 本次重構由 Claude Opus 4.8 (1M context) 協助處理。 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 8 +-- auto_remove_redlight.py | 126 +++++++++++++++++----------------------- config.json | 4 +- 3 files changed, 57 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 47141e8..8a18f69 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,9 @@ python auto_remove_redlight.py 01.MOV 03.MOV 05.MOV | `progress_every` | `60` | 進度回報間隔(秒),純顯示用,不影響效能 | | `stop_seconds` | `4.0` | 連續停車超過幾秒判定為紅燈 | | `speed_threshold` | `0` | 時速 ≤ 此值視為停止 | -| `min_confidence` | `0.7` | OCR 信心低於此值視為「讀不到」(交給平滑用前後判斷)| +| `min_confidence` | `0.5` | OCR 信心低於此值視為「讀不到」(當 0);誤讀改由物理過濾與短移動判定處理 | | `max_accel_kmh` | `25` | 物理合理加速上限(km/h 每秒):機車不可能一秒從 4 衝到 70,超過此加速度的讀數視為高信心誤讀丟棄(只擋暴增,煞車減速不受限)| -| `smooth_window` | `5` | 中位數濾波視窗(取樣點數) | -| `depart_seconds` | `8.0` | 起步門檻(秒):兩段停車間「持續行駛」需達到(滿)此秒數才算起步成功並保留(含這 8 秒);不足者(起步 N 秒內又停)視為仍在等待,連同短暫蠕動一起剪掉(處理車陣走走停停) | -| `depart_min_speed` | `15` | 真起步速度門檻(km/h):一段移動要「加速到」此速度才算真起步/行駛而保留(連同前面龜速起點一起留);若整段最高速都低於此(只是排隊龜速、從未真正騎走),整段改判停止剪掉 | +| `depart_seconds` | `8.0` | 起步門檻(秒):一段移動要持續達到(滿)此秒數、且其中沒有 ≥`stop_seconds` 的 GPS=0,才算「穩定起步」而保留;不足者(短暫蠕動/排隊龜速/誤讀)併入停止一起剪掉 | | `cut_before_stop` | `2.0` | 進入紅燈端(速度到 0)移除起點再往前幾秒,連減速進站一起砍 | | `keep_after_stop` | `2.0` | 綠燈起步端(速度從 0 開始跑)移除終點提早幾秒,多留卡達起步畫面 | | `min_keep` | `0.8` | 保留片段短於此秒數即丟棄 | @@ -141,7 +139,7 @@ python auto_remove_redlight.py 01.MOV 03.MOV 05.MOV 1. 用 OpenCV 每隔 N 秒抽一幀 2. 裁切出畫面上時速表的固定像素區域(ROI) 3. 用 EasyOCR 只辨識數字,讀出當下時速 -4. 對逐秒時速做平滑濾波(**只對可信讀數取中位數、空白跳過;整個視窗都讀不到才當 0**),停車段的空白被周圍的 0 判成停、行駛段的空白被周圍車速判成動,避免誤剪慢速起步 +4. 整理每秒速度(**讀不到當 0** + **物理過濾**剔除不可能的暴增誤讀),再用兩段判定:**Pass A** 把 <`stop_seconds` 的停止併入移動(OCR 瞬斷不算紅燈)、**Pass B** 把 <`depart_seconds` 的移動併入停止(沒撐滿就不算穩定起步),不做中位數平滑以免糊掉邊界 5. 時速持續為 0 超過門檻 → 判定為等紅燈,標記移除(走走停停會合併、結尾停車砍到底) 6. 用 FFmpeg 切割保留片段並拼接(可選淡出淡入轉場 + 音量處理),輸出去紅燈影片 7. 另輸出 `*_cut_log.txt` 剪輯紀錄,記下哪些片段被剪、原因 diff --git a/auto_remove_redlight.py b/auto_remove_redlight.py index 5337cea..2fcb14c 100644 --- a/auto_remove_redlight.py +++ b/auto_remove_redlight.py @@ -91,28 +91,22 @@ 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.70 # OCR 信心低於此值的結果視為「未知」(交給平滑用前後判斷) - # 0.7 可濾掉停車時的低信心誤讀(如把 0 看成 70@0.57) +MIN_CONFIDENCE = 0.50 # OCR 信心低於此值的結果視為「讀不到」(當 0)。 + # 不用設太高: 誤讀改由物理過濾(MAX_ACCEL_KMH)與 + # 「短移動併入停止」(Pass B)處理,門檻太高反而會濾掉真實 creep MAX_SPEED = 300 # 合理時速上限,超過視為誤判(雜訊),當成未知 MAX_ACCEL_KMH = 25 # 物理合理加速上限(km/h 每秒)。機車不可能一秒從 4 衝到 70, # 超過此加速度的讀數視為誤讀丟棄(擋高信心誤讀,如 4->70)。 # 只限制「暴增」;煞車減速很快,不受此限。 -# --- 平滑濾波(關鍵!解決停車時 OCR 在 0 與雜訊間跳動的問題)---- -# 停車時 OCR 多數讀到 0,但偶爾蹦出單格雜訊(6、60…)或空值,會把「連續停車」 -# 打斷,導致紅燈段沒被完整移除。對每秒速度做「補空值 + 中位數濾波」後再判定: -# - 孤立的雜訊讀數會被周圍的 0 吃掉(中位數) -# - 辨識失敗的空值會用前後最近的有效讀數補上 -SMOOTH_WINDOW = 7 # 中位數濾波視窗(取樣點數,奇數)。7 = 看前後各 3 秒。 - # 要夠大才能蓋過「行駛中連續讀不到」的空白(被當0), - # 避免把高速行駛誤判成停車(視窗 7 可容忍最多 3 連續空白)。 -DEPART_SECONDS = 8.0 # 起步門檻(秒): 兩段停車中間「持續行駛」需『達到(滿)』此秒數, - # 才算真正起步成功並保留(含這 8 秒);不足者(起步 N 秒內又停) - # 視為仍在等待,連同那段短暫蠕動一起剪掉(處理車陣走走停停)。 -DEPART_MIN_SPEED = 15 # 真起步速度門檻(km/h): 一段移動要「加速到」此速度才算真起步/行駛 - # 而保留(連同前面的龜速起點一起留);若整段移動最高速都低於此 - # (只是排隊龜速、從未真正騎走),則整段改判為停止一起剪掉。 - # 這能分辨「龜速後加速騎走(留)」vs「龜速後又停(剪)」。 +# --- 停止/起步判定門檻 ------------------------------------- +# 不做中位數平滑(改用 find_keep_intervals 的兩段邏輯處理雜訊,避免糊掉邊界): +# Pass A: 短於 STOP_SECONDS 的「停止」併入移動(OCR 瞬斷不算紅燈) +# Pass B: 短於 DEPART_SECONDS 的「移動」併入停止(沒撐滿就不算穩定起步) +DEPART_SECONDS = 8.0 # 起步門檻(秒): 一段移動要持續『達到(滿)』此秒數,且其中 + # 沒有 ≥STOP_SECONDS 的 GPS=0,才算「穩定起步」而保留; + # 不足者(短暫蠕動/排隊龜速/誤讀)併入停止一起剪掉。 +SMOOTH_WINDOW = 0 # (已停用,保留相容;雜訊改由上述兩段邏輯處理) # --- 剪輯 / 輸出參數 ---------------------------------------- # 紅燈移除段的頭尾「不對稱」微調: @@ -311,15 +305,15 @@ def is_stopped(s: Sample) -> bool: return s.speed <= SPEED_THRESHOLD -def smooth_speeds(samples: List[Sample], window: int) -> List[int]: +def smooth_speeds(samples: List[Sample], window: int = 0) -> List[int]: """ - 對逐秒速度做平滑,壓掉停車時的跳動雜訊,同時不誤殺慢速起步(creep)。 - 做法:每個取樣點取前後 window//2 範圍內「可信讀數(speed 不為 None)」的中位數; - 空白/低信心(None)『跳過不計』,而不是當 0。 - - 讀不到時改由前後決定: 前後都在停(0)→中位數 0(停);前後在動→中位數非0(動)。 - 這樣停車段的空白會被周圍的 0 判成停,行駛段的空白會被周圍的車速判成動。 - - 整個視窗都讀不到才視為 0(停止)。 - - 孤立的誤讀(被高信心門檻 + 中位數雙重過濾)會被周圍多數吃掉。 + 把逐秒辨識結果整理成「每秒速度」陣列(供停止/起步判定用)。 + 1. 讀不到/低信心(None)一律當 GPS=0。 + 2. 物理合理性過濾: 剔除「從上一個有效讀數暴增、不可能的加速」(多為高信心誤讀, + 如 4 -> 70);剔除後也當 0。只擋暴增,煞車減速很快故不限制下降。 + 不做中位數平滑(改由 find_keep_intervals 的「短停併入移動、短移動併入停止」 + 兩段邏輯處理雜訊),以免視窗把起步/停止的邊界往前後糊掉。 + (window 參數保留相容舊呼叫,目前不使用。) 回傳與 samples 等長的整數速度陣列。 """ n = len(samples) @@ -327,9 +321,6 @@ def smooth_speeds(samples: List[Sample], window: int) -> List[int]: return [] spd: List[Optional[int]] = [s.speed for s in samples] - - # 物理合理性過濾: 剔除「從上一個有效讀數一秒內暴增」的不可能加速(多為高信心誤讀, - # 如 4 -> 70)。只擋暴增;煞車減速很快故不限制下降。 last_v: Optional[int] = None last_i = 0 for i in range(n): @@ -340,15 +331,7 @@ def smooth_speeds(samples: List[Sample], window: int) -> List[int]: continue last_v, last_i = spd[i], i - # 中位數(只對有效讀數;空白跳過,整個視窗都讀不到才當 0) - 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) - vals = sorted(v for v in spd[lo:hi] if v is not None) - smoothed[i] = vals[len(vals) // 2] if vals else 0 # 全讀不到 → 視為停止 - return smoothed + return [v if v is not None else 0 for v in spd] # 讀不到/剔除 → 0 def find_keep_intervals(samples: List[Sample], duration: float, interval: float @@ -359,38 +342,47 @@ def find_keep_intervals(samples: List[Sample], duration: float, interval: float - keeps : 需要保留的行駛區段 [(start, end), ...] (= 全片扣掉 removals) """ n = len(samples) - # 用平滑後的速度判定停止,避免停車時 OCR 跳動把連續紅燈打斷 - smoothed = smooth_speeds(samples, SMOOTH_WINDOW) - stopped = [v <= SPEED_THRESHOLD for v in smoothed] + speeds = smooth_speeds(samples) # 讀不到當0 + 物理過濾(不做中位數) + moving = [v > SPEED_THRESHOLD for v in speeds] - # 0) 龜速重新歸類: 把「從未加速到真正速度」的移動段改判為停止(會被剪)。 - # 先把移動段串起來(中間 < STOP_SECONDS 的短暫停頓視為 OCR 瞬斷,併入同一段), - # 若整段最高速 < DEPART_MIN_SPEED → 只是排隊龜速,整段改判停止; - # 有加速到 DEPART_MIN_SPEED 的(真起步/行駛)才保留,且連龜速起點一起留。 - stop_gap = max(1, int(round(STOP_SECONDS / interval))) # 可橋接的最大停頓取樣數 + stop_n = max(1, int(round(STOP_SECONDS / interval))) # 短停門檻(取樣數) + move_n = max(1, int(round(DEPART_SECONDS / interval))) # 真起步門檻(取樣數) + + # Pass A: 把「短於 STOP_SECONDS 的停止」併入移動 —— OCR 瞬斷/短暫慢下不算紅燈, + # 讓真起步中間的零星讀不到/0 不會把移動段打斷。 i = 0 while i < n: - if not stopped[i]: + if not moving[i]: j = i - while True: - while j + 1 < n and not stopped[j + 1]: - j += 1 - k = j + 1 # 數一數後面連續停了幾格 - while k < n and stopped[k]: - k += 1 - if k < n and (k - (j + 1)) < stop_gap: # 短暫停頓 → 橋接,移動繼續 - j = k - else: - break - if max(smoothed[i:j + 1]) < DEPART_MIN_SPEED: # 整段都沒加速到真速度 + while j + 1 < n and not moving[j + 1]: + j += 1 + if (j - i + 1) < stop_n: for x in range(i, j + 1): - stopped[x] = True # → 龜速排隊,改判停止 + moving[x] = True i = j + 1 else: i += 1 - # 1) 先抓出所有「原始連續停止」區段(不論長短) - raw_runs: List[List[float]] = [] + # Pass B: 把「短於 DEPART_SECONDS 的移動」併入停止 —— 起步沒撐滿門檻(且中間沒有 + # ≥STOP_SECONDS 的 0)就不算穩定起步;短暫蠕動/排隊龜速/孤立誤讀都會被剪掉。 + # (= 你的規則: 穩定起步要連續 ≥8 秒、其中不能有 ≥4 秒的 GPS=0) + i = 0 + while i < n: + if moving[i]: + j = i + while j + 1 < n and moving[j + 1]: + j += 1 + if (j - i + 1) < move_n: + for x in range(i, j + 1): + moving[x] = False + i = j + 1 + else: + i += 1 + + stopped = [not m for m in moving] + + # 由最終 stopped 串出停止區段(短移動已併入,故相鄰停止自然連成一段) + merged_runs: List[List[float]] = [] i = 0 while i < n: if stopped[i]: @@ -399,21 +391,11 @@ def find_keep_intervals(samples: List[Sample], duration: float, interval: float j += 1 run_start = samples[i].t run_end = min(samples[j].t + interval, duration) # 持續到下個取樣點 - raw_runs.append([run_start, run_end]) + merged_runs.append([run_start, run_end]) i = j + 1 else: i += 1 - # 2) 合併「中間行駛不足 DEPART_SECONDS 秒」的相鄰停止段: - # 起步後不到門檻又停 → 視為仍在等待(車陣走走停停),連那段短暫蠕動一起算停止。 - # 行駛「達到(滿)」DEPART_SECONDS 即算起步成功,該行駛段(含這 8 秒)會被保留。 - merged_runs: List[List[float]] = [] - for run in raw_runs: - if merged_runs and (run[0] - merged_runs[-1][1]) < DEPART_SECONDS: - merged_runs[-1][1] = run[1] # 行駛 < 門檻 → 併入前一段停止 - else: - merged_runs.append(list(run)) - # 3) 套用門檻與頭尾偏移 → 產生移除區段(每段附帶移除原因,供 log 使用) removals: List[dict] = [] for (run_start, run_end) in merged_runs: @@ -1060,9 +1042,7 @@ _CONFIG_KEYS = { "speed_threshold": "SPEED_THRESHOLD", "min_confidence": "MIN_CONFIDENCE", "max_accel_kmh": "MAX_ACCEL_KMH", - "smooth_window": "SMOOTH_WINDOW", "depart_seconds": "DEPART_SECONDS", - "depart_min_speed": "DEPART_MIN_SPEED", "cut_before_stop": "CUT_BEFORE_STOP", "keep_after_stop": "KEEP_AFTER_STOP", "min_keep": "MIN_KEEP", diff --git a/config.json b/config.json index 77b5ad1..ce2e77b 100644 --- a/config.json +++ b/config.json @@ -9,11 +9,9 @@ "progress_every": 60, "stop_seconds": 4.0, "speed_threshold": 0, - "min_confidence": 0.7, + "min_confidence": 0.5, "max_accel_kmh": 25, - "smooth_window": 7, "depart_seconds": 8.0, - "depart_min_speed": 15, "cut_before_stop": 2.0, "keep_after_stop": 2.0, "min_keep": 0.8,