lyte-codes commited on
Commit
759312d
·
verified ·
1 Parent(s): 4837dcb

Add twostage.py

Browse files
Files changed (1) hide show
  1. twostage.py +120 -0
twostage.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Two-stage clock reading: find the dial, then read it.
2
+
3
+ A single-stage CNN has to locate the face and measure two angles at once, from
4
+ an image where the clock may fill 30% of the frame. Splitting those apart gives
5
+ each stage an easier job, and the supervision for stage 1 is free because the
6
+ renderer knows exactly where it put the dial.
7
+
8
+ stage 1 DialLocator full image -> (cx, cy, r) of the dial
9
+ stage 2 ClockReader a crop around the dial -> two angular distributions
10
+
11
+ Stage 2 is trained on ground-truth crops with jitter, so it tolerates the error
12
+ stage 1 will actually make at inference.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+
22
+ from model import ConvBlock, ClockNetCls, decode_cls, soft_targets, soft_argmax_angle # noqa: F401
23
+
24
+
25
+ class DialLocator(nn.Module):
26
+ """Predicts the dial's centre and radius, normalised to image width.
27
+
28
+ Radius is predicted in log space: dial sizes span a factor of three across
29
+ the corpus, and a plain linear target makes the large ones dominate the loss.
30
+ """
31
+
32
+ def __init__(self, width=16):
33
+ super().__init__()
34
+ w = width
35
+ self.net = nn.Sequential(
36
+ ConvBlock(3, w, stride=2),
37
+ ConvBlock(w, w * 2, stride=2), ConvBlock(w * 2, w * 2),
38
+ ConvBlock(w * 2, w * 4, stride=2), ConvBlock(w * 4, w * 4),
39
+ ConvBlock(w * 4, w * 8, stride=2), ConvBlock(w * 8, w * 8, stride=2),
40
+ nn.AdaptiveAvgPool2d(4), nn.Flatten(),
41
+ nn.Linear(w * 8 * 16, 128), nn.SiLU(inplace=True), nn.Linear(128, 3),
42
+ )
43
+
44
+ def forward(self, x):
45
+ out = self.net(x)
46
+ cx = torch.sigmoid(out[:, 0]) # centre stays inside the frame
47
+ cy = torch.sigmoid(out[:, 1])
48
+ log_r = out[:, 2]
49
+ return torch.stack([cx, cy, log_r], dim=1)
50
+
51
+
52
+ def crop_box(cx, cy, r, margin=1.25):
53
+ """Square box around a dial, in normalised coordinates."""
54
+ half = r * margin
55
+ return cx - half, cy - half, cx + half, cy + half
56
+
57
+
58
+ def crop_dial(img, cx, cy, r, out_res, margin=1.25, rng=None, jitter=0.0):
59
+ """Crop a PIL image around the dial and resize.
60
+
61
+ With jitter > 0 the box is perturbed, so stage 2 sees the kind of imperfect
62
+ box stage 1 will hand it rather than only perfect ones.
63
+ """
64
+ W, H = img.size
65
+ if rng is not None and jitter > 0:
66
+ cx += rng.uniform(-jitter, jitter) * r
67
+ cy += rng.uniform(-jitter, jitter) * r
68
+ r *= 1.0 + rng.uniform(-jitter, jitter)
69
+ x0, y0, x1, y1 = crop_box(cx * W, cy * H, r * W, margin)
70
+ return img.crop((int(x0), int(y0), int(x1), int(y1))).resize((out_res, out_res))
71
+
72
+
73
+ def locator_targets(dials, device):
74
+ cx = torch.tensor([d["cx"] for d in dials], dtype=torch.float32, device=device)
75
+ cy = torch.tensor([d["cy"] for d in dials], dtype=torch.float32, device=device)
76
+ r = torch.tensor([d["r_max"] for d in dials], dtype=torch.float32, device=device)
77
+ return torch.stack([cx, cy, torch.log(r.clamp_min(1e-4))], dim=1)
78
+
79
+
80
+ def locator_loss(pred, tgt):
81
+ """Centre in plain units, radius in log units, weighted so neither dominates."""
82
+ centre = ((pred[:, :2] - tgt[:, :2]) ** 2).mean()
83
+ radius = ((pred[:, 2] - tgt[:, 2]) ** 2).mean()
84
+ return centre * 4.0 + radius, centre.detach(), radius.detach()
85
+
86
+
87
+ class PretrainedReader(nn.Module):
88
+ """ImageNet-pretrained backbone + two angular heads.
89
+
90
+ A from-scratch CNN memorises 400 crops but sits on a plateau at 10.39
91
+ (= 2*ln(180), a uniform distribution) on 10,800. Yang/Xie/Zisserman used
92
+ ImageNet-pretrained ResNet-50 for the same task; the features that make a
93
+ dial's hands salient are exactly the low-level edge and orientation filters
94
+ ImageNet training already provides.
95
+ """
96
+
97
+ def __init__(self, bins=180, arch="mobilenet_v3_small", pretrained=True):
98
+ super().__init__()
99
+ import torchvision.models as tvm
100
+ self.bins = bins
101
+ if arch == "mobilenet_v3_small":
102
+ net = tvm.mobilenet_v3_small(weights=tvm.MobileNet_V3_Small_Weights.DEFAULT
103
+ if pretrained else None)
104
+ self.features = net.features
105
+ feat_dim = 576
106
+ elif arch == "resnet18":
107
+ net = tvm.resnet18(weights=tvm.ResNet18_Weights.DEFAULT if pretrained else None)
108
+ self.features = nn.Sequential(*list(net.children())[:-2])
109
+ feat_dim = 512
110
+ else:
111
+ raise ValueError(arch)
112
+ self.pool = nn.AdaptiveAvgPool2d(2) # keep some spatial layout
113
+ self.trunk = nn.Sequential(nn.Flatten(), nn.Linear(feat_dim * 4, 512),
114
+ nn.SiLU(inplace=True), nn.Dropout(0.1))
115
+ self.hour_head = nn.Linear(512, bins)
116
+ self.minute_head = nn.Linear(512, bins)
117
+
118
+ def forward(self, x):
119
+ f = self.trunk(self.pool(self.features(x)))
120
+ return self.hour_head(f), self.minute_head(f)