← Back to the problem

Romania ONIA 2026 · Correction by IOAI Training

Glitch Hunter 2: G3P1T Reloaded

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

Metric
0.7 macro-IoU + 0.3 count score (x100)
Evaluated on
held-out validation (official test labels are private)
Our score
93.0 on 40 held-out train images

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

Each 512x512 image is a smooth synthetic background into which axis-aligned rectangles of four kinds of "glitch" have been planted. Noise fills a rectangle with random RGB pixels. Invert replaces the colours by 255−x255 - x. Solid fills a rectangle with one colour. Scramble shuffles the original pixels inside a rectangle, so the colour histogram is unchanged. To make life harder, the images also contain decoys that must be left unmarked: JPEG re-encoded patches, hue-shifted patches and Gaussian-blurred patches. For every test image you submit a class mask (0 to 4) and the number of glitch instances of each class. You get 200 training images with masks and counts; the 200 test labels are private, so we evaluate on 40 training images held out from the start.

How you are scored

Per image and per glitch class c∈{1,2,3,4}c \in \{1,2,3,4\}, the intersection over union of predicted and true pixels,

IoUc=∣Pc∩Gc∣∣Pc∪Gc∣,\mathrm{IoU}_c = \frac{|P_c \cap G_c|}{|P_c \cup G_c|},

equals 1 when both are empty. Its mean over the four classes is the image's macro-IoU. Counts are scored by 1/(1+∣n^−n⋆∣)1/(1 + |\hat n - n^\star|) per image and class. The final score is

100×(0.7 IoUmacro‾+0.3 count score‾).100 \times \big(0.7\ \overline{\mathrm{IoU}_{\text{macro}}} + 0.3\ \overline{\text{count score}}\big).

Two consequences: an absent class must stay exactly absent (one false-positive blob turns an IoU of 1 into 0 for that class), and every spurious blob also costs a count point. No reference score was published that we could access, so there is no target to compare with.

First look at the data

Glitch pixels are only 8 % of the image, about 2 % per class. Every class appears 0 to 3 times per image with roughly uniform frequency. Two structural facts are worth a lot of points.

  • Glitches are rectangles and never touch. In all 200 training masks, the number of connected components of each class equals the official count, 800 out of 800 (image, class) pairs.
  • Each glitch type has a physical signature. Solid means exactly constant pixels. Invert means the content becomes continuous with its surroundings again once you re-invert it. Noise and scramble are textures: noise draws colours from the whole RGB cube, scramble from the local palette.

A first baseline

A small U-Net (channels 16, 32, 48, 64, 96; the first stage at full resolution so that pixel-level texture is not lost; about 0.6 M parameters) is trained with class-weighted cross-entropy (weight 0.3 on the background) on random 256x256 crops. Augmentation uses flips and 90-degree rotations, which keep rectangles rectangles. Training runs 18 minutes on 2 CPU threads (800 steps of batch 8), and inference averages the 4 rotations. Taking the argmax per pixel and counting connected components gives 70.3 on the 40 held-out images.

Rectangle filling. Using the first structural fact, label the connected components of "any glitch" pixels, drop the tiny ones, give each component the class with the largest summed probability, and replace it by its bounding box. Counts become the number of rectangles per class. Score 73.4 (count score from 73.0 to 81.5).

The pixel confusion matrix of this model is very instructive:

true \ predictedcleannoiseinvertsolidscramble
noise0 %100 %0 %0 %0 %
invert0 %0 %98 %2 %0 %
solid2 %0 %87 %11 %0 %
scramble4 %0 %0 %0 %96 %

Noise and scramble are solved; the network even separates them perfectly. But solid blocks are called "invert". On these smooth, saturated backgrounds, an inverted patch and a solid patch both look like "a flat rectangle of an odd colour", and after 800 steps the network has not learned the difference. It also produces invert blobs on the hue-shift decoys.

The key idea

When a network confuses two classes that differ by an exact property, do not train harder: test the property directly, at the level of the object, after segmentation.

Solid = exactly flat. Compute, for every pixel, whether its 3x3 neighbourhood is exactly constant in all three channels (max filter equals min filter). On the training images this is true for 93 % of solid pixels (all but the edges) and for 0 % of noise, invert and scramble pixels. Background and decoys are almost never flat, 0.6 % (JPEG blocks can be). So a rectangle more than half flat is relabelled solid, and a rectangle the network called solid that is not flat goes to its second-best class.

Invert = continuous after re-inversion. For a rectangle the network calls invert, compare a 2-pixel ring just inside each edge with the ring just outside it, twice:

draw=mean⁡∣xin−xout∣,dinv=mean⁡∣(255−xin)−xout∣.d_{\text{raw}} = \operatorname{mean}|x_{\text{in}} - x_{\text{out}}|, \qquad d_{\text{inv}} = \operatorname{mean}|(255 - x_{\text{in}}) - x_{\text{out}}|.

A truly inverted patch was cut out of a smooth background, so re-inverting it closes the jump at the border: dinv≪drawd_{\text{inv}} \ll d_{\text{raw}}, often almost 0. A hue-shift decoy is not an inversion, so re-inverting makes the edge worse. We keep the rectangle only if dinv<drawd_{\text{inv}} < d_{\text{raw}}. No threshold needs tuning: it is a comparison of two hypotheses.

Left: share of exactly flat pixels per class on 60 training images. Right: each blob the network labels invert on the held-out images, placed by its edge jump before and after re-inversion; true inversions fall below the diagonal.

On the held-out images, 82 blobs are predicted invert, 56 of them truly inverted. The re-inversion test keeps 49 of the 56 true ones and rejects all 26 impostors. The 7 it loses are mostly small patches touching other glitches or the image border, where the ring is polluted.

A held-out image: the network calls the three saturated solid blocks "invert"; the flatness test fixes all of them, and the hue-shift and JPEG decoys stay unmarked.

The full solution

  1. U-Net on 256x256 crops (full-resolution first stage), class-weighted cross-entropy, AdamW with cosine learning rate, flips and rotations; 4-rotation test-time averaging.
  2. Instances: connected components of the argmax "glitch" pixels; components under 60 pixels are dropped.
  3. Class of each component: largest summed softmax probability, then the two physical tests (flatness: solid; re-inversion: invert or reject).
  4. Mask: each kept component is filled as its bounding box. Counts: rectangles per class.
cls = int(prob[1:, sl[0], sl[1]][:, comp].sum(1).argmax()) + 1   # soft majority vote
if flat[sl][comp].mean() > 0.5:
    cls = 3                                                        # exactly constant: solid
if cls == 2 and not reinversion_test(img, sl):
    continue                                                       # hue-shift decoy, drop it
mask[sl] = cls                                                     # glitches are rectangles
counts[NAMES[cls - 1]] += 1

Results

40 training images held out from the start (official test labels are private); official metric re-implemented from the statement.

approachfinalmacro-IoUcount score
U-Net after 42 s of training (smoke test, 4 images)22.212.544.8
U-Net 18 min, pixel argmax + connected components70.369.273.0
+ rectangle filling and soft majority vote73.469.981.5
+ flatness test for solid89.488.192.4
+ re-inversion test for invert (final, solution.py)93.092.095.5

Exact-count accuracy per class in the final model: noise 100 %, scramble 97.5 %, solid 92.5 %, invert 75 %. Invert is the remaining weak spot: missed small inversions and a few decoys the network never flagged.

Caveat. The two rules were chosen by looking at the errors on these 40 held-out images. They have no fitted threshold (a majority at 0.5 and a comparison dinv<drawd_{\text{inv}} < d_{\text{raw}}) and follow from how the glitches are defined, so the optimism should be small, but a fully clean estimate would need a second untouched split. The minimum blob area (60 pixels) makes no difference between 30 and 300.

What to remember

  • Read the confusion matrix before training longer. It showed noise and scramble were solved and the whole loss was a solid/invert confusion plus decoys: a 20-point problem with a 20-line fix.
  • Exploit the generator's structure. Rectangles that never touch turn segmentation into "find blobs, fill bounding boxes", and turn counting into counting blobs.
  • Test physical definitions, not appearances. "Exactly constant" and "continuous after 255−x255 - x" are properties a network learns slowly but a few lines of NumPy check exactly. Comparing two hypotheses (dinvd_{\text{inv}} against drawd_{\text{raw}}) avoids tuning a threshold.
  • Metrics with "empty = 1" punish false positives hard. Rejecting the 26 decoy blobs was worth as much as finding the true ones.
  • Use the network for what it is good at (textures: noise against scramble), and rules for what is exactly defined.
Full code (solution.py)
"""ONIA 2026 camp test 1, Glitch Hunter 2: segment + count 4 glitch types, ignore decoys.

Pipeline: a small U-Net (full-resolution first stage, so pixel-level texture survives) trained with
class-weighted cross-entropy on random 256x256 crops, then a RECTANGLE post-processing: every glitch
is an axis-aligned rectangle and glitches never touch, so each connected component of "glitch" pixels
becomes one instance, gets the majority class of its pixels, and is replaced by its bounding box.
Counts = number of kept rectangles per class.
Usage:  python solution.py <data_dir> [--val 40] [--minutes 15] [--out submission.zip]
"""
import os, sys, json, time, argparse, zipfile, io, glob
os.environ.setdefault("OMP_NUM_THREADS", "2")
import numpy as np, torch, torch.nn as nn, torch.nn.functional as F
from PIL import Image
from scipy import ndimage
torch.set_num_threads(2); torch.manual_seed(0); np.random.seed(0)
NAMES = ["noise", "invert", "solid", "scramble"]


def block(i, o, d=1):
    return nn.Sequential(nn.Conv2d(i, o, 3, padding=d, dilation=d), nn.BatchNorm2d(o), nn.ReLU(inplace=True),
                         nn.Conv2d(o, o, 3, padding=d, dilation=d), nn.BatchNorm2d(o), nn.ReLU(inplace=True))


class UNet(nn.Module):
    def __init__(self, c=(16, 32, 48, 64, 96)):
        super().__init__()
        self.e = nn.ModuleList([block(3, c[0])] + [block(c[i], c[i + 1]) for i in range(4)])
        self.mid = block(c[4], c[4], d=2)
        self.d = nn.ModuleList([block(c[i + 1] + c[i], c[i]) for i in range(4)])
        self.head = nn.Conv2d(c[0], 5, 1)

    def forward(self, x):
        skips = []
        for i, b in enumerate(self.e):
            x = b(x if i == 0 else F.max_pool2d(x, 2)); skips.append(x)
        x = self.mid(x)
        for i in (3, 2, 1, 0):
            x = F.interpolate(x, scale_factor=2, mode="bilinear", align_corners=False)
            x = self.d[i](torch.cat([x, skips[i]], 1))
        return self.head(x)


def load(d, ids, masks=True):
    X = np.stack([np.array(Image.open(f"{d}/images/{k}.png").convert("RGB")) for k in ids])
    Y = np.stack([np.array(Image.open(f"{d}/masks/{k}.png")) for k in ids]) if masks else None
    return X, Y


def to_t(x):
    return torch.tensor(x).permute(0, 3, 1, 2).float() / 255.0


def train(X, Y, minutes, bs=8, crop=256):
    net = UNet(); opt = torch.optim.AdamW(net.parameters(), 2e-3, weight_decay=1e-4)
    w = torch.tensor([0.3, 1, 1, 1, 1.0])
    t0, it = time.time(), 0
    total = minutes * 60
    while time.time() - t0 < total:
        idx = np.random.randint(len(X), size=bs)
        ys, xs = np.random.randint(0, 512 - crop + 1, size=(2, bs))
        xb = np.stack([X[i, a:a + crop, b:b + crop] for i, a, b in zip(idx, ys, xs)])
        yb = np.stack([Y[i, a:a + crop, b:b + crop] for i, a, b in zip(idx, ys, xs)])
        k = np.random.randint(4)                       # rectangles survive flips and 90-degree turns
        xb, yb = np.rot90(xb, k, (1, 2)), np.rot90(yb, k, (1, 2))
        if np.random.rand() < .5:
            xb, yb = xb[:, :, ::-1], yb[:, :, ::-1]
        lr = 2e-3 * 0.5 * (1 + np.cos(np.pi * min(1, (time.time() - t0) / total)))
        for g in opt.param_groups: g["lr"] = lr
        loss = F.cross_entropy(net(to_t(xb.copy())), torch.tensor(yb.copy()).long(), weight=w)
        opt.zero_grad(); loss.backward(); opt.step(); it += 1
        if it % 50 == 0:
            print(f"it {it} loss {loss.item():.3f} {time.time() - t0:.0f}s", flush=True)
    return net.eval()


@torch.no_grad()
def predict(net, X):
    out = []
    for i in range(len(X)):
        p = 0
        for k in range(4):                                         # 4-fold rotation test-time augmentation
            x = np.rot90(X[i:i + 1], k, (1, 2)).copy()
            p = p + np.rot90(torch.softmax(net(to_t(x)), 1)[0].numpy(), -k, (1, 2))
        out.append(p / 4)
    return np.stack(out)


def flat_map(img):
    """True where the 3x3 neighbourhood is EXACTLY constant in all channels (a solid block's signature)."""
    x = img.astype(np.int16); f = np.ones(x.shape[:2], bool)
    for c in range(3):
        f &= ndimage.maximum_filter(x[..., c], 3) == ndimage.minimum_filter(x[..., c], 3)
    return f


def reinversion_test(img, sl, w=2):
    """An inverted rectangle becomes continuous with its surroundings once re-inverted (255 - x);
    a hue-shift decoy does not. Compare a 2-pixel ring inside each edge with the ring outside it."""
    H, W = img.shape[:2]; y0, y1, x0, x1 = sl[0].start, sl[0].stop, sl[1].start, sl[1].stop
    x = img.astype(float); pairs = []
    if y0 - w >= 0: pairs.append((x[y0:y0 + w, x0:x1].mean(0), x[y0 - w:y0, x0:x1].mean(0)))
    if y1 + w <= H: pairs.append((x[y1 - w:y1, x0:x1].mean(0), x[y1:y1 + w, x0:x1].mean(0)))
    if x0 - w >= 0: pairs.append((x[y0:y1, x0:x0 + w].mean(1), x[y0:y1, x0 - w:x0].mean(1)))
    if x1 + w <= W: pairs.append((x[y0:y1, x1 - w:x1].mean(1), x[y0:y1, x1:x1 + w].mean(1)))
    if not pairs:
        return True
    d_inv = np.mean([np.abs(255 - a - b).mean() for a, b in pairs])
    d_raw = np.mean([np.abs(a - b).mean() for a, b in pairs])
    return d_inv < d_raw


def rectangles(prob, img=None, min_area=60, flat_rule=True):
    """Probabilities (5,H,W) -> rectangle-cleaned class mask + instance counts."""
    raw = prob.argmax(0)
    flat = flat_map(img) if (flat_rule and img is not None) else None
    lab, n = ndimage.label(raw > 0)
    mask = np.zeros_like(raw, dtype=np.uint8); counts = dict.fromkeys(NAMES, 0)
    for j, sl in enumerate(ndimage.find_objects(lab), start=1):
        comp = lab[sl] == j
        if comp.sum() < min_area:
            continue
        cls = int(prob[1:, sl[0], sl[1]][:, comp].sum(1).argmax()) + 1  # majority (soft) class of the blob
        if flat is not None:   # an exactly constant blob is solid, a textured one cannot be
            fr = flat[sl][comp].mean()
            if fr > 0.5:
                cls = 3
            elif cls == 3:
                cls = int(np.argmax(np.delete(prob[1:, sl[0], sl[1]][:, comp].sum(1), 2))) + 1
                cls = cls + 1 if cls >= 3 else cls
            if cls == 2 and not reinversion_test(img, sl):
                continue                                               # hue-shift decoy, not an inversion
        mask[sl] = cls                                                 # fill the bounding box
        counts[NAMES[cls - 1]] += 1
    return raw.astype(np.uint8), mask, counts


def score(masks, counts, Y, C):
    """Official metric: 0.7 * mean macro-IoU (empty-empty = 1) + 0.3 * mean 1/(1+|dn|), x100."""
    ious, cs = [], []
    for m, y, c, t in zip(masks, Y, counts, C):
        per = []
        for k in range(1, 5):
            p, g = m == k, y == k; u = (p | g).sum()
            per.append(1.0 if u == 0 else (p & g).sum() / u)
        ious.append(np.mean(per)); cs += [1 / (1 + abs(c[n] - t[n])) for n in NAMES]
    return 100 * (0.7 * np.mean(ious) + 0.3 * np.mean(cs)), 100 * np.mean(ious), 100 * np.mean(cs)


if __name__ == "__main__":
    ap = argparse.ArgumentParser(); ap.add_argument("data"); ap.add_argument("--val", type=int, default=40)
    ap.add_argument("--minutes", type=float, default=15); ap.add_argument("--out", default="")
    a = ap.parse_args()
    truth = json.load(open(f"{a.data}/train/instance_counts.json")); ids = sorted(truth)
    rng = np.random.default_rng(0); perm = rng.permutation(len(ids))
    va, tr = [ids[i] for i in perm[:a.val]], [ids[i] for i in perm[a.val:]]
    X, Y = load(f"{a.data}/train", tr)
    net = train(X, Y, a.minutes)
    if va:
        Xv, Yv = load(f"{a.data}/train", va); P = predict(net, Xv)
        res = [rectangles(p, x, flat_rule=False) for p, x in zip(P, Xv)]
        raw_counts = [{n: int(ndimage.label(r == k + 1)[1]) for k, n in enumerate(NAMES)} for r, _, _ in res]
        print("raw argmax     : final %.2f  IoU %.2f  count %.2f" % score([r for r, _, _ in res], raw_counts, Yv, [truth[k] for k in va]))
        print("rectangle post : final %.2f  IoU %.2f  count %.2f" % score([m for _, m, _ in res], [c for _, _, c in res], Yv, [truth[k] for k in va]))
        res = [rectangles(p, x) for p, x in zip(P, Xv)]
        print("+ flat & re-inversion rules: final %.2f  IoU %.2f  count %.2f" % score([m for _, m, _ in res], [c for _, _, c in res], Yv, [truth[k] for k in va]))
        np.save("val_probs.npy", P.astype(np.float16)); json.dump(va, open("val_ids.json", "w"))
    if a.out:
        tid = sorted(os.path.basename(f)[:-4] for f in glob.glob(f"{a.data}/test/images/*.png"))
        Xt, _ = load(f"{a.data}/test", tid, masks=False); counts = {}
        with zipfile.ZipFile(a.out, "w") as z:
            for k, p, x in zip(tid, predict(net, Xt), Xt):
                _, m, c = rectangles(p, x); counts[k] = c
                buf = io.BytesIO(); Image.fromarray(m).save(buf, format="PNG"); z.writestr(f"masks/{k}.png", buf.getvalue())
            z.writestr("instance_counts.json", json.dumps(counts, indent=1))
    torch.save(net.state_dict(), "unet.pt")