ayzeksalimli's picture
Push project (code, README, Docker/compose) — models/results excluded, downloaded at runtime
1f47b06 verified
Raw
History Blame Contribute Delete
22.9 kB
from __future__ import annotations
import os
# macOS: PyTorch + NumPy/OpenCV may load libomp twice; avoid abort on import.
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
import argparse
import sys
from datetime import datetime
import torch
import cv2
import numpy as np
from pathlib import Path
from typing import Optional, Tuple
from ultralytics import YOLO
_orig_load = torch.load
def _patched_load(*args, **kwargs):
kwargs["weights_only"] = False
return _orig_load(*args, **kwargs)
torch.load = _patched_load
_DIR = Path(__file__).resolve().parent
_ROOT = _DIR.parent
MODELS_DIR = _ROOT / "models"
RESULTS_DIR = _ROOT / "results"
MODEL_PATH = MODELS_DIR / "segmentor" / "tram-dynamic-roi-tracker-yolo11s.pt"
VIDEO_PATH = str(_ROOT / "/Users/monadayzek/Desktop/Ayzek/1-spbstu/diploma/objects-recognation/yolo/a.mp4")
OUTPUT_PATH = str(RESULTS_DIR / "videos" / "roi_zones_stable___3.mp4")
CONF = 0.325
def _detect_device() -> str:
if torch.cuda.is_available():
return "cuda"
if torch.backends.mps.is_available():
return "mps"
return "cpu"
DEVICE = os.environ.get("TRAM_DEVICE") or _detect_device()
LOW_CONF_DIR = RESULTS_DIR / "low-conf"
LOW_CONF_NORM = 0.5
RAIL_CLS = {0, 1, 3, 4}
RAIL_BRANCH = 0
_MAX_CENTER_JUMP_PX = 120
GAUGE_MM = 1524
DEFAULT_FOV_DEG = 70.0
ZONE_DISTS_MM = {
"red": 500,
"orange": 1000,
"yellow": 1500,
}
GRID_STEP_MM = 2000
ZONE_ALPHA = 0.30
ZONE_COLORS = {
"red": (0, 0, 255),
"orange": (0, 140, 255),
"yellow": (0, 220, 255),
}
GRID_COLOR = (255, 255, 255)
TEXT_COLOR = (255, 255, 255)
MORPH_KERNEL = (5, 5)
MORPH_CLOSE_ITER = 1
MORPH_OPEN_ITER = 1
USE_MORPH = False
# Mask extraction: soft (probabilistic) accumulation + sub-pixel resize.
MASK_PROB_THRESH = 0.5
# Always bridge tiny seg holes (cheap, stabilizes per-row edges).
_BASE_CLOSE_KERNEL = (3, 3)
# rail_rows: bridge gaps up to this many px when finding the contiguous bed run.
_ROW_GAP_PX = 14
# Line rendering style (lightweight: outlines only, no filled polygons).
_ZONE_LINE_THICK = 2
_RAIL_LINE_THICK = 2
# Distance model: minimum rows for a trustworthy line fit, robust-trim params.
_FIT_MIN_ROWS = 6
_FIT_ITERS = 4
_FIT_TRIM_SIGMA = 2.0
def load_model(path: Optional[Path] = None) -> YOLO:
p = Path(path) if path else MODEL_PATH
if not p.exists():
print(
f"[ERROR] Модель не найдена: {p}\n"
"Запустите: python scripts/download_models.py"
)
sys.exit(1)
return YOLO(str(p))
def _max_rail_confidence(res) -> Optional[float]:
if res.boxes is None or len(res.boxes) == 0:
return None
cls = res.boxes.cls.cpu().numpy().astype(int)
conf = res.boxes.conf.cpu().numpy()
rail = np.isin(cls, list(RAIL_CLS))
if not np.any(rail):
return None
return float(np.max(conf[rail]))
def _next_low_conf_path() -> Path:
LOW_CONF_DIR.mkdir(parents=True, exist_ok=True)
stem = datetime.now().strftime("%d_%m_%Y")
p = LOW_CONF_DIR / f"{stem}.png"
if not p.exists():
return p
n = 1
while True:
p = LOW_CONF_DIR / f"{stem}_{n}.png"
if not p.exists():
return p
n += 1
def _maybe_save_low_conf_frame(frame: np.ndarray, max_rail_conf: Optional[float]) -> None:
if max_rail_conf is None or max_rail_conf >= LOW_CONF_NORM:
return
path = _next_low_conf_path()
cv2.imwrite(str(path), frame)
def get_focal_px(frame_w: int, fov_deg: float = DEFAULT_FOV_DEG) -> float:
return frame_w / (2.0 * np.tan(np.radians(fov_deg) / 2.0))
def extract_rail_mask(results, h: int, w: int, morph: bool = False) -> np.ndarray:
"""Accumulate rail-class masks as probabilities, then threshold.
Soft (float) accumulation + bilinear upscale yields sub-pixel-accurate,
less jagged boundaries than per-mask NEAREST + binary OR. A light close is
always applied to bridge tiny segmentation holes for stable per-row edges.
"""
if results.masks is None:
return np.zeros((h, w), dtype=np.uint8)
prob = np.zeros((h, w), dtype=np.float32)
has_any = False
for m, c in zip(
results.masks.data.cpu().numpy(),
results.boxes.cls.cpu().numpy().astype(int),
):
if c in RAIL_CLS:
mr = cv2.resize(
m.astype(np.float32), (w, h), interpolation=cv2.INTER_LINEAR
)
np.maximum(prob, mr, out=prob)
has_any = True
if not has_any:
return np.zeros((h, w), dtype=np.uint8)
mask = (prob >= MASK_PROB_THRESH).astype(np.uint8) * 255
base_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, _BASE_CLOSE_KERNEL)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, base_k, iterations=1)
return _clean_rail_mask(mask) if morph else mask
def _fill_holes(mask: np.ndarray) -> np.ndarray:
"""Fill enclosed background holes inside the rail blob (flood from border)."""
if not np.any(mask):
return mask
h, w = mask.shape
ff = mask.copy()
flood = np.zeros((h + 2, w + 2), dtype=np.uint8)
cv2.floodFill(ff, flood, (0, 0), 255)
holes = cv2.bitwise_not(ff)
return cv2.bitwise_or(mask, holes)
def _clean_rail_mask(mask: np.ndarray) -> np.ndarray:
if not np.any(mask):
return mask
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, MORPH_KERNEL)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=MORPH_OPEN_ITER)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=MORPH_CLOSE_ITER)
mask = _fill_holes(mask)
return mask
def _medfilt1d(a: np.ndarray, k: int = 7) -> np.ndarray:
n = len(a)
if n < k:
return a.copy()
if k % 2 == 0:
k += 1
pad = k // 2
padded = np.pad(a.astype(np.float64), pad, mode="edge")
out = np.empty(n, dtype=a.dtype)
for i in range(n):
out[i] = np.median(padded[i : i + k])
return out
def _smooth1d(a: np.ndarray, k: int = 21) -> np.ndarray:
n = len(a)
if n < 5:
return a.copy()
k = min(k, n // 2 * 2 + 1)
if k < 3:
k = 3
if k % 2 == 0:
k += 1
pad = k // 2
padded = np.pad(a.astype(np.float64), pad, mode="edge")
return np.convolve(padded, np.ones(k) / k, mode="valid")[:n].astype(a.dtype)
def _row_extent(cols: np.ndarray, gap_px: int = _ROW_GAP_PX) -> Tuple[int, int]:
"""Edges of the dominant contiguous run in a row (bridging small gaps).
Robust to stray segmentation pixels far from the rail bed: instead of the
global min/max (which any speck corrupts), pick the run holding the most
pixels and return its span.
"""
if len(cols) == 1:
return int(cols[0]), int(cols[0])
splits = np.where(np.diff(cols) > gap_px)[0]
starts = np.concatenate(([0], splits + 1))
ends = np.concatenate((splits, [len(cols) - 1]))
counts = ends - starts + 1
best = int(np.argmax(counts))
return int(cols[starts[best]]), int(cols[ends[best]])
def rail_rows(
mask: np.ndarray,
min_px: int = 3,
smooth: int = 21,
max_w_frac: float = 0.7,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
h, w = mask.shape
ys, ls, rs = [], [], []
for y in range(h):
cols = np.where(mask[y] > 0)[0]
if len(cols) < min_px:
continue
l, r = _row_extent(cols)
if (r - l) > max_w_frac * w:
continue
ys.append(y)
ls.append(l)
rs.append(r)
if len(ys) < 4:
return (
np.array(ys, dtype=int),
np.array(ls, dtype=int),
np.array(rs, dtype=int),
)
ys = np.array(ys, dtype=int)
ls_a = np.array(ls, dtype=int)
rs_a = np.array(rs, dtype=int)
# Width must shrink monotonically with distance (rows higher up are
# narrower). Reject rows whose width deviates grossly from the local
# median before smoothing — kills single-row blow-ups from merged blobs.
widths = np.maximum(rs_a - ls_a, 1)
wmed = _medfilt1d(widths, 9)
good = np.abs(widths - wmed) <= np.maximum(0.6 * wmed, 8)
if int(good.sum()) >= 4:
ys, ls_a, rs_a = ys[good], ls_a[good], rs_a[good]
ls = _smooth1d(_medfilt1d(ls_a, 9), smooth)
rs = _smooth1d(_medfilt1d(rs_a, 9), smooth)
return ys, ls, rs
def zone_bounds(
ys: np.ndarray,
ls: np.ndarray,
rs: np.ndarray,
w: int,
) -> dict:
if len(ys) == 0:
return {}
widths = np.maximum(rs - ls, 1).astype(float)
scale = GAUGE_MM / widths
out = {}
for name in ("yellow", "orange", "red"):
off = (ZONE_DISTS_MM[name] / scale).astype(int)
out[name] = (
np.clip(ls - off, 0, w - 1),
np.clip(rs + off, 0, w - 1),
)
return out
def _fit_width_line(
ys: np.ndarray, ls: np.ndarray, rs: np.ndarray
) -> Optional[Tuple[float, float]]:
"""Robust linear fit width(y)=a*y+b via iterative residual trimming."""
if len(ys) < _FIT_MIN_ROWS:
return None
yf = ys.astype(np.float64)
wf = np.maximum(rs - ls, 1).astype(np.float64)
try:
a, b = np.polyfit(yf, wf, 1)
except (np.linalg.LinAlgError, ValueError):
return None
for _ in range(_FIT_ITERS):
res = wf - (a * yf + b)
sigma = float(np.std(res))
if sigma < 1e-6:
break
keep = np.abs(res) <= _FIT_TRIM_SIGMA * sigma
if int(keep.sum()) < max(4, _FIT_MIN_ROWS // 2):
break
try:
a, b = np.polyfit(yf[keep], wf[keep], 1)
except (np.linalg.LinAlgError, ValueError):
break
# Width must grow toward the bottom of the image (a > 0) for a valid model.
if a <= 1e-6:
return None
return float(a), float(b)
class _DistanceModel:
"""Closed-form row<->metric-distance mapping from the fitted width line."""
__slots__ = ("a", "b", "y_vp", "C")
def __init__(self, a: float, b: float, f_px: float) -> None:
self.a = a
self.b = b
self.y_vp = -b / a
self.C = f_px * GAUGE_MM / a # mm * px
def width_at(self, y: float) -> float:
return self.a * float(y) + self.b
def dist_mm(self, y: float) -> float:
dy = max(float(y) - self.y_vp, 1e-3)
return self.C / dy
def row_at_dist(self, dist_mm: float) -> float:
return self.y_vp + self.C / max(dist_mm, 1e-3)
def build_distance_model(
ys: np.ndarray, ls: np.ndarray, rs: np.ndarray, f_px: float
) -> Optional[_DistanceModel]:
fit = _fit_width_line(ys, ls, rs)
if fit is None:
return None
a, b = fit
return _DistanceModel(a, b, f_px)
def estimate_distance_m(
y_px: int,
ys: np.ndarray,
ls: np.ndarray,
rs: np.ndarray,
f_px: float,
) -> Optional[float]:
"""Ground distance (m) of image row y_px, relative to nearest rail row."""
if len(ys) < 2:
return None
y_bot = float(np.max(ys))
if y_px >= y_bot:
return 0.0
model = build_distance_model(ys, ls, rs, f_px)
if model is not None:
d_obj = model.dist_mm(float(y_px))
d_bot = model.dist_mm(y_bot)
return max(0.0, float(d_obj - d_bot) / 1000.0)
widths = np.maximum(rs - ls, 1).astype(float)
w_at_y = float(np.interp(y_px, ys, widths))
if w_at_y < 1:
return None
d_obj = f_px * GAUGE_MM / w_at_y
d_bot = f_px * GAUGE_MM / float(widths[int(np.argmax(ys))])
return max(0.0, (d_obj - d_bot) / 1000.0)
def _grid_positions_fallback(
ys: np.ndarray, ls: np.ndarray, rs: np.ndarray, f_px: float
) -> list:
widths = np.maximum(rs - ls, 1).astype(float)
d_mm = f_px * GAUGE_MM / widths
d_ref = d_mm[int(np.argmax(ys))]
marks = []
for i in range(1, 50):
target = d_ref + i * GRID_STEP_MM
above = np.where(d_mm >= target)[0]
if len(above) == 0:
break
best = above[int(np.argmin(np.abs(d_mm[above] - target)))]
marks.append((int(ys[best]), i * GRID_STEP_MM / 1000.0))
return marks
def grid_positions(
ys: np.ndarray,
ls: np.ndarray,
rs: np.ndarray,
f_px: Optional[float] = None,
) -> list:
"""Rows for distance grid lines. Returns (row_px, distance_m) per mark."""
if len(ys) < 2:
return []
if f_px is None:
f_px = get_focal_px(1920)
model = build_distance_model(ys, ls, rs, f_px)
if model is None:
return _grid_positions_fallback(ys, ls, rs, f_px)
y_bot = float(np.max(ys))
y_top = float(np.min(ys))
d_ref = model.dist_mm(y_bot)
marks = []
for i in range(1, 80):
target = d_ref + i * GRID_STEP_MM
yy = model.row_at_dist(target)
# Stop once the mark climbs past the visible rail / approaches horizon.
if yy <= y_top - 1.0 or yy <= model.y_vp + 1.0:
break
if yy >= y_bot:
continue
marks.append((int(round(yy)), i * GRID_STEP_MM / 1000.0))
return marks
def _outline_pts(ys: np.ndarray, xs: np.ndarray) -> np.ndarray:
"""Single boundary polyline (one side) for crisp anti-aliased outlines."""
return np.column_stack([xs, ys]).astype(np.int32).reshape(-1, 1, 2)
def _put_label(frame: np.ndarray, text: str, org: Tuple[int, int]) -> None:
"""Distance label with a dark outline so it stays readable on any zone."""
x, y = org
cv2.putText(frame, text, (x, y), cv2.FONT_HERSHEY_PLAIN, 1,
(0, 0, 0), 3, cv2.LINE_AA)
cv2.putText(frame, text, (x, y), cv2.FONT_HERSHEY_PLAIN, 1,
TEXT_COLOR, 1, cv2.LINE_AA)
def draw_zones(
frame: np.ndarray,
ys: np.ndarray,
zones: dict,
ls: np.ndarray,
rs: np.ndarray,
grid: list,
) -> None:
"""Lightweight rendering: zone boundary lines + rails + distance markup.
No filled / translucent polygons — only crisp anti-aliased outlines drawn
directly on the frame, plus the distance grid lines and labels.
"""
# Zone boundary lines (left + right edge of each zone).
for name in ("yellow", "orange", "red"):
if name not in zones:
continue
zl, zr = zones[name]
c = ZONE_COLORS[name]
cv2.polylines(frame, [_outline_pts(ys, zl)], False, c,
_ZONE_LINE_THICK, cv2.LINE_AA)
cv2.polylines(frame, [_outline_pts(ys, zr)], False, c,
_ZONE_LINE_THICK, cv2.LINE_AA)
# Rail edges.
cv2.polylines(frame, [_outline_pts(ys, ls)], False, ZONE_COLORS["red"],
_RAIL_LINE_THICK, cv2.LINE_AA)
cv2.polylines(frame, [_outline_pts(ys, rs)], False, ZONE_COLORS["red"],
_RAIL_LINE_THICK, cv2.LINE_AA)
# Distance grid lines + labels across the outer zone.
outer = zones.get("yellow") or zones.get("orange") or zones.get("red")
if outer and grid:
gy_arr = np.fromiter((g[0] for g in grid), dtype=np.int32, count=len(grid))
ii = np.argmin(np.abs(ys[:, None] - gy_arr[None, :]), axis=0)
z0, z1 = outer[0], outer[1]
for k, idx in enumerate(ii):
gy = int(gy_arr[k])
dist_m = grid[k][1]
x1, x2 = int(z0[idx]), int(z1[idx])
cv2.line(frame, (x1, gy), (x2, gy), GRID_COLOR, 1, cv2.LINE_AA)
_put_label(frame, f"{dist_m:.0f} m", (x2 + 5, gy + 4))
def draw_legend(frame: np.ndarray) -> None:
h = frame.shape[0]
lx, ly = 10, h - 90
lt = cv2.LINE_8
for i, name in enumerate(("red", "orange", "yellow")):
yy = ly + i * 24
cv2.rectangle(frame, (lx, yy), (lx + 18, yy + 16), ZONE_COLORS[name], -1)
dist = ZONE_DISTS_MM[name] / 1000
if name == "red":
label = f"Red: rails + {dist:.1f} m"
elif name == "orange":
label = f"Orange: {dist:.1f} m"
else:
label = f"Yellow: {dist:.1f} m"
cv2.putText(
frame,
label,
(lx + 24, yy + 13),
cv2.FONT_HERSHEY_PLAIN,
1,
TEXT_COLOR,
1,
lt,
)
def find_groups(mask: np.ndarray, min_area: int = 300) -> list:
n, labels = cv2.connectedComponents(mask)
groups = []
for lab in range(1, n):
comp = (labels == lab).astype(np.uint8) * 255
if np.sum(comp > 0) < min_area:
continue
groups.append(comp)
return groups if groups else [mask]
def _branch_sort_key(branch: int):
if branch == 0:
return lambda c: (-c[1], -c[2])
return lambda c: (c[1], -c[2])
def _select_rail_group(
groups: list, h: int, prev_center: Optional[float] = None
) -> np.ndarray:
if len(groups) == 1:
return groups[0]
band = slice(int(0.55 * h), h)
candidates: list[Tuple[np.ndarray, float, float]] = []
for g in groups:
gy, gx = np.where(g > 0)
if len(gx) == 0:
continue
candidates.append((g, float(np.median(gx)), float(np.sum(g[band] > 0))))
if not candidates:
return groups[0]
if prev_center is not None:
candidates.sort(key=lambda c: abs(c[1] - prev_center))
if abs(candidates[0][1] - prev_center) <= _MAX_CENTER_JUMP_PX:
return candidates[0][0]
candidates.sort(key=_branch_sort_key(RAIL_BRANCH))
return candidates[0][0]
class _TemporalState:
EMA_ALPHA = 0.30
MAX_AREA_RATIO = 1.8
FALLBACK_FRAMES = 10
DECAY_SHRINK = 0.97
TURN_SHIFT_THRESHOLD = 30
TURN_ALPHA = 0.50
def __init__(self) -> None:
self.reset()
def reset(self) -> None:
self._ys: Optional[np.ndarray] = None
self._ls: Optional[np.ndarray] = None
self._rs: Optional[np.ndarray] = None
self._area: float = 0.0
self._miss: int = 0
@property
def active(self) -> bool:
return self._ys is not None and len(self._ys) >= 4
def center(self) -> Optional[float]:
if not self.active:
return None
return float(np.median((self._ls + self._rs) / 2.0))
def _area_of(self, ls: np.ndarray, rs: np.ndarray) -> float:
return float(np.sum(np.maximum(rs - ls, 0)))
def update(
self,
ys: np.ndarray,
ls: np.ndarray,
rs: np.ndarray,
w: int,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
if len(ys) < 4:
return self._fallback(w)
area = self._area_of(ls, rs)
if not self.active:
self._ys, self._ls, self._rs = ys.copy(), ls.copy(), rs.copy()
self._area = area
self._miss = 0
return ys, ls, rs
prev_c = self.center()
cur_c = float(np.median((ls + rs) / 2.0))
shift = abs(cur_c - prev_c) if prev_c is not None else 0.0
alpha = self.TURN_ALPHA if shift > self.TURN_SHIFT_THRESHOLD else self.EMA_ALPHA
if self._area > 0:
ratio = area / self._area
if ratio > self.MAX_AREA_RATIO:
alpha *= 0.2
elif ratio < 1.0 / self.MAX_AREA_RATIO:
alpha *= 0.4
ls_prev = np.interp(ys, self._ys, self._ls).astype(np.float64)
rs_prev = np.interp(ys, self._ys, self._rs).astype(np.float64)
ls_new = np.clip(
(alpha * ls.astype(np.float64) + (1 - alpha) * ls_prev), 0, w - 1
).astype(np.int32)
rs_new = np.clip(
(alpha * rs.astype(np.float64) + (1 - alpha) * rs_prev), 0, w - 1
).astype(np.int32)
bad = ls_new >= rs_new
if np.any(bad):
ls_new[bad] = np.minimum(ls[bad], ls_prev[bad].astype(np.int32))
rs_new[bad] = np.maximum(rs[bad], rs_prev[bad].astype(np.int32))
self._ys, self._ls, self._rs = ys.copy(), ls_new.copy(), rs_new.copy()
self._area = self._area_of(ls_new, rs_new)
self._miss = 0
return ys, ls_new, rs_new
def _fallback(self, w: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
empty = np.array([], dtype=np.int32)
if not self.active:
return empty, empty, empty
self._miss += 1
if self._miss > self.FALLBACK_FRAMES:
self.reset()
return empty, empty, empty
c = (self._ls + self._rs) / 2.0
hw = (self._rs - self._ls) / 2.0 * self.DECAY_SHRINK
self._ls = np.clip((c - hw).astype(np.int32), 0, w - 1)
self._rs = np.clip((c + hw).astype(np.int32), 0, w - 1)
self._area = self._area_of(self._ls, self._rs)
return self._ys.copy(), self._ls.copy(), self._rs.copy()
_temporal = _TemporalState()
def process_frame(frame: np.ndarray, model: YOLO) -> np.ndarray:
h, w = frame.shape[:2]
res = model(frame, conf=CONF, device=DEVICE, verbose=False)[0]
_maybe_save_low_conf_frame(frame, _max_rail_confidence(res))
full_mask = extract_rail_mask(res, h, w, morph=USE_MORPH)
if np.any(full_mask):
groups = find_groups(full_mask)
gmask = _select_rail_group(groups, h, _temporal.center())
ys, ls, rs = rail_rows(gmask)
else:
ys = np.array([], dtype=np.int32)
ls = np.array([], dtype=np.int32)
rs = np.array([], dtype=np.int32)
ys, ls, rs = _temporal.update(ys, ls, rs, w)
if len(ys) < 4:
return frame
f_px = get_focal_px(w)
zones = zone_bounds(ys, ls, rs, w)
grid = grid_positions(ys, ls, rs, f_px)
draw_zones(frame, ys, zones, ls, rs, grid)
return frame
def reset_temporal_state() -> None:
_temporal.reset()
def main() -> None:
global USE_MORPH
ap = argparse.ArgumentParser(
description="Building danger zones around rails"
)
ap.add_argument(
"-m", "--morph", action="store_true", dest="morph",
help="Enable morphological cleaning of the segmentation mask",
)
args = ap.parse_args()
USE_MORPH = args.morph
if USE_MORPH:
print("Morphology enabled: clean rail mask after segmentation")
model = load_model()
cap = cv2.VideoCapture(VIDEO_PATH)
if not cap.isOpened():
raise OSError(f"Failed to open video: {VIDEO_PATH}")
fps = int(cap.get(cv2.CAP_PROP_FPS)) or 25
W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
out = None
if OUTPUT_PATH:
Path(OUTPUT_PATH).parent.mkdir(parents=True, exist_ok=True)
out = cv2.VideoWriter(
OUTPUT_PATH, cv2.VideoWriter_fourcc(*"mp4v"), fps, (W, H)
)
print(f"Video {W}×{H} @ {fps} fps - 'q' for exit")
n = 0
while True:
ok, frame = cap.read()
if not ok:
break
frame = process_frame(frame, model)
cv2.imshow("Rail Danger Zones", frame)
if out:
out.write(frame)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
n += 1
cap.release()
if out:
out.release()
cv2.destroyAllWindows()
print(f"Done - {n} frames processed.")
if __name__ == "__main__":
main()