Remove spaces, ".", and "-" from ALPR

This commit is contained in:
2026-04-28 17:49:37 +10:00
parent 234f2c68a2
commit cd5d6f1923
4 changed files with 251 additions and 12 deletions

View File

@@ -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<std::recursive_mutex> 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 {

View File

@@ -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<std::string, std::unordered_map<int, TrackedPlateById>> 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 {

View File

@@ -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<size_t> 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<cv::Mat> allCrops;
std::vector<PlateInfo> 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 12 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<std::pair<std::string, float>> ocrResults;
if (!allCrops.empty()) {
ocrResults = _ocrEngine->RecognizeTextBatch(allCrops);
}
// Step 4: Assemble per-plate output
// Step 4: Assemble per-plate output.
std::vector<Object> 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;

View File

@@ -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