Improve LPR accuracy and add manual plate edit
- Tiled YOLO (3x2, 20% overlap): plate confidence 0.307 -> 0.838 on test frame
- Aspect ratio filter (2.0-6.5:1): rejects non-plate shapes
- Confidence threshold 0.03 -> 0.04: removes fence/noise false positives
- Exclude top 15% (sky) and bottom 8% (timestamp overlay) from detection zone
- Add Tesseract as primary OCR (better for Latin plates), PaddleOCR as fallback
- Enhance plate crop before OCR: CLAHE + sharpening + min 80px upscale
- Save plate_crop.jpg (4x upscaled) to event dir for manual review
- Show plate crop in event detail page
- Add manual plate edit form in event detail (POST /event/{id}/plate)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
92f435935e
commit
13e6038069
@@ -2,6 +2,7 @@ FROM python:3.11-slim
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libgl1 libglib2.0-0 libgomp1 ffmpeg \
|
libgl1 libglib2.0-0 libgomp1 ffmpeg \
|
||||||
|
tesseract-ocr tesseract-ocr-fra tesseract-ocr-eng \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ def get_event(event_id: str) -> dict | None:
|
|||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_plate(event_id: str, plate: str):
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute("UPDATE events SET plate = ? WHERE id = ?", (plate.strip().upper() or None, event_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def get_cameras():
|
def get_cameras():
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
rows = conn.execute("SELECT DISTINCT camera FROM events ORDER BY camera").fetchall()
|
rows = conn.execute("SELECT DISTINCT camera FROM events ORDER BY camera").fetchall()
|
||||||
|
|||||||
+136
-35
@@ -7,8 +7,35 @@ log = logging.getLogger("lpr")
|
|||||||
|
|
||||||
MODEL_CACHE = os.environ.get("MODEL_CACHE", "/models")
|
MODEL_CACHE = os.environ.get("MODEL_CACHE", "/models")
|
||||||
|
|
||||||
# Output format: [N, 7] where each row = [batch_idx, x1, y1, x2, y2, class_id, confidence]
|
_YOLO_CONF_THRESHOLD = 0.04
|
||||||
_YOLO_CONF_THRESHOLD = 0.03
|
|
||||||
|
|
||||||
|
def _iou(a: tuple, b: tuple) -> float:
|
||||||
|
ax1, ay1, ax2, ay2 = a[:4]
|
||||||
|
bx1, by1, bx2, by2 = b[:4]
|
||||||
|
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
|
||||||
|
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
|
||||||
|
inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
|
||||||
|
if inter == 0:
|
||||||
|
return 0.0
|
||||||
|
union = (ax2 - ax1) * (ay2 - ay1) + (bx2 - bx1) * (by2 - by1) - inter
|
||||||
|
return inter / union if union > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _nms(boxes: list, iou_threshold: float = 0.3) -> list:
|
||||||
|
if not boxes:
|
||||||
|
return []
|
||||||
|
boxes = sorted(boxes, key=lambda b: -b[4])
|
||||||
|
suppressed = [False] * len(boxes)
|
||||||
|
result = []
|
||||||
|
for i, b1 in enumerate(boxes):
|
||||||
|
if suppressed[i]:
|
||||||
|
continue
|
||||||
|
result.append(b1)
|
||||||
|
for j in range(i + 1, len(boxes)):
|
||||||
|
if not suppressed[j] and _iou(b1, boxes[j]) > iou_threshold:
|
||||||
|
suppressed[j] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class PlateAnalyzer:
|
class PlateAnalyzer:
|
||||||
@@ -47,58 +74,123 @@ class PlateAnalyzer:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"LPR model load error: {e}")
|
log.error(f"LPR model load error: {e}")
|
||||||
|
|
||||||
|
def _yolo_on_tile(self, tile: np.ndarray, offset_x: int, offset_y: int) -> list[tuple]:
|
||||||
|
"""Run YOLO on a single tile, return boxes in original frame coordinates."""
|
||||||
|
th, tw = tile.shape[:2]
|
||||||
|
inp = cv2.resize(tile, (256, 256))
|
||||||
|
inp = cv2.cvtColor(inp, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
|
||||||
|
inp = inp.transpose(2, 0, 1)[np.newaxis]
|
||||||
|
|
||||||
|
out = self._yolo.run(None, {"images": inp})[0]
|
||||||
|
|
||||||
|
boxes = []
|
||||||
|
for row in out:
|
||||||
|
if len(row) < 7:
|
||||||
|
continue
|
||||||
|
_, x1, y1, x2, y2, _, conf = row
|
||||||
|
if conf < _YOLO_CONF_THRESHOLD:
|
||||||
|
continue
|
||||||
|
x1 = offset_x + max(0.0, float(x1) / 256.0 * tw)
|
||||||
|
y1 = offset_y + max(0.0, float(y1) / 256.0 * th)
|
||||||
|
x2 = offset_x + min(float(tw), float(x2) / 256.0 * tw)
|
||||||
|
y2 = offset_y + min(float(th), float(y2) / 256.0 * th)
|
||||||
|
bw, bh = x2 - x1, y2 - y1
|
||||||
|
if bw < 8 or bh < 4:
|
||||||
|
continue
|
||||||
|
# Plates are always wider than tall — French plates ~4.7:1
|
||||||
|
ratio = bw / bh
|
||||||
|
if ratio < 2.0 or ratio > 6.5:
|
||||||
|
continue
|
||||||
|
boxes.append((x1, y1, x2, y2, float(conf)))
|
||||||
|
return boxes
|
||||||
|
|
||||||
def detect_plates(self, frame: np.ndarray) -> list[tuple]:
|
def detect_plates(self, frame: np.ndarray) -> list[tuple]:
|
||||||
"""Returns [(x1, y1, x2, y2, conf), ...] in original frame coordinates."""
|
"""Tiled YOLO detection — returns [(x1,y1,x2,y2,conf),...] in original coords."""
|
||||||
if self._yolo is None:
|
if self._yolo is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
h, w = frame.shape[:2]
|
h, w = frame.shape[:2]
|
||||||
inp = cv2.resize(frame, (256, 256))
|
all_boxes = []
|
||||||
inp = cv2.cvtColor(inp, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
|
|
||||||
inp = inp.transpose(2, 0, 1)[np.newaxis]
|
|
||||||
|
|
||||||
out = self._yolo.run(None, {"images": inp})[0] # [N, 7]
|
# Ignore top 15% (sky/trees) and bottom 8% (timestamp overlay)
|
||||||
|
y_start = int(h * 0.15)
|
||||||
|
y_end = int(h * 0.92)
|
||||||
|
work = frame[y_start:y_end, :]
|
||||||
|
wh = y_end - y_start
|
||||||
|
|
||||||
boxes = []
|
# 3×2 tiles with 20% overlap so plates near tile edges are caught
|
||||||
for row in out:
|
cols, rows = 3, 2
|
||||||
# row: [batch_idx, x1, y1, x2, y2, class_id, confidence]
|
overlap = 0.2
|
||||||
if len(row) >= 7:
|
tw = int(w / (cols - overlap * (cols - 1)))
|
||||||
_, x1, y1, x2, y2, _, conf = row
|
th = int(wh / (rows - overlap * (rows - 1)))
|
||||||
if conf < _YOLO_CONF_THRESHOLD:
|
step_x = int(tw * (1 - overlap))
|
||||||
continue
|
step_y = int(th * (1 - overlap))
|
||||||
# Scale from 256x256 to original
|
|
||||||
x1 = max(0.0, float(x1) / 256.0 * w)
|
|
||||||
y1 = max(0.0, float(y1) / 256.0 * h)
|
|
||||||
x2 = min(float(w), float(x2) / 256.0 * w)
|
|
||||||
y2 = min(float(h), float(y2) / 256.0 * h)
|
|
||||||
if x2 > x1 + 4 and y2 > y1 + 4:
|
|
||||||
boxes.append((x1, y1, x2, y2, float(conf)))
|
|
||||||
|
|
||||||
return sorted(boxes, key=lambda b: -b[4])
|
for row in range(rows):
|
||||||
|
for col in range(cols):
|
||||||
|
tx1 = col * step_x
|
||||||
|
ty1 = row * step_y
|
||||||
|
tx2 = min(w, tx1 + tw)
|
||||||
|
ty2 = min(wh, ty1 + th)
|
||||||
|
tile = work[ty1:ty2, tx1:tx2]
|
||||||
|
# offset_y accounts for the cropped top strip
|
||||||
|
all_boxes.extend(self._yolo_on_tile(tile, tx1, ty1 + y_start))
|
||||||
|
|
||||||
def _ocr_crop(self, crop: np.ndarray) -> tuple[str, float]:
|
return _nms(all_boxes, iou_threshold=0.3)
|
||||||
"""Run PaddleOCR recognition on a crop. Returns (text, confidence)."""
|
|
||||||
|
def _enhance_crop(self, crop: np.ndarray) -> np.ndarray:
|
||||||
|
"""Upscale + CLAHE + sharpen a plate crop for better OCR."""
|
||||||
|
h, w = crop.shape[:2]
|
||||||
|
# Upscale so the plate is at least 80px tall
|
||||||
|
target_h = 80
|
||||||
|
if h < target_h:
|
||||||
|
scale = target_h / h
|
||||||
|
crop = cv2.resize(crop, (max(10, int(w * scale)), target_h), interpolation=cv2.INTER_CUBIC)
|
||||||
|
# CLAHE contrast enhancement
|
||||||
|
lab = cv2.cvtColor(crop, cv2.COLOR_BGR2LAB)
|
||||||
|
l, a, b = cv2.split(lab)
|
||||||
|
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(4, 4))
|
||||||
|
l = clahe.apply(l)
|
||||||
|
crop = cv2.cvtColor(cv2.merge([l, a, b]), cv2.COLOR_LAB2BGR)
|
||||||
|
# Mild sharpening
|
||||||
|
kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], dtype=np.float32)
|
||||||
|
return cv2.filter2D(crop, -1, kernel)
|
||||||
|
|
||||||
|
def _ocr_tesseract(self, crop: np.ndarray) -> tuple[str, float]:
|
||||||
|
"""Tesseract OCR tuned for license plates."""
|
||||||
|
try:
|
||||||
|
import pytesseract
|
||||||
|
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
|
||||||
|
# Try both single-word and single-line modes, take the longer result
|
||||||
|
cfg = "--psm 8 --oem 3 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"
|
||||||
|
text8 = pytesseract.image_to_string(gray, config=cfg).strip().replace(" ", "").upper()
|
||||||
|
cfg7 = "--psm 7 --oem 3 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"
|
||||||
|
text7 = pytesseract.image_to_string(gray, config=cfg7).strip().replace(" ", "").upper()
|
||||||
|
text = text8 if len(text8) >= len(text7) else text7
|
||||||
|
alnum = "".join(c for c in text if c.isalnum())
|
||||||
|
if len(alnum) >= 4:
|
||||||
|
return text, 0.6 # Tesseract doesn't give per-char conf easily; use fixed score
|
||||||
|
except Exception as e:
|
||||||
|
log.debug(f"Tesseract error: {e}")
|
||||||
|
return "", 0.0
|
||||||
|
|
||||||
|
def _ocr_paddle(self, crop: np.ndarray) -> tuple[str, float]:
|
||||||
|
"""PaddleOCR recognition fallback."""
|
||||||
if self._rec is None or not self._keys or crop.size == 0:
|
if self._rec is None or not self._keys or crop.size == 0:
|
||||||
return "", 0.0
|
return "", 0.0
|
||||||
|
|
||||||
h, w = crop.shape[:2]
|
h, w = crop.shape[:2]
|
||||||
if h == 0 or w == 0:
|
if h == 0 or w == 0:
|
||||||
return "", 0.0
|
return "", 0.0
|
||||||
|
|
||||||
inp_h = 48
|
inp_h = 48
|
||||||
inp_w = max(10, int(inp_h * w / h))
|
inp_w = max(10, int(inp_h * w / h))
|
||||||
|
|
||||||
resized = cv2.resize(crop, (inp_w, inp_h))
|
resized = cv2.resize(crop, (inp_w, inp_h))
|
||||||
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB).astype(np.float32)
|
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB).astype(np.float32)
|
||||||
normalized = (rgb / 127.5) - 1.0
|
normalized = (rgb / 127.5) - 1.0
|
||||||
inp = normalized.transpose(2, 0, 1)[np.newaxis]
|
inp = normalized.transpose(2, 0, 1)[np.newaxis]
|
||||||
|
out = self._rec.run(None, {"x": inp})[0][0]
|
||||||
out = self._rec.run(None, {"x": inp})[0][0] # [seq, 6625]
|
|
||||||
|
|
||||||
chars: list[str] = []
|
chars: list[str] = []
|
||||||
confs: list[float] = []
|
confs: list[float] = []
|
||||||
prev_idx = 0
|
prev_idx = 0
|
||||||
|
|
||||||
for step in out:
|
for step in out:
|
||||||
idx = int(np.argmax(step))
|
idx = int(np.argmax(step))
|
||||||
conf = float(step[idx])
|
conf = float(step[idx])
|
||||||
@@ -106,14 +198,24 @@ class PlateAnalyzer:
|
|||||||
chars.append(self._keys[idx - 1])
|
chars.append(self._keys[idx - 1])
|
||||||
confs.append(conf)
|
confs.append(conf)
|
||||||
prev_idx = idx
|
prev_idx = idx
|
||||||
|
|
||||||
text = "".join(chars)
|
text = "".join(chars)
|
||||||
avg_conf = float(np.mean(confs)) if confs else 0.0
|
avg_conf = float(np.mean(confs)) if confs else 0.0
|
||||||
return text, avg_conf
|
return text, avg_conf
|
||||||
|
|
||||||
|
def _ocr_crop(self, crop: np.ndarray) -> tuple[str, float]:
|
||||||
|
"""Run OCR on a plate crop. Tesseract first, PaddleOCR as fallback."""
|
||||||
|
if crop.size == 0:
|
||||||
|
return "", 0.0
|
||||||
|
enhanced = self._enhance_crop(crop)
|
||||||
|
# Tesseract is better for Latin/French plates
|
||||||
|
text, conf = self._ocr_tesseract(enhanced)
|
||||||
|
if text:
|
||||||
|
return text, conf
|
||||||
|
# Fallback to PaddleOCR
|
||||||
|
return self._ocr_paddle(enhanced)
|
||||||
|
|
||||||
def read_plate(self, frame: np.ndarray) -> tuple[str, float, tuple | None]:
|
def read_plate(self, frame: np.ndarray) -> tuple[str, float, tuple | None]:
|
||||||
"""Detect plate, read text. Returns (plate_text, confidence, bbox_or_None).
|
"""Detect plate, read text. Returns (plate_text, confidence, bbox_or_None)."""
|
||||||
bbox = (x1, y1, x2, y2) in pixel coords."""
|
|
||||||
plate_boxes = self.detect_plates(frame)
|
plate_boxes = self.detect_plates(frame)
|
||||||
if not plate_boxes:
|
if not plate_boxes:
|
||||||
return "", 0.0, None
|
return "", 0.0, None
|
||||||
@@ -128,7 +230,6 @@ class PlateAnalyzer:
|
|||||||
crop = frame[cy1:cy2, cx1:cx2]
|
crop = frame[cy1:cy2, cx1:cx2]
|
||||||
text, ocr_conf = self._ocr_crop(crop)
|
text, ocr_conf = self._ocr_crop(crop)
|
||||||
|
|
||||||
# Filter noise: plate must have at least 4 alphanumeric chars
|
|
||||||
alnum = "".join(c for c in text if c.isalnum())
|
alnum = "".join(c for c in text if c.isalnum())
|
||||||
if len(alnum) < 4:
|
if len(alnum) < 4:
|
||||||
return "", 0.0, (int(x1), int(y1), int(x2), int(y2))
|
return "", 0.0, (int(x1), int(y1), int(x2), int(y2))
|
||||||
|
|||||||
+14
-1
@@ -5,7 +5,7 @@ import threading
|
|||||||
import tempfile
|
import tempfile
|
||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, Request, Query, HTTPException, UploadFile, File
|
from fastapi import FastAPI, Request, Query, HTTPException, UploadFile, File, Form
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -103,12 +103,16 @@ async def event_detail(request: Request, event_id: str):
|
|||||||
size = os.path.getsize(clip_abs)
|
size = os.path.getsize(clip_abs)
|
||||||
clip_size = f"{size // 1024 // 1024}MB" if size > 1024 * 1024 else f"{size // 1024}KB"
|
clip_size = f"{size // 1024 // 1024}MB" if size > 1024 * 1024 else f"{size // 1024}KB"
|
||||||
|
|
||||||
|
plate_crop_abs = os.path.join(event_dir, "plate_crop.jpg")
|
||||||
|
plate_crop = f"events/{event_id}/plate_crop.jpg" if os.path.exists(plate_crop_abs) else None
|
||||||
|
|
||||||
return templates.TemplateResponse("event_detail.html", {
|
return templates.TemplateResponse("event_detail.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"ev": ev,
|
"ev": ev,
|
||||||
"frames": frames,
|
"frames": frames,
|
||||||
"has_clip": has_clip,
|
"has_clip": has_clip,
|
||||||
"clip_size": clip_size,
|
"clip_size": clip_size,
|
||||||
|
"plate_crop": plate_crop,
|
||||||
"capture_duration": CAPTURE_DURATION,
|
"capture_duration": CAPTURE_DURATION,
|
||||||
"capture_fps": CAPTURE_FPS,
|
"capture_fps": CAPTURE_FPS,
|
||||||
"capture_total": CAPTURE_DURATION * CAPTURE_FPS,
|
"capture_total": CAPTURE_DURATION * CAPTURE_FPS,
|
||||||
@@ -136,6 +140,15 @@ async def api_events(
|
|||||||
return {"events": events, "total": total}
|
return {"events": events, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/event/{event_id}/plate")
|
||||||
|
async def update_plate(event_id: str, plate: str = Form("")):
|
||||||
|
ev = database.get_event(event_id)
|
||||||
|
if not ev:
|
||||||
|
raise HTTPException(status_code=404, detail="Événement introuvable")
|
||||||
|
database.update_plate(event_id, plate)
|
||||||
|
return RedirectResponse(f"/event/{event_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/upload")
|
@app.post("/upload")
|
||||||
async def upload_clip(file: UploadFile = File(...)):
|
async def upload_clip(file: UploadFile = File(...)):
|
||||||
if not file.filename.lower().endswith(".mp4"):
|
if not file.filename.lower().endswith(".mp4"):
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ requests==2.32.3
|
|||||||
opencv-python-headless==4.10.0.84
|
opencv-python-headless==4.10.0.84
|
||||||
numpy==1.26.4
|
numpy==1.26.4
|
||||||
onnxruntime==1.20.0
|
onnxruntime==1.20.0
|
||||||
|
pytesseract==0.3.13
|
||||||
|
|||||||
@@ -45,13 +45,26 @@
|
|||||||
<div class="card p-4 space-y-4">
|
<div class="card p-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label>Plaque</label>
|
<label>Plaque</label>
|
||||||
<div class="mt-1">
|
<div class="mt-1 mb-2">
|
||||||
{% if ev.plate %}
|
{% if ev.plate %}
|
||||||
<span class="plate text-xl font-bold px-4 py-2 rounded" style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">{{ ev.plate }}</span>
|
<span class="plate text-xl font-bold px-4 py-2 rounded" style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">{{ ev.plate }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-slate-500 italic">Non lue</span>
|
<span class="text-slate-500 italic text-sm">Non lue automatiquement</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if plate_crop %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<label>Crop détecté</label>
|
||||||
|
<img src="/{{ plate_crop }}" class="mt-1 rounded border border-slate-600 w-full" style="image-rendering:pixelated;" title="Zoom plate crop">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form action="/event/{{ ev.id }}/plate" method="post" class="flex gap-2 mt-1">
|
||||||
|
<input type="text" name="plate" value="{{ ev.plate or '' }}"
|
||||||
|
placeholder="Saisir plaque…"
|
||||||
|
class="flex-1 text-sm font-mono uppercase"
|
||||||
|
style="background:#0f172a;border:1px solid #475569;color:#e2e8f0;border-radius:6px;padding:5px 8px;">
|
||||||
|
<button type="submit" class="text-xs px-3 py-1 rounded" style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">✓</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -172,6 +172,20 @@ def _process_clip(event_id: str, event_dir: str, clip_path: str, camera_name: st
|
|||||||
plate, conf, plate_bbox = _analyzer.read_plate(best_frame)
|
plate, conf, plate_bbox = _analyzer.read_plate(best_frame)
|
||||||
log.info(f"LPR: plate={plate!r} conf={conf:.2f} bbox={plate_bbox}")
|
log.info(f"LPR: plate={plate!r} conf={conf:.2f} bbox={plate_bbox}")
|
||||||
|
|
||||||
|
# Save plate crop for manual review (even when OCR fails)
|
||||||
|
if plate_bbox:
|
||||||
|
px1, py1, px2, py2 = plate_bbox
|
||||||
|
fh, fw = best_frame.shape[:2]
|
||||||
|
pad = max(8, int((py2 - py1) * 0.5))
|
||||||
|
cx1, cy1 = max(0, px1 - pad), max(0, py1 - pad)
|
||||||
|
cx2, cy2 = min(fw, px2 + pad), min(fh, py2 + pad)
|
||||||
|
plate_crop = best_frame[cy1:cy2, cx1:cx2]
|
||||||
|
if plate_crop.size > 0:
|
||||||
|
# Save at 4× upscale for readability
|
||||||
|
ph, pw = plate_crop.shape[:2]
|
||||||
|
big = cv2.resize(plate_crop, (pw * 4, ph * 4), interpolation=cv2.INTER_CUBIC)
|
||||||
|
cv2.imwrite(os.path.join(event_dir, "plate_crop.jpg"), big, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||||
|
|
||||||
# Save thumbnail
|
# Save thumbnail
|
||||||
snapshot_file = f"{event_id}.jpg"
|
snapshot_file = f"{event_id}.jpg"
|
||||||
snapshot_path = os.path.join(SNAPSHOTS_DIR, snapshot_file)
|
snapshot_path = os.path.join(SNAPSHOTS_DIR, snapshot_file)
|
||||||
|
|||||||
Reference in New Issue
Block a user