← Back to the problem

Romania ONIA 2026 · Correction by IOAI Training

The Broken Keyboard

This page gives the full approach. Read it once you have searched.

Metric
MAE (angles, noise) + accuracy x2
Evaluated on
held-out validation (official test labels are private)
Our score
est. 100/100 (M3 ~0.95, M4 ~0.99 acc)
Expected level
angle MAE 0, noise MAE <= 2.5, acc >= 0.80 and 0.93

Expected level reached

We solved the problem ourselves: downloaded the data, ran the experiments and measured the score below. The statement and data belong to their organisers.

The problem in brief

A "secure" keyboard draws every keystroke as a 28 x 28 handwritten-looking character in a random style. After a coffee accident only 12 keys still work (letters A, B, E, F, O, I and digits 0, 1, 2, 4, 7, 8), and each one now corrupts its output in its own fixed way: it adds a fixed noise pattern (values in [−25,25][-25, 25]), clips the result to [0,255][0, 255], pastes it at the centre of a black 42 x 42 canvas and rotates the canvas by a fixed angle between −30°-30° and 30°30°.

You receive 50 calibration pairs [original | corrupted] per key, 500 clean training images per key, 200 suspect images (some typed on the broken keyboard, some characters it cannot type) and 300 password images of 3, 5 or 7 corrupted characters side by side. Four missions: recover each key's angle (1) and noise (2), tell which suspects come from one of the 12 keys (3), and decide for each password whether most of its characters are letters or digits (4).

The statement is in Romanian; this is our paraphrase.

How you are scored

missionmetricfull markszero
1. angles (10 pts)MAE over the 12 keys, in degreesMAE =0= 0MAE ≥1\ge 1
2. noise (10 pts)MAE over the 12 noise matricesMAE ≤2.5\le 2.5MAE ≥10\ge 10
3. suspects (30 pts)accuracy≥0.80\ge 0.80≤0.50\le 0.50
4. passwords (50 pts)accuracy≥0.93\ge 0.93≤0.50\le 0.50

Between the bounds the points are linear, e.g. for mission 4: 50⋅acc−0.500.93−0.5050 \cdot \frac{\text{acc} - 0.50}{0.93 - 0.50}. Mission 1 is unforgiving: any error at all costs points, so the angles must be found exactly. The organisers' labels are private: below, missions 1 and 2 are checked by how well our recovered model reproduces the calibration pairs, the classifier is validated on the 600 real calibration images, and mission 3 by a visual check of the suspects.

First look at the data

A calibration pair is an 84 x 42 image: on the left the clean character already padded to 42 x 42 (it occupies rows and columns 7 to 34), on the right what the broken key produced. Two things are visible at once: the rotation, and a faint speckle in the dark background near the centre of the corrupted image. That speckle is the key's noise, and it will turn out to be the most useful clue of the whole problem.

Key A on one calibration pair: the padded original, the corrupted output rotated by minus 8 degrees, the recovered noise of key A (a 14 by 14 block of random values in the centre, zero elsewhere), and the corrupted image with contrast stretched four times, where the noise appears as grey speckles on the black background inside the rotated central zone.

Why is the noise visible on the background? There the original pixel is 0, so after adding the noise and clipping, the pixel becomes max⁡(N,0)\max(N, 0): positive noise values survive, negative ones are clipped to black. Inside the strokes (value near 255) it is the other way round: negative noise survives and positive noise is clipped.

Mission 1: the angles

With the clean original xx and its corrupted version yy side by side, the angle can be found by brute force: rotate xx by every integer angle a∈[−30,30]a \in [-30, 30] and keep the one with the smallest mean absolute difference to yy, averaged over 15 pairs. The noise is small and only covers the centre, so it barely disturbs the comparison.

Two details make the difference between "close" and "exact":

  • Which rotation? We tried PIL bilinear, PIL bicubic, SciPy (orders 1 and 3) and OpenCV. They agree on the best integer angle, but only PIL bicubic reproduces the corrupted images almost perfectly (see mission 2). Its convention: a positive angle turns the image counter-clockwise.
  • Integer or not? Refining with steps of 0.1°0.1° never moved the optimum away from an integer (the best fractional angle had the same error as the integer one), and the best angle is always clearly separated from the second best (mean error 0.8 to 0.9 grey levels against 1.2 to 1.8).

Result:

keyABEFOI012478
angle (°)−8-8−30-30+16+16+5+5−28-28+10+10+15+15+9+9−30-30−27-27−24-24+10+10

Mission 2: the noise, by inverting the forward model

The key idea: write down the forward model exactly, then fit its unknowns. For key cc with angle aca_c and noise NcN_c, the corrupted image of an original xx is

y=clip⁡(Rac(pad⁡(clip⁡(x+Nc,0,255))),0,255),y = \operatorname{clip}\Big(\mathcal{R}_{a_c}\big(\operatorname{pad}(\operatorname{clip}(x + N_c, 0, 255))\big), 0, 255\Big),

where Ra\mathcal{R}_a is the bicubic rotation. Bicubic interpolation is linear in the pixel values, so Ra∘pad⁡\mathcal{R}_{a}\circ\operatorname{pad} is a fixed 1764×7841764 \times 784 matrix MaM_a. We build it column by column by rotating 784 "impulse" images (one pixel set to 1) in floating point. The outer clip is needed because bicubic interpolation overshoots slightly beyond 255 near sharp edges.

With 50 pairs per key, we know every xkx_k and yky_k, so we can estimate the 784 values of NcN_c by minimising

L(N)=∑k=150∥clip⁡(Mac clip⁡(xk+N))−yk∥2,\mathcal{L}(N) = \sum_{k=1}^{50} \big\lVert \operatorname{clip}\big(M_{a_c}\,\operatorname{clip}(x_k + N)\big) - y_k \big\rVert^2 ,

with Adam in PyTorch (800 steps, NN kept in [−25,25][-25, 25]). The clipping is not a nuisance here, it is the right model: where a sample is clipped, the gradient is zero and that sample simply carries no information about that pixel, while the other samples (with other fonts, hence other stroke positions) do.

How do we know the model is right? The fitted model reproduces the 600 corrupted images with a mean squared error of 0.011 to 0.018 grey levels squared, which is the size of the rounding to integers. With bilinear rotation instead, the same fit leaves an error of 15 to 44: the choice of interpolation is not a detail.

The fitted noise then revealed two facts the statement only hints at:

  • The "central zone" is the central 14 x 14 block (rows and columns 7 to 20 of the 28 x 28 image). Outside it, all estimates sit at about −0.5-0.5: the true noise there is 0, and −0.5-0.5 is a constant offset of our model (the generator apparently truncates rather than rounds somewhere). We subtract this offset and set the outside to exactly 0.
  • The noise values are integers. After removing the offset, the estimates inside the zone are within 0.1 of an integer for half of the pixels, and roughly uniform over [−25,25][-25, 25]. We round them.

Inside the zone, every pixel is covered by at least one unclipped sample for 11 keys out of 12 (key "1", a thin stroke, leaves 8 pixels that are always clipped and cannot be recovered). So the estimated noise should be essentially exact, far below the 2.5 MAE needed for full marks.

Missions 3 and 4: reading corrupted characters

A first baseline: a CNN trained on simulated corruption

Now that we know every key's transformation exactly, we can manufacture training data: push each of the 6,000 clean training images through its own key's forward model. A small CNN (five 3 x 3 convolution layers with batch norm, global average pooling, 13 outputs) trained for 8 epochs on these images recognises the 12 keys. We validate it on data it never saw and that come from the real generator: the 600 corrupted calibration images. Accuracy: 98.5 %.

For mission 3 we also need a class "not one of the 12 keys". We tried to provide negatives by rendering the other letters and digits with system fonts and passing them through random transformations. It did not work: the CNN called 98.5 % of the suspects valid, although about half of them are visibly other characters (K, m, N, H, 3, 5, 6, 9, ...). Printed fonts look too different from the handwritten style of the data.

The key idea for mission 3: check the key's fingerprint

A suspect typed on the broken keyboard carries the noise pattern of its key, rotated by the key's angle; an image from an external source does not. So for each suspect:

  1. let the CNN guess which key cc it would be;
  2. inside key cc's rotated 14 x 14 noise zone, take the dark pixels (value <45< 45, i.e. background);
  3. compute the correlation between those pixels and the template Rac(pad⁡(max⁡(Nc,0)))\mathcal{R}_{a_c}(\operatorname{pad}(\max(N_c, 0))), the speckle that key cc leaves on a black background.

Histogram of the fingerprint correlation: for the 600 calibration images with their true key, the correlation is centred around 0.5 and almost always above 0.22; for the 200 suspects it is bimodal, one group around 0 (no fingerprint) and one group around 0.5 like the calibration images.

The suspects split into two clear groups. Those with a correlation near 0 do not carry the fingerprint of the key they resemble; the others match it as well as genuine calibration images do. We call a suspect valid if its correlation is at least 0.22 (the valley between the two modes; only 3.8 % of genuine calibration images fall below it), or if its best correlation over all 12 keys reaches 0.35 (this rescues a valid character the CNN attributed to the wrong key). Result: 102 valid, 98 invalid.

Why does it work so well? A correlation over nn independent pixels has a standard deviation of about 1/n1/\sqrt{n} when there is no real link. With n≈85n \approx 85 background pixels in the zone, that is about 0.11, so a correlation of 0.5 is a 4 to 5 sigma event. The fingerprint is weak only for very bold glyphs that cover most of the zone and leave few background pixels.

We have no labels for the suspects, so we checked the result by eye: on all 200 images, the images flagged invalid are, with a handful of exceptions, characters the keyboard cannot type, and the images flagged valid are A, B, E, F, O, I, 0, 1, 2, 4, 7 or 8. The doubtful cases (about 5) are bold or very thin glyphs such as an "8" or a "1" with a weak fingerprint. Our estimate is an accuracy of about 0.95, comfortably above the 0.80 needed for the full 30 points.

Mission 4: majority vote

Password images are 126, 210 or 294 pixels wide: exactly 3, 5 or 7 corrupted characters of 42 pixels side by side. Cut them into 42 x 42 tiles, classify each tile with the CNN among the 12 keys, count letters versus digits, and output the majority. Since the passwords have an odd length, there is never a tie.

A majority vote is forgiving: with a per-character accuracy p=0.985p = 0.985, a 3-character password is wrong only if at least 2 characters are wrong and the errors flip the category, so its error rate is at most 3p(1−p)2+(1−p)3≈0.07 %3p(1-p)^2 + (1-p)^3 \approx 0.07\,\%; and most CNN errors stay within the same category anyway (confusing O with 0, or I with 1, is the dangerous case). Two CNNs trained independently agree on 99 % of the passwords, which suggests an accuracy around 0.99, well above 0.93.

Results

missionapproachcheckestimated points
1integer angle search with PIL bicubic rotationbest angle clearly separated, integer10 / 10 (if the grader uses PIL's sign convention)
2, first tryfit with bilinear rotationreproduction MSE 15 to 44not usable
2fit through the exact forward model (bicubic + both clips), zone, offset, roundingreproduction MSE 0.011 to 0.01810 / 10
3, first tryCNN with font-rendered "other" class98.5 % of suspects called validabout 0
3CNN key guess + noise fingerprint correlationbimodal scores, visual check about 0.9530 / 30
4CNN per tile (98.5 % on real calibration images) + majority vote99 % agreement between two CNNs50 / 50

Two honest caveats: mission 1 depends on the sign convention of the rotation (we report PIL's, counter-clockwise positive, which is also the convention of scipy.ndimage.rotate and of the generator we reverse-engineered); and the CNN's 98.5 % is measured on the calibration images, whose fonts may resemble the training fonts more than those of the passwords.

What to remember

  • Reverse-engineer the generator when the data is synthetic. Writing the forward model down exactly (including interpolation type and clipping) turned two "estimation" missions into near-exact recoveries, and gave unlimited labelled training data for the others.
  • A linear operator can be built by probing it with impulses, and then any differentiable model around it (here with clips) can be fitted with gradient descent in a few lines of PyTorch.
  • Check your model by its residual. The drop from an MSE of 15 to 0.015 when switching from bilinear to bicubic was the proof that the model was right, and it exposed hidden facts (the 14 x 14 zone, integer noise).
  • Open-set recognition is hard with a closed-set classifier. Instead of asking a CNN "is this some other character?", look for evidence that only genuine samples carry: here, a device fingerprint.
  • Majority votes amplify a good per-item classifier: 98.5 % per character becomes about 99 % or more per password.
Full code (solution.py)
"""The Broken Keyboard (ONIA 2026, national final).

Mission 1: rotation angle of each of the 12 working keys.   Mission 2: its fixed 28x28 noise pattern.
Mission 3: is a suspect image produced by one of the 12 keys?  Mission 4: majority letters/digits per password.

Forward model, recovered from the calibration pairs (and checked to reproduce them to ~0.1 grey level):
    out = rotate_PIL_bicubic( pad42( clip(orig28 + N_key, 0, 255) ), angle_key )      (uint8 everywhere)
with N_key integer in [-25, 25] and non-zero only on the central 14x14 block (rows/cols 7..20).

Run from the folder holding dataset_final/. Writes submission.zip (amprente_digitale.npy + parole.csv).
CPU, 2 threads, about 10 minutes.
"""
import glob, os, random, zipfile
import numpy as np, pandas as pd, torch, torch.nn as nn, torch.nn.functional as F
from PIL import Image, ImageDraw, ImageFont, ImageFilter

torch.set_num_threads(2); torch.manual_seed(0); random.seed(0); rng = np.random.default_rng(0)
D = "dataset_final"
KEYS = ["A", "B", "E", "F", "O", "I", "0", "1", "2", "4", "7", "8"]
load = lambda f: np.array(Image.open(f)).astype(np.float32)
rot_f = lambda im, a: np.array(Image.fromarray(im.astype(np.float32), "F").rotate(a, resample=Image.BICUBIC))
rot_u8 = lambda im, a: np.array(Image.fromarray(np.clip(im, 0, 255).astype(np.uint8)).rotate(a, resample=Image.BICUBIC)).astype(np.float32)
ZONE = np.zeros((28, 28), bool); ZONE[7:21, 7:21] = True


def calib_pairs(c):
    P = np.array([load(f) for f in sorted(glob.glob(f"{D}/amprente_digitale/{c}_*.png"))])
    return P[:, :, :42], P[:, :, 42:]                       # [original | transformed], each 42x42


def rotation_operator(a):
    """Linear map 28x28 patch -> padded and rotated 42x42 image (PIL bicubic is linear in float mode)."""
    M = np.zeros((1764, 784), np.float32)
    for j in range(784):
        im = np.zeros((42, 42)); im[7 + j // 28, 7 + j % 28] = 1; M[:, j] = rot_f(im, a).ravel()
    return M


# ---------------- Missions 1 and 2: calibration ----------------
calib = {}
for c in KEYS:
    O, R = calib_pairs(c)
    # Mission 1: the angle that best maps original -> transformed (noise is small, so plain L1 works)
    err = {a: np.mean([np.abs(rot_u8(o, a) - r).mean() for o, r in zip(O[:15], R[:15])]) for a in range(-30, 31)}
    angle = min(err, key=err.get)
    # Mission 2: fit N by gradient descent through the exact forward model, clipping included
    M = torch.tensor(rotation_operator(angle))
    Ot = torch.tensor(O[:, 7:35, 7:35].reshape(len(O), -1)); Rt = torch.tensor(R.reshape(len(R), -1))
    N = torch.full((784,), 0.01, requires_grad=True); opt = torch.optim.Adam([N], lr=0.5)
    for _ in range(800):
        pred = torch.clamp(torch.clamp(Ot + N, 0, 255) @ M.T, 0, 255)
        loss = ((pred - Rt) ** 2).mean(); opt.zero_grad(); loss.backward(); opt.step()
        with torch.no_grad(): N.clamp_(-25, 25)
    n = N.detach().numpy().reshape(28, 28)
    bias = np.median(n[~ZONE])                              # outside the zone the true noise is 0: ~ -0.5 offset
    noise = np.where(ZONE, np.round(n - bias), 0.0)         # true values are integers
    calib[c] = {"angle": float(angle), "noise": noise}
    print(f"key {c}: angle {angle:+d}  (L1 {err[angle]:.2f} vs next {sorted(err.values())[1]:.2f}),"
          f" forward-model MSE {loss.item():.3f}")


def transform(img28, angle, noise):
    x = np.clip(img28 + noise, 0, 255); can = np.zeros((42, 42)); can[7:35, 7:35] = x
    return rot_u8(can, angle)


# ---------------- noise fingerprint (Mission 3) ----------------
# On background pixels of the noise zone, the image shows max(N_key, 0) rotated by the key's angle.
TEMPL, ZMASK = {}, {}
for c in KEYS:
    p = np.zeros((42, 42)); p[7:35, 7:35] = np.maximum(calib[c]["noise"], 0); TEMPL[c] = rot_f(p, calib[c]["angle"])
    z = np.zeros((42, 42)); z[14:28, 14:28] = 1; ZMASK[c] = rot_f(z, calib[c]["angle"]) > 0.99


def fingerprint(Y, c):
    """Correlation between the dark pixels of the (rotated) noise zone and key c's positive-noise template."""
    m = ZMASK[c] & (Y < 45)
    if m.sum() < 15: return 0.0
    a, b = Y[m] - Y[m].mean(), TEMPL[c][m] - TEMPL[c][m].mean()
    return float((a * b).sum() / np.sqrt((a * a).sum() * (b * b).sum() + 1e-9))


# ---------------- CNN on synthetic transformed characters (Missions 3 and 4) ----------------
X, Y = [], []
for f in sorted(glob.glob(f"{D}/imagini_antrenare/*.png")):
    c = os.path.basename(f).split("_")[0]
    X.append(transform(load(f), calib[c]["angle"], calib[c]["noise"])); Y.append(KEYS.index(c))
FONTS = [f for f in glob.glob("/System/Library/Fonts/Supplemental/*.tt*")
         if any(k in f for k in ["Arial", "Comic", "Chalk", "Bradley", "Brush", "Courier", "Georgia", "Trebuchet",
                                 "Verdana", "Times", "Tahoma", "Futura", "Marker", "Noteworthy", "Chancery", "Impact"])]
OTHERS = list("CDGHJKLMNPQRSTUVWXYZ3569") + list("abdefghjkmnpqrstuvwxyz")


def render(ch, font):
    """A crude EMNIST-like glyph of a character the keyboard cannot type (class 12 = 'other')."""
    im = Image.new("L", (64, 64), 0)
    ImageDraw.Draw(im).text((32, 32), ch, fill=255, font=ImageFont.truetype(font, int(rng.integers(38, 50))), anchor="mm")
    a = np.array(im)
    if a.max() == 0: return None
    ys, xs = np.nonzero(a > 30); im = Image.fromarray(a[ys.min():ys.max() + 1, xs.min():xs.max() + 1])
    s = 20 / max(im.size); im = im.resize((max(1, int(im.size[0] * s)), max(1, int(im.size[1] * s))), Image.BILINEAR)
    can = Image.new("L", (28, 28), 0); can.paste(im, ((28 - im.size[0]) // 2, (28 - im.size[1]) // 2))
    if rng.random() < 0.6: can = can.filter(ImageFilter.MaxFilter(3))
    return np.array(can.rotate(rng.uniform(-12, 12), resample=Image.BILINEAR).filter(ImageFilter.GaussianBlur(0.6))).astype(np.float32)


while len(X) < 12000:
    g = render(random.choice(OTHERS), random.choice(FONTS))
    if g is None: continue
    if rng.random() < 0.5:
        k = random.choice(KEYS); ang, noi = calib[k]["angle"], calib[k]["noise"]
    else:
        ang = float(rng.integers(-30, 31)); noi = np.zeros((28, 28)); noi[7:21, 7:21] = rng.integers(-25, 26, (14, 14))
    X.append(transform(g, ang, noi)); Y.append(12)
X = torch.tensor(np.array(X) / 255., dtype=torch.float32).unsqueeze(1); Y = torch.tensor(Y)

net = nn.Sequential(
    nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.Conv2d(32, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2),
    nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.Conv2d(64, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2),
    nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.3), nn.Linear(128, 13))
EPOCHS = 8
opt = torch.optim.AdamW(net.parameters(), 1e-3, weight_decay=1e-4)
sched = torch.optim.lr_scheduler.OneCycleLR(opt, 2e-3, total_steps=EPOCHS * (len(X) // 128 + 1))
for ep in range(EPOCHS):
    net.train(); perm = torch.randperm(len(X))
    for i in range(0, len(X), 128):
        b = perm[i:i + 128]
        xb = torch.roll(X[b], shifts=(int(rng.integers(-1, 2)), int(rng.integers(-1, 2))), dims=(2, 3))
        loss = F.cross_entropy(net(xb), Y[b]); opt.zero_grad(); loss.backward(); opt.step(); sched.step()
net.eval()


@torch.no_grad()
def probs(images):
    return torch.softmax(net(torch.tensor(np.array(images) / 255., dtype=torch.float32).unsqueeze(1)), 1).numpy()


# validation on the 600 REAL transformed calibration images (never used for training)
cal_files = sorted(glob.glob(f"{D}/amprente_digitale/*.png"))
cal_y = np.array([KEYS.index(os.path.basename(f).split("_")[0]) for f in cal_files])
cal_img = [load(f)[:, 42:] for f in cal_files]
print("CNN key accuracy on real calibration images:", (probs(cal_img)[:, :12].argmax(1) == cal_y).mean())
cal_fp = np.array([fingerprint(im, KEYS[y]) for im, y in zip(cal_img, cal_y)])
print("fingerprint of the true key on calibration images: share below threshold 0.22 =", (cal_fp < 0.22).mean().round(3))

# ---------------- Mission 3 ----------------
rows = []
for f in sorted(glob.glob(f"{D}/imagini_suspecte/*.png")):
    im = load(f); k = KEYS[probs([im])[0, :12].argmax()]
    valid = fingerprint(im, k) >= 0.22 or max(fingerprint(im, c) for c in KEYS) >= 0.35
    rows.append((os.path.basename(f), 3, int(valid)))
print("Mission 3: share predicted valid", np.mean([r[2] for r in rows]))

# ---------------- Mission 4 ----------------
for f in sorted(glob.glob(f"{D}/parole/*.png")):
    im = load(f); chunks = [im[:, 42 * i:42 * (i + 1)] for i in range(im.shape[1] // 42)]   # 3, 5 or 7 keys
    chars = [KEYS[j] for j in probs(chunks)[:, :12].argmax(1)]
    letters = sum(ch.isalpha() for ch in chars)
    rows.append((os.path.basename(f), 4, "letters" if letters > len(chars) / 2 else "digits"))

np.save("amprente_digitale.npy", {c: {"angle": calib[c]["angle"], "noise": calib[c]["noise"]} for c in KEYS}, allow_pickle=True)
pd.DataFrame(rows, columns=["file", "subtaskID", "class"]).to_csv("parole.csv", index=False)
with zipfile.ZipFile("submission.zip", "w") as z:
    z.write("amprente_digitale.npy"); z.write("parole.csv")
print("wrote submission.zip")