SHOW:
|
|
- or go back to the newest paste.
| 1 | import cv2 | |
| 2 | import time | |
| 3 | import threading | |
| 4 | from collections import deque | |
| 5 | from datetime import datetime | |
| 6 | import socket | |
| 7 | import os | |
| 8 | import re | |
| 9 | ||
| 10 | # ================== KONFIGURACJA ================== | |
| 11 | ||
| 12 | CAMERAS = {
| |
| 13 | - | # nazwa_kamery : URL RTSP |
| 13 | + | |
| 14 | } | |
| 15 | - | # w przyszłości możesz dodać np.: |
| 15 | + | |
| 16 | - | # "KAMERA2": "rtsp://admin:[email protected]:554/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif", |
| 16 | + | PRE_SECONDS = 10 # ile sekund PRZED triggerem ma być w nagraniu |
| 17 | POST_SECONDS = 15 # ile sekund PO triggerze ma być w nagraniu | |
| 18 | FPS_DEFAULT = 25 # stały fps do pliku | |
| 19 | - | PRE_SECONDS = 20 # ile sekund wstecz ma być w nagraniu (dla pierwszego nagrania po przerwie) |
| 19 | + | |
| 20 | - | POST_SECONDS = 60 # ile sekund po triggerze nagrywać |
| 20 | + | OUTPUT_DIR = r"D:\wideo" |
| 21 | - | FPS_DEFAULT = 25 # używane gdy kamera nie zwraca sensownego FPS |
| 21 | + | RETENTION_DAYS = 1 # na testy 1 dzień, potem możesz dać 30 |
| 22 | ||
| 23 | - | # ŚCIEŻKA NA DYSK USB – ZMIEŃ LITERĘ DYSKU JEŚLI POTRZEBA: |
| 23 | + | TCP_HOST = "0.0.0.0" |
| 24 | - | OUTPUT_DIR = r"E:\Nagrania" |
| 24 | + | TCP_PORT = 5000 |
| 25 | ||
| 26 | - | RETENTION_DAYS = 30 # ile dni trzymać nagrania, starsze są kasowane |
| 26 | + | |
| 27 | # ================== POMOCNICZE ================== | |
| 28 | - | TCP_HOST = "0.0.0.0" # nasłuch na wszystkich interfejsach |
| 28 | + | |
| 29 | - | TCP_PORT = 5000 # port, na który HMI wysyła kod części |
| 29 | + | |
| 30 | os.makedirs(OUTPUT_DIR, exist_ok=True) | |
| 31 | ||
| 32 | ||
| 33 | def cleanup_old_files(): | |
| 34 | now = time.time() | |
| 35 | cutoff = now - RETENTION_DAYS * 24 * 3600 | |
| 36 | ||
| 37 | for filename in os.listdir(OUTPUT_DIR): | |
| 38 | filepath = os.path.join(OUTPUT_DIR, filename) | |
| 39 | - | """ |
| 39 | + | if os.path.isfile(filepath): |
| 40 | - | Usuwa pliki starsze niż RETENTION_DAYS w OUTPUT_DIR. |
| 40 | + | if os.path.getmtime(filepath) < cutoff: |
| 41 | - | """ |
| 41 | + | print(f"[CLEANUP] Usuwam stary plik: {filename}")
|
| 42 | os.remove(filepath) | |
| 43 | ||
| 44 | ||
| 45 | - | try: |
| 45 | + | |
| 46 | - | for filename in os.listdir(OUTPUT_DIR): |
| 46 | + | |
| 47 | - | filepath = os.path.join(OUTPUT_DIR, filename) |
| 47 | + | |
| 48 | - | if os.path.isfile(filepath): |
| 48 | + | time.sleep(24 * 3600) |
| 49 | - | mtime = os.path.getmtime(filepath) |
| 49 | + | |
| 50 | - | if mtime < cutoff: |
| 50 | + | |
| 51 | - | print(f"[CLEANUP] Usuwam stary plik: {filename}")
|
| 51 | + | |
| 52 | - | os.remove(filepath) |
| 52 | + | |
| 53 | - | except Exception as e: |
| 53 | + | |
| 54 | - | print(f"[CLEANUP] Błąd podczas czyszczenia: {e}")
|
| 54 | + | |
| 55 | return re.sub(r"[^0-9A-Za-z_-]", "_", part_code) | |
| 56 | ||
| 57 | ||
| 58 | - | """ |
| 58 | + | # ================== KAMERA ================== |
| 59 | - | Wątek, który co 24h uruchamia cleanup_old_files(). |
| 59 | + | |
| 60 | - | """ |
| 60 | + | |
| 61 | def __init__(self, name, url): | |
| 62 | super().__init__(daemon=True) | |
| 63 | - | time.sleep(24 * 3600) # 24 godziny |
| 63 | + | |
| 64 | self.url = url | |
| 65 | self.lock = threading.Lock() | |
| 66 | ||
| 67 | - | """ |
| 67 | + | |
| 68 | - | Czyści kod części tak, by nadawał się do nazwy pliku. |
| 68 | + | |
| 69 | - | Zostawiamy tylko cyfry, litery, _, -. |
| 69 | + | |
| 70 | - | """ |
| 70 | + | self.fps = FPS_DEFAULT |
| 71 | self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 640) | |
| 72 | self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 480) | |
| 73 | ||
| 74 | - | safe = re.sub(r"[^0-9A-Za-z_-]", "_", part_code) |
| 74 | + | # bufor: (timestamp, frame) |
| 75 | - | return safe |
| 75 | + | self.buffer = deque() |
| 76 | self.buffer_max_seconds = PRE_SECONDS + 2 # mały zapas | |
| 77 | ||
| 78 | - | # ================== KLASA DO OBSŁUGI KAMERY ================== |
| 78 | + | self.recording = False |
| 79 | self.record_end_time = 0.0 | |
| 80 | self.writer = None | |
| 81 | ||
| 82 | print(f"[{self.name}] Start. W={self.width} H={self.height}, fps={self.fps}")
| |
| 83 | ||
| 84 | def _open_stream(self): | |
| 85 | if self.cap: | |
| 86 | self.cap.release() | |
| 87 | print(f"[{self.name}] Otwieram RTSP...")
| |
| 88 | self.cap = cv2.VideoCapture(self.url) | |
| 89 | - | fps = self.cap.get(cv2.CAP_PROP_FPS) if self.cap else 0 |
| 89 | + | |
| 90 | - | if fps <= 0 or fps > 120: |
| 90 | + | print(f"[{self.name}] ❌ Nie można otworzyć strumienia RTSP!")
|
| 91 | - | fps = FPS_DEFAULT |
| 91 | + | |
| 92 | - | self.fps = fps |
| 92 | + | def _trim_buffer(self, now: float): |
| 93 | cutoff = now - self.buffer_max_seconds | |
| 94 | while self.buffer and self.buffer[0][0] < cutoff: | |
| 95 | self.buffer.popleft() | |
| 96 | ||
| 97 | - | maxlen = int(self.fps * PRE_SECONDS) |
| 97 | + | |
| 98 | - | self.buffer = deque(maxlen=maxlen) |
| 98 | + | |
| 99 | Tworzy nowy plik i: | |
| 100 | - | self.recording = False # czy aktualnie nagrywamy post-trigger |
| 100 | + | - jeśli use_prebuffer=True -> zapisuje dokładnie PRE_SECONDS wstecz, |
| 101 | a POTEM ustawia record_end_time = teraz + POST_SECONDS | |
| 102 | - jeśli use_prebuffer=False -> od razu ustawia record_end_time = teraz + POST_SECONDS | |
| 103 | """ | |
| 104 | # czas wyzwolenia do nazwy pliku | |
| 105 | - | print(f"[{self.name}] Start. FPS={self.fps}, {self.width}x{self.height}, bufor={maxlen} klatek")
|
| 105 | + | trigger_time_for_name = time.time() |
| 106 | ts_name = datetime.fromtimestamp(trigger_time_for_name).strftime("%Y_%m_%d_%H_%M_%S")
| |
| 107 | code = sanitize_part_code(part_code) | |
| 108 | - | if self.cap is not None: |
| 108 | + | filename = f"{ts_name}_{code}_{self.name}.mp4"
|
| 109 | filepath = os.path.join(OUTPUT_DIR, filename) | |
| 110 | - | print(f"[{self.name}] Otwieram strumień RTSP: {self.url}")
|
| 110 | + | |
| 111 | print(f"[{self.name}] Start nagrania: {filename} | pre={use_prebuffer}")
| |
| 112 | ||
| 113 | - | print(f"[{self.name}] ❌ Nie można otworzyć strumienia!")
|
| 113 | + | |
| 114 | filepath, | |
| 115 | cv2.VideoWriter_fourcc(*"mp4v"), | |
| 116 | self.fps, | |
| 117 | - | Tworzy nowy plik wideo: |
| 117 | + | |
| 118 | - | - use_prebuffer = True -> zapisuje bufor (-20 s) |
| 118 | + | |
| 119 | - | - use_prebuffer = False -> nagrywa tylko od triggera (bez cofania) |
| 119 | + | |
| 120 | - | Nazwa pliku: RRRR_MM_DD_GG_MM_SS_KODCZESCI_KAMERA.mp4 |
| 120 | + | |
| 121 | print(f"[{self.name}] ❌ Błąd otwierania pliku!")
| |
| 122 | - | now = time.time() |
| 122 | + | |
| 123 | - | timestamp_str = datetime.fromtimestamp(now).strftime("%Y_%m_%d_%H_%M_%S")
|
| 123 | + | |
| 124 | - | safe_code = sanitize_part_code(part_code) |
| 124 | + | # najpierw zapisujemy pre-buffer (10s wstecz) |
| 125 | if use_prebuffer: | |
| 126 | - | filename = f"{timestamp_str}_{safe_code}_{self.name}.mp4"
|
| 126 | + | # cutoff liczymy od momentu triggera (sprzed chwili) |
| 127 | cutoff = trigger_time_for_name - PRE_SECONDS | |
| 128 | frames_to_write = [frame for (t, frame) in list(self.buffer) if t >= cutoff] | |
| 129 | - | print(f"[{self.name}] Nowe nagranie: część={part_code}, plik={filename}, prebuffer={use_prebuffer}")
|
| 129 | + | print(f"[{self.name}] Zapisuję pre-buffer: {len(frames_to_write)} klatek (~{PRE_SECONDS}s)")
|
| 130 | for frame in frames_to_write: | |
| 131 | writer.write(frame) | |
| 132 | ||
| 133 | # UWAGA: koniec nagrywania liczymy OD TEGO MOMENTU, | |
| 134 | # czyli po zapisaniu pre-buffer (żeby nie ucinało po 5s). | |
| 135 | self.recording = True | |
| 136 | self.record_end_time = time.time() + POST_SECONDS | |
| 137 | ||
| 138 | return writer | |
| 139 | - | print(f"[{self.name}] ❌ Nie można otworzyć pliku do zapisu!")
|
| 139 | + | |
| 140 | def trigger(self, part_code: str): | |
| 141 | with self.lock: | |
| 142 | - | # jeśli mamy użyć bufora wstecznego (-20 s) |
| 142 | + | |
| 143 | if self.recording and self.writer: | |
| 144 | - | print(f"[{self.name}] Zapis bufora (~{PRE_SECONDS} s wstecz, {len(self.buffer)} klatek)...")
|
| 144 | + | print(f"[{self.name}] Kończę poprzednie nagranie (nowy trigger).")
|
| 145 | - | for (_t_frame, frame) in self.buffer: |
| 145 | + | |
| 146 | self.writer.release() | |
| 147 | except Exception: | |
| 148 | - | # ustaw parametry nagrywania post-trigger |
| 148 | + | |
| 149 | self.writer = None | |
| 150 | - | self.record_end_time = now + POST_SECONDS |
| 150 | + | |
| 151 | # nowe nagranie BEZ pre-buffer | |
| 152 | self.writer = self._start_new_writer(part_code, use_prebuffer=False) | |
| 153 | else: | |
| 154 | # pierwsze / po przerwie – z pre-buffer | |
| 155 | self.writer = self._start_new_writer(part_code, use_prebuffer=True) | |
| 156 | - | Wywoływane, gdy przychodzi trigger z kodem części. |
| 156 | + | |
| 157 | if self.writer: | |
| 158 | - | Logika: |
| 158 | + | |
| 159 | - | - jeżeli NIE nagrywamy -> nagranie z buforem (-20 s) + 60 s w przód |
| 159 | + | |
| 160 | - | - jeżeli JUŻ nagrywamy -> kończymy poprzednie, nowe nagranie bez bufora, |
| 160 | + | print(f"[{self.name}] ❌ Nie udało się rozpocząć nagrania.")
|
| 161 | - | 60 s w przód od TEGO momentu. |
| 161 | + | |
| 162 | def run(self): | |
| 163 | - | Dzięki temu pliki czasowo NIE nachodzą na siebie. |
| 163 | + | |
| 164 | if not self.cap.isOpened(): | |
| 165 | time.sleep(1) | |
| 166 | - | if self.recording and self.writer is not None: |
| 166 | + | |
| 167 | - | # trwa nagranie poprzedniej części -> przerywamy je w tym momencie |
| 167 | + | |
| 168 | - | print(f"[{self.name}] Przerywam poprzednie nagranie i zamykam plik.")
|
| 168 | + | |
| 169 | ret, frame = self.cap.read() | |
| 170 | if not ret: | |
| 171 | time.sleep(0.5) | |
| 172 | self._open_stream() | |
| 173 | continue | |
| 174 | ||
| 175 | now = time.time() | |
| 176 | - | # nowe nagranie -> BEZ bufora (-20), tylko od TEGO momentu |
| 176 | + | |
| 177 | with self.lock: | |
| 178 | # aktualizacja bufora | |
| 179 | self.buffer.append((now, frame.copy())) | |
| 180 | - | # nie nagrywaliśmy nic -> pierwsze nagranie po przerwie |
| 180 | + | self._trim_buffer(now) |
| 181 | - | # nagranie z buforem (-20 s) |
| 181 | + | |
| 182 | # zapis post-trigger | |
| 183 | if self.recording and self.writer: | |
| 184 | - | if self.writer is None: |
| 184 | + | |
| 185 | - | # nie udało się stworzyć pliku |
| 185 | + | |
| 186 | print(f"[{self.name}] Koniec nagrania (post {POST_SECONDS}s).")
| |
| 187 | - | self.record_end_time = 0.0 |
| 187 | + | |
| 188 | self.writer.release() | |
| 189 | except Exception: | |
| 190 | pass | |
| 191 | self.writer = None | |
| 192 | self.recording = False | |
| 193 | - | if self.cap is None or not self.cap.isOpened(): |
| 193 | + | |
| 194 | - | print(f"[{self.name}] Próba ponownego połączenia za 1 s...")
|
| 194 | + | |
| 195 | # ================== TCP SERVER ================== | |
| 196 | ||
| 197 | def tcp_server(cameras): | |
| 198 | srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |
| 199 | srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | |
| 200 | srv.bind((TCP_HOST, TCP_PORT)) | |
| 201 | - | print(f"[{self.name}] Brak klatki, reconnect...")
|
| 201 | + | |
| 202 | ||
| 203 | print(f"[TCP] Nasłuch na {TCP_HOST}:{TCP_PORT}")
| |
| 204 | ||
| 205 | while True: | |
| 206 | conn, addr = srv.accept() | |
| 207 | - | # zapis do bufora (dla potencjalnego pre-triggera) |
| 207 | + | |
| 208 | - | self.buffer.append((now, frame.copy())) |
| 208 | + | |
| 209 | data = conn.recv(1024) | |
| 210 | - | # jeśli trwa nagrywanie post-trigger -> zapisuj bieżące klatki |
| 210 | + | |
| 211 | continue | |
| 212 | - | if self.recording and self.writer is not None: |
| 212 | + | code = data.decode(errors="ignore").strip() |
| 213 | print(f"[TCP] Kod części: {code!r}")
| |
| 214 | if not code: | |
| 215 | - | print(f"[{self.name}] Koniec nagrania post-trigger, zamykam plik.")
|
| 215 | + | |
| 216 | for cam in cameras: | |
| 217 | cam.trigger(code) | |
| 218 | except Exception as e: | |
| 219 | print("[TCP] Błąd:", e)
| |
| 220 | ||
| 221 | ||
| 222 | # ================== MAIN ================== | |
| 223 | ||
| 224 | - | # ================== SERWER TCP DO ODBIORU KODU CZĘŚCI ================== |
| 224 | + | |
| 225 | ensure_output_dir() | |
| 226 | - | def tcp_server(camera_objects): |
| 226 | + | |
| 227 | - | """ |
| 227 | + | |
| 228 | - | Prosty serwer TCP: |
| 228 | + | |
| 229 | - | - HMI łączy się z IP mini-komputera:TCP_PORT |
| 229 | + | |
| 230 | - | - wysyła tekst z kodem części + np. \n |
| 230 | + | |
| 231 | - | - serwer wywołuje trigger() dla wszystkich kamer |
| 231 | + | |
| 232 | - | """ |
| 232 | + | |
| 233 | threading.Thread(target=cleanup_worker, daemon=True).start() | |
| 234 | threading.Thread(target=tcp_server, args=(cameras,), daemon=True).start() | |
| 235 | ||
| 236 | print("=== SYSTEM NAGRYWANIA URUCHOMIONY ===")
| |
| 237 | print("Katalog:", OUTPUT_DIR)
| |
| 238 | print(f"Pre: {PRE_SECONDS} s, Post: {POST_SECONDS} s")
| |
| 239 | print(f"Retencja: {RETENTION_DAYS} dni\n")
| |
| 240 | ||
| 241 | - | print(f"[TCP] Połączenie od: {addr}")
|
| 241 | + | |
| 242 | time.sleep(1) | |
| 243 | ||
| 244 | ||
| 245 | if __name__ == "__main__": | |
| 246 | main() | |
| 247 | - | part_code = data.decode("utf-8", errors="ignore").strip()
|
| 247 | + | |
| 248 | - | print(f"[TCP] Otrzymano kod części: '{part_code}'")
|
| 248 | + |