Add delete event, perspective correction OCR, and OCR crop display
- Delete event: POST /event/{id}/delete removes DB row + files
- Perspective correction: minAreaRect on white plate region → getPerspectiveTransform
gives a flat frontal view of the plate (e.g. 'GR B38 WR' instead of trapezoid)
- Tesseract now scales image 3x before OCR and skips EU blue strip (left 11%)
- Saves plate_ocr.jpg (perspective-corrected + enhanced crop actually fed to OCR)
- Event detail shows both raw detection crop and OCR crop side by side
- Manual plate edit form in event detail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
13e6038069
commit
accbae41ce
@@ -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 delete_event(event_id: str):
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute("DELETE FROM events WHERE id = ?", (event_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def update_plate(event_id: str, plate: str):
|
def update_plate(event_id: str, plate: str):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
conn.execute("UPDATE events SET plate = ? WHERE id = ?", (plate.strip().upper() or None, event_id))
|
conn.execute("UPDATE events SET plate = ? WHERE id = ?", (plate.strip().upper() or None, event_id))
|
||||||
|
|||||||
+110
-39
@@ -38,6 +38,18 @@ def _nms(boxes: list, iou_threshold: float = 0.3) -> list:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _order_points(pts: np.ndarray) -> np.ndarray:
|
||||||
|
"""Order 4 points: top-left, top-right, bottom-right, bottom-left."""
|
||||||
|
rect = np.zeros((4, 2), dtype=np.float32)
|
||||||
|
s = pts.sum(axis=1)
|
||||||
|
rect[0] = pts[np.argmin(s)]
|
||||||
|
rect[2] = pts[np.argmax(s)]
|
||||||
|
diff = np.diff(pts, axis=1)
|
||||||
|
rect[1] = pts[np.argmin(diff)]
|
||||||
|
rect[3] = pts[np.argmax(diff)]
|
||||||
|
return rect
|
||||||
|
|
||||||
|
|
||||||
class PlateAnalyzer:
|
class PlateAnalyzer:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._yolo = None
|
self._yolo = None
|
||||||
@@ -75,7 +87,6 @@ class PlateAnalyzer:
|
|||||||
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]:
|
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]
|
th, tw = tile.shape[:2]
|
||||||
inp = cv2.resize(tile, (256, 256))
|
inp = cv2.resize(tile, (256, 256))
|
||||||
inp = cv2.cvtColor(inp, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
|
inp = cv2.cvtColor(inp, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
|
||||||
@@ -97,7 +108,6 @@ class PlateAnalyzer:
|
|||||||
bw, bh = x2 - x1, y2 - y1
|
bw, bh = x2 - x1, y2 - y1
|
||||||
if bw < 8 or bh < 4:
|
if bw < 8 or bh < 4:
|
||||||
continue
|
continue
|
||||||
# Plates are always wider than tall — French plates ~4.7:1
|
|
||||||
ratio = bw / bh
|
ratio = bw / bh
|
||||||
if ratio < 2.0 or ratio > 6.5:
|
if ratio < 2.0 or ratio > 6.5:
|
||||||
continue
|
continue
|
||||||
@@ -118,7 +128,6 @@ class PlateAnalyzer:
|
|||||||
work = frame[y_start:y_end, :]
|
work = frame[y_start:y_end, :]
|
||||||
wh = y_end - y_start
|
wh = y_end - y_start
|
||||||
|
|
||||||
# 3×2 tiles with 20% overlap so plates near tile edges are caught
|
|
||||||
cols, rows = 3, 2
|
cols, rows = 3, 2
|
||||||
overlap = 0.2
|
overlap = 0.2
|
||||||
tw = int(w / (cols - overlap * (cols - 1)))
|
tw = int(w / (cols - overlap * (cols - 1)))
|
||||||
@@ -133,49 +142,107 @@ class PlateAnalyzer:
|
|||||||
tx2 = min(w, tx1 + tw)
|
tx2 = min(w, tx1 + tw)
|
||||||
ty2 = min(wh, ty1 + th)
|
ty2 = min(wh, ty1 + th)
|
||||||
tile = work[ty1:ty2, tx1:tx2]
|
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))
|
all_boxes.extend(self._yolo_on_tile(tile, tx1, ty1 + y_start))
|
||||||
|
|
||||||
return _nms(all_boxes, iou_threshold=0.3)
|
return _nms(all_boxes, iou_threshold=0.3)
|
||||||
|
|
||||||
def _enhance_crop(self, crop: np.ndarray) -> np.ndarray:
|
def _find_plate_quad(self, frame: np.ndarray, x1: int, y1: int, x2: int, y2: int) -> np.ndarray | None:
|
||||||
"""Upscale + CLAHE + sharpen a plate crop for better OCR."""
|
"""Find the 4 corners of the plate using minAreaRect on the white plate region."""
|
||||||
h, w = crop.shape[:2]
|
fh, fw = frame.shape[:2]
|
||||||
# Upscale so the plate is at least 80px tall
|
pw, ph = x2 - x1, y2 - y1
|
||||||
|
|
||||||
|
mx = int(pw * 0.6)
|
||||||
|
my = int(ph * 1.2)
|
||||||
|
rx1 = max(0, x1 - mx)
|
||||||
|
ry1 = max(0, y1 - my)
|
||||||
|
rx2 = min(fw, x2 + mx)
|
||||||
|
ry2 = min(fh, y2 + my)
|
||||||
|
region = frame[ry1:ry2, rx1:rx2]
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(region, cv2.COLOR_BGR2GRAY)
|
||||||
|
_, thresh = cv2.threshold(gray, 160, 255, cv2.THRESH_BINARY)
|
||||||
|
kernel = np.ones((3, 3), np.uint8)
|
||||||
|
thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
|
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
if not contours:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Use the largest bright contour (plate background)
|
||||||
|
cnt = max(contours, key=cv2.contourArea)
|
||||||
|
if cv2.contourArea(cnt) < pw * ph * 0.3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
rect = cv2.minAreaRect(cnt)
|
||||||
|
box = cv2.boxPoints(rect).astype(np.float32)
|
||||||
|
box[:, 0] += rx1
|
||||||
|
box[:, 1] += ry1
|
||||||
|
|
||||||
|
# Validate: the oriented rect should be plate-shaped
|
||||||
|
bw = rect[1][0]
|
||||||
|
bh = rect[1][1]
|
||||||
|
long_side = max(bw, bh)
|
||||||
|
short_side = min(bw, bh)
|
||||||
|
if short_side < 4 or long_side / short_side < 2.0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return box
|
||||||
|
|
||||||
|
def _perspective_correct(self, frame: np.ndarray, quad: np.ndarray) -> np.ndarray:
|
||||||
|
"""Warp the detected plate quad to a frontal rectangle."""
|
||||||
|
pts = _order_points(quad)
|
||||||
|
# French plate: 520×110mm → 4.73:1
|
||||||
target_h = 80
|
target_h = 80
|
||||||
if h < target_h:
|
target_w = int(target_h * 4.73)
|
||||||
scale = target_h / h
|
dst = np.array([
|
||||||
crop = cv2.resize(crop, (max(10, int(w * scale)), target_h), interpolation=cv2.INTER_CUBIC)
|
[0, 0], [target_w - 1, 0],
|
||||||
# CLAHE contrast enhancement
|
[target_w - 1, target_h - 1], [0, target_h - 1],
|
||||||
|
], dtype=np.float32)
|
||||||
|
M = cv2.getPerspectiveTransform(pts, dst)
|
||||||
|
return cv2.warpPerspective(frame, M, (target_w, target_h))
|
||||||
|
|
||||||
|
def _enhance_crop(self, crop: np.ndarray) -> np.ndarray:
|
||||||
|
"""CLAHE contrast + sharpen for better OCR."""
|
||||||
lab = cv2.cvtColor(crop, cv2.COLOR_BGR2LAB)
|
lab = cv2.cvtColor(crop, cv2.COLOR_BGR2LAB)
|
||||||
l, a, b = cv2.split(lab)
|
l, a, b = cv2.split(lab)
|
||||||
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(4, 4))
|
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(4, 4))
|
||||||
l = clahe.apply(l)
|
l = clahe.apply(l)
|
||||||
crop = cv2.cvtColor(cv2.merge([l, a, b]), cv2.COLOR_LAB2BGR)
|
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)
|
kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], dtype=np.float32)
|
||||||
return cv2.filter2D(crop, -1, kernel)
|
return cv2.filter2D(crop, -1, kernel)
|
||||||
|
|
||||||
def _ocr_tesseract(self, crop: np.ndarray) -> tuple[str, float]:
|
def _ocr_tesseract(self, crop: np.ndarray) -> tuple[str, float]:
|
||||||
"""Tesseract OCR tuned for license plates."""
|
|
||||||
try:
|
try:
|
||||||
import pytesseract
|
import pytesseract
|
||||||
|
# Skip EU blue strip (left ~11%) which confuses OCR
|
||||||
|
eu_skip = max(0, int(crop.shape[1] * 0.11))
|
||||||
|
crop = crop[:, eu_skip:]
|
||||||
|
|
||||||
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
|
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
|
||||||
# Try both single-word and single-line modes, take the longer result
|
# Scale to at least 3× for reliable Tesseract recognition
|
||||||
cfg = "--psm 8 --oem 3 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"
|
if gray.shape[0] < 120:
|
||||||
text8 = pytesseract.image_to_string(gray, config=cfg).strip().replace(" ", "").upper()
|
scale = max(2, 120 // gray.shape[0])
|
||||||
cfg7 = "--psm 7 --oem 3 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"
|
gray = cv2.resize(gray, (gray.shape[1] * scale, gray.shape[0] * scale),
|
||||||
text7 = pytesseract.image_to_string(gray, config=cfg7).strip().replace(" ", "").upper()
|
interpolation=cv2.INTER_CUBIC)
|
||||||
text = text8 if len(text8) >= len(text7) else text7
|
|
||||||
alnum = "".join(c for c in text if c.isalnum())
|
wl = "-c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"
|
||||||
if len(alnum) >= 4:
|
results = []
|
||||||
return text, 0.6 # Tesseract doesn't give per-char conf easily; use fixed score
|
for psm in [8, 7, 11]:
|
||||||
|
cfg = f"--psm {psm} --oem 3 {wl}"
|
||||||
|
t = pytesseract.image_to_string(gray, config=cfg).strip().upper()
|
||||||
|
t = "".join(c for c in t if c.isalnum() or c == "-")
|
||||||
|
alnum = "".join(c for c in t if c.isalnum())
|
||||||
|
if len(alnum) >= 4:
|
||||||
|
results.append(t)
|
||||||
|
if results:
|
||||||
|
# Pick the result with the most alphanumeric characters
|
||||||
|
best = max(results, key=lambda r: len([c for c in r if c.isalnum()]))
|
||||||
|
return best, 0.6
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.debug(f"Tesseract error: {e}")
|
log.debug(f"Tesseract error: {e}")
|
||||||
return "", 0.0
|
return "", 0.0
|
||||||
|
|
||||||
def _ocr_paddle(self, crop: np.ndarray) -> tuple[str, float]:
|
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]
|
||||||
@@ -203,35 +270,39 @@ class PlateAnalyzer:
|
|||||||
return text, avg_conf
|
return text, avg_conf
|
||||||
|
|
||||||
def _ocr_crop(self, crop: np.ndarray) -> tuple[str, float]:
|
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:
|
if crop.size == 0:
|
||||||
return "", 0.0
|
return "", 0.0
|
||||||
enhanced = self._enhance_crop(crop)
|
enhanced = self._enhance_crop(crop)
|
||||||
# Tesseract is better for Latin/French plates
|
|
||||||
text, conf = self._ocr_tesseract(enhanced)
|
text, conf = self._ocr_tesseract(enhanced)
|
||||||
if text:
|
if text:
|
||||||
return text, conf
|
return text, conf
|
||||||
# Fallback to PaddleOCR
|
|
||||||
return self._ocr_paddle(enhanced)
|
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, np.ndarray | None]:
|
||||||
"""Detect plate, read text. Returns (plate_text, confidence, bbox_or_None)."""
|
"""Detect + read plate.
|
||||||
|
Returns (plate_text, confidence, bbox_or_None, corrected_crop_or_None)."""
|
||||||
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, None
|
||||||
|
|
||||||
x1, y1, x2, y2, plate_conf = plate_boxes[0]
|
x1, y1, x2, y2, plate_conf = plate_boxes[0]
|
||||||
pad = 8
|
bbox = (int(x1), int(y1), int(x2), int(y2))
|
||||||
cx1 = max(0, int(x1) - pad)
|
|
||||||
cy1 = max(0, int(y1) - pad)
|
|
||||||
cx2 = min(frame.shape[1], int(x2) + pad)
|
|
||||||
cy2 = min(frame.shape[0], int(y2) + pad)
|
|
||||||
|
|
||||||
crop = frame[cy1:cy2, cx1:cx2]
|
# Try perspective correction first
|
||||||
text, ocr_conf = self._ocr_crop(crop)
|
quad = self._find_plate_quad(frame, int(x1), int(y1), int(x2), int(y2))
|
||||||
|
if quad is not None:
|
||||||
|
corrected = self._perspective_correct(frame, quad)
|
||||||
|
log.debug("Perspective correction applied")
|
||||||
|
else:
|
||||||
|
fh, fw = frame.shape[:2]
|
||||||
|
pad = 8
|
||||||
|
corrected = frame[max(0, int(y1) - pad):min(fh, int(y2) + pad),
|
||||||
|
max(0, int(x1) - pad):min(fw, int(x2) + pad)]
|
||||||
|
|
||||||
|
text, ocr_conf = self._ocr_crop(corrected)
|
||||||
|
|
||||||
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, bbox, corrected
|
||||||
|
|
||||||
return text, (plate_conf + ocr_conf) / 2.0, (int(x1), int(y1), int(x2), int(y2))
|
return text, (plate_conf + ocr_conf) / 2.0, bbox, corrected
|
||||||
|
|||||||
+18
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import glob
|
import glob
|
||||||
|
import shutil
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -105,6 +106,8 @@ async def event_detail(request: Request, event_id: str):
|
|||||||
|
|
||||||
plate_crop_abs = os.path.join(event_dir, "plate_crop.jpg")
|
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
|
plate_crop = f"events/{event_id}/plate_crop.jpg" if os.path.exists(plate_crop_abs) else None
|
||||||
|
plate_ocr_abs = os.path.join(event_dir, "plate_ocr.jpg")
|
||||||
|
plate_ocr = f"events/{event_id}/plate_ocr.jpg" if os.path.exists(plate_ocr_abs) else None
|
||||||
|
|
||||||
return templates.TemplateResponse("event_detail.html", {
|
return templates.TemplateResponse("event_detail.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
@@ -113,6 +116,7 @@ async def event_detail(request: Request, event_id: str):
|
|||||||
"has_clip": has_clip,
|
"has_clip": has_clip,
|
||||||
"clip_size": clip_size,
|
"clip_size": clip_size,
|
||||||
"plate_crop": plate_crop,
|
"plate_crop": plate_crop,
|
||||||
|
"plate_ocr": plate_ocr,
|
||||||
"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,
|
||||||
@@ -140,6 +144,20 @@ async def api_events(
|
|||||||
return {"events": events, "total": total}
|
return {"events": events, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/event/{event_id}/delete")
|
||||||
|
async def delete_event(event_id: str):
|
||||||
|
ev = database.get_event(event_id)
|
||||||
|
if not ev:
|
||||||
|
raise HTTPException(status_code=404, detail="Événement introuvable")
|
||||||
|
database.delete_event(event_id)
|
||||||
|
event_dir = os.path.join(EVENTS_DIR, event_id)
|
||||||
|
shutil.rmtree(event_dir, ignore_errors=True)
|
||||||
|
snapshot = os.path.join(SNAPSHOTS_DIR, f"{event_id}.jpg")
|
||||||
|
if os.path.exists(snapshot):
|
||||||
|
os.unlink(snapshot)
|
||||||
|
return RedirectResponse("/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/event/{event_id}/plate")
|
@app.post("/event/{event_id}/plate")
|
||||||
async def update_plate(event_id: str, plate: str = Form("")):
|
async def update_plate(event_id: str, plate: str = Form("")):
|
||||||
ev = database.get_event(event_id)
|
ev = database.get_event(event_id)
|
||||||
|
|||||||
@@ -34,7 +34,11 @@
|
|||||||
<header class="sticky top-0 z-10 px-4 py-3 flex items-center gap-3" style="background:#0f172a;border-bottom:1px solid #1e293b;">
|
<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>
|
<a href="/" class="btn-ghost">← Retour</a>
|
||||||
<span class="text-xl">🚗</span>
|
<span class="text-xl">🚗</span>
|
||||||
<span class="font-bold">{{ ev.time_str }}</span>
|
<span class="font-bold flex-1">{{ ev.time_str }}</span>
|
||||||
|
<form action="/event/{{ ev.id }}/delete" method="post"
|
||||||
|
onsubmit="return confirm('Supprimer cet événement ?')">
|
||||||
|
<button type="submit" class="btn-ghost text-red-400 hover:text-red-300 hover:border-red-500">✕ Supprimer</button>
|
||||||
|
</form>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="max-w-5xl mx-auto px-3 py-5 space-y-4">
|
<main class="max-w-5xl mx-auto px-3 py-5 space-y-4">
|
||||||
@@ -54,8 +58,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if plate_crop %}
|
{% if plate_crop %}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label>Crop détecté</label>
|
<label>Détection brute (4×)</label>
|
||||||
<img src="/{{ plate_crop }}" class="mt-1 rounded border border-slate-600 w-full" style="image-rendering:pixelated;" title="Zoom plate crop">
|
<img src="/{{ plate_crop }}" class="mt-1 rounded border border-slate-600 w-full" style="image-rendering:pixelated;">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if plate_ocr %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<label>Image utilisée pour l'OCR</label>
|
||||||
|
<img src="/{{ plate_ocr }}" class="mt-1 rounded border border-blue-700 w-full" style="image-rendering:pixelated;">
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form action="/event/{{ ev.id }}/plate" method="post" class="flex gap-2 mt-1">
|
<form action="/event/{{ ev.id }}/plate" method="post" class="flex gap-2 mt-1">
|
||||||
|
|||||||
+9
-4
@@ -167,12 +167,12 @@ def _process_clip(event_id: str, event_dir: str, clip_path: str, camera_name: st
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# LPR on best frame
|
# LPR on best frame
|
||||||
plate, conf, plate_bbox = ("", 0.0, None)
|
plate, conf, plate_bbox, plate_corrected = ("", 0.0, None, None)
|
||||||
if _analyzer:
|
if _analyzer:
|
||||||
plate, conf, plate_bbox = _analyzer.read_plate(best_frame)
|
plate, conf, plate_bbox, plate_corrected = _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)
|
# Save raw plate crop (4× upscale) and the perspective-corrected OCR crop
|
||||||
if plate_bbox:
|
if plate_bbox:
|
||||||
px1, py1, px2, py2 = plate_bbox
|
px1, py1, px2, py2 = plate_bbox
|
||||||
fh, fw = best_frame.shape[:2]
|
fh, fw = best_frame.shape[:2]
|
||||||
@@ -181,10 +181,15 @@ def _process_clip(event_id: str, event_dir: str, clip_path: str, camera_name: st
|
|||||||
cx2, cy2 = min(fw, px2 + pad), min(fh, py2 + pad)
|
cx2, cy2 = min(fw, px2 + pad), min(fh, py2 + pad)
|
||||||
plate_crop = best_frame[cy1:cy2, cx1:cx2]
|
plate_crop = best_frame[cy1:cy2, cx1:cx2]
|
||||||
if plate_crop.size > 0:
|
if plate_crop.size > 0:
|
||||||
# Save at 4× upscale for readability
|
|
||||||
ph, pw = plate_crop.shape[:2]
|
ph, pw = plate_crop.shape[:2]
|
||||||
big = cv2.resize(plate_crop, (pw * 4, ph * 4), interpolation=cv2.INTER_CUBIC)
|
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])
|
cv2.imwrite(os.path.join(event_dir, "plate_crop.jpg"), big, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||||
|
if plate_corrected is not None and plate_corrected.size > 0:
|
||||||
|
enhanced = _analyzer._enhance_crop(plate_corrected)
|
||||||
|
ch, cw = enhanced.shape[:2]
|
||||||
|
if cw < 300: # upscale small crops for display
|
||||||
|
enhanced = cv2.resize(enhanced, (300, int(300 * ch / cw)), interpolation=cv2.INTER_CUBIC)
|
||||||
|
cv2.imwrite(os.path.join(event_dir, "plate_ocr.jpg"), enhanced, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||||
|
|
||||||
# Save thumbnail
|
# Save thumbnail
|
||||||
snapshot_file = f"{event_id}.jpg"
|
snapshot_file = f"{event_id}.jpg"
|
||||||
|
|||||||
Reference in New Issue
Block a user