clockface / README.md
lyte-codes's picture
Model card: measured results, including the sim-to-real failure
21298ad verified
|
Raw History Blame
5.39 kB
metadata
license: apache-2.0
datasets:
  - lyte-codes/clockface-synth-dataset-10k
pipeline_tag: image-to-text
tags:
  - analog-clock
  - time-reading
  - sim2real

clockface

Reads an analog clock face and returns the time. Two stages: find the dial, then read it. 11.5 MB of weights in total.

This model does not work well on real photographs. On 1,728 real photos it scores 155.9 minutes mean error, against 180 minutes for guessing at random. It scores 4.8 minutes on the synthetic data it was trained on. It is published because the gap between those two numbers is the useful part.

Version 263701. Trained on synth v2, revision main of the dataset above.

The metric

A clock face is a ring of 720 minutes. Error is the shorter way round it, so it lies in [0, 360] and 11:58 against 12:02 is 4 minutes, not 716. AM/PM does not exist: a face cannot show it, so 3:47 and 15:47 are the same reading.

Guessing uniformly at random scores 180 minutes. That is the number to beat, and it is the number this model is measured against.

Results

Real photographs (1,728 images, third-party labels)

MAE median within 5 min worse than 30 min
two-stage 155.9 min 139.9 11.4% 82.2%
whole image, no locator 151.8 min 133.7 9.2% 82.5%
random guessing 180 min — — —

Sources: 1,092 film stills from Marclay's The Clock, 506 photographs from moondream/analog-clock-benchmark, 131 cropped faces from oliverj990/clock-faces-v1-times. Their labels are third party and mostly unverified; restricted to the 106 labels checked by hand against the image, the result is MAE 148.5, so label noise does not explain it.

By source, the pattern is a domain gap and not much else:

source MAE within 5 min
cropped clock faces (dial fills frame) 87.8 min 45.0%
photographs of scenes 163.3 min 9.3%

The closer an image is to the synthetic framing, the better it does.

Synthetic validation (1,200 held-out renders) — training telemetry only

MAE median within 1 min within 5 min
ground-truth crops 4.82 min 0.42 79.0% 92.5%
end to end 5.07 min 0.43 79.0% 92.2%

These numbers are not a result. The model is being scored by the same renderer that produced its labels. They are here to show the size of the sim-to-real gap, which is the point: 4.8 minutes on renders, 156 on photographs.

What goes wrong

  • Hand confusion. 720 of the 1,421 gross failures would improve if the hour and minute hands were swapped. The generator used a narrow band of hand lengths and widths, and the model learned that convention rather than learning which hand is longer.
  • The confidence signal does not survive. The two hands' disagreement is a good confidence signal on synthetic data (MAE 4.82 falls to 1.64 when keeping the most confident 75%) and is inert on real photographs (155.9 to 153.5). The model is confidently wrong, which is worse than being uncertain.
  • Small clocks. The locator finds a dial to 1.5% of image width on renders and fails on wide scenes where the clock is a small part of the frame.

Architecture

stage 1  DialLocator      554K params, 2.2 MB   image -> dial centre, radius
stage 2  PretrainedReader 2.3M params, 9.2 MB   crop -> two angle distributions

Stage 2 is a MobileNetV3-Small backbone pretrained on ImageNet, with two heads predicting a distribution over 180 angular bins for each hand. Targets are von Mises bumps rather than one-hot, and decoding takes a circular soft-argmax.

An identical from-scratch network never leaves chance on this task: it sits at 2*ln(180), a uniform distribution, however long it trains, while overfitting 400 images perfectly. ImageNet initialisation is what makes it learn at all.

Both hands are predicted, never the time directly, so their disagreement gives a confidence signal for free. Decoding is a vernier: the minute hand supplies precision, the hour hand picks which hour. Measured against reading the hour hand alone, that is about 11x more accurate under noise.

Usage

import torch
from twostage import DialLocator, PretrainedReader, crop_dial
from model import decode_cls

reader = PretrainedReader(bins=180, arch="mobilenet_v3_small")
reader.load_state_dict(torch.load("stage2_best.pt")["model"])
hour_logits, minute_logits = reader(crop)          # crop: 1x3x256x256, ImageNet norm
minutes, hour_only, disagreement, sharpness = decode_cls(hour_logits, minute_logits)

minutes is the position on the 720-minute ring; disagreement is how far apart the two hands' readings are, in minutes.

Intended use

Reading round analog clock faces. Out of scope: digital-analog hybrids, subdials, chronographs, 24-hour faces.

Not suitable for any use where a wrong time carries a cost. It is wrong by more than half an hour on 82% of real photographs.

Reproducing

git clone https://github.com/lyte-codes/clockface
./eval.py --self-test                              # 32/32, checks the metric
python3 predict.py --labels <labels> --root <dir> \
    --stage2 stage2_best.pt --stage1 stage1_best.pt --out preds.jsonl
./eval.py --labels <labels> --pred preds.jsonl

Licence

Apache 2.0. The synthetic dataset is CC BY 4.0.