lyte-codes commited on
Commit
24e3898
·
verified ·
1 Parent(s): 56ae190

Sync twostage.py for 263702

Browse files
Files changed (1) hide show
  1. twostage.py +134 -17
twostage.py CHANGED
@@ -18,19 +18,28 @@ 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),
@@ -38,15 +47,40 @@ class DialLocator(nn.Module):
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):
@@ -71,30 +105,47 @@ def crop_dial(img, cx, cy, r, out_res, margin=1.25, rng=None, jitter=0.0):
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
@@ -107,6 +158,10 @@ class PretrainedReader(nn.Module):
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
@@ -114,7 +169,69 @@ class PretrainedReader(nn.Module):
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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  import torch
20
  import torch.nn as nn
21
+ from PIL import Image
22
 
23
  from model import ConvBlock, ClockNetCls, decode_cls, soft_targets, soft_argmax_angle # noqa: F401
24
 
25
 
26
  class DialLocator(nn.Module):
27
+ """Predicts the dial's centre and its 2x2 shape matrix.
28
 
29
+ A centre and a radius describe a circle, and a dial viewed off-axis is an
30
+ ellipse. The 2x2 matrix carries foreshortening, rotation and scale together,
31
+ which is what makes rectification possible: invert it and the dial comes
32
+ back frontal and upright.
33
+
34
+ Output is (cx, cy, a, b, c, d) where the matrix maps dial-local coordinates
35
+ to image offsets.
36
  """
37
 
38
+ def __init__(self, width=16, outputs=6):
39
  super().__init__()
40
+ # outputs=6 is (cx, cy) + the 2x2 matrix. outputs=3 is the older
41
+ # (cx, cy, log r) head, kept so existing checkpoints still load.
42
+ self.outputs = outputs
43
  w = width
44
  self.net = nn.Sequential(
45
  ConvBlock(3, w, stride=2),
 
47
  ConvBlock(w * 2, w * 4, stride=2), ConvBlock(w * 4, w * 4),
48
  ConvBlock(w * 4, w * 8, stride=2), ConvBlock(w * 8, w * 8, stride=2),
49
  nn.AdaptiveAvgPool2d(4), nn.Flatten(),
50
+ nn.Linear(w * 8 * 16, 128), nn.SiLU(inplace=True), nn.Linear(128, outputs),
51
  )
52
 
53
  def forward(self, x):
54
  out = self.net(x)
55
  cx = torch.sigmoid(out[:, 0]) # centre stays inside the frame
56
  cy = torch.sigmoid(out[:, 1])
57
+ return torch.cat([cx.unsqueeze(1), cy.unsqueeze(1), out[:, 2:]], dim=1)
58
+
59
+
60
+ def rectify_dial(img, dial, out_res, margin=1.15):
61
+ """Warp a tilted dial back to a frontal, upright circle.
62
+
63
+ The dial is a circle in the world and an ellipse in the image. Its 2x2
64
+ matrix M maps dial-local coordinates to image offsets, so M inverse undoes
65
+ the foreshortening AND the in-image rotation in one step, handing stage 2 a
66
+ canonical face instead of a squashed one at an arbitrary angle.
67
+
68
+ PIL's AFFINE transform maps OUTPUT coordinates back into the INPUT, which is
69
+ exactly the direction we already have, so no inversion is needed: output
70
+ pixel -> dial-local -> input pixel.
71
+ """
72
+ W, H = img.size
73
+ M = dial["M"]
74
+ cx, cy = dial["cx"] * W, dial["cy"] * H
75
+ # pixels per unit of dial-local coordinate in the output
76
+ k = out_res / (2.0 * margin)
77
+ a, b = M[0][0] * W / k, M[0][1] * W / k
78
+ d, e = M[1][0] * H / k, M[1][1] * H / k
79
+ half = out_res / 2.0
80
+ c = cx - (a + b) * half
81
+ f = cy - (d + e) * half
82
+ return img.transform((out_res, out_res), Image.AFFINE, (a, b, c, d, e, f),
83
+ resample=Image.BILINEAR)
84
 
85
 
86
  def crop_box(cx, cy, r, margin=1.25):
 
105
 
106
 
107
  def locator_targets(dials, device):
108
+ rows = []
109
+ for d in dials:
110
+ M = d["M"]
111
+ rows.append([d["cx"], d["cy"], M[0][0], M[0][1], M[1][0], M[1][1]])
112
+ return torch.tensor(rows, dtype=torch.float32, device=device)
113
 
114
 
115
  def locator_loss(pred, tgt):
116
+ """Centre and matrix, weighted so neither dominates.
117
+
118
+ Matrix entries run about 0.1 to 0.8 and the centre runs 0 to 1, so the two
119
+ are already on comparable scales; the centre is weighted up because getting
120
+ it wrong moves the whole crop while a slightly wrong matrix only skews it.
121
+ """
122
  centre = ((pred[:, :2] - tgt[:, :2]) ** 2).mean()
123
+ shape = ((pred[:, 2:6] - tgt[:, 2:6]) ** 2).mean()
124
+ return centre * 4.0 + shape, centre.detach(), shape.detach()
125
 
126
 
127
  class PretrainedReader(nn.Module):
128
+ """ImageNet-pretrained backbone + two angular heads and a whole-time head.
129
 
130
  A from-scratch CNN memorises 400 crops but sits on a plateau at 10.39
131
  (= 2*ln(180), a uniform distribution) on 10,800. Yang/Xie/Zisserman used
132
  ImageNet-pretrained ResNet-50 for the same task; the features that make a
133
  dial's hands salient are exactly the low-level edge and orientation filters
134
  ImageNet training already provides.
135
+
136
+ The third head exists because of what the two angular heads get wrong. On
137
+ real photographs 27.5% of readings put the minute right and the hour wrong,
138
+ split between hands read in the wrong roles and hours simply missed. Both
139
+ angular heads describe a hand, so a face whose hands are hard to tell apart
140
+ corrupts both of them at once and no amount of decoding recovers the hour --
141
+ measured, in jointdecode.py. `time_head` is asked instead for the time
142
+ itself over the whole 12-hour ring, coarsely, with no notion of a hand. It
143
+ cannot express a role confusion because it never assigns roles, and it only
144
+ has to be good enough to break the twelve-way tie: the minute hand, which
145
+ the model already reads well, supplies the precision.
146
  """
147
 
148
+ def __init__(self, bins=180, arch="mobilenet_v3_small", pretrained=True, time_bins=144):
149
  super().__init__()
150
  import torchvision.models as tvm
151
  self.bins = bins
 
158
  net = tvm.resnet18(weights=tvm.ResNet18_Weights.DEFAULT if pretrained else None)
159
  self.features = nn.Sequential(*list(net.children())[:-2])
160
  feat_dim = 512
161
+ elif arch == "resnet50":
162
+ net = tvm.resnet50(weights=tvm.ResNet50_Weights.DEFAULT if pretrained else None)
163
+ self.features = nn.Sequential(*list(net.children())[:-2])
164
+ feat_dim = 2048
165
  else:
166
  raise ValueError(arch)
167
  self.pool = nn.AdaptiveAvgPool2d(2) # keep some spatial layout
 
169
  nn.SiLU(inplace=True), nn.Dropout(0.1))
170
  self.hour_head = nn.Linear(512, bins)
171
  self.minute_head = nn.Linear(512, bins)
172
+ self.time_bins = time_bins
173
+ self.time_head = nn.Linear(512, time_bins) if time_bins else None
174
 
175
  def forward(self, x):
176
  f = self.trunk(self.pool(self.features(x)))
177
+ t = self.time_head(f) if self.time_head is not None else None
178
+ return self.hour_head(f), self.minute_head(f), t
179
+
180
+
181
+ class CocoDialDetector:
182
+ """Stage 1: find the dial with a COCO-pretrained detector.
183
+
184
+ This replaces DialLocator. Measured on 200 held-out real photographs, same
185
+ reader, three ways of producing a crop:
186
+
187
+ whole image, no locator MAE 120.6 min median 88.3
188
+ DialLocator (2.2 MB) MAE 120.7 min median 67.9
189
+ this (78 MB) MAE 73.6 min median 18.4
190
+
191
+ DialLocator was trained on renders and contributes nothing over having no
192
+ locator at all -- 120.7 against 120.6. Distilling this detector into it
193
+ could not have helped either: a student cannot beat its teacher, and the
194
+ training would have run through the renderer whose domain gap is the thing
195
+ being escaped.
196
+
197
+ It costs 75 MB. That buys halving the error, so the single-digit-MB target
198
+ loses.
199
+ """
200
+
201
+ COCO_CLOCK = 85
202
+
203
+ def __init__(self, arch="mobilenet", score_thresh=0.3, device=None, det_res=640):
204
+ import torchvision.models.detection as tvd
205
+ if arch == "mobilenet":
206
+ # ResNet50-FPN trips the M2 GPU watchdog under sustained load
207
+ # (kIOGPUCommandBufferCallbackErrorImpactingInteractivity).
208
+ w = tvd.FasterRCNN_MobileNet_V3_Large_FPN_Weights.DEFAULT
209
+ self.net = tvd.fasterrcnn_mobilenet_v3_large_fpn(weights=w)
210
+ else:
211
+ w = tvd.FasterRCNN_ResNet50_FPN_V2_Weights.DEFAULT
212
+ self.net = tvd.fasterrcnn_resnet50_fpn_v2(weights=w)
213
+ self.device = device or torch.device("mps" if torch.backends.mps.is_available() else "cpu")
214
+ self.net = self.net.eval().to(self.device)
215
+ self.score_thresh = score_thresh
216
+ self.det_res = det_res
217
+
218
+ @torch.no_grad()
219
+ def locate(self, img):
220
+ """(cx, cy, r) normalised to image width, or None when nothing is found."""
221
+ import numpy as np
222
+ scale = min(1.0, self.det_res / max(img.size))
223
+ small = (img.resize((max(32, round(img.width * scale)),
224
+ max(32, round(img.height * scale)))) if scale < 1.0 else img)
225
+ x = torch.from_numpy(np.asarray(small.convert("RGB"), dtype=np.float32) / 255.0)
226
+ x = x.permute(2, 0, 1).to(self.device)
227
+ out = self.net([x])[0]
228
+ best, best_score = None, 0.0
229
+ for box, label, score in zip(out["boxes"], out["labels"], out["scores"]):
230
+ s = float(score)
231
+ if int(label) == self.COCO_CLOCK and s >= self.score_thresh and s > best_score:
232
+ best, best_score = [float(v) / scale for v in box], s
233
+ if best is None:
234
+ return None
235
+ x0, y0, x1, y1 = best
236
+ return ((x0 + x1) / 2 / img.width, (y0 + y1) / 2 / img.height,
237
+ max(x1 - x0, y1 - y0) / 2 / img.width, best_score)