Add twostage.py
Browse files- 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)
|