add snapshots & counter

This commit is contained in:
2026-01-12 21:59:59 -03:00
parent de399fe3f8
commit 24b04f84ab
4 changed files with 92 additions and 25 deletions

View File

@@ -5,16 +5,18 @@ import os
import time import time
import threading import threading
import re import re
from datetime import datetime
from queue import Queue from queue import Queue
from flask import Flask, Response from flask import Flask, Response, jsonify
from flask_cors import CORS from flask_cors import CORS
from ultralytics import YOLO from ultralytics import YOLO
# Configuration # Configuration
BACKEND_URL = os.environ.get('BACKEND_URL', 'http://localhost:3000') BACKEND_URL = os.environ.get('BACKEND_URL', 'http://localhost:3000')
CAMERA_ID = 0 CAMERA_ID = 0
PROCESS_INTERVAL = 1.5 # Más reactivo PROCESS_INTERVAL = 1.5
MODEL_PATH = 'best.pt' MODEL_PATH = 'best.pt'
DATASET_DIR = '/app/dataset' # Carpeta para guardar capturas
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
@@ -25,9 +27,32 @@ frame_lock = threading.Lock()
latest_detections = [] latest_detections = []
detection_lock = threading.Lock() detection_lock = threading.Lock()
# Cola para procesamiento OCR asíncrono # Cola para procesamiento OCR asíncrono (ahora incluye frame completo)
ocr_queue = Queue(maxsize=5) ocr_queue = Queue(maxsize=5)
# Crear carpeta de dataset si no existe
os.makedirs(DATASET_DIR, exist_ok=True)
print(f"📁 Dataset directory: {DATASET_DIR}")
def save_plate_capture(plate_number, plate_img, full_frame):
"""Guarda la captura de la patente para el dataset"""
try:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# Guardar imagen recortada de la patente
plate_filename = f"{DATASET_DIR}/{plate_number}_{timestamp}_plate.jpg"
cv2.imwrite(plate_filename, plate_img, [cv2.IMWRITE_JPEG_QUALITY, 95])
# Guardar frame completo con contexto
frame_filename = f"{DATASET_DIR}/{plate_number}_{timestamp}_full.jpg"
cv2.imwrite(frame_filename, full_frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
print(f"📸 Saved to dataset: {plate_number}")
return True
except Exception as e:
print(f"❌ Error saving capture: {e}")
return False
def send_plate(plate_number): def send_plate(plate_number):
"""Envía la patente detectada al backend""" """Envía la patente detectada al backend"""
try: try:
@@ -37,22 +62,21 @@ def send_plate(plate_number):
except Exception as e: except Exception as e:
print(f"✗ Error sending plate: {e}") print(f"✗ Error sending plate: {e}")
def validate_and_send(text): def validate_plate(text):
"""Valida formato chileno y envía""" """Valida formato chileno"""
# Formato nuevo: XXXX-00 | Formato antiguo: XX-0000 # Formato nuevo: XXXX-00 | Formato antiguo: XX-0000
if re.match(r'^[A-Z]{4}\d{2}$', text) or re.match(r'^[A-Z]{2}\d{4}$', text): return bool(re.match(r'^[A-Z]{4}\d{2}$', text) or re.match(r'^[A-Z]{2}\d{4}$', text))
send_plate(text)
return True
return False
def ocr_worker(reader): def ocr_worker(reader):
"""Hilo dedicado para OCR - no bloquea el stream""" """Hilo dedicado para OCR - no bloquea el stream"""
while True: while True:
try: try:
plate_img = ocr_queue.get(timeout=1) data = ocr_queue.get(timeout=1)
if plate_img is None: if data is None:
continue continue
plate_img, full_frame = data
# Preprocesamiento para mejor OCR # Preprocesamiento para mejor OCR
gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY)
@@ -60,8 +84,11 @@ def ocr_worker(reader):
allowlist='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') allowlist='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
for text in ocr_results: for text in ocr_results:
clean_text = ''.join(e for e in text if e.isalnum()).upper() clean_text = ''.join(e for e in text if e.isalnum()).upper()
if len(clean_text) >= 6: if len(clean_text) >= 6 and validate_plate(clean_text):
validate_and_send(clean_text) # Enviar al backend
send_plate(clean_text)
# Guardar captura para dataset
save_plate_capture(clean_text, plate_img, full_frame)
except: except:
pass pass
@@ -96,10 +123,9 @@ def camera_loop():
print("✅ System ready!") print("✅ System ready!")
last_process_time = 0 last_process_time = 0
frame_count = 0
while True: while True:
# Captura eficiente - solo 2 grabs # Captura eficiente
cap.grab() cap.grab()
cap.grab() cap.grab()
ret, frame = cap.retrieve() ret, frame = cap.retrieve()
@@ -108,14 +134,13 @@ def camera_loop():
time.sleep(0.01) time.sleep(0.01)
continue continue
frame_count += 1
current_time = time.time() current_time = time.time()
# Procesar ALPR cada PROCESS_INTERVAL segundos # Procesar ALPR cada PROCESS_INTERVAL segundos
if current_time - last_process_time > PROCESS_INTERVAL: if current_time - last_process_time > PROCESS_INTERVAL:
last_process_time = current_time last_process_time = current_time
# YOLO detection - usar imgsz pequeño para velocidad # YOLO detection
results = model(frame, verbose=False, imgsz=320, conf=0.5) results = model(frame, verbose=False, imgsz=320, conf=0.5)
new_detections = [] new_detections = []
@@ -125,15 +150,16 @@ def camera_loop():
conf = float(box.conf[0]) conf = float(box.conf[0])
new_detections.append((x1, y1, x2, y2, conf)) new_detections.append((x1, y1, x2, y2, conf))
# Extraer imagen de placa y enviar a cola OCR # Extraer imagen de placa
plate_img = frame[y1:y2, x1:x2].copy() plate_img = frame[y1:y2, x1:x2].copy()
if plate_img.size > 0 and not ocr_queue.full(): if plate_img.size > 0 and not ocr_queue.full():
ocr_queue.put(plate_img) # Enviar placa Y frame completo para dataset
ocr_queue.put((plate_img, frame.copy()))
with detection_lock: with detection_lock:
latest_detections = new_detections latest_detections = new_detections
# Actualizar frame para streaming (sin bloquear) # Actualizar frame para streaming
display_frame = frame display_frame = frame
with detection_lock: with detection_lock:
for (x1, y1, x2, y2, conf) in latest_detections: for (x1, y1, x2, y2, conf) in latest_detections:
@@ -148,7 +174,7 @@ def generate():
"""Generador para streaming MJPEG""" """Generador para streaming MJPEG"""
global outputFrame global outputFrame
while True: while True:
time.sleep(0.033) # ~30 FPS para el stream time.sleep(0.033)
with frame_lock: with frame_lock:
if outputFrame is None: if outputFrame is None:
continue continue
@@ -163,6 +189,16 @@ def video_feed():
def health(): def health():
return {"status": "ok", "service": "alpr"} return {"status": "ok", "service": "alpr"}
@app.route("/dataset/count")
def dataset_count():
"""Endpoint para ver cuántas capturas hay en el dataset"""
try:
files = os.listdir(DATASET_DIR)
plates = len([f for f in files if f.endswith('_plate.jpg')])
return {"plates_captured": plates, "total_files": len(files)}
except:
return {"plates_captured": 0, "total_files": 0}
if __name__ == "__main__": if __name__ == "__main__":
t = threading.Thread(target=camera_loop, daemon=True) t = threading.Thread(target=camera_loop, daemon=True)
t.start() t.start()

View File

@@ -60,6 +60,8 @@ services:
- backend - backend
restart: unless-stopped restart: unless-stopped
privileged: true privileged: true
volumes:
- ./alpr-service/dataset:/app/dataset
# Frontend Service (React) # Frontend Service (React)
frontend: frontend:

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import io from 'socket.io-client'; import io from 'socket.io-client';
import { Users, CheckCircle, XCircle, Shield, Trash2, Camera, AlertCircle } from 'lucide-react'; import { Users, CheckCircle, XCircle, Shield, Trash2, Camera, AlertCircle, Database } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import LanguageSelector from '../components/LanguageSelector'; import LanguageSelector from '../components/LanguageSelector';
@@ -26,8 +26,15 @@ function AdminDashboard({ token }) {
const [searchRut, setSearchRut] = useState(''); const [searchRut, setSearchRut] = useState('');
const [searchResult, setSearchResult] = useState(null); // null | { found: true, data: ... } | { found: false } const [searchResult, setSearchResult] = useState(null); // null | { found: true, data: ... } | { found: false }
// Dataset State
const [datasetCount, setDatasetCount] = useState(0);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
fetchDatasetCount();
// Actualizar contador de dataset cada 10 segundos
const datasetInterval = setInterval(fetchDatasetCount, 10000);
// Live detection listener // Live detection listener
socket.on('new_detection', (data) => { socket.on('new_detection', (data) => {
@@ -48,6 +55,7 @@ function AdminDashboard({ token }) {
socket.off('plate_status_updated'); socket.off('plate_status_updated');
socket.off('plate_deleted'); socket.off('plate_deleted');
socket.off('person_deleted'); socket.off('person_deleted');
clearInterval(datasetInterval);
}; };
}, [token]); }, [token]);
@@ -88,6 +96,15 @@ function AdminDashboard({ token }) {
} }
}; };
const fetchDatasetCount = async () => {
try {
const res = await axios.get('/dataset/count');
setDatasetCount(res.data.plates_captured || 0);
} catch (err) {
console.log('Dataset count not available');
}
};
const handleSearchRut = (e) => { const handleSearchRut = (e) => {
e.preventDefault(); e.preventDefault();
const normalizedRut = searchRut.replace(/\./g, '').toUpperCase(); const normalizedRut = searchRut.replace(/\./g, '').toUpperCase();
@@ -201,11 +218,19 @@ function AdminDashboard({ token }) {
<h2 className="text-xl font-bold flex items-center gap-2"> <h2 className="text-xl font-bold flex items-center gap-2">
<Camera /> {t('monitor_area')} <Camera /> {t('monitor_area')}
</h2> </h2>
<div className="flex items-center gap-4">
{/* Dataset Counter */}
<div className="flex items-center gap-2 bg-gradient-to-r from-emerald-900/50 to-teal-900/50 px-4 py-2 rounded-lg border border-emerald-700/50">
<Database size={18} className="text-emerald-400" />
<span className="text-emerald-300 font-mono font-bold">{datasetCount}</span>
<span className="text-emerald-400/70 text-sm">capturas</span>
</div>
<div className="flex bg-slate-800 rounded p-1"> <div className="flex bg-slate-800 rounded p-1">
<button onClick={() => setViewMode('live')} className={`px-3 py-1 rounded ${viewMode === 'live' ? 'bg-blue-600' : ''}`}>Live</button> <button onClick={() => setViewMode('live')} className={`px-3 py-1 rounded ${viewMode === 'live' ? 'bg-blue-600' : ''}`}>Live</button>
<button onClick={() => setViewMode('history')} className={`px-3 py-1 rounded ${viewMode === 'history' ? 'bg-blue-600' : ''}`}>History</button> <button onClick={() => setViewMode('history')} className={`px-3 py-1 rounded ${viewMode === 'history' ? 'bg-blue-600' : ''}`}>History</button>
</div> </div>
</div> </div>
</div>
{/* Visitor Lookup Banner */} {/* Visitor Lookup Banner */}
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700"> <div className="bg-slate-800 rounded-xl p-6 border border-slate-700">

View File

@@ -18,6 +18,10 @@ export default defineConfig({
'/video_feed': { '/video_feed': {
target: 'http://alpr-service:5001', target: 'http://alpr-service:5001',
changeOrigin: true changeOrigin: true
},
'/dataset': {
target: 'http://alpr-service:5001',
changeOrigin: true
} }
} }
} }