add snapshots & counter
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user