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
camstack-train 0.1.0: YOLOv9 root detector fine-tune (job.json in, result.json + bundle.tar out)
ebeed67 verified Download camstack_train/_vendor/pipeline_python/postprocessors/ctc.py from camstack/camstack-trainer: direct link, hf CLI and curl.
- Browser
- Download file 17.7 kB
-
https://huggingface.co/camstack/camstack-trainer/resolve/main/camstack_train/_vendor/pipeline_python/postprocessors/ctc.py
- Command line
-
hf download hf://camstack/camstack-trainer/camstack_train/_vendor/pipeline_python/postprocessors/ctc.py
-
curl -L -o ctc.py https://huggingface.co/camstack/camstack-trainer/resolve/main/camstack_train/_vendor/pipeline_python/postprocessors/ctc.py
17.7 kB
| """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 | |