PedroM2626 commited on
Commit
a2b5362
·
1 Parent(s): 58ae82a

feat(yolo): add object detection app with YOLOv3-tiny integration

Browse files
.env.example ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ YOLO_CFG_PATH=darknet/cfg/yolov3-tiny-obj.cfg
2
+ YOLO_WEIGHTS_PATH=darknet/backup/yolov3-tiny-obj_final.weights
3
+ YOLO_NAMES_PATH=darknet/cfg/obj.names
4
+ YOLO_CONF_THRESHOLD=0.5
5
+ YOLO_NMS_THRESHOLD=0.4
6
+ YOLO_USE_GPU=false
Dockerfile CHANGED
@@ -1,20 +1,26 @@
1
- FROM python:3.13.5-slim
2
 
3
  WORKDIR /app
4
 
 
5
  RUN apt-get update && apt-get install -y \
6
  build-essential \
7
  curl \
8
  git \
 
 
9
  && rm -rf /var/lib/apt/lists/*
10
 
 
11
  COPY requirements.txt ./
12
- COPY src/ ./src/
13
 
14
- RUN pip3 install -r requirements.txt
 
15
 
16
  EXPOSE 8501
17
 
18
  HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
19
 
20
- ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
 
1
+ FROM python:3.11-slim
2
 
3
  WORKDIR /app
4
 
5
+ # Instalar dependências de sistema
6
  RUN apt-get update && apt-get install -y \
7
  build-essential \
8
  curl \
9
  git \
10
+ libgl1 \
11
+ libglib2.0-0 \
12
  && rm -rf /var/lib/apt/lists/*
13
 
14
+ # Copiar apenas requirements primeiro para aproveitar o cache do Docker
15
  COPY requirements.txt ./
16
+ RUN pip3 install --no-cache-dir -r requirements.txt
17
 
18
+ # Copiar o restante dos arquivos (isso invalidará o cache se algum arquivo mudar)
19
+ COPY . .
20
 
21
  EXPOSE 8501
22
 
23
  HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
24
 
25
+ # Flags para evitar erro 403 e garantir funcionamento em proxies
26
+ ENTRYPOINT ["streamlit", "run", "streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0", "--server.enableCORS=false", "--server.enableXsrfProtection=false"]
models/coco.names ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ person
2
+ bicycle
3
+ car
4
+ motorbike
5
+ aeroplane
6
+ bus
7
+ train
8
+ truck
9
+ boat
10
+ traffic light
11
+ fire hydrant
12
+ stop sign
13
+ parking meter
14
+ bench
15
+ bird
16
+ cat
17
+ dog
18
+ horse
19
+ sheep
20
+ cow
21
+ elephant
22
+ bear
23
+ zebra
24
+ giraffe
25
+ backpack
26
+ umbrella
27
+ handbag
28
+ tie
29
+ suitcase
30
+ frisbee
31
+ skis
32
+ snowboard
33
+ sports ball
34
+ kite
35
+ baseball bat
36
+ baseball glove
37
+ skateboard
38
+ surfboard
39
+ tennis racket
40
+ bottle
41
+ wine glass
42
+ cup
43
+ fork
44
+ knife
45
+ spoon
46
+ bowl
47
+ banana
48
+ apple
49
+ sandwich
50
+ orange
51
+ broccoli
52
+ carrot
53
+ hot dog
54
+ pizza
55
+ donut
56
+ cake
57
+ chair
58
+ sofa
59
+ pottedplant
60
+ bed
61
+ diningtable
62
+ toilet
63
+ tvmonitor
64
+ laptop
65
+ mouse
66
+ remote
67
+ keyboard
68
+ cell phone
69
+ microwave
70
+ oven
71
+ toaster
72
+ sink
73
+ refrigerator
74
+ book
75
+ clock
76
+ vase
77
+ scissors
78
+ teddy bear
79
+ hair drier
80
+ toothbrush
models/yolov3-tiny.cfg ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [net]
2
+ # Testing
3
+ batch=1
4
+ subdivisions=1
5
+ # Training
6
+ # batch=64
7
+ # subdivisions=2
8
+ width=416
9
+ height=416
10
+ channels=3
11
+ momentum=0.9
12
+ decay=0.0005
13
+ angle=0
14
+ saturation = 1.5
15
+ exposure = 1.5
16
+ hue=.1
17
+
18
+ learning_rate=0.001
19
+ burn_in=1000
20
+ max_batches = 500200
21
+ policy=steps
22
+ steps=400000,450000
23
+ scales=.1,.1
24
+
25
+ [convolutional]
26
+ batch_normalize=1
27
+ filters=16
28
+ size=3
29
+ stride=1
30
+ pad=1
31
+ activation=leaky
32
+
33
+ [maxpool]
34
+ size=2
35
+ stride=2
36
+
37
+ [convolutional]
38
+ batch_normalize=1
39
+ filters=32
40
+ size=3
41
+ stride=1
42
+ pad=1
43
+ activation=leaky
44
+
45
+ [maxpool]
46
+ size=2
47
+ stride=2
48
+
49
+ [convolutional]
50
+ batch_normalize=1
51
+ filters=64
52
+ size=3
53
+ stride=1
54
+ pad=1
55
+ activation=leaky
56
+
57
+ [maxpool]
58
+ size=2
59
+ stride=2
60
+
61
+ [convolutional]
62
+ batch_normalize=1
63
+ filters=128
64
+ size=3
65
+ stride=1
66
+ pad=1
67
+ activation=leaky
68
+
69
+ [maxpool]
70
+ size=2
71
+ stride=2
72
+
73
+ [convolutional]
74
+ batch_normalize=1
75
+ filters=256
76
+ size=3
77
+ stride=1
78
+ pad=1
79
+ activation=leaky
80
+
81
+ [maxpool]
82
+ size=2
83
+ stride=2
84
+
85
+ [convolutional]
86
+ batch_normalize=1
87
+ filters=512
88
+ size=3
89
+ stride=1
90
+ pad=1
91
+ activation=leaky
92
+
93
+ [maxpool]
94
+ size=2
95
+ stride=1
96
+
97
+ [convolutional]
98
+ batch_normalize=1
99
+ filters=1024
100
+ size=3
101
+ stride=1
102
+ pad=1
103
+ activation=leaky
104
+
105
+ ###########
106
+
107
+ [convolutional]
108
+ batch_normalize=1
109
+ filters=256
110
+ size=1
111
+ stride=1
112
+ pad=1
113
+ activation=leaky
114
+
115
+ [convolutional]
116
+ batch_normalize=1
117
+ filters=512
118
+ size=3
119
+ stride=1
120
+ pad=1
121
+ activation=leaky
122
+
123
+ [convolutional]
124
+ size=1
125
+ stride=1
126
+ pad=1
127
+ filters=255
128
+ activation=linear
129
+
130
+
131
+
132
+ [yolo]
133
+ mask = 3,4,5
134
+ anchors = 10,14, 23,27, 37,58, 81,82, 135,169, 344,319
135
+ classes=80
136
+ num=6
137
+ jitter=.3
138
+ ignore_thresh = .7
139
+ truth_thresh = 1
140
+ random=1
141
+
142
+ [route]
143
+ layers = -4
144
+
145
+ [convolutional]
146
+ batch_normalize=1
147
+ filters=128
148
+ size=1
149
+ stride=1
150
+ pad=1
151
+ activation=leaky
152
+
153
+ [upsample]
154
+ stride=2
155
+
156
+ [route]
157
+ layers = -1, 8
158
+
159
+ [convolutional]
160
+ batch_normalize=1
161
+ filters=256
162
+ size=3
163
+ stride=1
164
+ pad=1
165
+ activation=leaky
166
+
167
+ [convolutional]
168
+ size=1
169
+ stride=1
170
+ pad=1
171
+ filters=255
172
+ activation=linear
173
+
174
+ [yolo]
175
+ mask = 0,1,2
176
+ anchors = 10,14, 23,27, 37,58, 81,82, 135,169, 344,319
177
+ classes=80
178
+ num=6
179
+ jitter=.3
180
+ ignore_thresh = .7
181
+ truth_thresh = 1
182
+ random=1
models/yolov3-tiny.weights ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dccea06f59b781ec1234ddf8d1e94b9519a97f4245748a7d4db75d5b7080a42c
3
+ size 35434956
requirements.txt CHANGED
@@ -1,3 +1,13 @@
1
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
 
 
 
 
 
1
+ opencv-python>=4.8.0
2
+ numpy>=1.24.0
3
+ python-dotenv>=1.0.0
4
+ jupyter>=1.0.0
5
+ ipywidgets>=8.0.0
6
+ matplotlib>=3.7.0
7
+ pytest>=7.0.0
8
+ ultralytics>=8.0.0
9
+ PyYAML>=6.0.0
10
+ requests>=2.31.0
11
+ tqdm>=4.66.0
12
+ streamlit>=1.25.0
13
+ pillow>=10.0.0
src/streamlit_app.py DELETED
@@ -1,40 +0,0 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
4
- import streamlit as st
5
-
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
streamlit_app.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Importações necessárias para Streamlit, OpenCV e processamento de imagem
2
+ import streamlit as st
3
+ import cv2
4
+ import numpy as np
5
+ from PIL import Image
6
+ from yolo_inference import build_detector_from_env
7
+
8
+ # Configuração inicial da página do Streamlit (Título e Layout)
9
+ st.set_page_config(page_title="YOLO Detection - Streamlit", layout="wide", page_icon="🚗")
10
+
11
+ def main():
12
+ """
13
+ Função principal que gerencia a interface Streamlit.
14
+ Permite alternar entre detecção em imagens estáticas e vídeo em tempo real via webcam.
15
+ """
16
+ st.title("🚀 YOLO Object Detection")
17
+ st.markdown("---")
18
+ st.markdown("### Interface interativa para detecção de objetos usando YOLOv3-tiny.")
19
+
20
+ # Sidebar: Painel lateral para controle de parâmetros e seleção de modo
21
+ st.sidebar.header("🛠️ Configurações do Modelo")
22
+
23
+ # Sliders para ajuste dinâmico dos limiares de detecção
24
+ conf_threshold = st.sidebar.slider("Confiança Mínima (Threshold)", 0.0, 1.0, 0.5, 0.05,
25
+ help="Nível mínimo de certeza para exibir uma detecção.")
26
+ nms_threshold = st.sidebar.slider("NMS Threshold", 0.0, 1.0, 0.4, 0.05,
27
+ help="Limiar para supressão de não-máximos (remove bboxes sobrepostas).")
28
+
29
+ st.sidebar.markdown("---")
30
+ # Seleção do modo de operação
31
+ mode = st.sidebar.radio("📡 Escolha o Modo de Entrada", ["Imagem", "Câmera (Real-time)"])
32
+
33
+ # Inicializa o detector YOLO
34
+ # A função build_detector_from_env gerencia o download automático dos pesos se necessário.
35
+ try:
36
+ detector = build_detector_from_env(conf_threshold=conf_threshold, nms_threshold=nms_threshold)
37
+ except Exception as e:
38
+ st.error(f"❌ Erro ao inicializar detector: {e}")
39
+ return
40
+
41
+ # Lista de classes do dataset personalizado para monitoramento especial
42
+ CUSTOM_CLASSES = {"car", "truck", "bus", "motorbike", "bicycle", "van", "threewheel"}
43
+
44
+ if mode == "Imagem":
45
+ st.subheader("📁 Upload e Detecção em Imagem")
46
+ uploaded_file = st.file_uploader("Arraste ou selecione uma imagem...", type=["jpg", "jpeg", "png"])
47
+
48
+ if uploaded_file is not None:
49
+ # Converte o arquivo carregado (BytesIO) para uma imagem PIL e depois para array numpy
50
+ image = Image.open(uploaded_file)
51
+ image_np = np.array(image)
52
+
53
+ # Streamlit/PIL trabalham em RGB, mas o detector OpenCV espera BGR
54
+ frame_bgr = cv2.cvtColor(image_np, cv2.COLOR_RGB2BGR)
55
+
56
+ # Realiza a detecção de objetos
57
+ with st.spinner('Processando imagem...'):
58
+ detections = detector.detect(frame_bgr)
59
+
60
+ # Filtra e exibe classes encontradas que pertencem ao dataset customizado
61
+ hits = sorted({d['class_name'] for d in detections if d['class_name'] in CUSTOM_CLASSES})
62
+
63
+ # Layout em duas colunas: Imagem original vs Resultado
64
+ col1, col2 = st.columns(2)
65
+
66
+ with col1:
67
+ st.image(image, caption="Imagem Original", use_column_width=True)
68
+
69
+ with col2:
70
+ # Desenha os retângulos e labels no frame BGR
71
+ result_bgr = detector.draw(frame_bgr, detections)
72
+ # Converte de volta para RGB para exibição correta no Streamlit
73
+ result_rgb = cv2.cvtColor(result_bgr, cv2.COLOR_BGR2RGB)
74
+ st.image(result_rgb, caption="Detecções Encontradas", use_column_width=True)
75
+
76
+ # Exibe alertas baseados nas classes detectadas
77
+ if hits:
78
+ st.success(f"✅ Objetos do dataset detectados: **{', '.join(hits)}**")
79
+ else:
80
+ st.info("ℹ️ Nenhuma classe do dataset específico foi detectada nesta imagem.")
81
+
82
+ elif mode == "Câmera (Real-time)":
83
+ st.subheader("🎥 Detecção via Webcam em Tempo Real")
84
+ st.warning("⚠️ Certifique-se de que sua webcam não está sendo usada por outro aplicativo.")
85
+
86
+ # Checkbox para ligar/desligar o loop da câmera
87
+ run = st.checkbox("Ativar Câmera")
88
+
89
+ # Placeholders para atualização dinâmica do frame e status sem recarregar a página toda
90
+ frame_placeholder = st.empty()
91
+ status_placeholder = st.empty()
92
+
93
+ if run:
94
+ # Inicializa a captura de vídeo (ID 0 costuma ser a webcam padrão)
95
+ cap = cv2.VideoCapture(0)
96
+ if not cap.isOpened():
97
+ st.error("Não foi possível acessar a câmera. Verifique as permissões.")
98
+ return
99
+
100
+ while run:
101
+ ret, frame = cap.read()
102
+ if not ret:
103
+ st.error("Falha ao capturar vídeo.")
104
+ break
105
+
106
+ # Processa o frame atual
107
+ detections = detector.detect(frame)
108
+
109
+ # Renderiza as detecções no frame
110
+ frame_out = detector.draw(frame, detections)
111
+
112
+ # Adiciona overlay de instrução no frame (estilo solicitado anteriormente)
113
+ cv2.putText(frame_out, "Desmarque 'Ativar Camera' para sair", (20, 40),
114
+ cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
115
+
116
+ # Identifica classes do dataset para exibição de status dinâmico
117
+ hits = sorted({d['class_name'] for d in detections if d['class_name'] in CUSTOM_CLASSES})
118
+ if hits:
119
+ status_placeholder.success(f"Detectado: **{', '.join(hits)}**")
120
+ else:
121
+ status_placeholder.empty()
122
+
123
+ # Conversão BGR -> RGB para o Streamlit renderizar corretamente
124
+ frame_rgb = cv2.cvtColor(frame_out, cv2.COLOR_BGR2RGB)
125
+ frame_placeholder.image(frame_rgb, channels="RGB", use_column_width=True)
126
+
127
+ # Pequeno delay opcional para sincronia (cv2.waitKey não é necessário aqui para exibição,
128
+ # mas ajuda a liberar CPU)
129
+ if cv2.waitKey(1) & 0xFF == ord('q'):
130
+ break
131
+
132
+ # Libera recursos ao encerrar
133
+ cap.release()
134
+ st.write("🏁 Captura encerrada.")
135
+ else:
136
+ st.write("💤 Câmera em espera.")
137
+
138
+ if __name__ == "__main__":
139
+ main()
140
+
yolo_inference.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import cv2
3
+ import numpy as np
4
+ from typing import List, Tuple, Dict, Optional
5
+ import urllib.request
6
+ import pathlib
7
+
8
+ try:
9
+ from dotenv import load_dotenv
10
+ except Exception:
11
+ load_dotenv = None
12
+
13
+
14
+ def _load_classes(names_path: str) -> List[str]:
15
+ # Lê arquivo .names e retorna lista de classes
16
+ if not os.path.isfile(names_path):
17
+ raise FileNotFoundError(f"Arquivo de classes não encontrado: {names_path}")
18
+ with open(names_path, "r", encoding="utf-8") as f:
19
+ classes = [line.strip() for line in f if line.strip()]
20
+ if not classes:
21
+ raise ValueError("Lista de classes vazia")
22
+ return classes
23
+
24
+
25
+ def _get_output_layer_names(net: cv2.dnn_Net) -> List[str]:
26
+ # Extrai nomes das camadas de saída (YOLO) para forward
27
+ layer_names = net.getLayerNames()
28
+ out_layers = net.getUnconnectedOutLayers()
29
+ return [layer_names[i - 1] for i in out_layers.flatten()]
30
+
31
+
32
+ class YoloDetector:
33
+ # Wrapper para inferência com OpenCV DNN + Darknet cfg/weights
34
+ def __init__(
35
+ self,
36
+ cfg_path: str,
37
+ weights_path: str,
38
+ names_path: str,
39
+ conf_threshold: float = 0.5,
40
+ nms_threshold: float = 0.4,
41
+ use_gpu: bool = False,
42
+ ):
43
+ if not os.path.isfile(cfg_path):
44
+ raise FileNotFoundError(f"CFG não encontrado: {cfg_path}")
45
+ if not os.path.isfile(weights_path):
46
+ raise FileNotFoundError(f"Pesos não encontrados: {weights_path}")
47
+ self.classes = _load_classes(names_path)
48
+ self.net = cv2.dnn.readNetFromDarknet(cfg_path, weights_path)
49
+ if use_gpu:
50
+ self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_CUDA)
51
+ self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CUDA)
52
+ else:
53
+ self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
54
+ self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
55
+ self.conf_threshold = conf_threshold
56
+ self.nms_threshold = nms_threshold
57
+ self.output_layer_names = _get_output_layer_names(self.net)
58
+
59
+ def detect(
60
+ self,
61
+ image_bgr: np.ndarray,
62
+ input_size: Tuple[int, int] = (416, 416),
63
+ ) -> List[Dict]:
64
+ # Executa inferência e retorna lista de detecções com bbox, classe e confiança
65
+ if image_bgr is None or image_bgr.size == 0:
66
+ raise ValueError("Imagem inválida para detecção")
67
+ h, w = image_bgr.shape[:2]
68
+ blob = cv2.dnn.blobFromImage(image_bgr, 1 / 255.0, input_size, swapRB=True, crop=False)
69
+ self.net.setInput(blob)
70
+ layer_outputs = self.net.forward(self.output_layer_names)
71
+
72
+ boxes: List[List[int]] = []
73
+ confidences: List[float] = []
74
+ class_ids: List[int] = []
75
+
76
+ for output in layer_outputs:
77
+ for detection in output:
78
+ scores = detection[5:]
79
+ class_id = int(np.argmax(scores))
80
+ confidence = float(scores[class_id])
81
+ if confidence >= self.conf_threshold:
82
+ center_x = int(detection[0] * w)
83
+ center_y = int(detection[1] * h)
84
+ width = int(detection[2] * w)
85
+ height = int(detection[3] * h)
86
+ x = int(center_x - width / 2)
87
+ y = int(center_y - height / 2)
88
+ boxes.append([x, y, width, height])
89
+ confidences.append(confidence)
90
+ class_ids.append(class_id)
91
+
92
+ indices = cv2.dnn.NMSBoxes(boxes, confidences, self.conf_threshold, self.nms_threshold)
93
+
94
+ detections: List[Dict] = []
95
+ if len(indices) > 0:
96
+ for i in indices.flatten():
97
+ x, y, w_box, h_box = boxes[i]
98
+ detections.append(
99
+ {
100
+ "class_id": class_ids[i],
101
+ "class_name": self.classes[class_ids[i]] if 0 <= class_ids[i] < len(self.classes) else str(class_ids[i]),
102
+ "confidence": confidences[i],
103
+ "box": (max(0, x), max(0, y), max(0, w_box), max(0, h_box)),
104
+ }
105
+ )
106
+ return detections
107
+
108
+ def draw(self, image_bgr: np.ndarray, detections: List[Dict]) -> np.ndarray:
109
+ # Desenha retângulos e labels no frame
110
+ out = image_bgr.copy()
111
+ for det in detections:
112
+ x, y, w, h = det["box"]
113
+ label = f"{det['class_name']} {det['confidence']:.2f}"
114
+ color = (0, 255, 0)
115
+ cv2.rectangle(out, (x, y), (x + w, y + h), color, 2)
116
+ (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
117
+ cv2.rectangle(out, (x, y - th - 6), (x + tw + 4, y), color, -1)
118
+ cv2.putText(out, label, (x + 2, y - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 2)
119
+ return out
120
+
121
+
122
+ def build_detector_from_env(
123
+ conf_threshold: Optional[float] = None,
124
+ nms_threshold: Optional[float] = None,
125
+ use_gpu: Optional[bool] = None,
126
+ ) -> YoloDetector:
127
+ # Inicializa via .env; se faltarem caminhos/arquivos, baixa YOLOv3-tiny automaticamente (models/)
128
+ if load_dotenv is not None:
129
+ load_dotenv()
130
+ cfg_path = os.getenv("YOLO_CFG_PATH", "").strip()
131
+ weights_path = os.getenv("YOLO_WEIGHTS_PATH", "").strip()
132
+ names_path = os.getenv("YOLO_NAMES_PATH", "").strip()
133
+ # Se variáveis não existirem OU arquivos não existirem, usar fallback auto-download
134
+ if (not cfg_path or not weights_path or not names_path
135
+ or not os.path.isfile(cfg_path)
136
+ or not os.path.isfile(weights_path)
137
+ or not os.path.isfile(names_path)):
138
+ models_dir = pathlib.Path("models")
139
+ models_dir.mkdir(exist_ok=True)
140
+ cfg_path = str(models_dir / "yolov3-tiny.cfg")
141
+ weights_path = str(models_dir / "yolov3-tiny.weights")
142
+ names_path = str(models_dir / "coco.names")
143
+ if not os.path.isfile(cfg_path):
144
+ url_cfg = "https://raw.githubusercontent.com/pjreddie/darknet/master/cfg/yolov3-tiny.cfg"
145
+ urllib.request.urlretrieve(url_cfg, cfg_path)
146
+ if not os.path.isfile(weights_path):
147
+ url_weights = "https://pjreddie.com/media/files/yolov3-tiny.weights"
148
+ urllib.request.urlretrieve(url_weights, weights_path)
149
+ if not os.path.isfile(names_path):
150
+ url_names = "https://raw.githubusercontent.com/pjreddie/darknet/master/data/coco.names"
151
+ urllib.request.urlretrieve(url_names, names_path)
152
+ ct = float(os.getenv("YOLO_CONF_THRESHOLD", conf_threshold if conf_threshold is not None else 0.5))
153
+ nt = float(os.getenv("YOLO_NMS_THRESHOLD", nms_threshold if nms_threshold is not None else 0.4))
154
+ gpu_flag = os.getenv("YOLO_USE_GPU", "false").lower() in {"1", "true", "yes"} if use_gpu is None else use_gpu
155
+ return YoloDetector(cfg_path=cfg_path, weights_path=weights_path, names_path=names_path, conf_threshold=ct, nms_threshold=nt, use_gpu=gpu_flag)
156
+