SHOW:
|
|
- or go back to the newest paste.
| 1 | - | import cv2 |
| 1 | + | |
| 2 | import time | |
| 3 | import socket | |
| 4 | - | from collections import deque |
| 4 | + | |
| 5 | import subprocess | |
| 6 | from datetime import datetime | |
| 7 | import re | |
| 8 | ||
| 9 | # =============== KONFIGURACJA =============== | |
| 10 | - | # ================== KONFIGURACJA ================== |
| 10 | + | |
| 11 | RTSP_URL = "rtsp://admin:[email protected]:554/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif" | |
| 12 | - | CAMERAS = {
|
| 12 | + | |
| 13 | - | "KAMERA1": "rtsp://admin:[email protected]:554/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif", |
| 13 | + | BASE_DIR = r"D:\wideo" |
| 14 | - | } |
| 14 | + | RAW_DIR = os.path.join(BASE_DIR, "raw") |
| 15 | CLIPS_DIR = os.path.join(BASE_DIR, "clips") | |
| 16 | - | PRE_SECONDS = 10 # ile sekund PRZED triggerem w buforze |
| 16 | + | |
| 17 | - | POST_SECONDS = 15 # ile sekund PO triggerze nagrywać |
| 17 | + | RAW_PATH = os.path.join(RAW_DIR, "cam1_raw.mp4") # tu ffmpeg nagrywa ciągły strumień |
| 18 | - | FPS_DEFAULT = 25 # jeśli kamera nie poda sensownego FPS |
| 18 | + | CAMERA_NAME = "KAMERA1" |
| 19 | ||
| 20 | - | OUTPUT_DIR = r"D:\wideo" |
| 20 | + | PRE_SECONDS = 10 # ile sekund PRZED triggerem |
| 21 | - | RETENTION_DAYS = 1 # na testy, potem np. 30 |
| 21 | + | POST_SECONDS = 15 # ile sekund PO triggerze |
| 22 | RETENTION_DAYS = 1 # na testy, potem np. 30 | |
| 23 | ||
| 24 | TCP_HOST = "0.0.0.0" | |
| 25 | TCP_PORT = 5000 | |
| 26 | ||
| 27 | - | # ================== POMOCNICZE ================== |
| 27 | + | FFMPEG_EXE = "ffmpeg" # jeśli nie w PATH, podaj pełną ścieżkę, np. r"C:\ffmpeg\bin\ffmpeg.exe" |
| 28 | ||
| 29 | - | def ensure_output_dir(): |
| 29 | + | # globalnie zapamiętany czas startu RAW-a |
| 30 | - | os.makedirs(OUTPUT_DIR, exist_ok=True) |
| 30 | + | raw_start_time = None |
| 31 | raw_lock = threading.Lock() | |
| 32 | ||
| 33 | - | def cleanup_old_files(): |
| 33 | + | |
| 34 | - | now = time.time() |
| 34 | + | # =============== POMOCNICZE =============== |
| 35 | - | cutoff = now - RETENTION_DAYS * 24 * 3600 |
| 35 | + | |
| 36 | - | for filename in os.listdir(OUTPUT_DIR): |
| 36 | + | def ensure_dirs(): |
| 37 | - | filepath = os.path.join(OUTPUT_DIR, filename) |
| 37 | + | os.makedirs(RAW_DIR, exist_ok=True) |
| 38 | - | if os.path.isfile(filepath): |
| 38 | + | os.makedirs(CLIPS_DIR, exist_ok=True) |
| 39 | - | if os.path.getmtime(filepath) < cutoff: |
| 39 | + | |
| 40 | - | print(f"[CLEANUP] Usuwam stary plik: {filename}")
|
| 40 | + | |
| 41 | - | os.remove(filepath) |
| 41 | + | def sanitize_part_code(code: str) -> str: |
| 42 | code = code.strip() | |
| 43 | if not code: | |
| 44 | - | def cleanup_worker(): |
| 44 | + | code = "NO_CODE" |
| 45 | return re.sub(r"[^0-9A-Za-z_-]", "_", code) | |
| 46 | - | cleanup_old_files() |
| 46 | + | |
| 47 | ||
| 48 | def cleanup_old_clips(): | |
| 49 | while True: | |
| 50 | - | def sanitize_part_code(part_code: str) -> str: |
| 50 | + | |
| 51 | - | part_code = part_code.strip() |
| 51 | + | cutoff = now - RETENTION_DAYS * 24 * 3600 |
| 52 | - | if not part_code: |
| 52 | + | |
| 53 | - | part_code = "NO_CODE" |
| 53 | + | for fname in os.listdir(CLIPS_DIR): |
| 54 | - | return re.sub(r"[^0-9A-Za-z_-]", "_", part_code) |
| 54 | + | fpath = os.path.join(CLIPS_DIR, fname) |
| 55 | if os.path.isfile(fpath) and os.path.getmtime(fpath) < cutoff: | |
| 56 | print(f"[CLEANUP] Usuwam stary klip: {fname}")
| |
| 57 | - | # ================== KAMERA – BUFOR + TRIGGER (LICZONY NA KLATKACH) ================== |
| 57 | + | os.remove(fpath) |
| 58 | except Exception as e: | |
| 59 | - | class CameraRecorder(threading.Thread): |
| 59 | + | print("[CLEANUP] Błąd cleanup:", e)
|
| 60 | - | def __init__(self, name, url): |
| 60 | + | |
| 61 | - | super().__init__(daemon=True) |
| 61 | + | |
| 62 | - | self.name = name |
| 62 | + | |
| 63 | - | self.url = url |
| 63 | + | # =============== START FFMPEG (RAW) =============== |
| 64 | ||
| 65 | - | self.cap = None |
| 65 | + | def start_ffmpeg_raw(): |
| 66 | - | self._open_stream() |
| 66 | + | """ |
| 67 | Uruchamia ffmpeg, który ciągle nagrywa RTSP do RAW_PATH. | |
| 68 | - | fps = self.cap.get(cv2.CAP_PROP_FPS) if self.cap else 0 |
| 68 | + | """ |
| 69 | - | if fps <= 0 or fps > 120: |
| 69 | + | global raw_start_time |
| 70 | - | fps = FPS_DEFAULT |
| 70 | + | |
| 71 | - | self.fps = fps |
| 71 | + | # jeśli plik już istnieje – spróbuj go usunąć, żeby zacząć od nowa |
| 72 | if os.path.exists(RAW_PATH): | |
| 73 | - | self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 640) |
| 73 | + | |
| 74 | - | self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 480) |
| 74 | + | os.remove(RAW_PATH) |
| 75 | except Exception: | |
| 76 | - | # ile klatek trzymamy w buforze |
| 76 | + | |
| 77 | - | self.pre_frames = int(self.fps * PRE_SECONDS) |
| 77 | + | |
| 78 | - | # ile klatek nagrywamy po triggerze |
| 78 | + | cmd = [ |
| 79 | - | self.post_frames_target = int(self.fps * POST_SECONDS) |
| 79 | + | FFMPEG_EXE, |
| 80 | "-rtsp_transport", "tcp", | |
| 81 | - | # bufor z ostatnimi klatkami |
| 81 | + | "-i", RTSP_URL, |
| 82 | - | self.buffer = deque(maxlen=self.pre_frames) |
| 82 | + | "-c", "copy", |
| 83 | "-an", | |
| 84 | - | self.lock = threading.Lock() |
| 84 | + | "-reset_timestamps", "1", |
| 85 | RAW_PATH, | |
| 86 | - | # stan nagrywania |
| 86 | + | ] |
| 87 | - | self.recording = False |
| 87 | + | |
| 88 | - | self.writer = None |
| 88 | + | print("[FFMPEG] Start nagrywania RAW...")
|
| 89 | - | self.post_frames_left = 0 # ile klatek jeszcze zapisać po triggerze |
| 89 | + | # odpalamy ffmpeg jako proces w tle |
| 90 | proc = subprocess.Popen( | |
| 91 | - | # do reconnectów |
| 91 | + | cmd, |
| 92 | - | self.fail_count = 0 |
| 92 | + | stdout=subprocess.DEVNULL, |
| 93 | - | self.max_fail_before_reopen = int(self.fps * 2) # ok. 2 sekundy błędów z rzędu |
| 93 | + | stderr=subprocess.DEVNULL |
| 94 | ) | |
| 95 | - | print( |
| 95 | + | |
| 96 | - | f"[{self.name}] Start. {self.width}x{self.height} @ {self.fps} fps, "
|
| 96 | + | with raw_lock: |
| 97 | - | f"bufor={self.pre_frames} klatek, post={self.post_frames_target} klatek"
|
| 97 | + | raw_start_time = time.time() |
| 98 | - | ) |
| 98 | + | |
| 99 | print(f"[FFMPEG] RAW zapisuje się do: {RAW_PATH}")
| |
| 100 | - | def _open_stream(self): |
| 100 | + | print(f"[FFMPEG] Czas startu RAW: {datetime.fromtimestamp(raw_start_time)}")
|
| 101 | - | if self.cap: |
| 101 | + | |
| 102 | - | self.cap.release() |
| 102 | + | # proces ffmpeg będzie sobie chodził w tle; nie czekamy na niego tutaj |
| 103 | - | print(f"[{self.name}] Otwieram RTSP...")
|
| 103 | + | return proc |
| 104 | - | # ograniczamy buforowanie po stronie OpenCV (jeśli jest obsługiwane) |
| 104 | + | |
| 105 | - | self.cap = cv2.VideoCapture(self.url) |
| 105 | + | |
| 106 | # =============== WYCIĘCIE KLIPU =============== | |
| 107 | - | self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) |
| 107 | + | |
| 108 | def cut_clip(part_code: str, trigger_time: float): | |
| 109 | """ | |
| 110 | Wywołuje ffmpeg, żeby wyciąć klip: | |
| 111 | - | if not self.cap.isOpened(): |
| 111 | + | [trigger_time - PRE, trigger_time + POST] |
| 112 | - | print(f"[{self.name}] ❌ Nie można otworzyć strumienia RTSP!")
|
| 112 | + | z pliku RAW. |
| 113 | """ | |
| 114 | - | def _start_new_writer(self, part_code: str, use_prebuffer: bool): |
| 114 | + | with raw_lock: |
| 115 | - | """ |
| 115 | + | rst = raw_start_time |
| 116 | - | Tworzy nowy plik: |
| 116 | + | |
| 117 | - | - use_prebuffer=True -> przed bieżącymi klatkami dopisuje cały bufor (ostatnie PRE_SECONDS) |
| 117 | + | if rst is None: |
| 118 | - | - use_prebuffer=False -> tylko od TERAZ, bez cofania |
| 118 | + | print("[CLIP] RAW jeszcze nie wystartował – pomijam.")
|
| 119 | - | Nazwa: RRRR_MM_DD_GG_MM_SS_KOD_KAMERA.mp4 |
| 119 | + | return |
| 120 | - | """ |
| 120 | + | |
| 121 | if not os.path.exists(RAW_PATH): | |
| 122 | - | ts_name = datetime.fromtimestamp(now).strftime("%Y_%m_%d_%H_%M_%S")
|
| 122 | + | print(f"[CLIP] RAW nie istnieje: {RAW_PATH}")
|
| 123 | - | safe_code = sanitize_part_code(part_code) |
| 123 | + | return |
| 124 | - | filename = f"{ts_name}_{safe_code}_{self.name}.mp4"
|
| 124 | + | |
| 125 | - | filepath = os.path.join(OUTPUT_DIR, filename) |
| 125 | + | window_start = trigger_time - PRE_SECONDS |
| 126 | offset = window_start - rst | |
| 127 | - | print(f"[{self.name}] Start nagrania: {filename} | prebuffer={use_prebuffer}")
|
| 127 | + | if offset < 0: |
| 128 | offset = 0.0 # jeśli trigger był bardzo szybko po starcie | |
| 129 | - | writer = cv2.VideoWriter( |
| 129 | + | |
| 130 | - | filepath, |
| 130 | + | duration = PRE_SECONDS + POST_SECONDS |
| 131 | - | cv2.VideoWriter_fourcc(*"mp4v"), |
| 131 | + | |
| 132 | - | self.fps, |
| 132 | + | ts_name = datetime.fromtimestamp(trigger_time).strftime("%Y_%m_%d_%H_%M_%S")
|
| 133 | - | (self.width, self.height) |
| 133 | + | safe_code = sanitize_part_code(part_code) |
| 134 | - | ) |
| 134 | + | out_name = f"{ts_name}_{safe_code}_{CAMERA_NAME}.mp4"
|
| 135 | out_path = os.path.join(CLIPS_DIR, out_name) | |
| 136 | - | if not writer.isOpened(): |
| 136 | + | |
| 137 | - | print(f"[{self.name}] ❌ Nie można otworzyć pliku do zapisu!")
|
| 137 | + | print(f"[CLIP] Wycinam klip: {out_name}")
|
| 138 | - | return None |
| 138 | + | print(f"[CLIP] offset={offset:.2f}s, duration={duration:.2f}s")
|
| 139 | ||
| 140 | - | if use_prebuffer: |
| 140 | + | cmd = [ |
| 141 | - | frames = list(self.buffer) # snapshot, żeby nie iterować po żywym deque |
| 141 | + | FFMPEG_EXE, |
| 142 | - | print(f"[{self.name}] Zapis pre-buffer: {len(frames)} klatek")
|
| 142 | + | "-y", |
| 143 | - | for frame in frames: |
| 143 | + | "-ss", f"{offset:.3f}",
|
| 144 | - | writer.write(frame) |
| 144 | + | "-i", RAW_PATH, |
| 145 | "-t", f"{duration:.3f}",
| |
| 146 | - | self.recording = True |
| 146 | + | "-c", "copy", |
| 147 | - | self.writer = writer |
| 147 | + | out_path, |
| 148 | - | self.post_frames_left = self.post_frames_target # koniec = post_frames_left == 0 |
| 148 | + | ] |
| 149 | ||
| 150 | - | return writer |
| 150 | + | try: |
| 151 | subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) | |
| 152 | - | def trigger(self, part_code: str): |
| 152 | + | print(f"[CLIP] Klip zapisany: {out_path}")
|
| 153 | - | with self.lock: |
| 153 | + | except Exception as e: |
| 154 | - | if self.recording and self.writer: |
| 154 | + | print(f"[CLIP] Błąd wycinania: {e}")
|
| 155 | - | # przerywamy stare nagranie natychmiast |
| 155 | + | |
| 156 | - | print(f"[{self.name}] Kończę poprzednie nagranie (nowy trigger).")
|
| 156 | + | |
| 157 | - | try: |
| 157 | + | # =============== SERVER TCP =============== |
| 158 | - | self.writer.release() |
| 158 | + | |
| 159 | - | except Exception: |
| 159 | + | def tcp_server(): |
| 160 | - | pass |
| 160 | + | |
| 161 | - | self.writer = None |
| 161 | + | |
| 162 | - | self.recording = False |
| 162 | + | |
| 163 | - | self.post_frames_left = 0 |
| 163 | + | |
| 164 | print(f"[TCP] Nasłuch na {TCP_HOST}:{TCP_PORT}")
| |
| 165 | - | # nowe nagranie bez pre-buffer |
| 165 | + | |
| 166 | - | self._start_new_writer(part_code, use_prebuffer=False) |
| 166 | + | |
| 167 | - | else: |
| 167 | + | |
| 168 | - | # pierwsze nagranie po przerwie – z PRE_SECONDS wstecz |
| 168 | + | |
| 169 | - | self._start_new_writer(part_code, use_prebuffer=True) |
| 169 | + | |
| 170 | data = conn.recv(1024) | |
| 171 | - | if self.writer: |
| 171 | + | |
| 172 | - | print( |
| 172 | + | |
| 173 | - | f"[{self.name}] Nagrywanie: pre={self.pre_frames} klatek, "
|
| 173 | + | |
| 174 | - | f"post={self.post_frames_target} klatek"
|
| 174 | + | |
| 175 | - | ) |
| 175 | + | |
| 176 | - | else: |
| 176 | + | |
| 177 | - | print(f"[{self.name}] ❌ Nie udało się rozpocząć nagrania.")
|
| 177 | + | |
| 178 | trigger_time = time.time() | |
| 179 | - | def run(self): |
| 179 | + | # wycinanie w osobnym wątku, żeby nie blokować TCP |
| 180 | - | while True: |
| 180 | + | th = threading.Thread(target=cut_clip, args=(code, trigger_time), daemon=True) |
| 181 | - | if not self.cap or not self.cap.isOpened(): |
| 181 | + | th.start() |
| 182 | - | self._open_stream() |
| 182 | + | |
| 183 | - | time.sleep(1) |
| 183 | + | |
| 184 | - | continue |
| 184 | + | |
| 185 | ||
| 186 | - | ret, frame = self.cap.read() |
| 186 | + | |
| 187 | - | if not ret: |
| 187 | + | # =============== MAIN =============== |
| 188 | - | # nie panikujemy od razu, dajemy trochę błędów pod rząd |
| 188 | + | |
| 189 | - | self.fail_count += 1 |
| 189 | + | |
| 190 | - | if self.fail_count >= self.max_fail_before_reopen: |
| 190 | + | ensure_dirs() |
| 191 | - | print(f"[{self.name}] Za dużo błędów z rzędu, ponowne otwarcie RTSP.")
|
| 191 | + | |
| 192 | - | self._open_stream() |
| 192 | + | # startujemy ffmpeg |
| 193 | - | self.fail_count = 0 |
| 193 | + | ffmpeg_proc = start_ffmpeg_raw() |
| 194 | - | # krótka pauza, ale bardzo mała |
| 194 | + | |
| 195 | - | time.sleep(0.01) |
| 195 | + | # uruchamiamy sprzątanie starych klipów |
| 196 | - | continue |
| 196 | + | threading.Thread(target=cleanup_old_clips, daemon=True).start() |
| 197 | ||
| 198 | - | self.fail_count = 0 # mamy poprawną klatkę |
| 198 | + | print("=== TRIGGER + FFMPEG RECORDER URUCHOMIONY ===")
|
| 199 | print("RAW:", RAW_PATH)
| |
| 200 | - | with self.lock: |
| 200 | + | print("CLIPS:", CLIPS_DIR)
|
| 201 | - | # zawsze aktualizujemy bufor PRE_SECONDS |
| 201 | + | print(f"Pre={PRE_SECONDS}s, Post={POST_SECONDS}s, Retencja={RETENTION_DAYS} dni\n")
|
| 202 | - | self.buffer.append(frame.copy()) |
| 202 | + | |
| 203 | try: | |
| 204 | - | if self.recording and self.writer: |
| 204 | + | tcp_server() |
| 205 | - | # zapisujemy bieżącą klatkę |
| 205 | + | finally: |
| 206 | - | self.writer.write(frame) |
| 206 | + | # przy zamknięciu – spróbuj zabić ffmpeg |
| 207 | - | self.post_frames_left -= 1 |
| 207 | + | |
| 208 | ffmpeg_proc.terminate() | |
| 209 | - | if self.post_frames_left <= 0: |
| 209 | + | |
| 210 | - | print(f"[{self.name}] Koniec nagrania (wyczerpane post-klatki).")
|
| 210 | + | |
| 211 | - | try: |
| 211 | + | |
| 212 | - | self.writer.release() |
| 212 | + | |
| 213 | - | except Exception: |
| 213 | + | |
| 214 | - | pass |
| 214 | + | |
| 215 | - | self.writer = None |
| 215 | + |