Add detection zone configuration with polygon editor
New /config/zone page lets users draw a polygon over a camera frame (including live RTSP snapshot) to define the area where vehicle detection applies. Zone is stored in /data/zone.json and applied in two ways: - Motion scoring: inter-frame diff is masked outside the zone so garden movement doesn't inflate frame scores - YOLO plate detection: detections whose center falls outside the zone are filtered out Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
d6f95131bc
commit
0fc2be31aa
+76
@@ -316,6 +316,82 @@ async def whitelist_remove(plate: str, back: str = Form("")):
|
|||||||
return RedirectResponse(back or "/whitelist", status_code=303)
|
return RedirectResponse(back or "/whitelist", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
ZONE_PATH = "/data/zone.json"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/config/zone", response_class=HTMLResponse)
|
||||||
|
async def zone_page(request: Request):
|
||||||
|
import json
|
||||||
|
zone: dict = {}
|
||||||
|
if os.path.exists(ZONE_PATH):
|
||||||
|
try:
|
||||||
|
with open(ZONE_PATH) as f:
|
||||||
|
zone = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
latest_frame = None
|
||||||
|
events = database.get_events(limit=1)
|
||||||
|
if events:
|
||||||
|
eid = events[0]["id"]
|
||||||
|
bf = os.path.join(EVENTS_DIR, eid, "best_frame.jpg")
|
||||||
|
if os.path.exists(bf):
|
||||||
|
latest_frame = f"events/{eid}/best_frame.jpg"
|
||||||
|
else:
|
||||||
|
first = sorted(glob.glob(os.path.join(EVENTS_DIR, eid, "frame_*.jpg")))
|
||||||
|
if first:
|
||||||
|
latest_frame = f"events/{eid}/{os.path.basename(first[0])}"
|
||||||
|
|
||||||
|
return templates.TemplateResponse("zone.html", {
|
||||||
|
"request": request,
|
||||||
|
"zone": zone,
|
||||||
|
"latest_frame": latest_frame,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/config/zone")
|
||||||
|
async def save_zone(request: Request):
|
||||||
|
import json
|
||||||
|
data = await request.json()
|
||||||
|
points = data.get("points", [])
|
||||||
|
if len(points) == 0:
|
||||||
|
if os.path.exists(ZONE_PATH):
|
||||||
|
os.unlink(ZONE_PATH)
|
||||||
|
return {"ok": True, "deleted": True}
|
||||||
|
if len(points) < 3:
|
||||||
|
raise HTTPException(400, "Minimum 3 points requis")
|
||||||
|
with open(ZONE_PATH, "w") as f:
|
||||||
|
json.dump({"points": points}, f)
|
||||||
|
return {"ok": True, "points": len(points)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/config/snapshot")
|
||||||
|
async def live_snapshot():
|
||||||
|
import tempfile, cv2
|
||||||
|
url = watcher._rtsp_url()
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||||
|
tmp_path = f.name
|
||||||
|
|
||||||
|
def _grab():
|
||||||
|
subprocess.run([
|
||||||
|
"ffmpeg", "-y", "-rtsp_transport", "tcp",
|
||||||
|
"-i", url, "-vframes", "1", "-q:v", "2", tmp_path,
|
||||||
|
], capture_output=True, timeout=10)
|
||||||
|
return os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
ok = await loop.run_in_executor(None, _grab)
|
||||||
|
if ok:
|
||||||
|
with open(tmp_path, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
from fastapi.responses import Response
|
||||||
|
return Response(content=data, media_type="image/jpeg")
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
raise HTTPException(500, "Snapshot RTSP impossible")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/event/{event_id}/test-lpr")
|
@app.post("/event/{event_id}/test-lpr")
|
||||||
async def test_lpr(event_id: str, frame_path: str = Form(""), bbox: str = Form("")):
|
async def test_lpr(event_id: str, frame_path: str = Form(""), bbox: str = Form("")):
|
||||||
import json, base64, cv2, numpy as np
|
import json, base64, cv2, numpy as np
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
<span class="text-slate-400 text-sm">{{ total }} passage{{ 's' if total != 1 else '' }}</span>
|
<span class="text-slate-400 text-sm">{{ total }} passage{{ 's' if total != 1 else '' }}</span>
|
||||||
<a href="/stats" class="btn-upload" style="text-decoration:none;">📊 Stats</a>
|
<a href="/stats" class="btn-upload" style="text-decoration:none;">📊 Stats</a>
|
||||||
<a href="/whitelist" class="btn-upload" style="text-decoration:none;">🛡 Whitelist</a>
|
<a href="/whitelist" class="btn-upload" style="text-decoration:none;">🛡 Whitelist</a>
|
||||||
|
<a href="/config/zone" class="btn-upload" style="text-decoration:none;">⬡ Zone</a>
|
||||||
<button class="btn-upload" onclick="document.getElementById('upload-panel').classList.toggle('open')">⬆ Tester un clip</button>
|
<button class="btn-upload" onclick="document.getElementById('upload-panel').classList.toggle('open')">⬆ Tester un clip</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CamWatch — Zone de détection</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
body { background: #0f172a; color: #e2e8f0; }
|
||||||
|
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
|
||||||
|
.btn-ghost { background: #1e293b; border: 1px solid #475569; color: #94a3b8; border-radius: 6px; padding: 6px 14px; font-size: 0.85rem; text-decoration: none; display: inline-block; cursor: pointer; }
|
||||||
|
.btn-ghost:hover { background: #334155; color: #e2e8f0; }
|
||||||
|
.btn-primary { background: #1d4ed8; border: 1px solid #3b82f6; color: #bfdbfe; border-radius: 6px; padding: 6px 14px; font-size: 0.85rem; cursor: pointer; }
|
||||||
|
.btn-primary:hover { background: #1e40af; }
|
||||||
|
.btn-danger { background: #1e293b; border: 1px solid #7f1d1d; color: #fca5a5; border-radius: 6px; padding: 6px 14px; font-size: 0.85rem; cursor: pointer; }
|
||||||
|
.btn-danger:hover { background: #450a0a; }
|
||||||
|
label { color: #64748b; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
|
||||||
|
#canvas-wrap { position: relative; width: 100%; background: #000; border-radius: 8px; overflow: hidden; }
|
||||||
|
#zone-canvas { display: block; width: 100%; cursor: crosshair; user-select: none; }
|
||||||
|
#zone-canvas.closed-mode { cursor: default; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen">
|
||||||
|
|
||||||
|
<header class="sticky top-0 z-10 px-4 py-3 flex items-center gap-3" style="background:#0f172a;border-bottom:1px solid #1e293b;">
|
||||||
|
<a href="/" class="btn-ghost">← Retour</a>
|
||||||
|
<span class="text-xl">⬡</span>
|
||||||
|
<span class="font-bold flex-1">Zone de détection</span>
|
||||||
|
<button onclick="loadSnapshot()" class="btn-ghost text-sm" id="btn-snap">📷 Snapshot live</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-5xl mx-auto px-3 py-5 space-y-4">
|
||||||
|
|
||||||
|
<!-- Canvas -->
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div id="canvas-wrap">
|
||||||
|
<canvas id="zone-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-2 flex items-center justify-between flex-wrap gap-2" style="background:#111827;">
|
||||||
|
<span id="zone-status" class="text-sm text-slate-400">
|
||||||
|
{% if zone.points %}Zone active — {{ zone.points | length }} points{% else %}Aucune zone définie{% endif %}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button onclick="undoPoint()" class="btn-ghost text-xs py-1 px-2">↩ Annuler</button>
|
||||||
|
<button onclick="resetZone()" class="btn-ghost text-xs py-1 px-2">✕ Effacer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info + actions -->
|
||||||
|
<div class="card p-4 space-y-3">
|
||||||
|
<div id="zone-info" class="text-sm text-slate-400">
|
||||||
|
{% if zone.points %}
|
||||||
|
<span class="text-green-400 font-medium">✓ Zone active</span> — {{ zone.points | length }} points définis.
|
||||||
|
Seuls les mouvements et détections à l'intérieur de cette zone seront pris en compte.
|
||||||
|
{% else %}
|
||||||
|
<span class="text-slate-500">Aucune zone définie</span> — toute l'image est utilisée pour la détection.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<button onclick="saveZone()" class="btn-primary px-5 py-2" id="btn-save">
|
||||||
|
💾 Enregistrer la zone
|
||||||
|
</button>
|
||||||
|
{% if zone.points %}
|
||||||
|
<button onclick="deleteZone()" class="btn-danger">🗑 Supprimer la zone</button>
|
||||||
|
{% endif %}
|
||||||
|
<span id="save-feedback" class="text-sm hidden"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instructions -->
|
||||||
|
<div class="card p-5 space-y-3 text-sm text-slate-400">
|
||||||
|
<label class="block">Comment utiliser</label>
|
||||||
|
<ul class="space-y-2 list-disc ml-4">
|
||||||
|
<li><strong class="text-slate-200">Clique</strong> sur l'image pour ajouter les points du polygone dans l'ordre</li>
|
||||||
|
<li><strong class="text-slate-200">Clique près du premier point</strong> (cercle rouge) ou <strong class="text-slate-200">double-clique</strong> pour fermer le polygone</li>
|
||||||
|
<li>Utilise <strong class="text-slate-200">Snapshot live</strong> pour récupérer l'image actuelle de ta caméra comme fond</li>
|
||||||
|
<li>La zone s'applique à : scoring de mouvement inter-frames, filtrage des détections YOLO</li>
|
||||||
|
<li>Les voitures dont la plaque sort de la zone seront ignorées</li>
|
||||||
|
</ul>
|
||||||
|
<div class="p-3 rounded-lg text-xs" style="background:#1c2942;border:1px solid #1e3a5f;color:#93c5fd;">
|
||||||
|
💡 Dessine autour de la partie de l'image où les voitures passent (ex: ta voie d'accès, la rue devant le portail). Exclue ton jardin, la végétation, le ciel.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const canvas = document.getElementById('zone-canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const img = new Image();
|
||||||
|
let points = []; // normalized [[x,y], ...] in 0..1
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
// ── Load existing zone ─────────────────────────────────────────────────
|
||||||
|
const existingPoints = {{ (zone.points or []) | tojson }};
|
||||||
|
if (existingPoints.length >= 3) {
|
||||||
|
points = existingPoints;
|
||||||
|
closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Image loading ──────────────────────────────────────────────────────
|
||||||
|
img.onload = function() {
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
const wrap = document.getElementById('canvas-wrap');
|
||||||
|
canvas.style.width = '100%';
|
||||||
|
canvas.style.height = Math.round(wrap.clientWidth * img.naturalHeight / img.naturalWidth) + 'px';
|
||||||
|
redraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
{% if latest_frame %}
|
||||||
|
img.src = '/{{ latest_frame }}';
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (!img.naturalWidth) return;
|
||||||
|
const wrap = document.getElementById('canvas-wrap');
|
||||||
|
canvas.style.height = Math.round(wrap.clientWidth * img.naturalHeight / img.naturalWidth) + 'px';
|
||||||
|
redraw();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Coord helpers ──────────────────────────────────────────────────────
|
||||||
|
function clientToNorm(cx, cy) {
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
return [(cx - r.left) * (canvas.width / r.width) / canvas.width,
|
||||||
|
(cy - r.top) * (canvas.height / r.height) / canvas.height];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normToCanvas(nx, ny) {
|
||||||
|
return [nx * canvas.width, ny * canvas.height];
|
||||||
|
}
|
||||||
|
|
||||||
|
// screen-space distance from canvas point (nx,ny) to client point (cx,cy)
|
||||||
|
function screenDist(nx, ny, cx, cy) {
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
const sx = nx * r.width, sy = ny * r.height;
|
||||||
|
const px = cx - r.left, py = cy - r.top;
|
||||||
|
return Math.hypot(sx - px, sy - py);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mouse interaction ──────────────────────────────────────────────────
|
||||||
|
canvas.addEventListener('click', e => {
|
||||||
|
if (closed) return;
|
||||||
|
const [nx, ny] = clientToNorm(e.clientX, e.clientY);
|
||||||
|
// Close if clicking near first point (< 16px screen distance)
|
||||||
|
if (points.length >= 3 && screenDist(points[0][0], points[0][1], e.clientX, e.clientY) < 16) {
|
||||||
|
closed = true;
|
||||||
|
updateStatus();
|
||||||
|
redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
points.push([nx, ny]);
|
||||||
|
updateStatus();
|
||||||
|
redraw();
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('dblclick', e => {
|
||||||
|
if (points.length >= 3 && !closed) {
|
||||||
|
closed = true;
|
||||||
|
updateStatus();
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────
|
||||||
|
function redraw() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
if (img.complete && img.naturalWidth) ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
if (points.length === 0) return;
|
||||||
|
|
||||||
|
const cp = points.map(([nx, ny]) => normToCanvas(nx, ny));
|
||||||
|
|
||||||
|
// Draw polygon path
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cp[0][0], cp[0][1]);
|
||||||
|
for (let i = 1; i < cp.length; i++) ctx.lineTo(cp[i][0], cp[i][1]);
|
||||||
|
if (closed) {
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.22)';
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = '#3b82f6';
|
||||||
|
ctx.lineWidth = Math.max(2, canvas.width / 700);
|
||||||
|
ctx.setLineDash(closed ? [] : [canvas.width / 100, canvas.width / 200]);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// Draw vertex points
|
||||||
|
const r = Math.max(6, canvas.width / 280);
|
||||||
|
cp.forEach(([cx, cy], i) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = i === 0 ? '#ef4444' : '#3b82f6';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = Math.max(1.5, canvas.width / 900);
|
||||||
|
ctx.stroke();
|
||||||
|
// Point index label
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.font = `bold ${Math.max(10, canvas.width / 220)}px monospace`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
if (i === 0 && !closed) ctx.fillText('⬤', cx, cy);
|
||||||
|
else ctx.fillText(i + 1, cx, cy);
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.style.cursor = closed ? 'default' : 'crosshair';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ───────────────────────────────────────────────────────────
|
||||||
|
function undoPoint() {
|
||||||
|
if (closed) { closed = false; } else { points.pop(); }
|
||||||
|
updateStatus();
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZone() {
|
||||||
|
points = []; closed = false;
|
||||||
|
updateStatus();
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus() {
|
||||||
|
const el = document.getElementById('zone-status');
|
||||||
|
if (closed) {
|
||||||
|
el.innerHTML = `<span class="text-green-400">✓ Zone fermée</span> — ${points.length} points · Clique "Enregistrer" pour sauvegarder`;
|
||||||
|
} else if (points.length >= 3) {
|
||||||
|
el.textContent = `${points.length} points — double-clique ou clique sur ⬤ pour fermer`;
|
||||||
|
} else if (points.length > 0) {
|
||||||
|
el.textContent = `${points.length} point${points.length > 1 ? 's' : ''} — continue à cliquer pour définir la zone`;
|
||||||
|
} else {
|
||||||
|
el.textContent = 'Aucune zone — clique sur l\'image pour commencer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save / Delete ──────────────────────────────────────────────────────
|
||||||
|
async function saveZone() {
|
||||||
|
if (!closed && points.length < 3) {
|
||||||
|
showFeedback('⚠ Ferme le polygone d\'abord (double-clique ou clique sur le premier point)', 'yellow');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = JSON.stringify({ points: closed ? points : [] });
|
||||||
|
try {
|
||||||
|
const r = await fetch('/config/zone', { method: 'POST', headers: {'Content-Type':'application/json'}, body });
|
||||||
|
const data = await r.json();
|
||||||
|
if (data.ok) showFeedback(`✓ Zone enregistrée — ${points.length} points`, 'green');
|
||||||
|
else showFeedback('Erreur: ' + (data.error || 'inconnue'), 'red');
|
||||||
|
} catch(e) { showFeedback('Erreur réseau: ' + e.message, 'red'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteZone() {
|
||||||
|
if (!confirm('Supprimer la zone de détection ?')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/config/zone', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({points:[]}) });
|
||||||
|
const data = await r.json();
|
||||||
|
if (data.ok) {
|
||||||
|
points = []; closed = false;
|
||||||
|
redraw();
|
||||||
|
showFeedback('Zone supprimée — toute l\'image sera utilisée', 'slate');
|
||||||
|
updateStatus();
|
||||||
|
document.getElementById('zone-info').innerHTML = '<span class="text-slate-500">Aucune zone définie</span> — toute l\'image est utilisée pour la détection.';
|
||||||
|
}
|
||||||
|
} catch(e) { showFeedback('Erreur: ' + e.message, 'red'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFeedback(msg, color) {
|
||||||
|
const el = document.getElementById('save-feedback');
|
||||||
|
const colors = {green:'text-green-400', red:'text-red-400', yellow:'text-yellow-400', slate:'text-slate-400'};
|
||||||
|
el.className = `text-sm ${colors[color] || 'text-slate-400'}`;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
setTimeout(() => el.classList.add('hidden'), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live snapshot ──────────────────────────────────────────────────────
|
||||||
|
async function loadSnapshot() {
|
||||||
|
const btn = document.getElementById('btn-snap');
|
||||||
|
btn.textContent = '⏳ Capture en cours…';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/config/snapshot');
|
||||||
|
if (!r.ok) throw new Error('RTSP inaccessible');
|
||||||
|
const blob = await r.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
img.src = url;
|
||||||
|
showFeedback('✓ Snapshot chargé', 'green');
|
||||||
|
} catch(e) {
|
||||||
|
showFeedback('Snapshot échoué: ' + e.message, 'red');
|
||||||
|
} finally {
|
||||||
|
btn.textContent = '📷 Snapshot live';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+41
-1
@@ -35,7 +35,28 @@ _last_event_time = 0.0
|
|||||||
_analyzer: PlateAnalyzer | None = None
|
_analyzer: PlateAnalyzer | None = None
|
||||||
|
|
||||||
import re as _re
|
import re as _re
|
||||||
|
import json as _json
|
||||||
|
|
||||||
_FR_PLATE_RE = _re.compile(r'^([A-Z]{2})(\d{3})([A-Z]{2})$')
|
_FR_PLATE_RE = _re.compile(r'^([A-Z]{2})(\d{3})([A-Z]{2})$')
|
||||||
|
_ZONE_PATH = "/data/zone.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_zone_points() -> list | None:
|
||||||
|
try:
|
||||||
|
with open(_ZONE_PATH) as f:
|
||||||
|
pts = _json.load(f).get("points", [])
|
||||||
|
if len(pts) >= 3:
|
||||||
|
return pts
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _zone_mask(h: int, w: int, pts: list) -> np.ndarray:
|
||||||
|
poly = np.array([[int(x * w), int(y * h)] for x, y in pts], dtype=np.int32)
|
||||||
|
mask = np.zeros((h, w), dtype=np.uint8)
|
||||||
|
cv2.fillPoly(mask, [poly], 255)
|
||||||
|
return mask
|
||||||
|
|
||||||
def _normalize_plate(raw: str) -> str:
|
def _normalize_plate(raw: str) -> str:
|
||||||
"""Validate and format a French plate (6-8 alphanumeric chars).
|
"""Validate and format a French plate (6-8 alphanumeric chars).
|
||||||
@@ -189,6 +210,9 @@ def _process_clip(event_id: str, event_dir: str, clip_path: str, camera_name: st
|
|||||||
loaded: list[tuple[np.ndarray, str, float, float]] = [] # frame, path, sharpness, motion
|
loaded: list[tuple[np.ndarray, str, float, float]] = [] # frame, path, sharpness, motion
|
||||||
prev_small: np.ndarray | None = None
|
prev_small: np.ndarray | None = None
|
||||||
|
|
||||||
|
zone_pts = _read_zone_points()
|
||||||
|
zone_mask_small: np.ndarray | None = None # computed lazily on first frame
|
||||||
|
|
||||||
for path in frame_files:
|
for path in frame_files:
|
||||||
frame = cv2.imread(path)
|
frame = cv2.imread(path)
|
||||||
if frame is None:
|
if frame is None:
|
||||||
@@ -197,7 +221,16 @@ def _process_clip(event_id: str, event_dir: str, clip_path: str, camera_name: st
|
|||||||
lap = cv2.Laplacian(gray, cv2.CV_64F).var()
|
lap = cv2.Laplacian(gray, cv2.CV_64F).var()
|
||||||
h, w = gray.shape
|
h, w = gray.shape
|
||||||
small = cv2.resize(gray, (_MOTION_W, _MOTION_W * h // w))
|
small = cv2.resize(gray, (_MOTION_W, _MOTION_W * h // w))
|
||||||
motion = float(np.mean(np.abs(small.astype(np.float32) - prev_small.astype(np.float32)))) if prev_small is not None else 0.0
|
if zone_mask_small is None and zone_pts:
|
||||||
|
sh, sw = small.shape
|
||||||
|
zone_mask_small = cv2.resize(_zone_mask(h, w, zone_pts), (sw, sh))
|
||||||
|
if prev_small is not None:
|
||||||
|
diff = np.abs(small.astype(np.float32) - prev_small.astype(np.float32))
|
||||||
|
if zone_mask_small is not None:
|
||||||
|
diff = diff * (zone_mask_small.astype(np.float32) / 255.0)
|
||||||
|
motion = float(np.mean(diff))
|
||||||
|
else:
|
||||||
|
motion = 0.0
|
||||||
prev_small = small
|
prev_small = small
|
||||||
loaded.append((frame, path, lap, motion))
|
loaded.append((frame, path, lap, motion))
|
||||||
|
|
||||||
@@ -221,6 +254,13 @@ def _process_clip(event_id: str, event_dir: str, clip_path: str, camera_name: st
|
|||||||
scored: list[tuple[float, np.ndarray, str]] = []
|
scored: list[tuple[float, np.ndarray, str]] = []
|
||||||
for _, frame, path in top15:
|
for _, frame, path in top15:
|
||||||
plates = _analyzer.detect_plates(frame) if _analyzer else []
|
plates = _analyzer.detect_plates(frame) if _analyzer else []
|
||||||
|
if plates and zone_pts:
|
||||||
|
fh, fw = frame.shape[:2]
|
||||||
|
poly = np.array([[int(x * fw), int(y * fh)] for x, y in zone_pts], dtype=np.int32)
|
||||||
|
plates = [p for p in plates
|
||||||
|
if cv2.pointPolygonTest(poly.reshape(-1, 1, 2),
|
||||||
|
((p[0] + p[2]) / 2, (p[1] + p[3]) / 2),
|
||||||
|
False) >= 0]
|
||||||
score = _frame_score(frame, plates)
|
score = _frame_score(frame, plates)
|
||||||
scored.append((score, frame, path))
|
scored.append((score, frame, path))
|
||||||
scored.sort(key=lambda x: -x[0])
|
scored.sort(key=lambda x: -x[0])
|
||||||
|
|||||||
Reference in New Issue
Block a user