CoolFace
Datasetpublic

S3CUR/badger-55-watermeter

badger model 55 water-meter drum digits (v2) Hand-verified slot crops + continuous-angle labels from a residential Badger Model 55 water meter, captured over ~2 months by an on-prem USB webcam + on-device VLM/CNN reader (/nfs/ai/metermaid-*). The project's internal training pipelines feed off the same data; this directory is a publishable mirror, separate from the active tagging pipeline, with junk filtered. What's new in v2 (2026-05-24) Self-contained parquets… See the full description on the dataset page: https://huggingface.co/datasets/S3CUR/badger-55-watermeter.

sourceHugging Facecc-by-4.0updated 4mo agoView on Hugging Face
0likes33downloads
Dataset Card

badger model 55 water-meter drum digits (v2)

Hand-verified slot crops + continuous-angle labels from a residential Badger Model 55 water meter, captured over ~2 months by an on-prem USB webcam + on-device VLM/CNN reader (/nfs/ai/metermaid-*). The project's internal training pipelines feed off the same data; this directory is a publishable mirror, separate from the active tagging pipeline, with junk filtered.

What's new in v2 (2026-05-24)

  • —Self-contained parquets, no loose images. Both splits embed the full JPEG bytes inline as an image_bytes column. The dataset is now two files at the repo root (slots.parquet, captures.parquet) instead of thousands of loose JPEGs in slots/images/ and captures/images/. A cold pull is one HTTP request per split; no more rate-limited per-file HEAD storms.

What's still inherited from v1 (the multi-source retagging release)

  • —Every committed slot row was re-tagged by hand with multi-source evidence on screen (bao P90 + qwen3-vl single-slot probe + DINOv2 cosine cousin) after dHash label-propagation contamination was discovered in v0.
  • —captures rows have all 8 slots filled by a priority stack (fixed → human → sdr → bao_p90); the sources column carries the per-slot provenance.
  • —mechanism column corrected to the empirically-supported direction: d7 is the continuous-rotation drum, d4–d6 are the Geneva-snap drums.

Layout

badger-55-watermeter/
├── README.md
├── slots.parquet           # 4,548 rows, ~29 MB (one row per slot crop)
└── captures.parquet        # 2,506 rows, ~994 MB (one row per full-frame meter shot)

Two views:

  • —`slots` — single-digit crops with a continuous angle label.
  • —`captures` — full meter face per row, all 8 slots populated, with a per-slot sources array telling you the provenance of each digit and an sdr_* triplet describing the radio-anchored bound.

All JPEGs are re-encoded through Pillow with EXIF / ICC / comment metadata explicitly stripped before being embedded. No meter serial number, device ID, or street-level identifier is present anywhere in the dataset.

Reading the dataset

The simplest way:

python
import pandas as pd
import io
from PIL import Image
from huggingface_hub import hf_hub_download

# Slots view — single-digit crops
slots_path = hf_hub_download('S3CUR/badger-55-watermeter',
                              filename='slots.parquet', repo_type='dataset')
df = pd.read_parquet(slots_path)
print(df.iloc[0][['slot', 'digit', 'theta_deg', 'tier', 'mechanism']])
img = Image.open(io.BytesIO(df.iloc[0]['image_bytes']))
img.show()

Or via the datasets library, which casts image_bytes to a PIL image automatically when the column is named image:

python
from datasets import load_dataset
ds = load_dataset('S3CUR/badger-55-watermeter', 'slots')['train']
# `ds[0]['image_bytes']` is raw bytes; decode with PIL or OpenCV as above.

slots.parquet schema

columntypeexamplenotes
image_bytesbytes(raw JPEG, ~7 KB each)Decode with PIL or cv2.imdecode
framestring20260511_065817.jpgOriginal filename in the source pool — stable handle for cross-referencing
slotint85Drum position 4–7 on the 8-digit display
digitint87Class label 0–9 (band-center derivation for platinum)
theta_degfloat32268.4Angle 0–360° — the primary label
tierstringgold / platinumSource quality tier (see below)
mechanismstringcontinuous / genevad7 is continuous-rotation; d4–d6 use a Geneva snap mechanism
source_poolstringgold_d5_envelope_misreadsInternal pool name for provenance
source_capture_idint32?124261Nullable — only populated for newer pools
captured_atstring2026-05-17 17:02:22Wall-clock timestamp (parsed from filename for older pools)
splitstringtrain / val / testHash-based 80/10/10, stable across rebuilds

captures.parquet schema

One row per full-frame meter capture. Platinum frames are not in this view — they're a rapid-roll session, not normal operational captures.

columntypenotes
image_bytesbytesFull-frame JPEG (~400 KB each), metadata-stripped
capture_idint32?Nullable — only newer pools record it
captured_atstringWall-clock timestamp
digitslist[int8]Length 8. Every entry populated; consult sources for provenance
thetas_deglist[float32?]Length 8. Null where the source is SDR or fixed; populated where the source is human or bao_p90
truth_knownlist[bool]Length 8. True only where a human tagged that slot directly. Other slots may still be highly accurate (radio-anchored or model-predicted) — this flag is specifically about human attestation.
sourceslist[string]Length 8. Per-slot provenance: 'fixed' / 'human' / 'sdr' / 'bao_p90'
n_slots_knownint8Count of True entries in truth_known (= number of slots a human directly tagged).
sdr_confidencestring'lock', 'bracket_diff', 'time_growth', or 'no_sdr' — see SDR anchor stack below
sdr_floor_rawint32Lower bound on raw_reading (8-digit-display units) derived from the SDR packet before the capture
sdr_lookahead_rawint32Upper bound on raw_reading from the bracket-after packet (or time-growth bound for live-tail rows). True reading is guaranteed in [sdr_floor_raw, sdr_lookahead_raw].
splitstringHash-based, same basis as slots — captures and their constituent slot crops land in the same split
SDR anchor stack

The meter's Itron radio (SCM+) transmits its consumption register every ~hour. Each packet's reading_raw is a lower bound on the current consumption at packet time (×100 to convert to 8-digit-display units — the radio drops the bottom 10-gallon resolution). For each capture at time T we look at:

  • —P_before — last SDR packet with received_at < T
  • —P_after — first SDR packet with received_at >= T
`sdr_confidence`conditionwhat's pinned
lockPbefore, Pafter both exist and reading_raw matches → register did not move across the windowd0–d5 all anchored to that exact value
bracket_diffPbefore, Pafter differ → consumption ticked at least once during the windowonly the leftmost digits that match in both zfill8(floor) and zfill8(upper) are anchored; lower digits fall back to bao_p90
time_growthonly Pbefore exists (live-tail row near "now") → use a max-physical-flow growth bound (`28 GPM × minutessincelastregister_change`) for the upperas bracket_diff
no_sdrno SDR packets in the ±6h windowall 8 slots fall back to bao_p90 (rare)

In v2 every capture has SDR coverage — distribution: 1,742 `lock`, 764 `bracket_diff`, 0 `time_growth`, 0 `no_sdr`.

Tiers

  • —`platinum` (1,035 frames, all d7) — the platinum_d7 atlas: a high-FPS rapid-roll capture of a single digit rotating continuously, hand-anchored to ground-truth angles at every ~0.35° step. The highest-precision angular labels in the dataset.
  • —`gold` (3,513 frames across d4/d5/d6/d7) — production-camera crops reviewed in a tagger UI by a human, each committed with a precise theta to a platinum reference. Every gold row was hand-touched with bao P90 + qwen single-slot + DINOv2 cousin evidence visible on screen. Multiple internal pools feed this tier:
  • —base pools (gold_d{4..7}, gold_d4_v2, gold_d5_misreads_v1)
  • —manual-flag harvests from the web UI (gold_d{N}_manual_flags)
  • —envelope-truth harvests where SDR pins the upper digits (gold_d{N}_envelope_misreads)

Mechanism note (read this before training)

The Badger 55 dial isn't a single class of drum:

  • —`d7` is geared directly to flow and rotates continuously. It spends roughly half its time at non-integer angles, smoothly traversing every value 0°–360°. Theta is the real label here — digit is just a derived quantization. The platinum atlas captures this continuous rotation densely (~0.35° per frame).
  • —`d4`–`d6` are higher-order drums coupled to the drum below them through a Geneva mechanism (and their own gear chain). They stay parked at an integer angle (0°, 36°, 72°…) until the digit immediately below them rolls 9→0, at which point they snap one position. In this dataset, d4 sits on an integer angle 99.9% of the time, d5 96%, d6 66% — the rest is mid-snap. Mid-snap is the genuinely-hard regime for these slots, and the gold_d{N}_manual_flags pools were curated to oversample them.

Theta is the right label for both — use digit only when you need classification. For d4–d6, an angular error of even a few degrees almost always still produces the right integer digit because the drum is parked on it. For d7, angle accuracy matters because the digit is genuinely continuous; the model needs to learn the mapping from the rotating digit's appearance to its phase angle, not just to a discrete class.

The mechanism column in slots.parquet carries this: 'continuous' for d7 (and the platinum atlas), 'geneva' for d4–d6.

Dataset versions through v1.0 had this inverted in both the `mechanism` column and the README's mechanism section. Corrected 2026-05-24 based on the empirical theta distribution above.

Splits

Hash-based on (source_pool, frame): md5 % 100 → 0–79 train, 80–89 val, 90–99 test. Stable across re-runs of the build script. Not stratified by digit or pool — feel free to re-split for your task.

Known limits

  • —Single meter, single household, single camera. No diversity in lighting/bezel/angle.
  • —Digit distribution skews to actual readings. During collection the meter was in the ~648K–668K gallon range; d2/d3 are dominated by 6,6, and d4 by 7,8. Class weights / resampling help.
  • —No mid-roll flag. Derive it yourself from theta_deg: the band center for digit d is d * 36° + 18°; mid-roll is anywhere ≥ ~9° from band center.
  • —423 captures dropped from the captures view because their original full-frame source image was purged during a service decomposition. The slot crops from those captures still appear in the slots view — they just don't roll up into a full-meter row.

Build pipeline (for reference)

bash
# Step 1: rebuild from gold pool reviewed.csv commits
/nfs/ai/metermaid-bao/venv/bin/python3 package.py --clean

# Step 2: enrich captures view with SDR + bao P90 anchors
/nfs/ai/metermaid-bao/venv/bin/python3 enrich_captures.py

Source filters applied during build:

  • —Only action='commit' rows from each pool's reviewed.csv
  • —Multiple commits per frame → latest wins (so a later disqualify also overrides an earlier commit)
  • —User-flagged mis-crops in /nfs/ai/metermaid-training/datasets/.disqualify.csv excluded

enrich_captures.py runs a PII guard before writing — the build refuses to emit any column whose name matches meter_serial, serial, device_id, or endpoint_id.

Related artifacts

The trained reader model and a clean-room demo that consumes this dataset will live at <https://huggingface.co/S3CUR/badger-55-meterreader> (pending publish). That repo is the place to look for inference code, weights, and a runnable end-to-end demo.

License

Released under **CC-BY-4.0**. You may use, modify, and redistribute (including commercially) provided you attribute the dataset. No fee, no warranty.

Attribution

Dataset author has chosen to remain pseudonymous. Cite as:

badger model 55 water-meter drum digits dataset. Three, 2026.

No author name, email, or institutional affiliation is associated with this release.