| """Clock-time parsing and the error metric, in MINUTES. |
| |
| An analog clock face is a ring of 720 minutes (12 hours). Two times that look |
| identical on a face are identical here: 3:15 and 15:15 are the same reading. |
| Error is the shorter way round that ring, so it lives in [0, 360]. |
| |
| Nothing in this file knows about models, images or torch. It is the definition |
| of "correct" for the whole project, so it is kept small and tested. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import re |
|
|
| RING = 720.0 |
| MAX_ERR = RING / 2 |
|
|
| _TIME_RE = re.compile(r"^\s*(\d{1,2})\s*[:.h]\s*(\d{1,2}(?:\.\d+)?)\s*$") |
|
|
|
|
| def to_minutes(hour: float, minute: float) -> float: |
| """Position on the face, in minutes past 12 o'clock, in [0, 720).""" |
| return ((hour % 12) * 60.0 + minute) % RING |
|
|
|
|
| def from_minutes(minutes: float) -> tuple[int, float]: |
| """Inverse of to_minutes. Returns (hour 1-12, minute).""" |
| m = minutes % RING |
| hour = int(m // 60) |
| return (12 if hour == 0 else hour), m - hour * 60 |
|
|
|
|
| def fmt(minutes: float) -> str: |
| """Human clock string, e.g. 227.0 -> '3:47'. Fractions kept if present.""" |
| h, m = from_minutes(minutes) |
| if abs(m - round(m)) < 1e-9: |
| return f"{h}:{int(round(m)):02d}" |
| return f"{h}:{m:05.2f}" |
|
|
|
|
| def parse(text: str) -> float: |
| """Parse a human-typed time into minutes-on-the-face. |
| |
| Accepts '3:47', '03:47', '3.47', '15:47' (same face position as 3:47) and |
| bare digits '347' / '1215' as typed by the labelling UI. |
| """ |
| text = (text or "").strip() |
| if not text: |
| raise ValueError("empty time") |
| m = _TIME_RE.match(text) |
| if not m: |
| if text.isdigit() and 3 <= len(text) <= 4: |
| m = re.match(r"^(\d{1,2})(\d{2})$", text) |
| if not m: |
| raise ValueError(f"cannot parse time: {text!r}") |
| hour = float(m.group(1)) |
| minute = float(m.group(2)) |
| if not (0 <= hour <= 23): |
| raise ValueError(f"hour out of range: {text!r}") |
| if not (0 <= minute < 60): |
| raise ValueError(f"minute out of range: {text!r}") |
| return to_minutes(hour, minute) |
|
|
|
|
| def error_minutes(pred: float, label: float) -> float: |
| """Shorter arc between two face positions, in minutes. In [0, 360].""" |
| d = abs((pred - label) % RING) |
| return min(d, RING - d) |
|
|
|
|
| def swapped(minutes: float) -> float: |
| """The reading you get if the hour and minute hands are confused. |
| |
| The minute hand sits at (minutes % 60) minutes-of-the-hour, i.e. face |
| position (minutes % 60) * 12. The hour hand sits at face position |
| `minutes`, which reads as minute-of-hour (minutes / 12). Swapping the two |
| gives this. Used only as a diagnostic on failures. |
| """ |
| minute_hand_pos = (minutes % 60) * 12.0 |
| new_hour = minute_hand_pos // 60 |
| new_minute = (minutes % RING) / 12.0 |
| return to_minutes(new_hour, new_minute) |
|
|