lyte-codes commited on
Commit
810e5aa
·
verified ·
1 Parent(s): 1f7b895

Add clocktime.py

Browse files
Files changed (1) hide show
  1. clocktime.py +82 -0
clocktime.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Clock-time parsing and the error metric, in MINUTES.
2
+
3
+ An analog clock face is a ring of 720 minutes (12 hours). Two times that look
4
+ identical on a face are identical here: 3:15 and 15:15 are the same reading.
5
+ Error is the shorter way round that ring, so it lives in [0, 360].
6
+
7
+ Nothing in this file knows about models, images or torch. It is the definition
8
+ of "correct" for the whole project, so it is kept small and tested.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ RING = 720.0 # minutes on a 12-hour face
16
+ MAX_ERR = RING / 2 # 360: the two hands are as far apart as they can get
17
+
18
+ _TIME_RE = re.compile(r"^\s*(\d{1,2})\s*[:.h]\s*(\d{1,2}(?:\.\d+)?)\s*$")
19
+
20
+
21
+ def to_minutes(hour: float, minute: float) -> float:
22
+ """Position on the face, in minutes past 12 o'clock, in [0, 720)."""
23
+ return ((hour % 12) * 60.0 + minute) % RING
24
+
25
+
26
+ def from_minutes(minutes: float) -> tuple[int, float]:
27
+ """Inverse of to_minutes. Returns (hour 1-12, minute)."""
28
+ m = minutes % RING
29
+ hour = int(m // 60)
30
+ return (12 if hour == 0 else hour), m - hour * 60
31
+
32
+
33
+ def fmt(minutes: float) -> str:
34
+ """Human clock string, e.g. 227.0 -> '3:47'. Fractions kept if present."""
35
+ h, m = from_minutes(minutes)
36
+ if abs(m - round(m)) < 1e-9:
37
+ return f"{h}:{int(round(m)):02d}"
38
+ return f"{h}:{m:05.2f}"
39
+
40
+
41
+ def parse(text: str) -> float:
42
+ """Parse a human-typed time into minutes-on-the-face.
43
+
44
+ Accepts '3:47', '03:47', '3.47', '15:47' (same face position as 3:47) and
45
+ bare digits '347' / '1215' as typed by the labelling UI.
46
+ """
47
+ text = (text or "").strip()
48
+ if not text:
49
+ raise ValueError("empty time")
50
+ m = _TIME_RE.match(text)
51
+ if not m:
52
+ if text.isdigit() and 3 <= len(text) <= 4:
53
+ m = re.match(r"^(\d{1,2})(\d{2})$", text)
54
+ if not m:
55
+ raise ValueError(f"cannot parse time: {text!r}")
56
+ hour = float(m.group(1))
57
+ minute = float(m.group(2))
58
+ if not (0 <= hour <= 23):
59
+ raise ValueError(f"hour out of range: {text!r}")
60
+ if not (0 <= minute < 60):
61
+ raise ValueError(f"minute out of range: {text!r}")
62
+ return to_minutes(hour, minute)
63
+
64
+
65
+ def error_minutes(pred: float, label: float) -> float:
66
+ """Shorter arc between two face positions, in minutes. In [0, 360]."""
67
+ d = abs((pred - label) % RING)
68
+ return min(d, RING - d)
69
+
70
+
71
+ def swapped(minutes: float) -> float:
72
+ """The reading you get if the hour and minute hands are confused.
73
+
74
+ The minute hand sits at (minutes % 60) minutes-of-the-hour, i.e. face
75
+ position (minutes % 60) * 12. The hour hand sits at face position
76
+ `minutes`, which reads as minute-of-hour (minutes / 12). Swapping the two
77
+ gives this. Used only as a diagnostic on failures.
78
+ """
79
+ minute_hand_pos = (minutes % 60) * 12.0 # where the minute hand is
80
+ new_hour = minute_hand_pos // 60 # read it as the hour hand
81
+ new_minute = (minutes % RING) / 12.0 # read hour hand as minutes
82
+ return to_minutes(new_hour, new_minute)