← Retour à l'épreuve

Peru OPEIA 2026 · Correction IOAI Training

Crowd Counting

Cette page donne toute la méthode. Lisez-la une fois que vous avez cherché.

Métrique
RMSE
Évaluée sur
held-out validation (official test labels are private)
Notre score
RMSE 29.7, MAE 17.2 (5-fold CV)
Niveau attendu
organisers' baseline RMSE 143.9 (only LB entry)

Niveau attendu atteint

Nous avons résolu l'épreuve nous-mêmes : téléchargé les données, mené les expériences et mesuré le score ci-dessous. L'énoncé et les données appartiennent à leurs organisateurs. Cette correction est rédigée en anglais.

The problem in brief

You must count the people in photographs of busy streets and shopping areas (1024 × 768 pixels). The 100 training images come with a point on every head; the 216 test images come with nothing. The deliverable is one number per test image. Pretrained models are explicitly allowed (the rules name YOLOv8 and CSRNet as examples).

The data comes from the Kaggle competition archive. The test counts are private and we never submitted anything, so all scores below are 5-fold cross-validation on the 100 labelled images.

How you are scored

Root mean squared error between predicted and true counts:

RMSE=1N∑i=1N(yi−y^i)2.\text{RMSE} = \sqrt{\frac{1}{N}\sum_{i=1}^{N}\big(y_i - \hat y_i\big)^2}.

Lower is better. Because the errors are squared, a few large mistakes on very crowded images dominate the score.

What to aim for. The only entry on the public leaderboard is the organisers' baseline, at 143.9. No other threshold is published.

First look at the data

Counts range from 15 to 539 people (median 90, mean 126, standard deviation 105). Simply predicting the training mean for every image gives an RMSE of 105.5 in cross-validation, already better than the published baseline. People near the camera are large; people at the back are a few pixels wide and overlap each other, which is why the hints warn that a person detector struggles with dense crowds: it cannot draw a box around a head it cannot separate from its neighbours.

A first baseline

approachCV RMSE
organisers' baseline (public leaderboard, different images)143.9
predict the training mean105.5

The key idea

Do not detect people: estimate a density and integrate it. Put a small Gaussian blob of total mass 1 on every annotated head. The resulting density map DD is zero where there is nobody, high where heads are packed, and its integral is exactly the number of people:

D(x)=∑j=1NN(x; pj,σ2I),∑xD(x)=N.D(x) = \sum_{j=1}^{N} \mathcal N(x;\, p_j, \sigma^2 I), \qquad \sum_x D(x) = N.

Annotations, density target and cross-validated predictions

A fully convolutional network is trained to reproduce DD pixel by pixel, and the predicted count is the sum of the predicted map. This changes the problem in three good ways:

  • No need to separate people. Two overlapping heads simply add up to a mass of 2. The network only has to recognise "head-like texture at this scale", which is local and works the same for one person or a thousand.
  • Much more supervision. A count is one number per image; a density map gives a target for every location, so 100 images become tens of thousands of supervised patches.
  • Crops are allowed. Since a count is additive, any crop of an image has a known count (the sum of the density inside it). We train on random 256 × 256 crops, which is both data augmentation and a way to fit in memory.

The architecture is CSRNet: the first ten convolutional layers of an ImageNet-pretrained VGG16 (stride 8, they already know edges, textures and object parts), followed by six dilated 3 × 3 convolutions. Dilation spaces the taps of each filter apart, so the receptive field grows to cover a whole crowd region without further pooling, and the output keeps a resolution of 1/8 of the input.

The full solution

  1. Resize images to half resolution (512 × 384) and build density maps with σ=4\sigma = 4 pixels, renormalised so that blobs cut by the border still sum to the exact count; downsample them by 8 with a sum (not a mean), multiplied by 100 so that the targets are not tiny numbers.
  2. CSRNet with VGG16-BN weights (timm/vgg16_bn.tv_in1k, safetensors), the first two VGG blocks frozen to save memory and to keep generic low-level filters.
  3. Train 100 epochs on random 256 × 256 crops with horizontal flips, batch 4, pixel-wise squared error on the density, Adam with learning rate 10−410^{-4} and cosine decay (about 5 minutes on the Apple GPU).
  4. Predict on the whole half-resolution image and sum the map:
def count(m, X):
    return [float(m(to_tensor(x)).sum()) / 100 for x in X]   # integral of the density = number of people
  1. For the submission, train on all 100 images and predict the 216 test images.

Results

5-fold cross-validation on the 100 labelled images (official test counts private).

approachRMSEMAE
predict the training mean105.5
CSRNet, half resolution, fold 0 only34.419.5
CSRNet, 0.75 resolution, fold 0 only (failed)48.629.2
CSRNet, half resolution, 5-fold CV (final)29.717.2

The final model reaches an RMSE of 29.7 and a mean absolute error of 17 people, against 105.5 for the constant prediction and 143.9 for the published baseline. The folds vary a lot (RMSE 16.8 to 34.8), and the right panel above shows why: almost all of the squared error comes from a handful of very dense images (261 people predicted as 136, 539 as 426). Without the five worst images the RMSE would be 17.4. Training at 0.75 resolution with the same crop size was worse on fold 0: each crop then covers less of the scene, and with only 80 training images context seems to matter more than detail.

What to remember

  • Counting is integration. Turning points into a density map makes counting a dense regression problem that handles occlusion naturally and gives far more supervision than one number per image.
  • Additive targets allow crops. When the label of a part is known (here, the mass inside the crop), random crops are free augmentation and save memory.
  • Pretrained convolutional features transfer to new tasks. Ten VGG layers trained on ImageNet plus a small new head learn a good density estimator from just 100 images.
  • Dilated convolutions enlarge the receptive field without losing resolution, which is what dense prediction needs.
  • RMSE is ruled by the worst cases. Look at the largest errors first: here a few very crowded images account for most of the score.

Note on the code. The full solution.py below reproduces the setup that produced the reported score, but it was not re-run end to end after it was assembled.

Code complet (solution.py)
"""Crowd Counting (Peru OPEIA 2026, computer vision).

Density-map regression (CSRNet style):
  * each annotated head becomes a small Gaussian blob (sigma = 4 px at half resolution),
    so the density map integrates to the number of people;
  * a VGG16-BN backbone pretrained on ImageNet (first 10 conv layers, stride 8) followed by
    6 dilated 3x3 convolutions predicts the density map at 1/8 resolution;
  * the predicted count is the sum of the predicted map.
Training: random 256x256 crops of half-resolution images, horizontal flips, MSE on the
density map (x100), Adam lr 1e-4 with cosine decay, 100 epochs; first 2 VGG blocks frozen.

Data: kaggle competition "ioai-computer-vision-challenge-2026" (labeled/, test/, ground_truth_train.json).
Weights: timm/vgg16_bn.tv_in1k model.safetensors (saved as vgg16_bn.safetensors).
Usage: python solution.py cv   -> 5-fold cross-validated RMSE on the 100 labelled images
       python solution.py      -> train on all 100 images, write submission.csv (not submitted)
Runtime: ~5 min per training run on Apple MPS, ~1.5 GB GPU memory.
"""
import glob, json, os, sys, numpy as np, torch, torch.nn as nn, torch.nn.functional as F, torchvision
from PIL import Image
from scipy.io import loadmat
from scipy.ndimage import gaussian_filter
from safetensors.torch import load_file
from sklearn.model_selection import KFold

torch.set_num_threads(2)
DEV = 'mps' if torch.backends.mps.is_available() else 'cpu'
SCALE, SIGMA, EPOCHS, CROP, BS = 0.5, 4, 100, 256, 4
MEAN, STD = np.array([0.485, 0.456, 0.406]), np.array([0.229, 0.224, 0.225])


def load_img(path):
    im = Image.open(path).convert('RGB'); w, h = im.size
    im = im.resize((int(w * SCALE), int(h * SCALE)), Image.BILINEAR)
    return ((np.asarray(im, np.float32) / 255 - MEAN) / STD).astype(np.float32)


def density(pts, shape):
    d = np.zeros(shape, np.float32)
    for x, y in pts * SCALE:
        d[min(int(y), shape[0] - 1), min(int(x), shape[1] - 1)] += 1
    g = gaussian_filter(d, SIGMA, mode='constant')
    return g * (len(pts) / max(g.sum(), 1e-6))            # blobs cut by the border: keep the exact count


def down8(d):                                             # sum-preserving 8x downsampling
    H, W = d.shape[0] // 8 * 8, d.shape[1] // 8 * 8
    return d[:H, :W].reshape(H // 8, 8, W // 8, 8).sum((1, 3))


class CSRNet(nn.Module):
    def __init__(self):
        super().__init__()
        vgg = torchvision.models.vgg16_bn()
        vgg.load_state_dict({k: v for k, v in load_file('vgg16_bn.safetensors').items() if k.startswith('features.')},
                            strict=False)
        self.front = vgg.features[:33]                    # conv1_1 ... conv4_3, stride 8
        layers, c = [], 512
        for o in [512, 512, 512, 256, 128, 64]:           # dilated convs: large receptive field, no more pooling
            layers += [nn.Conv2d(c, o, 3, padding=2, dilation=2), nn.ReLU(inplace=True)]; c = o
        self.back, self.out = nn.Sequential(*layers), nn.Conv2d(64, 1, 1)

    def forward(self, x):
        return F.relu(self.out(self.back(self.front(x))))[:, 0]


def load_train():
    g = json.load(open('ground_truth_train.json')); names = [f'IMG_{i}.jpg' for i in range(1, 101)]
    X, D = [], []
    for n in names:
        x = load_img(f'labeled/labeled/images/{n}')
        p = loadmat(f'labeled/labeled/ground-truth/GT_{n[:-4]}.mat')['image_info'][0][0][0][0][0]
        X.append(x); D.append(down8(density(p, x.shape[:2])) * 100.0)   # x100: easier to regress
    return np.stack(X), np.stack(D), np.array([g[n] for n in names])


def train(X, D, idx, seed=0):
    torch.manual_seed(seed); rng = np.random.RandomState(seed)
    m = CSRNet().to(DEV)
    for p in m.front[:14].parameters():                   # first two VGG blocks frozen (memory, few images)
        p.requires_grad = False
    opt = torch.optim.Adam([p for p in m.parameters() if p.requires_grad], lr=1e-4, weight_decay=1e-4)
    sch = torch.optim.lr_scheduler.CosineAnnealingLR(opt, EPOCHS)
    H, W = X.shape[1:3]
    for ep in range(EPOCHS):
        m.train(); perm = rng.permutation(idx)
        for b in range(0, len(perm), BS):
            xb, db = [], []
            for i in perm[b:b + BS]:
                y0 = rng.randint(0, (H - CROP) // 8 + 1) * 8; x0 = rng.randint(0, (W - CROP) // 8 + 1) * 8
                x, d = X[i, y0:y0 + CROP, x0:x0 + CROP], D[i, y0 // 8:(y0 + CROP) // 8, x0 // 8:(x0 + CROP) // 8]
                if rng.rand() < 0.5:
                    x, d = x[:, ::-1], d[:, ::-1]
                xb.append(x.copy()); db.append(d.copy())
            x = torch.from_numpy(np.stack(xb)).permute(0, 3, 1, 2).contiguous().to(DEV)
            loss = F.mse_loss(m(x), torch.from_numpy(np.stack(db)).to(DEV), reduction='sum') / len(xb)
            loss.backward(); opt.step(); opt.zero_grad()
        sch.step()
    return m


@torch.no_grad()
def count(m, X):
    m.eval()
    return np.array([float(m(torch.from_numpy(x).permute(2, 0, 1)[None].contiguous().to(DEV)).sum()) / 100 for x in X])


if __name__ == '__main__':
    if DEV == 'mps':
        torch.mps.set_per_process_memory_fraction(0.1)
    X, D, y = load_train()
    if sys.argv[1:] == ['cv']:
        pred = np.zeros(len(y))
        for f, (tr, va) in enumerate(KFold(5, shuffle=True, random_state=0).split(y)):
            pred[va] = count(train(X, D, tr), X[va])
            print(f'fold {f}: RMSE {np.sqrt(np.mean((pred[va] - y[va]) ** 2)):.2f}', flush=True)
        print(f'5-fold CV RMSE {np.sqrt(np.mean((pred - y) ** 2)):.2f}, MAE {np.abs(pred - y).mean():.2f}')
    else:
        m = train(X, D, np.arange(len(y)))
        files = sorted(glob.glob('test/test/*.jpg'), key=lambda p: int(p.split('_')[-1][:-4]))
        c = count(m, [load_img(p) for p in files])
        with open('submission.csv', 'w') as fh:
            fh.write('image_name,count\n')
            for p, v in zip(files, c):
                fh.write(f'{os.path.basename(p)},{max(v, 0):.1f}\n')
        print('wrote submission.csv (not submitted)')