From cd5d6f1923977a16e8745772113ce68263684f89 Mon Sep 17 00:00:00 2001 From: Tuan Nghia Nguyen Date: Tue, 28 Apr 2026 17:49:37 +1000 Subject: [PATCH] Remove spaces, ".", and "-" from ALPR --- modules/ANSLPR/ANSLPR.cpp | 47 ++++++++++ modules/ANSLPR/ANSLPR.h | 28 ++++++ modules/ANSLPR/ANSLPR_OCR.cpp | 171 +++++++++++++++++++++++++++++++--- modules/ANSLPR/ANSLPR_OCR.h | 17 ++++ 4 files changed, 251 insertions(+), 12 deletions(-) diff --git a/modules/ANSLPR/ANSLPR.cpp b/modules/ANSLPR/ANSLPR.cpp index cdfb43f..d5dbff7 100644 --- a/modules/ANSLPR/ANSLPR.cpp +++ b/modules/ANSLPR/ANSLPR.cpp @@ -450,6 +450,53 @@ namespace ANSCENTER { } } + // Skip-when-locked query — see header for full contract. + // + // Returns the locked plate text if the caller may skip OCR this frame for + // (cameraId, trackId), or "" if the caller must run OCR. Increments the + // re-verify counter on skip; resets it when the counter hits reverifyEvery + // so the next frame falls through to a real OCR run. + std::string ALPRChecker::tryReuseLockedText(const std::string& cameraId, + int trackId, + int reverifyEvery) { + std::lock_guard lock(_mutex); + try { + auto camIt = trackedPlatesById.find(cameraId); + if (camIt == trackedPlatesById.end()) return ""; + auto& plates = camIt->second; + auto it = plates.find(trackId); + if (it == plates.end()) return ""; + + auto& tp = it->second; + if (tp.lockedText.empty()) return ""; // not locked yet — caller must run OCR + + // Locked. Decide whether to skip this frame's OCR or fall through + // for a periodic re-verify. reverifyEvery <= 0 means "always skip" + // (no re-verify), reverifyEvery == 1 means "always run OCR". + if (reverifyEvery > 0 && tp.framesSinceVerify >= reverifyEvery) { + tp.framesSinceVerify = 0; + ANS_DBG("ALPR_TrackId", + "cam=%s tid=%d REVERIFY (every=%d) — letting OCR through", + cameraId.c_str(), trackId, reverifyEvery); + return ""; // signal: caller should run OCR this frame + } + + ++tp.framesSinceVerify; + // Keep the entry alive — checkPlateByTrackId is what normally + // resets framesSinceLastSeen, but on skipped frames it's never + // called, and the prune pass would eventually delete this lock. + tp.framesSinceLastSeen = 0; + ANS_DBG("ALPR_TrackId", + "cam=%s tid=%d SKIP-LOCKED text='%s' reverifyIn=%d", + cameraId.c_str(), trackId, tp.lockedText.c_str(), + reverifyEvery > 0 ? (reverifyEvery - tp.framesSinceVerify) : -1); + return tp.lockedText; + } + catch (const std::exception&) { + return ""; // any failure → fall back to running OCR + } + } + // static void VerifyGlobalANSALPRLicense(const std::string& licenseKey) { try { diff --git a/modules/ANSLPR/ANSLPR.h b/modules/ANSLPR/ANSLPR.h index 6f0ffb2..8509138 100644 --- a/modules/ANSLPR/ANSLPR.h +++ b/modules/ANSLPR/ANSLPR.h @@ -47,6 +47,14 @@ namespace ANSCENTER std::string lockedText; int lockCount = 0; int framesSinceLastSeen = 0; + // Frames since the last full OCR was used to verify the lock. When + // skip-when-locked is active in ANSALPR_OCR::RunInference, this + // counter increments on every frame whose OCR was skipped; once it + // reaches the re-verify period, the next frame falls through to a + // real OCR run (resetting the counter) so a stale or wrong lock + // can self-correct via majority-vote re-lock. Zero when not in + // use (e.g. raw OCR pass-through, pipeline mode). + int framesSinceVerify = 0; }; // cameraId → (trackId → tracked plate) std::unordered_map> trackedPlatesById; @@ -61,6 +69,26 @@ namespace ANSCENTER [[nodiscard]] std::string checkPlate(const std::string& cameraId, const std::string& detectedPlate, const cv::Rect& plateBox); // Hybrid API: trackId as primary identity, Levenshtein fallback for lost tracks [[nodiscard]] std::string checkPlateByTrackId(const std::string& cameraId, const std::string& detectedPlate, int trackId); + + // Skip-when-locked query: returns the locked plate text for (cameraId, trackId) + // if the caller may safely SKIP running OCR this frame, or an empty string if + // the caller must run OCR and feed the result back through checkPlateByTrackId. + // + // Behaviour: + // * Track not found, or found but not locked yet → returns "". + // * Locked, and < `reverifyEvery` frames since the last full OCR for this + // track → returns the locked text and increments the per-track + // re-verify counter. Caller skips the recognizer entirely. + // * Locked, and ≥ `reverifyEvery` frames since the last full OCR → + // returns "" and resets the counter, so the caller runs OCR this frame + // and the regular voting/re-lock logic in checkPlateByTrackId can + // self-correct a wrong lock or notice a vehicle swap on the same trackId. + // + // Side-effect: also resets framesSinceLastSeen so the periodic prune pass + // in checkPlateByTrackId does not retire a track just because we skipped + // OCR on it. Without this the locked plate would silently disappear from + // the registry after a few hundred skipped frames. + [[nodiscard]] std::string tryReuseLockedText(const std::string& cameraId, int trackId, int reverifyEvery = 30); }; class ANSLPR_API ANSALPR { diff --git a/modules/ANSLPR/ANSLPR_OCR.cpp b/modules/ANSLPR/ANSLPR_OCR.cpp index d4b53ea..5c0baf5 100644 --- a/modules/ANSLPR/ANSLPR_OCR.cpp +++ b/modules/ANSLPR/ANSLPR_OCR.cpp @@ -866,6 +866,69 @@ namespace ANSCENTER return collapsed.substr(first, last - first + 1); } + // Drop visual separators (hyphens, commas, every flavour of whitespace) + // from a plate string so the emitted className is a contiguous token. + // See header comment for full rationale. UTF-8-aware so it handles + // Japanese OCR output that may produce full-width or CJK variants. + std::string ANSALPR_OCR::StripPlateSeparators(const std::string& text) { + if (text.empty()) return text; + std::string stripped; + stripped.reserve(text.size()); + size_t pos = 0; + while (pos < text.size()) { + const size_t before = pos; + uint32_t cp = ANSOCRUtility::NextUTF8Codepoint(text, pos); + if (cp == 0 || pos == before) break; + + bool drop = false; + switch (cp) { + // --- Spaces (every flavour the recognizer can emit) --- + case 0x0020: // ASCII space + case 0x0009: // horizontal tab + case 0x00A0: // no-break space + case 0x1680: // ogham space mark + case 0x2000: // en quad + case 0x2001: // em quad + case 0x2002: // en space + case 0x2003: // em space + case 0x2004: // three-per-em space + case 0x2005: // four-per-em space + case 0x2006: // six-per-em space + case 0x2007: // figure space + case 0x2008: // punctuation space + case 0x2009: // thin space + case 0x200A: // hair space + case 0x202F: // narrow no-break space + case 0x205F: // medium mathematical space + case 0x3000: //   ideographic space (CJK) + // --- Periods / full stops --- + case 0x002E: // . ASCII full stop / period + case 0xFF0E: // . fullwidth full stop + // --- Hyphens / dashes --- + case 0x002D: // - ASCII hyphen-minus + case 0x2010: // ‐ HYPHEN + case 0x2011: // ‑ NON-BREAKING HYPHEN + case 0x2012: // ‒ FIGURE DASH + case 0x2013: // – EN DASH + case 0x2014: // — EM DASH + case 0x2015: // ― HORIZONTAL BAR + case 0x2212: // − MINUS SIGN + case 0xFF0D: // - fullwidth hyphen-minus + case 0x30FC: // ー katakana-hiragana prolonged sound mark + // (visually similar to a dash and the OCR + // sometimes emits it where a hyphen sits) + drop = true; + break; + default: + break; + } + if (!drop) { + stripped.append(text, before, pos - before); + } + } + return stripped; + } + std::string ANSALPR_OCR::RecoverKanaFromBottomHalf( const cv::Mat& plateROI, int halfH) const { @@ -1338,11 +1401,26 @@ namespace ANSCENTER // lets SetCountry(nonJapan) take effect on the very next // frame without a restart. const bool useRectification = (_country == Country::JAPAN); + + // Decide once per frame whether the tracker-based correction + // layer should run. Used by both the OCR-skip optimization + // below and by Step 4's checkPlateByTrackId call further down. + // We auto-detect full-frame vs pipeline mode by watching for + // pixel-identical consecutive frames, exactly the same way + // ANSALPR_OD does it. + const bool useChecker = shouldUseALPRChecker( + cv::Size(frameWidth, frameHeight), cameraId); + struct PlateInfo { size_t origIndex; // into lprOutput std::vector cropIndices; // into allCrops cv::Mat plateROI; // full (unsplit) ROI, kept for colour + kana recovery int halfH = 0; // row-split Y inside plateROI (0 = single row) + // Skip-when-locked: if non-empty, this plate's track is already + // locked in alprChecker and we re-used the locked text instead + // of running OCR. Step 4 emits this directly; cropIndices is + // empty for these entries. See ALPRChecker::tryReuseLockedText. + std::string presetClassName; }; std::vector allCrops; std::vector plateInfos; @@ -1359,6 +1437,34 @@ namespace ANSCENTER const int height = std::min(frameHeight - y1, box.height); if (width <= 0 || height <= 0) continue; + // Skip-when-locked fast path. If alprChecker already has a + // locked text for this plate's trackId, re-use it directly and + // don't push any crops onto allCrops. Saves ~95 ms (AMD DML) + // or ~50 ms (Intel OpenVINO) per locked plate per frame. The + // re-verify period inside tryReuseLockedText lets one OCR run + // through every N frames so a wrong lock can self-correct. + // + // Gated on useChecker so pipeline mode (no tracker, no voting) + // always runs raw OCR exactly like before — the optimization + // only applies in the full-frame ALPR pipeline. + if (useChecker) { + std::string locked = alprChecker.tryReuseLockedText( + cameraId, lprOutput[i].trackId); + if (!locked.empty()) { + // Build a minimal plateROI for the colour-detection + // pass in Step 4 — we still want to run colour even + // on skipped plates because it's cached and very + // cheap once the cache is warm. + const cv::Rect safeBox(x1, y1, width, height); + PlateInfo info; + info.origIndex = i; + info.plateROI = frame(safeBox); // non-owning view + info.presetClassName = std::move(locked); + plateInfos.push_back(std::move(info)); + continue; + } + } + // Pad the YOLO LP bbox by 5% on each side. Gives the // rectifier some background for edge detection and helps // when the detector cropped a character edge. @@ -1416,28 +1522,50 @@ namespace ANSCENTER plateInfos.push_back(std::move(info)); } - if (allCrops.empty()) { + // Nothing to emit: no detected plates AND no skipped-locked plates. + if (plateInfos.empty()) { return {}; } - // Step 3: Single batched recognizer call for every crop. + // Step 3: Single batched recognizer call for every crop that + // wasn't short-circuited by the skip-when-locked fast path. // ONNXOCRRecognizer groups crops by bucket width and issues // one ORT Run per bucket — typically 1–2 GPU calls for an - // entire frame regardless of plate count. - auto ocrResults = _ocrEngine->RecognizeTextBatch(allCrops); + // entire frame regardless of plate count. When every plate + // in the frame is already locked, allCrops is empty and we + // skip the recognizer call entirely. + std::vector> ocrResults; + if (!allCrops.empty()) { + ocrResults = _ocrEngine->RecognizeTextBatch(allCrops); + } - // Step 4: Assemble per-plate output + // Step 4: Assemble per-plate output. std::vector output; output.reserve(plateInfos.size()); - // Decide once per frame whether the tracker-based correction - // layer should run. We auto-detect full-frame vs pipeline mode - // by watching for pixel-identical consecutive frames, exactly - // the same way ANSALPR_OD does it. - const bool useChecker = shouldUseALPRChecker( - cv::Size(frameWidth, frameHeight), cameraId); - for (const auto& info : plateInfos) { + // Skip-when-locked fast path: this plate's track was already + // locked in alprChecker, no OCR was run, just emit the locked + // text directly. Bypasses CTC / artifact stripping / kana + // recovery / re-vote — all of which would no-op anyway since + // the locked text is already the canonical plate string. + if (!info.presetClassName.empty()) { + Object lprObject = lprOutput[info.origIndex]; + lprObject.cameraId = cameraId; + lprObject.className = info.presetClassName; + + // Colour detection still runs — it's cached per-(camera, + // plate-text) so subsequent frames are O(1) anyway. + std::string colour = DetectLPColourCached( + info.plateROI, cameraId, lprObject.className); + if (!colour.empty()) { + lprObject.extraInfo = "color:" + colour; + } + + output.push_back(std::move(lprObject)); + continue; + } + // Reassemble row-by-row so we can target the bottom row // for kana recovery when the fast path silently dropped // the hiragana on a Japanese 2-row plate. @@ -1502,6 +1630,17 @@ namespace ANSCENTER if (combinedText.empty()) continue; + // Final-pass post-processing: drop hyphens, commas, every + // flavour of whitespace so the emitted plate is a single + // contiguous token. Done BEFORE the voter so: + // 1. textHistory + lockedText store the canonical form + // (better vote convergence — "50H 304.07" and + // "50H304.07" no longer compete as separate strings). + // 2. Skip-when-locked emits stripped text automatically + // via ALPRChecker::tryReuseLockedText, no second strip. + combinedText = StripPlateSeparators(combinedText); + if (combinedText.empty()) continue; + Object lprObject = lprOutput[info.origIndex]; lprObject.cameraId = cameraId; @@ -1759,6 +1898,14 @@ namespace ANSCENTER if (combined.empty()) continue; + // Final-pass post-processing — same separator strip the + // full-frame path applies before the voter. Pipeline mode + // has no voter, so we strip just before emit. Keeps the + // className consistent with the full-frame output format + // regardless of which inference path the caller chose. + combined = StripPlateSeparators(combined); + if (combined.empty()) continue; + Object out = pm.lpObj; out.className = combined; // raw OCR — no ALPRChecker out.cameraId = cameraId; diff --git a/modules/ANSLPR/ANSLPR_OCR.h b/modules/ANSLPR/ANSLPR_OCR.h index 4a115f0..9169551 100644 --- a/modules/ANSLPR/ANSLPR_OCR.h +++ b/modules/ANSLPR/ANSLPR_OCR.h @@ -191,6 +191,23 @@ namespace ANSCENTER // characters are collapsed and leading/trailing spaces trimmed. [[nodiscard]] static std::string StripPlateArtifacts(const std::string& text); + // Final-pass post-processing: strip the visual separators that some + // countries put between plate sections — spaces, periods, hyphens — + // so the emitted plate string is a contiguous alphanumeric token. + // Examples: + // "84-F1 273.36" → "84F127336" + // "54-0L 7564" → "540L7564" + // "59-N2 363.25" → "59N236325" + // "東京 500あ12-34" → "東京500あ1234" + // Handles ASCII space/period/hyphen-minus plus the common Unicode + // variants (NBSP, ideographic space, em/en dashes, full-width + // period and hyphen, JP prolonged-sound mark) so JP-OCR output + // is normalised the same way as Latin output. + // Applied BEFORE the ALPRChecker voter so voting + locking both + // operate on the canonical stripped form, and the skip-when-locked + // path inherits stripped output without a second strip site. + [[nodiscard]] static std::string StripPlateSeparators(const std::string& text); + // Run recognizer-only on a tight crop of the left portion of the // bottom half, trying three vertical offsets to absorb row-split // inaccuracies. Returns the first non-empty result that contains