File size: 9,664 Bytes
4837dcb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e1a304
 
 
 
 
 
 
 
 
 
 
 
 
 
4837dcb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
"""The clockface model: a small CNN that reads both hands.

Two angles come out, not one time. The hour hand alone determines the time, and
the minute hand alone determines it modulo an hour; predicting both lets them
be checked against each other, which is where the confidence signal comes from.

Angles are represented as (sin, cos), never as raw degrees. Degrees wrap at 360,
so 359 and 1 are neighbours on a dial but maximally distant in the loss, and a
model trained on raw degrees blows up at the seam.
"""

from __future__ import annotations

import math

import torch
import torch.nn as nn


class ConvBlock(nn.Module):
    def __init__(self, cin, cout, stride=1):
        super().__init__()
        self.conv = nn.Conv2d(cin, cout, 3, stride=stride, padding=1, bias=False)
        # BatchNorm rather than GroupNorm: measured on this M2, GroupNorm runs a
        # forward+backward of this net at 39 img/s against BatchNorm's 65, and
        # the batch is large enough (64) for batch statistics to be stable.
        self.norm = nn.BatchNorm2d(cout)
        self.act = nn.SiLU(inplace=True)

    def forward(self, x):
        return self.act(self.norm(self.conv(x)))


class ClockNet(nn.Module):
    """Small CNN -> 4 numbers: (sin, cos) for the hour hand and the minute hand."""

    def __init__(self, width=32, in_res=256):
        super().__init__()
        w = width
        self.stem = ConvBlock(3, w, stride=2)                 # 112
        self.stage1 = nn.Sequential(ConvBlock(w, w), ConvBlock(w, w * 2, stride=2))      # 56
        self.stage2 = nn.Sequential(ConvBlock(w * 2, w * 2), ConvBlock(w * 2, w * 4, stride=2))  # 28
        self.stage3 = nn.Sequential(ConvBlock(w * 4, w * 4), ConvBlock(w * 4, w * 8, stride=2))  # 14
        self.stage4 = nn.Sequential(ConvBlock(w * 8, w * 8), ConvBlock(w * 8, w * 8, stride=2))  # 7
        # Keep a 4x4 spatial grid rather than collapsing to 1x1. Reading a hand
        # angle is a question about WHERE something points, and global average
        # pooling discards exactly that: it is the right head for "is there a
        # clock" and the wrong one for "which way does the hand point".
        # 256px in -> 8x8 final map -> pool to 4x4. MPS cannot adaptive-pool
        # when the input size is not divisible by the output size, which 7->4
        # is not; 8->4 is.
        self.pool = nn.AdaptiveAvgPool2d(4)
        self.head = nn.Sequential(
            nn.Flatten(),
            nn.Linear(w * 8 * 16, 256), nn.SiLU(inplace=True),
            nn.Dropout(0.1),
            nn.Linear(256, 4),
        )

    def forward(self, x):
        x = self.stem(x)
        x = self.stage1(x); x = self.stage2(x); x = self.stage3(x); x = self.stage4(x)
        out = self.head(self.pool(x))
        # Return the RAW (sin, cos) pairs. Normalising here divides by a
        # magnitude that is near zero at initialisation, so the gradient through
        # the division scales as 1/||x|| and the early steps thrash. The loss
        # against unit-length targets pulls the magnitude to 1 on its own, and
        # decode() normalises when it needs a direction. The magnitude is still
        # a usable confidence signal: a short vector means the model is unsure.
        h_mag = out[:, 0:2].norm(dim=1, keepdim=True)
        m_mag = out[:, 2:4].norm(dim=1, keepdim=True)
        return out, torch.cat([h_mag, m_mag], dim=1)


def angles_to_targets(minutes: torch.Tensor) -> torch.Tensor:
    """minutes on the 720 ring -> (sin,cos) of each hand's angle."""
    hour_ang = minutes / 720.0 * 2 * math.pi
    min_ang = (minutes % 60.0) / 60.0 * 2 * math.pi
    return torch.stack([torch.sin(hour_ang), torch.cos(hour_ang),
                        torch.sin(min_ang), torch.cos(min_ang)], dim=1)


def decode(pred: torch.Tensor):
    """(sin,cos) pairs -> a time in minutes, plus the two hands' disagreement.

    The minute hand is precise but says nothing about which hour it is; the hour
    hand says which hour but reads the minutes coarsely. Combine them the way a
    vernier scale does: take the minute-of-hour from the minute hand, and take
    only the hour count from the hour hand.
    """
    # atan2 is scale-invariant, so raw (unnormalised) outputs decode correctly.
    h_ang = torch.atan2(pred[:, 0], pred[:, 1]) % (2 * math.pi)
    m_ang = torch.atan2(pred[:, 2], pred[:, 3]) % (2 * math.pi)

    hour_minutes = h_ang / (2 * math.pi) * 720.0        # what the hour hand alone says
    minute_of_hour = m_ang / (2 * math.pi) * 60.0       # what the minute hand alone says

    # which hour does the minute hand belong to, given the hour hand
    k = torch.round((hour_minutes - minute_of_hour) / 60.0)
    combined = (k * 60.0 + minute_of_hour) % 720.0

    # disagreement in minutes between the two readings, on the 720 ring
    d = (hour_minutes - combined).abs() % 720.0
    disagreement = torch.minimum(d, 720.0 - d)
    return combined, hour_minutes, disagreement


def count_params(model):
    n = sum(p.numel() for p in model.parameters())
    return n, n * 4 / 1e6      # float32 megabytes


if __name__ == "__main__":
    m = ClockNet()
    n, mb = count_params(m)
    x = torch.randn(2, 3, 256, 256)
    pred, mag = m(x)
    t, hm, dis = decode(pred)
    print(f"ClockNet: {n:,} params, {mb:.2f} MB float32")
    print(f"  forward {tuple(x.shape)} -> pred {tuple(pred.shape)}, magnitudes {tuple(mag.shape)}")
    print(f"  decoded times {t.tolist()}")
    print(f"  disagreement  {dis.tolist()}")


# ---------------------------------------------------------------------------
# Classification head.
#
# Regressing (sin, cos) under MSE collapses to the mean: predicting the zero
# vector scores 0.5 against unit-length targets, and on this data the optimiser
# settles there rather than finding the hands (measured: loss 0.4986, val MAE
# 180 min = chance). Yang/Xie/Zisserman report the same thing, 5.4% against
# 59.6% for classification, and give the reason: too little penalty for being
# slightly wrong.
#
# So predict a DISTRIBUTION over angles for each hand instead. Bins are
# circular, the target is a von Mises bump rather than a one-hot (so being one
# bin out is genuinely cheaper than being ten out), and decoding takes a
# circular soft-argmax, which recovers sub-bin precision.
# ---------------------------------------------------------------------------

class ClockNetCls(nn.Module):
    def __init__(self, width=32, bins=180):
        super().__init__()
        w = width
        self.bins = bins
        self.stem = ConvBlock(3, w, stride=2)
        self.stage1 = nn.Sequential(ConvBlock(w, w), ConvBlock(w, w * 2, stride=2))
        self.stage2 = nn.Sequential(ConvBlock(w * 2, w * 2), ConvBlock(w * 2, w * 4, stride=2))
        self.stage3 = nn.Sequential(ConvBlock(w * 4, w * 4), ConvBlock(w * 4, w * 8, stride=2))
        self.stage4 = nn.Sequential(ConvBlock(w * 8, w * 8), ConvBlock(w * 8, w * 8, stride=2))
        self.pool = nn.AdaptiveAvgPool2d(4)
        self.trunk = nn.Sequential(nn.Flatten(), nn.Linear(w * 8 * 16, 512), nn.SiLU(inplace=True),
                                   nn.Dropout(0.1))
        self.hour_head = nn.Linear(512, bins)
        self.minute_head = nn.Linear(512, bins)

    def forward(self, x):
        x = self.stem(x)
        x = self.stage1(x); x = self.stage2(x); x = self.stage3(x); x = self.stage4(x)
        f = self.trunk(self.pool(x))
        return self.hour_head(f), self.minute_head(f)


def soft_targets(minutes: torch.Tensor, bins: int, kappa: float = 40.0):
    """von Mises bumps over circular bins, for the hour and minute hands."""
    dev = minutes.device
    centres = (torch.arange(bins, device=dev, dtype=torch.float32) + 0.5) / bins * 2 * math.pi
    out = []
    for ang in (minutes / 720.0 * 2 * math.pi, (minutes % 60.0) / 60.0 * 2 * math.pi):
        d = centres.unsqueeze(0) - ang.unsqueeze(1)
        t = torch.exp(kappa * (torch.cos(d) - 1.0))
        out.append(t / t.sum(dim=1, keepdim=True))
    return out


def ring_target(minutes: torch.Tensor, bins: int, kappa: float = 40.0):
    """A von Mises bump over the whole 12-hour ring, for the time-itself head.

    The angular heads are trained on hand directions; this one is trained on the
    time, so bin b covers minutes b/bins of the way round 720 rather than round
    a single revolution of a hand.
    """
    dev = minutes.device
    centres = (torch.arange(bins, device=dev, dtype=torch.float32) + 0.5) / bins * 2 * math.pi
    d = centres.unsqueeze(0) - (minutes / 720.0 * 2 * math.pi).unsqueeze(1)
    t = torch.exp(kappa * (torch.cos(d) - 1.0))
    return t / t.sum(dim=1, keepdim=True)


def soft_argmax_angle(logits: torch.Tensor):
    """Circular expectation of a distribution over angle bins -> radians."""
    bins = logits.shape[1]
    p = torch.softmax(logits, dim=1)
    centres = (torch.arange(bins, device=logits.device, dtype=torch.float32) + 0.5) / bins * 2 * math.pi
    s = (p * torch.sin(centres)).sum(dim=1)
    c = (p * torch.cos(centres)).sum(dim=1)
    return torch.atan2(s, c) % (2 * math.pi), torch.sqrt(s ** 2 + c ** 2)


def decode_cls(hour_logits, minute_logits):
    """Same vernier decode as the regression head, from two distributions."""
    h_ang, h_conf = soft_argmax_angle(hour_logits)
    m_ang, m_conf = soft_argmax_angle(minute_logits)
    hour_minutes = h_ang / (2 * math.pi) * 720.0
    minute_of_hour = m_ang / (2 * math.pi) * 60.0
    k = torch.round((hour_minutes - minute_of_hour) / 60.0)
    combined = (k * 60.0 + minute_of_hour) % 720.0
    d = (hour_minutes - combined).abs() % 720.0
    return combined, hour_minutes, torch.minimum(d, 720.0 - d), torch.stack([h_conf, m_conf], 1)