改用兩段判定取代中位數平滑: 精準實作穩定起步規則

依使用者定義: 穩定起步 = 一段移動連續 >=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) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 23:33:21 +08:00
parent 9c077284db
commit b26c32d410
3 changed files with 57 additions and 81 deletions
+3 -5
View File
@@ -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` 剪輯紀錄,記下哪些片段被剪、原因
+53 -73
View File
@@ -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",
+1 -3
View File
@@ -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,