-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgate_detector_app.py
More file actions
1169 lines (963 loc) · 45.4 KB
/
Copy pathgate_detector_app.py
File metadata and controls
1169 lines (963 loc) · 45.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
Sistema de Detecção de Carros e Abertura Automática de Portão
Monitora câmera DVR Intelbras Intelbras via ONVIF e detecta carros com YOLOv8
Aguarda veículo parado por tempo configurável antes de abrir portão
"""
import logging
import time
import json
import re
import requests
import cv2
import numpy as np
import os
import warnings
import sys
import base64
from io import BytesIO
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Tuple, List
from urllib.parse import quote
from threading import Thread, Lock
import traceback
# Suprimir avisos do PyTorch/NNPACK ANTES de importar YOLO
os.environ['TORCH_WARN_ONCE'] = '0'
os.environ['PYTHONWARNINGS'] = 'ignore'
warnings.filterwarnings('ignore')
# Suprimir logs de debug do PyTorch
logging.getLogger('torch').setLevel(logging.ERROR)
logging.getLogger('torchvision').setLevel(logging.ERROR)
logging.getLogger('urllib3').setLevel(logging.ERROR)
# Importar YOLO após suprimir avisos
from ultralytics import YOLO
# Configuração de logging
# LOG_DIR é configurável para permitir execução fora do container (tuning local da ROI)
_log_dir = Path(os.getenv('LOG_DIR', '/app/logs'))
_log_handlers = [logging.StreamHandler()]
try:
_log_dir.mkdir(parents=True, exist_ok=True)
_log_handlers.append(logging.FileHandler(_log_dir / 'gate_detector.log'))
except OSError as e:
print(f"Aviso: não foi possível criar {_log_dir} ({e}). Logando apenas no console.", file=sys.stderr)
logging.basicConfig(
level=getattr(logging, os.getenv('LOG_LEVEL', 'INFO').upper(), logging.INFO),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=_log_handlers
)
logger = logging.getLogger(__name__)
# Silenciar bibliotecas externas para que LOG_LEVEL=DEBUG mostre apenas o app.
# Sem isso o zeep despeja o schema ONVIF inteiro (centenas de linhas) a cada
# boot, o que torna o DEBUG inutilizável justamente quando ele é necessário.
for _lib in ('onvif', 'zeep', 'urllib3', 'requests', 'PIL', 'matplotlib'):
logging.getLogger(_lib).setLevel(logging.WARNING)
# Tradução dos nomes de classe do YOLO para exibição. Usada só nas mensagens e
# nos rótulos da imagem: a comparação com car_classes continua em inglês, que é
# como o modelo nomeia as classes.
CLASSES_PT = {
'car': 'carro',
'truck': 'caminhão',
'bus': 'ônibus',
'motorcycle': 'moto',
}
def classe_pt(nome: str) -> str:
"""Nome da classe em português, ou o original se não houver tradução"""
return CLASSES_PT.get(nome.lower(), nome)
class GateDetectionSystem:
"""Sistema de detecção de carros e abertura de portão"""
def __init__(
self,
dvr_host: str,
dvr_port: int,
dvr_user: str,
dvr_pass: str,
camera_index: int,
gate_api_url: str,
gate_cooldown_seconds: int = 60,
confidence_threshold: float = 0.5,
car_classes: list = None,
rocket_chat_webhook: str = None,
car_stationary_seconds: int = 3,
roi_exclude: Tuple[float, float, float, float] = None,
min_box_height_ratio: float = 0.0,
roi_include: Tuple[float, float, float, float] = None
):
"""
Inicializa o sistema de detecção
Args:
dvr_host: IP da DVR
dvr_port: Porta da DVR
dvr_user: Usuário da DVR
dvr_pass: Senha da DVR
camera_index: Índice da câmera (1-based)
gate_api_url: URL da API para abrir o portão
gate_cooldown_seconds: Tempo mínimo entre aberturas
confidence_threshold: Confiança mínima para detecção
car_classes: Classes de objetos a detectar (ex: ['car', 'truck'])
rocket_chat_webhook: URL do webhook do Rocket.Chat
car_stationary_seconds: Tempo que o carro deve ficar parado (segundos)
roi_exclude: Área a ignorar (x1, y1, x2, y2). Se os quatro valores forem
<= 1.0 são tratados como fração do frame; caso contrário, pixels absolutos
min_box_height_ratio: Altura mínima da caixa como fração da altura do frame.
Descarta veículos distantes (estacionamento ao fundo). 0.0 desativa
"""
self.dvr_host = dvr_host
self.dvr_port = dvr_port
self.dvr_user = dvr_user
self.dvr_pass = dvr_pass
self.camera_index = camera_index
self.gate_api_url = gate_api_url
self.gate_cooldown_seconds = gate_cooldown_seconds
self.confidence_threshold = confidence_threshold
self.car_classes = car_classes or ['car', 'truck', 'bus', 'motorcycle']
self.rocket_chat_webhook = rocket_chat_webhook
self.car_stationary_seconds = car_stationary_seconds
self.roi_exclude = roi_exclude # (x1, y1, x2, y2) para excluir detecções
self.min_box_height_ratio = min_box_height_ratio
self.roi_include = roi_include # Área onde a detecção conta (portão)
self._roi_abs = None # ROI de exclusão em pixels, resolvida no primeiro frame
self._roi_inc_abs = None # ROI de inclusão em pixels
# Estado do sistema
self.lock = Lock()
self.is_running = False
self.gate_last_opened = None
self.is_gate_open = False
self.stream_url = None
self.cap = None
self.model = None
self.last_frame = None
# Rastreamento de veículos parados
self.car_detection_start_time = None
self.last_car_position = None
# Instante da última tentativa de POST, bem-sucedida ou não. Separado de
# gate_last_opened porque uma falha não deve bloquear por um cooldown
# inteiro, mas também não pode ser repetida a cada frame.
self.gate_last_attempt = None
self.gate_retry_seconds = 10
# Dimensão e qualidade da imagem anexada às notificações
self.notify_image_width = int(os.getenv('NOTIFY_IMAGE_WIDTH', '1280'))
self.notify_image_quality = int(os.getenv('NOTIFY_IMAGE_QUALITY', '85'))
# Notificar também a simples passagem de veículos pela área, não só os
# que param e acionam o portão. Desligado por padrão: a maioria apenas
# atravessa o pátio, e o aviso do acionamento já sai sempre.
self.notify_on_detection = os.getenv('NOTIFY_ON_DETECTION', 'false').lower() == 'true'
self.notify_detection_cooldown = int(os.getenv('NOTIFY_DETECTION_COOLDOWN', '30'))
self.last_detection_notify = None
# Leitura de frames em thread separada.
# A inferência do YOLO (~800ms em CPU) é muito mais lenta que a taxa do
# stream (~20fps). Sem drenar o buffer do OpenCV continuamente, os frames
# se acumulam e o detector passa a analisar vídeo cada vez mais antigo.
self.frame_lock = Lock()
self.latest_frame = None
self.reader_thread = None
self.stream_alive = False
self.frames_received = 0
self.frames_processed = 0
logger.info(f"Sistema inicializado: DVR={dvr_host}:{dvr_port}, Câmera={camera_index}, Tempo parado={car_stationary_seconds}s")
# Registrar a configuração efetiva de notificação. Em produção as
# variáveis vêm do painel do Coolify, não do .env - sem esta linha não
# há como saber se o valor em vigor é o do painel ou o padrão do código.
logger.info(
f"Notificações: passagem={'ATIVADA' if self.notify_on_detection else 'desativada'}"
f" (NOTIFY_ON_DETECTION), acionamento=sempre"
)
def get_stream_url_from_onvif(self) -> Optional[str]:
"""
Obtem a URL de stream RTSP da camera via ONVIF
Tenta multiplas portas comuns em DVRs Intelbras
Returns:
URL RTSP ou None se falhar
"""
try:
logger.info("Conectando à DVR via ONVIF...")
from onvif import ONVIFCamera
# Conectar à DVR
mycam = ONVIFCamera(
self.dvr_host,
self.dvr_port,
self.dvr_user,
self.dvr_pass
)
# Obter perfis de mídia
media_service = mycam.create_media_service()
profiles = media_service.GetProfiles()
if not profiles:
logger.error("Nenhum perfil de mídia encontrado")
return None
# Usar o perfil correspondente à câmera
profile = profiles[self.camera_index - 1] if len(profiles) >= self.camera_index else profiles[0]
# Obter URL de stream
stream_uri = media_service.GetStreamUri({'ProfileToken': profile.token})
stream_url = stream_uri.Uri
logger.info(f"URL de stream obtida: {stream_url}")
return stream_url
except Exception as e:
logger.error(f"Erro ao obter stream via ONVIF: {e}")
logger.debug(traceback.format_exc())
return None
@staticmethod
def _mask_url(url: str) -> str:
"""
Remove a senha de uma URL antes de logar.
Os logs vão para disco, para o Docker Desktop e para o painel do Coolify --
credencial em texto puro em qualquer um deles é vazamento.
Args:
url: URL possivelmente contendo user:senha@host
Returns:
URL com a senha substituída por ***
"""
return re.sub(r'(://[^:/@]+:)[^@]*(@)', r'\1***\2', url)
def get_stream_url_fallback(self) -> str:
"""
URL de fallback caso ONVIF falhe
Usa URL RTSP correta para Intelbras iMHDX
"""
# URL RTSP correta para Intelbras: rtsp://user:pass@host/cam/realmonitor?channel=X&subtype=0
# A senha é percent-encoded: caracteres como @ / : quebrariam o parse da URL
safe_pass = quote(self.dvr_pass, safe='')
url = f"rtsp://{self.dvr_user}:{safe_pass}@{self.dvr_host}/cam/realmonitor?channel={self.camera_index}&subtype=0"
logger.info(f"Usando URL de fallback: {self._mask_url(url)}")
return url
def initialize_stream(self) -> bool:
"""
Inicializa a conexão com o stream de vídeo
Returns:
True se bem-sucedido, False caso contrário
"""
try:
# Tentar obter URL via ONVIF
self.stream_url = self.get_stream_url_from_onvif()
# Se falhar, usar fallback
if not self.stream_url:
self.stream_url = self.get_stream_url_fallback()
# Conectar ao stream
logger.info(f"Conectando ao stream: {self._mask_url(self.stream_url)}")
self.cap = cv2.VideoCapture(self.stream_url)
if not self.cap.isOpened():
logger.error("Falha ao abrir stream de vídeo")
return False
# Pedir o menor buffer possível ao backend (nem todos respeitam;
# a thread leitora é a garantia real de que não há acúmulo)
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
logger.info("Stream de vídeo inicializado com sucesso")
return True
except Exception as e:
logger.error(f"Erro ao inicializar stream: {e}")
logger.debug(traceback.format_exc())
return False
def _reader_loop(self) -> None:
"""
Lê frames do stream continuamente, mantendo apenas o mais recente.
Roda em thread própria. Descartar frames aqui é intencional: é melhor
analisar o instante atual do que processar uma fila atrasada.
"""
logger.info("Thread de leitura iniciada")
while self.is_running:
ret, frame = self.cap.read()
if not ret:
logger.warning("Falha ao ler frame do stream")
self.stream_alive = False
return
with self.frame_lock:
self.latest_frame = frame
self.frames_received += 1
logger.info("Thread de leitura encerrada")
def _start_reader(self) -> None:
"""Inicia a thread de leitura do stream"""
self.stream_alive = True
self.reader_thread = Thread(target=self._reader_loop, daemon=True)
self.reader_thread.start()
def _take_latest_frame(self) -> Optional[np.ndarray]:
"""
Remove e retorna o frame mais recente, ou None se nenhum novo chegou.
Returns:
Frame mais recente ou None
"""
with self.frame_lock:
frame = self.latest_frame
self.latest_frame = None
return frame
def initialize_model(self) -> bool:
"""
Carrega o modelo YOLOv8 para detecção
Returns:
True se bem-sucedido, False caso contrário
"""
try:
# Caminho do modelo. Na imagem Docker o arquivo já vem embutido, então
# o carregamento não depende de internet no boot. Fora do container,
# o padrão relativo faz o ultralytics baixar sob demanda.
model_path = os.getenv('YOLO_MODEL_PATH', 'yolov8n.pt')
if os.path.isfile(model_path):
logger.info(f"Carregando modelo YOLOv8 de {model_path}...")
else:
logger.warning(
f"Modelo não encontrado em '{model_path}' - o ultralytics vai "
f"baixá-lo (requer acesso à internet)"
)
# Suprimir avisos do PyTorch durante carregamento
with warnings.catch_warnings():
warnings.simplefilter('ignore')
self.model = YOLO(model_path)
logger.info("Modelo YOLOv8 carregado com sucesso")
return True
except Exception as e:
logger.error(f"Erro ao carregar modelo: {e}")
logger.debug(traceback.format_exc())
return False
def detect_cars_in_frame(self, frame: np.ndarray) -> Tuple[bool, List]:
"""
Detecta carros em um frame usando YOLOv8
Args:
frame: Frame de vídeo (numpy array)
Returns:
Tupla (tem_carro, detecções)
"""
try:
if self.model is None:
return False, []
frame_h, frame_w = frame.shape[:2]
# Executar detecção
results = self.model(frame, verbose=False, conf=self.confidence_threshold)
detections = []
has_car = False
for result in results:
if result.boxes is not None:
for box in result.boxes:
# Obter classe e confiança
class_id = int(box.cls[0])
confidence = float(box.conf[0])
class_name = result.names[class_id]
# Verificar se é um veículo de interesse
if class_name.lower() in self.car_classes:
coords = box.xyxy[0].tolist()
# Veículos distantes (estacionamento ao fundo) geram caixas
# pequenas; um carro no portão ocupa boa parte do frame
height_ratio = (coords[3] - coords[1]) / frame_h
if self.min_box_height_ratio > 0 and height_ratio < self.min_box_height_ratio:
logger.debug(
f"Ignorado (pequeno demais): {class_name} altura={height_ratio:.3f} "
f"< {self.min_box_height_ratio} centro={self._normalized_center(coords, frame_w, frame_h)}"
)
continue
# Fora da área de interesse (pátio do portão): ignorar
if not self._is_in_included_roi(coords, frame_w, frame_h):
logger.debug(
f"Ignorado (fora da area de interesse): {class_name} "
f"base={self._normalized_ground(coords, frame_w, frame_h)}"
)
continue
# Verificar se está na ROI de exclusão
if self._is_in_excluded_roi(coords, frame_w, frame_h):
logger.debug(
f"Ignorado (ROI de exclusao): {class_name} "
f"centro={self._normalized_center(coords, frame_w, frame_h)}"
)
continue
has_car = True
logger.debug(
f"Aceito: {class_name} {confidence:.1%} altura={height_ratio:.3f} "
f"centro={self._normalized_center(coords, frame_w, frame_h)}"
)
detections.append({
'class': class_name,
'confidence': confidence,
'box': coords
})
return has_car, detections
except Exception as e:
logger.error(f"Erro ao detectar carros: {e}")
logger.debug(traceback.format_exc())
return False, []
def send_gate_open_request(self) -> Tuple[bool, str]:
"""
Envia requisição POST para abrir o portão
Returns:
(sucesso, detalhe) - o detalhe vai para a notificação no Rocket.Chat
"""
try:
logger.info(f"Enviando requisição para abrir portão: {self.gate_api_url}")
payload = {"action": "open"}
response = requests.post(
self.gate_api_url,
json=payload,
timeout=5
)
if response.status_code == 200:
logger.info(f"Portão aberto com sucesso. Status: {response.status_code}")
return True, "HTTP 200"
else:
# O corpo do erro pode ser uma página HTML inteira; o log completo
# fica no arquivo, mas a notificação leva só o essencial
logger.warning(f"Resposta inesperada: {response.status_code} - {response.text[:200]}")
return False, f"HTTP {response.status_code}"
except requests.exceptions.Timeout:
logger.error("Timeout ao enviar requisição para abrir portão")
return False, "tempo esgotado"
except requests.exceptions.ConnectionError:
logger.error("Falha de conexão ao enviar requisição para abrir portão")
return False, "falha de conexão"
except Exception as e:
logger.error(f"Erro ao enviar requisição: {e}")
logger.debug(traceback.format_exc())
return False, f"erro inesperado ({type(e).__name__})"
def draw_detections_on_frame(self, frame: np.ndarray, detections: List) -> np.ndarray:
"""
Desenha bounding boxes e informacoes das deteccoes no frame
Args:
frame: Frame original
detections: Lista de deteccoes com box e confidence
Returns:
Frame com bounding boxes desenhados
"""
frame_copy = frame.copy()
for detection in detections:
box = detection['box']
confidence = detection['confidence']
class_name = detection['class']
# Coordenadas do retangulo
x1, y1, x2, y2 = int(box[0]), int(box[1]), int(box[2]), int(box[3])
# Desenhar retangulo verde
cv2.rectangle(frame_copy, (x1, y1), (x2, y2), (0, 255, 0), 2)
# Texto com label e confianca
label = f"{classe_pt(class_name)} {confidence:.1%}"
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = 0.6
thickness = 2
# Obter tamanho do texto
text_size = cv2.getTextSize(label, font, font_scale, thickness)[0]
# Desenhar fundo para o texto
cv2.rectangle(
frame_copy,
(x1, y1 - text_size[1] - 5),
(x1 + text_size[0] + 5, y1),
(0, 255, 0),
-1
)
# Desenhar texto
cv2.putText(
frame_copy,
label,
(x1 + 2, y1 - 5),
font,
font_scale,
(0, 0, 0),
thickness
)
return frame_copy
def notify_async(
self,
titulo: str,
detalhe: str,
cor: str = "#764FA5",
frame: np.ndarray = None,
detections: List = None
) -> None:
"""
Dispara uma notificação ao Rocket.Chat sem bloquear a detecção.
O envio roda em thread própria: o webhook tem timeout de 10s e é chamado
de dentro do lock do loop principal, então um chat lento congelaria a
análise de vídeo por todo esse tempo.
Args:
titulo: Linha principal da mensagem
detalhe: Texto do anexo
cor: Barra lateral do anexo (hex)
frame: Frame a anexar; None envia só texto
detections: Detecções a desenhar sobre o frame
"""
if not self.rocket_chat_webhook:
return
# Copiar o frame agora: a thread roda depois, e o buffer original
# já terá sido substituído pelo próximo frame do stream
frame_copy = frame.copy() if frame is not None else None
Thread(
target=self._send_rocket_chat,
args=(titulo, detalhe, cor, frame_copy, detections),
daemon=True
).start()
def _send_rocket_chat(
self,
titulo: str,
detalhe: str,
cor: str,
frame: Optional[np.ndarray],
detections: Optional[List]
) -> bool:
"""
Envia a mensagem ao Rocket.Chat. Roda em thread separada.
Returns:
True se bem-sucedido, False caso contrário
"""
try:
timestamp = datetime.now().strftime('%d/%m/%Y %H:%M:%S')
anexo = {"text": detalhe, "color": cor}
if frame is not None:
imagem = self.draw_detections_on_frame(frame, detections or [])
# JPEG redimensionado, não PNG em resolução cheia: um quadro
# 1920x1080 em PNG dá ~3,9 MB em base64 e estoura o timeout do
# webhook. A 1280px com qualidade 85 fica em ~230 KB.
altura, largura = imagem.shape[:2]
if largura > self.notify_image_width:
nova_altura = int(altura * self.notify_image_width / largura)
imagem = cv2.resize(imagem, (self.notify_image_width, nova_altura))
ok, buffer = cv2.imencode(
'.jpg', imagem, [cv2.IMWRITE_JPEG_QUALITY, self.notify_image_quality]
)
if ok:
b64 = base64.b64encode(buffer).decode('utf-8')
logger.debug(f"Imagem da notificação: {len(b64)/1024:.0f} KB em base64")
anexo["image_url"] = f"data:image/jpeg;base64,{b64}"
else:
logger.error("Falha ao codificar frame em JPEG")
response = requests.post(
self.rocket_chat_webhook,
json={"text": f"{titulo} - {timestamp}", "attachments": [anexo]},
timeout=10
)
if response.status_code == 200:
logger.info(f"Notificação enviada ao Rocket.Chat: {titulo}")
return True
logger.warning(f"Falha ao enviar para Rocket.Chat: {response.status_code}")
return False
except requests.exceptions.Timeout:
logger.error("Timeout ao enviar notificação para Rocket.Chat")
return False
except Exception as e:
logger.error(f"Erro ao enviar notificação para Rocket.Chat: {e}")
logger.debug(traceback.format_exc())
return False
def _calculate_detection_center(self, detections: List) -> Tuple[float, float]:
"""
Calcula o centro da detecção (média das caixas)
Args:
detections: Lista de detecções
Returns:
Tupla (x_center, y_center)
"""
if not detections:
return (0, 0)
total_x = 0
total_y = 0
for detection in detections:
box = detection['box'] # [x1, y1, x2, y2]
x_center = (box[0] + box[2]) / 2
y_center = (box[1] + box[3]) / 2
total_x += x_center
total_y += y_center
avg_x = total_x / len(detections)
avg_y = total_y / len(detections)
return (avg_x, avg_y)
def _is_position_stable(self, last_pos: Tuple[float, float], current_pos: Tuple[float, float], threshold: float = 50.0) -> bool:
"""
Verifica se a posição do carro é estável (não se moveu muito)
Args:
last_pos: Posição anterior
current_pos: Posição atual
threshold: Distância máxima em pixels para considerar estável
Returns:
True se posição é estável, False caso contrário
"""
if last_pos is None:
return True
# Calcular distância euclidiana
distance = np.sqrt((current_pos[0] - last_pos[0])**2 + (current_pos[1] - last_pos[1])**2)
return distance < threshold
@staticmethod
def _normalized_center(box: List[float], frame_w: int, frame_h: int) -> str:
"""Centro da caixa em fração do frame, para calibrar a ROI a partir dos logs"""
cx = ((box[0] + box[2]) / 2) / frame_w
cy = ((box[1] + box[3]) / 2) / frame_h
return f"({cx:.3f}, {cy:.3f})"
@staticmethod
def _normalized_ground(box: List[float], frame_w: int, frame_h: int) -> str:
"""Ponto de contato com o solo em fração do frame - é o que a área testa"""
gx = ((box[0] + box[2]) / 2) / frame_w
gy = box[3] / frame_h
return f"({gx:.3f}, {gy:.3f})"
def _resolve_roi(self, frame_w: int, frame_h: int) -> Optional[Tuple[float, float, float, float]]:
"""
Converte a ROI configurada para pixels absolutos.
Aceita valores normalizados (0.0-1.0, fração do frame) ou pixels absolutos.
Normalizado é preferível: independe da resolução do stream, que muda
conforme o perfil ONVIF ou o subtype do RTSP.
Args:
frame_w: Largura do frame
frame_h: Altura do frame
Returns:
(x1, y1, x2, y2) em pixels, ou None se não há ROI configurada
"""
if self.roi_exclude is None:
return None
if self._roi_abs is not None:
return self._roi_abs
x1, y1, x2, y2 = self.roi_exclude
# Se todos os valores cabem em 0.0-1.0, são frações do frame
if all(0.0 <= v <= 1.0 for v in (x1, y1, x2, y2)):
self._roi_abs = (x1 * frame_w, y1 * frame_h, x2 * frame_w, y2 * frame_h)
logger.info(
f"ROI de exclusao (normalizada {self.roi_exclude}) resolvida para "
f"{tuple(round(v) for v in self._roi_abs)} px em frame {frame_w}x{frame_h}"
)
else:
self._roi_abs = (x1, y1, x2, y2)
logger.info(f"ROI de exclusao em pixels absolutos: {self._roi_abs} (frame {frame_w}x{frame_h})")
return self._roi_abs
@staticmethod
def parse_polygon(spec: str) -> Optional[List[Tuple[float, float]]]:
"""
Converte "x1,y1 x2,y2 ..." em lista de pontos.
Args:
spec: Pares separados por espaço, cada par "x,y"
Returns:
Lista de pontos, ou None se a string for vazia
Raises:
ValueError: se o formato for inválido ou houver menos de 3 pontos
"""
if not spec or not spec.strip():
return None
pontos = []
for par in spec.split():
try:
x, y = par.split(',')
pontos.append((float(x), float(y)))
except ValueError:
raise ValueError(f"Ponto inválido em ROI_INCLUDE_POLYGON: '{par}' (esperado 'x,y')")
if len(pontos) < 3:
raise ValueError(f"Polígono precisa de ao menos 3 pontos, recebeu {len(pontos)}")
return pontos
def _is_in_included_roi(self, box: List[float], frame_w: int, frame_h: int) -> bool:
"""
Verifica se a deteccao esta na area de interesse (o patio do portao).
Delimitar onde o portão importa é mais robusto que listar o que ignorar:
estacionamento, estrada externa e qualquer fundo ficam de fora por
construção, sem precisar de um recorte para cada um.
Args:
box: Caixa de deteccao [x1, y1, x2, y2]
frame_w: Largura do frame
frame_h: Altura do frame
Returns:
True se conta (ou se nenhuma area foi configurada), False caso contrario
"""
if self.roi_include is None:
return True
if self._roi_inc_abs is None:
pts = self.roi_include
normalizado = all(0.0 <= x <= 1.0 and 0.0 <= y <= 1.0 for x, y in pts)
if normalizado:
pts = [(x * frame_w, y * frame_h) for x, y in pts]
self._roi_inc_abs = np.array(pts, dtype=np.int32)
logger.info(
f"Area de interesse: poligono de {len(pts)} pontos "
f"({'normalizado' if normalizado else 'pixels'}) em frame {frame_w}x{frame_h}"
)
# Ponto de contato com o solo (base da caixa): é onde o veículo realmente
# toca o chão. O centro geométrico de um veículo próximo fica bem acima
# do solo e cairia fora da área mesmo com o veículo dentro dela.
ground_x = (box[0] + box[2]) / 2
ground_y = box[3]
return cv2.pointPolygonTest(self._roi_inc_abs, (float(ground_x), float(ground_y)), False) >= 0
def _is_in_excluded_roi(self, box: List[float], frame_w: int, frame_h: int) -> bool:
"""
Verifica se a deteccao esta na area de exclusao (ROI).
Testa o CENTRO da caixa, não a sobreposição: um veículo grande em primeiro
plano no portão pode ter a caixa encostando na ROI do fundo sem estar nela.
Args:
box: Caixa de deteccao [x1, y1, x2, y2]
frame_w: Largura do frame
frame_h: Altura do frame
Returns:
True se esta na area de exclusao, False caso contrario
"""
roi = self._resolve_roi(frame_w, frame_h)
if roi is None:
return False
x1, y1, x2, y2 = box
roi_x1, roi_y1, roi_x2, roi_y2 = roi
center_x = (x1 + x2) / 2
center_y = (y1 + y2) / 2
return roi_x1 <= center_x <= roi_x2 and roi_y1 <= center_y <= roi_y2
def _notify_detection(self, current_time: datetime, detections: List) -> None:
"""
Avisa no Rocket.Chat que um veículo entrou na área de interesse.
Independente da abertura do portão: a maioria dos veículos atravessa o
pátio sem parar e nunca chega a acionar nada. Chamado com o lock adquirido.
Args:
current_time: Instante atual
detections: Detecções que entraram na área
"""
if not self.notify_on_detection:
return
# Um veículo manobrando entra e sai da área várias vezes; sem janela
# mínima o chat receberia uma mensagem a cada reaparecimento
if self.last_detection_notify is not None and \
(current_time - self.last_detection_notify).total_seconds() < self.notify_detection_cooldown:
logger.debug("Notificação de detecção suprimida (dentro da janela)")
return
self.last_detection_notify = current_time
tipos = ", ".join(sorted({classe_pt(d['class']) for d in (detections or [])})) or "veículo"
self.notify_async(
titulo="🚗 Veículo detectado",
detalhe=f"{tipos} na área do portão principal",
cor="#F5A623",
frame=self.last_frame,
detections=detections
)
def _try_open_gate(self, current_time: datetime, elapsed_time: float, detections: List) -> None:
"""
Envia o comando de abertura, respeitando cooldown e backoff de falha.
Chamado com self.lock já adquirido.
Args:
current_time: Instante atual
elapsed_time: Segundos que o veículo ficou parado
detections: Detecções que motivaram a abertura (para a notificação)
"""
# Cooldown normal: tempo que o portão leva para fechar
if self.gate_last_opened is not None and \
(current_time - self.gate_last_opened).total_seconds() < self.gate_cooldown_seconds:
return
# Backoff de falha: se a API está fora do ar ou a rota está errada, não
# adianta repetir a cada frame - eram ~5 requisições por segundo.
if self.gate_last_attempt is not None and \
(current_time - self.gate_last_attempt).total_seconds() < self.gate_retry_seconds:
return
logger.info(f"Carro parado por {elapsed_time:.1f}s - Abrindo portão")
self.gate_last_attempt = current_time
# Avisar ANTES de chamar a API: esta é a mensagem que carrega a foto e
# ela precisa chegar mesmo que a chamada falhe em seguida
tipos = ", ".join(sorted({classe_pt(d['class']) for d in (detections or [])})) or "veículo"
self.notify_async(
titulo="🚗 Veículo parado no portão",
detalhe=f"{tipos} parado por {elapsed_time:.1f}s - acionando abertura",
cor="#F5A623",
frame=self.last_frame,
detections=detections
)
sucesso, detalhe = self.send_gate_open_request()
if sucesso:
self.is_gate_open = True
self.gate_last_opened = current_time
logger.info(f"Portão aberto. Próxima abertura permitida em {self.gate_cooldown_seconds}s")
self.notify_async(
titulo="✅ Portão aberto",
detalhe=f"Veículo parado por {elapsed_time:.1f}s. Comando aceito ({detalhe}). "
f"Próxima abertura em {self.gate_cooldown_seconds}s",
cor="#2ECC71"
)
else:
logger.warning(
f"Falha ao abrir portão - nova tentativa em no mínimo {self.gate_retry_seconds}s"
)
self.notify_async(
titulo="❌ Falha ao abrir portão",
detalhe=f"{self.gate_api_url} respondeu {detalhe}. "
f"Nova tentativa em no mínimo {self.gate_retry_seconds}s",
cor="#E74C3C"
)
# Rearmar o rastreamento nos dois casos: este veículo já foi avaliado
self.car_detection_start_time = None
self.last_car_position = None
def check_and_open_gate(self, has_car: bool, detections: List = None) -> None:
"""
Verifica se deve abrir o portão baseado na detecção de carro parado
Args:
has_car: Se foi detectado um carro
detections: Lista de detecções com posições dos carros
"""
with self.lock:
current_time = datetime.now()
# Se detectou carro
if has_car and detections:
# Calcular posição média dos carros detectados
current_position = self._calculate_detection_center(detections)
# Se é a primeira detecção de carro
if self.car_detection_start_time is None:
self.car_detection_start_time = current_time
self.last_car_position = current_position
logger.debug("Carro detectado - iniciando contagem de tempo")
self._notify_detection(current_time, detections)
# Se carro mantém posição similar (parado).
# A posição de referência NÃO é atualizada aqui: comparar sempre
# contra a âncora inicial faz um veículo em deslocamento lento
# acabar excedendo o limiar, em vez de parecer parado a cada frame.
elif self._is_position_stable(self.last_car_position, current_position):
elapsed_time = (current_time - self.car_detection_start_time).total_seconds()
if elapsed_time >= self.car_stationary_seconds and not self.is_gate_open:
self._try_open_gate(current_time, elapsed_time, detections)
else:
# Veículo se moveu: o cronômetro precisa recomeçar do zero.
# Sem isso, "parado por N segundos" vira apenas "presente há N
# segundos", e um carro atravessando o pátio aciona a abertura.
self.car_detection_start_time = current_time
self.last_car_position = current_position
logger.debug("Carro em movimento - reiniciando contagem")
# Se não detectou carro
else:
# Resetar rastreamento
if self.car_detection_start_time is not None:
logger.debug("Carro desapareceu - resetando rastreamento")
self.car_detection_start_time = None
self.last_car_position = None
# Se portão está aberto e passou o tempo de cooldown
if self.is_gate_open and self.gate_last_opened:
if (current_time - self.gate_last_opened).total_seconds() >= self.gate_cooldown_seconds:
self.is_gate_open = False
logger.info("Portão marcado como fechado")
def process_frame(self, frame: np.ndarray) -> None:
"""
Processa um frame: detecta carros e abre portão se necessário
Args:
frame: Frame de vídeo
"""
try:
# Armazenar frame atual para possível envio ao Rocket.Chat
self.last_frame = frame.copy()
# Detectar carros
has_car, detections = self.detect_cars_in_frame(frame)
if has_car:
logger.debug(f"Carros detectados: {len(detections)}")
# Verificar e abrir portão se necessário (com detecção de parada)
self.check_and_open_gate(has_car, detections)
except Exception as e:
logger.error(f"Erro ao processar frame: {e}")
logger.debug(traceback.format_exc())
def run(self) -> None: