Instructions to use camstack/camstack-trainer with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- ultralytics
How to use camstack/camstack-trainer with ultralytics:
from huggingface_hub import hf_hub_download from ultralytics import YOLO # pick the weights file from this repo's "Files and versions" tab weights = hf_hub_download("camstack/camstack-trainer", "<weights>.pt") model = YOLO(weights) source = 'http://images.cocodataset.org/val2017/000000039769.jpg' model.predict(source=source, save=True) - Notebooks
- Google Colab
- Kaggle
File size: 17,720 Bytes
ebeed67 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 | """CTC text recognition postprocessor.
Used for: vgg_english_g2 (plate OCR).
Input: [1, seqLen, numChars] logits
Output: {"kind": "text", "text": "ABC123", "confidence": 0.95, "formatValid": true}
Algorithm: argmax per timestep β collapse consecutive duplicates β remove blank (index 0) β join
Plausibility gate
-----------------
When the recognizer is fed a too-low-resolution / heavily-blurred plate crop
its logit map collapses toward the CTC blank and greedy decode emits a spurious
1β2 character read (e.g. a lone "A") at whatever confidence the single surviving
timestep carries. That noise was being stored verbatim as a "plate" label. The
charset + greedy decode themselves are correct β this only rejects a result too
short to be a real plate (`minTextLength`, non-space chars) or below a confidence
floor (`minTextConfidence`), returning empty text so no garbage label is stored.
Real plates read at 5+ chars / conf ~0.86β1.0, so the conservative defaults never
suppress a genuine read; both are config-overridable for per-camera tuning.
Normalisation before the grammar
--------------------------------
The recognizer's charset carries lowercase letters and a space, and live reads
use them freely ("dn Ro 3o9" is the same plate as "DNRO309"). The grammar and
the confusable table are both defined over UPPERCASE alphanumerics, so a raw
read is upper-cased and stripped of non-alphanumerics BEFORE either runs β
otherwise a perfectly readable plate fails the grammar for its case, and the
0βO repair cannot even be attempted (lowercase 'o' is not a CONFUSABLES key).
The per-character scores and aligned timesteps are filtered alongside the
glyphs, so the rescorer keeps reading the softmax mass at the right timestep.
The RAW text still feeds the symbol gate below: normalisation must never be
able to launder hallucinated punctuation into a conforming plate. A read that
carries a symbol glyph is rejected before the grammar is ever consulted, so
repair only ever runs on a read that already passed the plausibility gate.
Format-aware rescoring (post-hoc)
---------------------------------
After the plausibility gate, the NORMALISED read is validated against the active
region's plate grammar (`plateRegion`, default 'DE'; 'off' disables). If the
letter/digit sequence already matches, it is accepted as-is (`formatValid: true`).
If it does NOT match, candidate reads are generated by substituting classic OCR
confusables (0βO, 1βI, 8βB, 5βS, 2βZ, 6βG, 4βA) position-by-position; each
candidate is scored by the mean per-character probability and the highest-scoring
GRAMMAR-CONFORMING candidate replaces the read (`formatValid: true`). If no
candidate matches, the original greedy read is kept verbatim (`formatValid:
false`) β validation NEVER drops a plausible read, it only fixes or annotates it.
Per-character probability approximation
---------------------------------------
The greedy CTC path collapses consecutive-duplicate argmax timesteps; for each
emitted output character we retain the single timestep `t` at which its run
began. A candidate's per-character probability is then approximated by the
softmax at that ONE aligned timestep β for an unchanged position the original
argmax probability, and for a substituted position `probs[t, altIndex]` (the
real softmax mass the confusable already carried at that timestep). This is an
approximation: it does not re-run a full CTC forward alignment for the candidate
string (which could re-collapse differently). For single-character confusable
swaps it is a faithful, cheap proxy β the timestep that produced the original
glyph is exactly where its confusable competes for probability.
"""
import itertools
import re
import numpy as np
# Shortest plausible plate (non-space chars). No real plate is 1β2 chars, so
# this only rejects the degenerate near-all-blank collapse; kept conservative to
# never drop a genuine short/vanity plate.
DEFAULT_MIN_TEXT_LENGTH = 3
# Confidence floor (0.0 = disabled by default β character count is the reliable
# discriminator for the degenerate collapse; the "A"@1.0 case is caught by
# length, not confidence). Available for per-camera tuning via config.
DEFAULT_MIN_TEXT_CONFIDENCE = 0.0
# Product-level default plate grammar region β supplied to this postprocessor by
# the TS step config (`plateRegion`, see step-definitions.ts). When the key is
# ABSENT from `config` the postprocessor falls back to 'off' (no rescoring) so a
# bare / pre-plumbing call is byte-identical to the historical decode behaviour;
# the DE default only takes effect once the config plumbing supplies it.
DEFAULT_PLATE_REGION = "DE"
# Fallback when `plateRegion` is absent from config (backward-compatible: no
# rescoring, no formatValid annotation).
ABSENT_PLATE_REGION = "off"
# Cap on the number of positions that may simultaneously carry a confusable
# alternative. Each substitutable position doubles the candidate count, so the
# worst case is 2**MAX_SUBST_POSITIONS candidates. Plates are β€8 glyphs so this
# is rarely binding, but the cap guarantees a bounded search (β€256 candidates)
# even on a pathological long read: when more positions than this have a
# confusable, only the LOWEST-confidence positions (the most likely misreads)
# stay substitutable; the rest are locked to their greedy glyph.
MAX_SUBST_POSITIONS = 8
# Classic OCR confusable pairs, bidirectional. Maps a glyph to the alternates it
# is commonly mistaken for/against. Used ONLY to repair a read that fails the
# grammar β never to alter a read that already conforms.
CONFUSABLES: dict[str, tuple[str, ...]] = {
"0": ("O",),
"O": ("0",),
"1": ("I",),
"I": ("1",),
"8": ("B",),
"B": ("8",),
"5": ("S",),
"S": ("5",),
"2": ("Z",),
"Z": ("2",),
"6": ("G",),
"G": ("6",),
"4": ("A",),
"A": ("4",),
}
# Region plate grammars, keyed by ISO-3166 alpha-2 code. Each pattern validates
# the RAW letter/digit sequence the OCR emits (no separators β the recognizer
# never outputs the dash/space of the physical plate). Ready for future
# countries ('IT', 'FR', β¦); 'off' (handled before lookup) disables rescoring.
#
# GERMANY ('DE'): district code (1β3 letters) + 1β2 letters + 1β4 digits +
# optional E (electric) / H (historic) suffix; β€8 glyphs excluding separators.
# Separators are optional in the pattern for robustness, though the OCR emits
# none. The letter-block split (district vs. series) is inherently ambiguous on
# the raw string, but that does not matter β the regex validates the whole
# sequence, not the split.
PLATE_GRAMMARS: dict[str, str] = {
"DE": r"^[A-ZΓΓΓ]{1,3}[- ]?[A-Z]{1,2}[- ]?[0-9]{1,4}[EH]?$",
}
def _matches_grammar(text: str, pattern) -> bool:
return pattern.match(text) is not None
def _normalize_read(
chars: list[str],
char_scores: list[float],
char_timesteps: list[int],
) -> tuple[str, list[float], list[int]]:
"""Upper-case + drop non-alphanumeric glyphs, keeping scores/timesteps aligned.
The grammar patterns and CONFUSABLES are defined over uppercase alnums; the
charset the recognizer decodes from is not. Returning the filtered score and
timestep lists alongside the text is what lets the rescorer keep reading a
candidate glyph's softmax mass at the timestep that produced its position.
A glyph whose upper-case form is not a single character (no such glyph in
this charset, but the fold is locale-dependent in general) is kept as-is
rather than silently changing the string length.
"""
out_chars: list[str] = []
out_scores: list[float] = []
out_timesteps: list[int] = []
for ch, score, timestep in zip(chars, char_scores, char_timesteps):
if not ch.isalnum():
continue
upper = ch.upper()
out_chars.append(upper if len(upper) == 1 else ch)
out_scores.append(score)
out_timesteps.append(timestep)
return "".join(out_chars), out_scores, out_timesteps
def _rescore_for_grammar(
text: str,
char_scores: list[float],
char_timesteps: list[int],
probs: np.ndarray,
charset: list[str],
pattern,
) -> tuple[str, bool]:
"""Return (repaired_or_original_text, format_valid).
If `text` already matches `pattern`, return it unchanged with True.
Otherwise enumerate confusable substitutions (bounded), score each candidate
by mean per-character probability, and return the highest-scoring candidate
that matches the grammar. If none match, return the original text with False.
"""
if _matches_grammar(text, pattern):
return text, True
chars = list(text)
n = len(chars)
# Reverse charset lookup (glyph β first index) so a substituted glyph's
# softmax mass can be read from the aligned timestep. Built once per call.
char_to_index: dict[str, int] = {}
for idx, glyph in enumerate(charset):
if glyph and glyph not in char_to_index:
char_to_index[glyph] = idx
# Which positions carry a usable confusable alternative (both the glyph has a
# mapping AND the alternate is representable in this charset so its timestep
# probability can be looked up).
subst_positions: list[int] = []
for i, ch in enumerate(chars):
alts = CONFUSABLES.get(ch)
if not alts:
continue
if any(alt in char_to_index for alt in alts):
subst_positions.append(i)
if not subst_positions:
return text, False
# Bound the search: keep only the MAX_SUBST_POSITIONS lowest-confidence
# positions substitutable (most likely misreads); lock the rest.
if len(subst_positions) > MAX_SUBST_POSITIONS:
subst_positions.sort(key=lambda i: char_scores[i])
subst_positions = sorted(subst_positions[:MAX_SUBST_POSITIONS])
subst_set = set(subst_positions)
# Per-position option list: original glyph, plus any representable confusables
# (only for substitutable positions). itertools.product enumerates the grid.
options: list[list[str]] = []
for i, ch in enumerate(chars):
if i in subst_set:
alts = [a for a in CONFUSABLES.get(ch, ()) if a in char_to_index]
options.append([ch, *alts])
else:
options.append([ch])
def _prob_at(i: int, glyph: str) -> float:
if glyph == chars[i]:
return char_scores[i]
idx = char_to_index.get(glyph)
if idx is None:
return 0.0
return float(probs[char_timesteps[i], idx])
best_text: str | None = None
best_score = -1.0
for combo in itertools.product(*options):
candidate = "".join(combo)
if candidate == text:
continue # already known non-matching (checked above)
if not _matches_grammar(candidate, pattern):
continue
score = sum(_prob_at(i, combo[i]) for i in range(n)) / n if n else 0.0
if score > best_score:
best_score = score
best_text = candidate
if best_text is not None:
return best_text, True
return text, False
# Glyphs the recognizer emits for a plate's physical registration seal.
SEAL_GLYPHS = frozenset(".:;,*-'`\"Β·")
def _strip_seal_glyph(text: str, region: str) -> "str | None":
"""Return the normalised plate when `text` is a conforming read carrying ONE
interior seal glyph; ``None`` otherwise (caller applies the normal gate).
Narrow by construction: exactly one seal-like glyph, never at either end,
nothing else non-alphanumeric left over, and the normalised remainder must
match the region grammar. A read that does not become a legal plate is not
rescued.
"""
pattern_src = PLATE_GRAMMARS.get(region)
if pattern_src is None:
return None
seals = [i for i, c in enumerate(text) if c in SEAL_GLYPHS]
if len(seals) != 1:
return None
pos = seals[0]
if pos == 0 or pos == len(text) - 1:
return None
stripped = text[:pos] + text[pos + 1 :]
if any(not (c.isalnum() or c.isspace()) for c in stripped):
return None
normalised = "".join(c for c in stripped.upper() if c.isalnum())
if re.match(pattern_src, normalised) is None:
return None
return normalised
def postprocess_ctc(
predictions: dict,
config: dict,
orig_w: int,
orig_h: int,
scale: float,
pad: tuple[int, int],
) -> dict:
"""CTC greedy decode."""
charset = config.get("charset", [])
# Get first output tensor
raw = np.array(list(predictions.values())[0]).astype(np.float32)
# Handle shapes: [1, T, C] or [T, C]
if raw.ndim == 3:
raw = raw[0] # remove batch
# raw is now [T, C] β logits (not probabilities)
seq_len, num_chars = raw.shape
# Apply softmax to get probabilities
raw_max = np.max(raw, axis=1, keepdims=True)
exp_raw = np.exp(raw - raw_max)
probs = exp_raw / np.sum(exp_raw, axis=1, keepdims=True)
# Argmax per timestep
best_indices = np.argmax(probs, axis=1) # [T]
best_scores = probs[np.arange(seq_len), best_indices] # [T]
# Collapse consecutive duplicates (retain the timestep each run began at so
# the rescorer can read a confusable's softmax mass at that aligned step).
collapsed = []
collapsed_scores = []
collapsed_timesteps = []
for i, idx in enumerate(best_indices):
if i == 0 or idx != best_indices[i - 1]:
collapsed.append(int(idx))
collapsed_scores.append(float(best_scores[i]))
collapsed_timesteps.append(i)
# Remove blank (index 0) and join
chars = []
scores = []
char_timesteps = []
for idx, score, t in zip(collapsed, collapsed_scores, collapsed_timesteps):
if idx != 0:
if idx < len(charset):
chars.append(charset[idx])
else:
chars.append(chr(idx))
scores.append(score)
char_timesteps.append(t)
text = "".join(chars)
confidence = float(np.mean(scores)) if scores else 0.0
# Plausibility gate β suppress the degenerate near-all-blank collapse so
# low-resolution noise is never stored as a plate label. Count ALPHANUMERIC
# characters only ("DW RO 309" β 7): the charset carries symbol glyphs
# ('[', ')', '.', β¦) that a blurred crop hallucinates, and they must not
# count toward "long enough to be a plate".
min_len = int(config.get("minTextLength", DEFAULT_MIN_TEXT_LENGTH))
min_conf = float(config.get("minTextConfidence", DEFAULT_MIN_TEXT_CONFIDENCE))
region = str(config.get("plateRegion", ABSENT_PLATE_REGION))
alnum_count = sum(1 for c in text if c.isalnum())
# Symbol-blind gate: when a plate grammar/region is ACTIVE, no real plate
# contains a non-alphanumeric glyph (spaces allowed β word gaps are real).
# Observed live labels "[miv4o)" / "6ou.2" (2026-07-22) sailed through the
# length-only gate; reject them exactly like the other implausible reads.
has_symbol_glyph = region != "off" and any(
not (c.isalnum() or c.isspace()) for c in text
)
# β¦except the SEAL. A German plate physically carries a registration seal
# between the district code and the letter group, and the recognizer
# transcribes that badge as a punctuation glyph ('.', ':', '-', β¦). Once the
# quad warp (2026-08-20) started delivering legible strips, the correct
# decodes arrived as "DN.D7578" / "DN-P 1091" / "DN:HF 974" and this gate
# threw away every one of them β the warp scored 1/21 instead of 4/21 on the
# cam-617 ground-truth set purely because of this line.
#
# The exemption is deliberately narrow: EXACTLY ONE seal-like glyph, strictly
# INTERIOR, and the remainder must CONFORM to the region grammar. Noise
# cannot be laundered by it β the 2026-07-22 junk ("6ou.2x", "[miv4o)") has
# either the wrong count, the wrong position, or no conforming remainder.
if has_symbol_glyph:
repaired = _strip_seal_glyph(text, region)
if repaired is not None:
return {
"kind": "text",
"text": repaired,
"confidence": round(confidence, 4),
"formatValid": True,
}
if alnum_count < min_len or confidence < min_conf or has_symbol_glyph:
return {
"kind": "text",
"text": "",
"confidence": round(confidence, 4),
}
result = {
"kind": "text",
"text": text,
"confidence": round(confidence, 4),
}
# Post-hoc format validation + confusable rescoring. 'off' disables it
# entirely (no grammar lookup, no formatValid annotation).
if region != "off":
pattern_src = PLATE_GRAMMARS.get(region)
if pattern_src is not None:
pattern = re.compile(pattern_src)
# Grammar + confusables run on the NORMALISED read (upper, alnum
# only). Reached only past the plausibility gate above, so a repair
# can never turn sub-legible noise into a more credible string: the
# symbol gate already rejected the reads that would qualify, and the
# normalised length equals the alnum count the length gate cleared.
norm_text, norm_scores, norm_timesteps = _normalize_read(
chars, scores, char_timesteps
)
fixed_text, format_valid = _rescore_for_grammar(
norm_text, norm_scores, norm_timesteps, probs, list(charset), pattern
)
result["text"] = fixed_text
result["formatValid"] = format_valid
return result
|