Discord

Checklist OAI 2025 Stage I · Task 1

Coin Counting Machine

Polish title: Maszynka do Liczenia Monet

Detect and classify Polish coins by denomination in photographs, returning bounding boxes with labels and confidences.

  • Vision
  • Object detection
  • Polish original · English translation

The task

Given photographs of scattered coins, the goal is to determine their total value by locating every coin and assigning it to its denomination. Only Polish coins, photographed on the denomination side, are considered; banknotes and the eagle side are not in the dataset. The nine classes are 1, 2, 5, 10, 20 and 50 groszy and 1, 2 and 5 złotych (labels 0–8).

The contestant implements YourDetector, an nn.Module whose forward method takes an image and returns a set of tuples (x_min, y_min, x_max, y_max, c, φ) — the box corners (origin at the top-left corner, y growing downwards), the class label and the model's confidence. The notebook explains the typical detection errors (false positives, false negatives, wrong class, imprecise box, duplicate detections) and the IoU, precision, recall and average-precision concepts.

A baseline is provided: a ResNet-18 classifier (trained from scratch, with a tenth "background" class) applied in a 64-pixel sliding window with stride 32, which classifies well but localises poorly.

Abridged and translated by SOTA from the official Polish materials. The official statement has the exact rules, and it wins wherever this summary differs.

In English

This task was published in Polish. SOTA translated it into English on 16 September 2026. Only the words changed in the notebooks: markdown, code comments, messages and printed output. The code, file names and paths are the original's, so a translated notebook runs with the original data.

Read the task notebook in English 2124 words and 17 code cells

Coin Counting Machine

coin_counter_image.png Image generated with the Flux-dev model.

Introduction

Imagine that you find a large chest full of coins in the attic and decide to count their total value. Counting every coin by hand would be time-consuming and tedious, so you decide to automate the solution. Given photos of scattered coins, your goal is to determine their total value. Breaking this task down into its components, your task will be to:

  • locate each coin in the photo,
  • assign the coins to the appropriate categories according to their denominations (e.g. 1 grosz or 2 złote).

In computer vision, this task is called object detection. Note that detection is simultaneously a classification task and a regression task, because you must determine both the class of an object and its position in the photo. An additional challenge that distinguishes detection from an ordinary classification task is the fact that, instead of a single object representing one of the classes, a photo may contain many objects of different classes.

Formally, we can say that the solution B^\hat{\mathcal{B}} should be a set of tuples containing, in order, the coordinates of the rectangle in which a coin is located, a label that specifies the denomination of the coin, and the confidence with which the model believes that it has found a coin.

B^={(xmin,ymin,xmax,ymax,c,ϕ),}\hat{\mathcal{B}} = \{(x_{min}, y_{min}, x_{max}, y_{max}, c, \phi), \dots\}

where (xmin,ymin)(x_{min}, y_{min}) is the top-left corner of the rectangle, (xmax,ymax)(x_{max}, y_{max}) is the bottom-right corner of the rectangle, cc is the label of the coin, and ϕ\phi is the model's confidence in the prediction. Depending on the photo, the set B^\hat{\mathcal{B}} may contain a different number of elements, corresponding to the number of coins found. The order of the coins in the set does not matter.

Note: Regarding the positions of the rectangles in the photo, bear in mind that the point (0, 0) is located in the top-left corner of the image and that the Y axis increases downwards. This is the standard coordinate system used in computer graphics, and it differs from the coordinate system used in mathematics.

When performing detection, we can make five types of errors, which are illustrated in the images below:

  • The model detected an object in a place where there is none (this situation is called a False Positive)
  • The model did not detect an object that is present in the image (this situation is called a False Negative)
  • The model detected an object but classified it incorrectly
  • The model correctly detected and classified an object but returned imprecise rectangle coordinates
  • The model detected the same object several times

coin_counter_prediction.png

In this case, we consider only Polish coins, and we restrict ourselves to the sides showing the denomination. Neither banknotes nor the side bearing the image of the eagle are included in the dataset, and they are not taken into account.

Link to the lecture slides on object detection and segmentation.

Task

Your task is to implement the YourDetector class, in which the forward method takes an image and returns a set containing the coin predictions in the format described in the definition of the set B^\hat{\mathcal{B}}.

Scoring Criterion

Note: Both the methods that compute the metrics and the data loading have been implemented in the task. Your only goal is to implement the detection model; however, we encourage you to read the description of the metrics in order to understand the problem better.

The task will be scored using the mAP metric (mean Average Precision), which is the standard metric in the field of object detection.
Computing this metric begins with pairing the predictions with the ground-truth objects. For this purpose, the IoU metric (Intersection over Union) is used, which measures the degree of overlap between two rectangles. The IoU value is defined as the ratio of the area common to both rectangles, AinterA_{\text{inter}}, to the area of their union, AunionA_{\text{union}}.

IoU=AinterAunion IoU = \frac{A_{\text{inter}}}{A_{\text{union}}}

Looking at the figure below, we can say that IoU is the ratio of the yellow area to the blue area. Note that if the rectangles had no part in common, the IoU value would be 00, and if both rectangles were identical, the IoU value would be 11. The IoU value must exceed a fixed threshold for us to consider two rectangles to be overlapping. The higher the threshold, the more accurate the object locations indicated by the model must be in order to be paired with the rectangles created from the ground-truth coordinates.

coin_counter_explanation_1.png

After matching the model's predictions to the ground-truth objects, we can compute two key measures: precision and recall. We compute both measures separately for each class.

  • Precision expresses the proportion of objects correctly classified into a given class among all objects that were assigned to that class.
  • Recall describes the proportion of correctly classified objects of a given class relative to all objects of that particular class.

These two measures help to assess the effectiveness of the model. However, because models are imperfect and make various types of errors, an attempt to improve one of the metrics usually entails a deterioration of the other. For example, if we lower the model's confidence threshold, we will take into account a larger number of objects indicated by the model, but at the same time this may increase the number of objects that the model falsely indicates as relevant.

To better understand this trade-off, we use the model's confidence values (denoted by ϕ\phi) and use them to determine the precision-recall curve. This curve shows how precision and recall change depending on the chosen confidence threshold.

The area under the curve drawn below is the average precision (AP) for a given class.

coin_counter_explanation_2.png

The mAP metric is the average precision over all classes, i.e. the mean area under the precision-recall curve over all classes, given by the formula:

mAP=1Kk=1KAPk, mAP = \frac{1}{K} \sum_{k=1}^K {AP}_{k},

where KK is the number of classes and APk{AP}_{k} is the average precision for class kk.

The last step is to choose the IoU value for which we want to compute mAP. The standard practice, which we will use to score your solution, is to compute mAP for various IoU values (starting from 0.5 and increasing the value in steps of 0.05 up to 0.95) and to average the results. As a result, the metric better reflects not only the quality of the classification but also the quality of the object localisation (for the threshold IoU=0.95, the model must return predictions that overlap the target rectangles almost perfectly, whereas for IoU=0.5 the metric "forgives" much larger differences in position).

Ultimately, your solution will be scored on a secret test set using the mAP metric. The test set does not differ significantly from the validation set.

  • If the mAP of your model is 0.2 (or below), you will receive 0 points for the task.
  • If the mAP of your model is 0.85 (or above), you will receive 100 points for the task.
  • Otherwise, the number of points will be calculated in proportion to the mAP value:

score=mAP0.20.850.2×100\text{score} = \frac{mAP - 0.2}{0.85 - 0.2} \times 100

Constraints

  • Your solution will be tested on the Contest Platform without internet access and in an environment with a GPU.
  • The evaluation of your final solution on the Contest Platform may not take longer than 10 minutes with a GPU, and the evaluation of a single photo may not take longer than 5 seconds.
  • The model may not use other datasets or weights pre-trained on other datasets.
  • The model must return results in a format that is compatible with the predict_all_bounding_boxes function (as the example solution does).
  • The model must inherit from the nn.Module class.

Submission Files

This notebook, completed with your solution (see the YourDetector class).

Evaluation

Remember that during grading the FINAL_EVALUATION_MODE flag will be set to True.

You can earn between 0 and 100 points for this task. The number of points you earn will be calculated on the (secret) test set on the Contest Platform using the formula given above, rounded to the nearest integer. If your solution does not meet the above criteria or does not run correctly, you will receive 0 points for the task.

Starter Code

In this section, we initialise the environment by importing the required libraries and functions. The prepared code will help you to work with the data efficiently and to build your actual solution.

######################### DO NOT CHANGE THIS CELL ###########################

FINAL_EVALUATION_MODE = False  # During grading, we will set this flag to True.
######################### DO NOT CHANGE THIS CELL ###########################

import os
import torch
import pickle
import gdown
import numpy as np
import torch.nn as nn
import matplotlib.pyplot as plt
import seaborn as sns
import torchvision.transforms.v2 as T
from collections.abc import Callable
from matplotlib import patches
from matplotlib.collections import PatchCollection
from torchvision.models import resnet18
from torchvision.ops import box_iou
from torch.utils.data import Dataset
from tqdm import tqdm
from torchmetrics.detection.mean_ap import MeanAveragePrecision 

DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")

assert torch.cuda.is_available(), "CUDA is not available!"
######################### DO NOT CHANGE THIS CELL ###########################

seed = 12345

os.environ["PYTHONHASHSEED"] = str(seed)
torch.manual_seed(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
######################### DO NOT CHANGE THIS CELL ###########################
# Cell containing helper functions for preparing the data.

class CoinsDataset(Dataset):
    """
    Coin dataset loaded from a pickle file.
    
    Args:
        pickle_file (str): Path to the pickle file containing the data.
        transform (callable, optional): Transformations applied to the images and labels.
    """
    def __init__(
            self, 
            pickle_file: str, 
            transform: Callable | None = None
        ):
        self.transform = transform

        with open(pickle_file, 'rb') as f:
            self.data = pickle.load(f)

    def __len__(self) -> int:
        """Returns the number of samples in the dataset."""
        return len(self.data)
    
    def __getitem__(
            self, 
            idx: int
        ) -> dict:
        """
        Retrieves a data sample by its index.
        
        Args:
            idx (int): Index of the sample.
        
        Returns:
            dict: A dictionary containing the image and its corresponding target objects (boxes, labels).
        """
        sample = self.data[idx]
        image = sample['image']
        target = {
            'boxes': sample['boxes'],
            'labels': sample['labels']
        }

        if self.transform:
            image, target = self.transform(image, target)

        return {
            'image': image,
            **target
        }


def setup_data(
        train_transform: Callable | None = None, 
        val_transform: Callable | None = None, 
        root: str = 'data/'
    ) -> tuple:
    """
    Prepares the training and validation datasets, downloading them if necessary.
    
    Args:
        train_transform (callable, optional): Augmentations for the training set.
        val_transform (callable, optional): Augmentations for the validation set.
        root (str, optional): Base directory for the data files.
    
    Returns:
        tuple: The datasets (train_ds, val_ds).
    """
    if train_transform is None:
        train_transform = T.Compose([T.ToImage(), T.ToDtype(torch.float32, scale=True)])
    if val_transform is None:
        val_transform = T.Compose([T.ToImage(), T.ToDtype(torch.float32, scale=True)])

    train_file = root+'train.pkl'
    val_file = root+'val.pkl'

    if not os.path.exists(root):
        os.makedirs(root)

    if not os.path.exists(train_file):
        url = "https://drive.google.com/uc?id=1KC8FBlCuwh9ITUt0CcPeRJCpqy5j4WBp"
        gdown.download(url, train_file, quiet=True)

    if not os.path.exists(val_file):
        url = "https://drive.google.com/uc?id=1Oza4UjnmAUeae2cA8YDwMWxVHOb7SKdP"
        gdown.download(url, val_file, quiet=True)

    train_ds = CoinsDataset(root+'train.pkl', transform=train_transform)
    val_ds = CoinsDataset(root+'val.pkl', transform=val_transform)

    return train_ds, val_ds
######################### DO NOT CHANGE THIS CELL ###########################
# Cell containing helper functions for computing the metrics that assess the quality of the model.

def predict_all_bounding_boxes(
        model: nn.Module, 
        ds: Dataset
    ) -> list:
    """
    Function that predicts all bounding boxes for a dataset using the model.

    Args:
        model: Object detection model.
        ds: Dataset.

    Returns:
        A list containing all predicted bounding boxes for each sample in the dataset.
    """
    all_pred_bboxes = []

    for sample in ds:
        img = sample["image"].to(DEVICE)
        pred_bboxes = model(img)
        all_pred_bboxes.append(pred_bboxes)

    return all_pred_bboxes


def calculate_map(
        predictions: list, 
        ds: Dataset, 
        return_all: bool = False
    ) -> dict | float:
    """
    Function that computes the mean average precision (mAP) of the predicted bounding boxes for the whole dataset.

    Args:
        predictions: A list containing the predicted bounding boxes for each sample in the dataset.
        ds: Dataset containing the ground-truth bounding boxes.
        return_all (bool, optional): Whether to return all metrics or only mAP:0.5:0.95:0.05.

    Returns:
        The mAP value, or all metrics in the form of a dictionary.
    """
    meta = []

    for img_meta in predictions:
        entry = {
            "boxes": [],
            "labels": [],
            "scores": []
        }

        for box in img_meta:
            entry["boxes"].append(box[:4])
            entry["labels"].append(box[4])
            entry["scores"].append(box[5])

        meta.append(entry)

    for i in range(len(meta)):
        meta[i]['boxes'] = torch.tensor(meta[i]['boxes'])
        meta[i]['labels'] = torch.tensor(meta[i]['labels']).view(-1)
        meta[i]['scores'] = torch.tensor(meta[i]['scores'])

    mAP = MeanAveragePrecision()

    GT = [{
        "boxes": sample["boxes"],
        "labels": sample["labels"]
    } for sample in ds]

    output = mAP(meta, GT)

    if return_all:
        return output

    return mAP(meta, GT)['map'].item()

def compute_confusion_matrix(
        predictions: list, 
        ds: Dataset, 
        iou_threshold: float = 0.5
    ) -> np.ndarray:
    """
    Function that computes the confusion matrix of the predicted bounding boxes for the whole dataset.

    Args:
        predictions: A list containing the predicted bounding boxes for each sample in the dataset.
        ds: Dataset containing the ground-truth bounding boxes.
        iou_threshold (float, optional): IoU threshold for assigning a prediction to an object.

    Returns:
        The confusion matrix.
    """
    num_classes = 10  # 9 coin classes + 1 background
    conf_matrix = np.zeros((num_classes, num_classes), dtype=int)

    for pred_boxes, item in zip(predictions, ds):
        # Ground truth
        gt_boxes = item['boxes']
        gt_labels = item['labels']

        # predictions
        pred_boxes_tensor = torch.tensor([p[:4] for p in pred_boxes]) if pred_boxes else torch.empty((0, 4))
        pred_labels = torch.tensor([p[4] for p in pred_boxes]) if pred_boxes else torch.empty((0,), dtype=torch.long)

        # IoU values for all pairs of predictions and ground truth
        iou_matrix = box_iou(pred_boxes_tensor, gt_boxes) if pred_boxes else torch.empty((0, gt_boxes.shape[0]))

        # assign the predictions to the ground truth based on IoU
        matched_gt = set()
        for pred_idx, ious in enumerate(iou_matrix):
            max_iou, gt_idx = torch.max(ious, dim=0)
            if max_iou >= iou_threshold and gt_idx.item() not in matched_gt:  
                conf_matrix[gt_labels[gt_idx].item(), pred_labels[pred_idx].item()] += 1
                matched_gt.add(gt_idx.item())
            else:
                conf_matrix[-1, pred_labels[pred_idx].item()] += 1  # False positive

        # Add all unassigned ground-truth objects as False negatives
        for gt_idx in range(len(gt_boxes)):
            if gt_idx not in matched_gt:
                conf_matrix[gt_labels[gt_idx].item(), -1] += 1

    return conf_matrix
######################### DO NOT CHANGE THIS CELL ###########################
# Cell containing helper functions for visualising the results

colors = ["red", "green", "blue", "yellow", "black", "purple", "orange", "brown", "pink"]
label_names = ['1 grosz', '2 grosze', '5 groszy', '10 groszy', '20 groszy', '50 groszy', '1 złotych', '2 złote', '5 złotych']  # Polish coin names (1 złoty = 100 groszy); "grosze"/"groszy" and "złote"/"złotych" are plural forms

def show_sample(
        sample: dict
    ):
    """
    Function that displays an image with the bounding boxes of the objects.

    Args:
        sample: A dictionary containing the image and the labels.
    """
    image = sample['image']
    meta = sample

    plt.figure(figsize=(9, 5))
    plt.imshow(image.permute((1, 2, 0)))
    plt.xticks([])
    plt.yticks([])

    if meta is not None:
        patches_list = []
        legend_labels = []

        for bbox, label in zip(meta['boxes'], meta['labels']):
            points = np.array(bbox)
            points = points.astype(int)

            # Draw the rectangle that encloses the object
            rect = patches.Rectangle(
                (points[0], points[1]), 
                points[2] - points[0], 
                points[3] - points[1], 
                linewidth=2, 
                edgecolor=colors[label.item()],
                facecolor='none'
            )
            patches_list.append(rect)

            # legend with unique labels
            if label_names[label.item()] not in legend_labels:
                legend_labels.append(label_names[label.item()])

        patch_collection = PatchCollection(patches_list, match_original=True)
        plt.gca().add_collection(patch_collection)

        # Add a legend with unique labels
        handles = [patches.Patch(color=colors[i], label=label) for i, label in enumerate(label_names) if label in legend_labels]
        plt.legend(handles=handles, loc="upper right")

    plt.show()


def plot_detection_results_grid(
        predictions: list, 
        ds: Dataset, 
        size: tuple = (2, 3)
    ):
    """
    Function that displays the detection results on selected validation photos.

    Args:
        predictions: A list containing the predicted bounding boxes for each sample in the dataset.
        ds: Dataset.
        size (tuple, optional): Size of the image grid.
    """

    fig, axes = plt.subplots(size[0], size[1], figsize=(size[1]*6, size[0]*5))

    for i, (sample, pred_meta) in enumerate(zip(ds, predictions)):
        if i >= size[0] * size[1]:
            break

        img = sample["image"]

        ax = axes[i // size[1], i % size[1]]
        ax.imshow(img.permute(1, 2, 0))
        ax.axis('off')

        for x1, y1, x2, y2, label, _ in pred_meta:
            rect = patches.Rectangle(
                (x1, y1),
                x2 - x1,
                y2 - y1,
                linewidth=2,
                edgecolor=colors[label],
                facecolor='none',
            )
            ax.add_patch(rect)
    
    handles = [patches.Patch(color=colors[i], label=label) for i, label in enumerate(label_names)]
    plt.legend(handles=handles, loc='upper right')
    plt.suptitle("Coin detection on six selected validation photos")
    plt.tight_layout()
    plt.show()


def plot_confusion_matrix(
        predictions: list, 
        ds: Dataset, 
        iou_threshold: float = 0.5
    ):
    """
    Function that displays the confusion matrix for coin detection.

    Args:
        predictions: The coin detection results.
        ds: Dataset.
        iou_threshold float: IoU threshold for assigning a prediction to an object
    """
    conf_matrix = compute_confusion_matrix(predictions, ds, iou_threshold=iou_threshold)

    labels = ["1 grosz", "2 grosze", "5 groszy", "10 groszy", "20 groszy", "50 groszy", "1 złotych", "2 złote", "5 złotych", "none (background)"]
    plt.figure(figsize=(10, 8))
    sns.heatmap(conf_matrix, annot=True, fmt="d", cmap="Blues", xticklabels=labels, yticklabels=labels, cbar=False)

    plt.xlabel("The model predicted", labelpad=15)
    plt.ylabel("whereas it should have predicted", labelpad=15)
    plt.xticks(rotation=45)

    plt.title("Confusion matrix for coin detection using IOU={}".format(iou_threshold))
    plt.show()

Data Loading

The code below loads the data and prepares it appropriately.

######################### DO NOT CHANGE THIS CELL ###########################

train_ds, val_ds = setup_data(root='./data/')

if not FINAL_EVALUATION_MODE:
    print("Number of photos in the training set:", len(train_ds), ", number of photos in the validation set:", len(val_ds))

    sample = train_ds[0]

    print("Each sample contains:", list(sample.keys()))
    print("Each photo has the dimensions:", list(sample["image"].shape))
    print("This sample contains", sample['labels'].shape[0], "objects")
    print("Each rectangle is described by", sample['boxes'].shape[1], "values (x1, y1, x2, y2)")

    show_sample(sample) # display an example photo with the objects marked
Object Classes and Their Labels

Below is a table of the labels of the classes present in the dataset, together with a short description of each.

Label Description
0 Coin with a face value of 1 grosz
1 Coin with a face value of 2 grosze
2 Coin with a face value of 5 groszy
3 Coin with a face value of 10 groszy
4 Coin with a face value of 20 groszy
5 Coin with a face value of 50 groszy
6 Coin with a face value of 1 złoty
7 Coin with a face value of 2 złote
8 Coin with a face value of 5 złotych

Example Solution

Below we present a simplified solution that serves as an example demonstrating the basic functionality of the notebook. It can serve as a starting point for developing your own solution.

A simple example is a convolutional network applied to a sliding window. This method consists of extracting fragments of the photo (in our case, of sizes typical of the coins in the dataset) and classifying each of them with a convolutional network. The network outputs one of 10 classes: nine of them correspond to the different denominations, while the last class is reserved for the background, i.e. places where there is no coin. We perform the classification for each fragment of the photo, moving the window by a fixed step each time. If the network returns the background class, we skip the fragment; otherwise, we assign the coin label to the fragment being analysed. In this way, we obtain a set of rectangles that contain coins.

The implementation must take into account an additional class that will represent the background (a rectangle that contains no coin). During training, we can take random crops of the photo. If a crop overlaps a coin with an IoU value higher than 0.50.5, we choose the denomination of that coin as the label. If the crop does not overlap any coin, we choose the background as the label.

We will start by defining a simple model, and in the following cells we will implement the training function.

######################### DO NOT CHANGE THIS CELL ###########################

class BasicCNNClassifier(nn.Module):
    def __init__(self):
        super(BasicCNNClassifier, self).__init__()
        self.net = resnet18(weights=None, num_classes=10)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        Function that classifies the image x into one of 10 classes: 9 coins + background.

        Args:
            x (torch.Tensor): The image to classify.

        Returns:
            torch.Tensor: The model's predictions.
        """
        return self.net(x)
######################### DO NOT CHANGE THIS CELL ###########################
# code for converting a photo containing many coins into training data for the classifier
# in 90% of cases, we randomly pick an object from the image and crop its surroundings
# in 10% of cases, we choose a completely random fragment of the image

class ClassificationDataPreprocessor:
    def __init__(
            self, 
            num_of_crops_per_image: int = 4, 
            box_size: int = 64
        ):
        self.num_of_crops_per_image = num_of_crops_per_image
        self.box_size = box_size

    def get_label_of_crop(
            self, 
            crop_box: tuple, 
            boxes: torch.Tensor, 
            labels: torch.Tensor,
            iou_threshold: float = 0.5
        ) -> int:
        """
        Function that returns the label for an image crop, depending on whether the crop overlaps a coin with an IoU value > 0.5.

        Args:
            crop_box (tuple): Coordinates of the top-left and bottom-right corners of the image crop (x1, y1, x2, y2).
            boxes (torch.Tensor): Tensor containing the bounding boxes of the coins.
            labels (torch.Tensor): Tensor containing the labels of the coins.
            iou_threshold (float): IoU threshold for assigning a prediction to an object.
        """
        for box, label in zip(boxes, labels):
            if box_iou(torch.tensor(crop_box).view(1, 4), box.view(1, 4)) > iou_threshold:
                return label
        return 9 # values 0-8 are the coin labels, so 9 will be the background

    def __call__(
            self, 
            batch: list
        ) -> dict:
        """
        Function that converts images containing many coins into training data for the classifier, consisting of image crops with their labels.

        Args:
            batch (list): A list of dictionaries containing the images, the bounding boxes of the coins and their labels.

        Returns:
            dict: A dictionary containing the image crops and their labels.
        """
        crops, labels = [], []

        for sample in batch:
            img = sample['image']

            for _ in range(self.num_of_crops_per_image):
                if torch.rand(1) < 0.1: # in one draw out of 10, we sample the background (this is how we try to balance the dataset)
                    central_x = torch.randint(0, img.shape[2], (1,)).item()
                    central_y = torch.randint(0, img.shape[1], (1,)).item()
                else: # in the remaining 90%, we randomly pick an object from the image and crop its surroundings
                    idx_gt = torch.randint(0, sample['labels'].shape[0], (1,)).item()
                    central_x = (sample['boxes'][idx_gt, 0] + sample['boxes'][idx_gt, 2]) // 2 + torch.randint(-10, 10, (1,)).item()
                    central_y = (sample['boxes'][idx_gt, 1] + sample['boxes'][idx_gt, 3]) // 2 + torch.randint(-10, 10, (1,)).item()

                x1 = np.clip(central_x - self.box_size // 2, 0, img.shape[2] - self.box_size)
                y1 = np.clip(central_y - self.box_size // 2, 0, img.shape[1] - self.box_size)
                x2, y2 = x1 + self.box_size, y1 + self.box_size

                crop = img[:, y1:y2, x1:x2]
                label = self.get_label_of_crop((x1, y1, x2, y2), sample['boxes'], sample['labels'])

                crops.append(crop)
                labels.append(label)

        return {
            "crops": torch.stack(crops, dim=0),
            "labels": torch.tensor(labels)
        }
######################### DO NOT CHANGE THIS CELL ###########################
# code for validation using the sliding-window method

class SlidingWindowDetector(nn.Module):
    """
    Class implementing an object detector based on the sliding-window method. It is an example solution to the coin detection problem.

    Args:
        classifier (nn.Module): The object classifier model. For each position of the sliding window,
                    the classifier returns a prediction for the given image crop.
        crop_size: Size of the sliding window
        stride: the distance by which the window is moved in each iteration.
    """
    def __init__(
            self, 
            classifier: nn.Module, 
            crop_size: int = 64, 
            stride: int = 32
        ):
        super(SlidingWindowDetector, self).__init__()
        self.classifier = classifier
        self.crop_size = crop_size
        self.stride = stride

    def forward(
            self, 
            image: torch.Tensor
        ) -> list:
        """
        Function that predicts the objects in an image using the sliding-window method.

        Args:
            image: The image to process.

        Returns:
            a list of the objects found, each in the form of a tuple (x1, y1, x2, y2, label, confidence).
        """
        # move the image to the appropriate device (the same one as the model)
        device = next(self.parameters()).device
        image = image.to(device)

        found_objects = [] # list of objects, where each object is a tuple (x1, y1, x2, y2, label, confidence)

        # slide the window over the image
        for y in range(0, image.shape[1] - self.crop_size, self.stride):
            for x in range(0, image.shape[2] - self.crop_size, self.stride):
                crop = image[:, y:y+self.crop_size, x:x+self.crop_size]
                pred = self.classifier(crop.unsqueeze(0))[0]

                if pred.argmax() != 9: # we skip the background
                    label = pred.argmax().item()
                    confidence = torch.softmax(pred, dim=0)[label].item()
                    found_objects.append((x, y, x+self.crop_size, y+self.crop_size, label, confidence))

        return found_objects
######################### DO NOT CHANGE THIS CELL ###########################

def train_basic_detector(
        train_ds: Dataset
    ) -> SlidingWindowDetector:
    """
    Function that trains a coin classifier by creating image crops and classifying them.
    Once the classifier has been trained, we create an object detector based on the sliding-window method (SlidingWindowDetector).

    Args:
        train_ds (Dataset): The training dataset.

    Returns:
        SlidingWindowDetector: A trained object detector that uses the sliding-window method.
    """

    # what should the size of the sliding window be? the answer to this question can be found in the data...
    # let us compute the average size of all objects in the training set
    total = 0
    boxes = 0

    for sample in train_ds:
        total += sum(sample["boxes"][:, 2] - sample["boxes"][:, 0]) # x2 - x1 (we assume that the objects are square, so we ignore y)
        boxes += len(sample["boxes"])

    box_size = (total / boxes).item()

    print("Average object size in the training set:", box_size) # the result should be about 62

    # this value is close to 64, which is a power of 2; this will make the network architecture easier for us
    box_size = 64
    preprocess = ClassificationDataPreprocessor(num_of_crops_per_image=128, box_size=box_size)

    # let us prepare the model, the optimiser and the loss function (thanks to the sliding-window technique, the problem of training our model has become a classification problem)
    model = BasicCNNClassifier()
    model.to(DEVICE)
    optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=0.001)
    criterion = nn.CrossEntropyLoss()

    # let us prepare the dataloaders
    train_dl = torch.utils.data.DataLoader(train_ds, batch_size=2, shuffle=True, collate_fn=preprocess)

    # let us train the model for the chosen number of epochs
    epochs = 30

    pbar = tqdm(range(epochs), desc="Training", total=epochs)
    for _ in pbar:
        epoch_losses = []

        for batch in train_dl:
            X = batch["crops"].to(DEVICE)
            y = batch["labels"].to(DEVICE)

            optimizer.zero_grad()
            output = model(X)
            loss = criterion(output, y)
            loss.backward()
            optimizer.step()

            epoch_losses.append(loss.detach().cpu().item())

        avg_loss = np.mean(epoch_losses)
        pbar.set_postfix({'train loss': avg_loss})

    model.eval()

    # we construct an object detector based on the sliding-window method, which uses the trained classifier to make predictions for the crops
    return SlidingWindowDetector(model)
######################### DO NOT CHANGE THIS CELL ###########################

if not FINAL_EVALUATION_MODE:
    train_ds, val_ds = setup_data(root='data/')

    model = train_basic_detector(train_ds)
######################### DO NOT CHANGE THIS CELL ###########################

if not FINAL_EVALUATION_MODE:
    out = predict_all_bounding_boxes(model, val_ds)

    map_val = calculate_map(out, val_ds, return_all=True)

    print(f"mAP using the IoU=0.5 threshold on the validation set: {map_val['map_50'].item():.2f}")
    print(f"mAP over many IoU values on the validation set:  {map_val['map']:.2f}, this is the metric that is scored in the contest. Your task is to maximise it.")

    plot_detection_results_grid(out, val_ds)

    plot_confusion_matrix(out, val_ds, iou_threshold=0.5)
    plot_confusion_matrix(out, val_ds, iou_threshold=0.8)

It is worth noting the discrepancy between the mAP values obtained with the IoU=0.5 threshold and with many different IoU values (0.5, ..., 0.95). A similar discrepancy is visible in the confusion matrices for different IoU values. This means that the model can predict the classes quite well but has difficulty localising the objects precisely.

This is an expected result, because the model uses the sliding-window technique, which does not place the rectangle exactly on the coin but only moves it by a fixed amount (in our case, 32 pixels); as a result, the positions proposed by the model are only in the vicinity of the actual coins. Moreover, the size of the returned windows is fixed, which further worsens the mAP results for higher IoU threshold values.

Your task is to implement a model that improves these results.

Your Solution

Place your solution in this section. Make changes only here!

# here you can implement your detector, which will return a list of the objects found in the format (x1, y1, x2, y2, label, confidence)

class YourDetector(nn.Module):
    def __init__(self):
        # here you can initialise your model
        super(YourDetector, self).__init__()
        pass

    def forward(
            self, 
            img: torch.Tensor
        ) -> list:
        """
        Your function for detecting coins in an image.

        Args:
            img: The image to process.

        Returns:
            a list of the objects found, each in the form of a tuple (x1, y1, x2, y2, label, confidence).
        """
        # implement the logic of your detector here
        return []  # an example model that returns an empty list
# definitions of the augmentations for the training and validation sets. By default, None means no augmentation
train_transform = None
val_transform = None

train_ds, val_ds = setup_data(train_transform, val_transform, root='data/')

# train your model here
# ... 

your_model = YourDetector()

Evaluation

Running the cell below lets you check how many points your solution would earn on the validation data. Before submitting, make sure that the whole notebook runs from start to finish without errors and without requiring any user intervention after selecting the "Run All" option.

######################### DO NOT CHANGE THIS CELL ###########################

if not FINAL_EVALUATION_MODE:
    your_out = predict_all_bounding_boxes(your_model, val_ds)

    your_map_val = calculate_map(your_out, val_ds, return_all=False) # we return only the averaged mAP (the main scoring criterion)

    score = (np.clip(your_map_val, 0.2, 0.85) - 0.2) / 0.65 * 100
    score = int(round(score))

    print(f"mAP on the validation set: {your_map_val:.2f}")
    print(f"Estimated number of points for the task: {score}")

    plot_detection_results_grid(your_out, val_ds)

    plot_confusion_matrix(your_out, val_ds, iou_threshold=0.5)
    plot_confusion_matrix(your_out, val_ds, iou_threshold=0.8)

During grading, the model will be saved as your_model.pkl and scored on the test set.

######################### DO NOT CHANGE THIS CELL ###########################

if FINAL_EVALUATION_MODE:
    import cloudpickle

    # If the model has parameters, set it to evaluation mode and move it to the CPU
    if list(your_model.parameters()):
        your_model.eval()
        your_model.cpu()

    OUTPUT_PATH = "file_output"
    FUNCTION_FILENAME = "your_model.pkl"
    FUNCTION_OUTPUT_PATH = os.path.join(OUTPUT_PATH, FUNCTION_FILENAME)

    if not os.path.exists(OUTPUT_PATH):
        os.makedirs(OUTPUT_PATH)

    with open(FUNCTION_OUTPUT_PATH, "wb") as f:
        cloudpickle.dump(your_model, f)

Translated by SOTA. The Polish original is the official version and wins wherever the two differ. Coin denominations keep their Polish names (grosz, grosze, groszy; złoty, złote, złotych), as in the notebook's class labels; 1 złoty = 100 groszy. If you organise this olympiad and would like the translation removed, email [email protected] and we will take it down.

At a glance

You get
train.pkl and val.pkl (images with boxes and labels), downloaded from Google Drive with gdown.
You submit
This notebook with YourDetector; during checking the model is serialised with cloudpickle to your_model.pkl and evaluated on the test set.
Scoring
mAP averaged over IoU thresholds 0.5:0.05:0.95 (torchmetrics MeanAveragePrecision). score = (mAP − 0.2) / (0.85 − 0.2) × 100, clipped to 0 for mAP ≤ 0.2 and to 100 for mAP ≥ 0.85.
Rules
  • Tested on the Competition Platform without Internet access, with a GPU.
  • Evaluation must take at most 10 minutes with a GPU, and at most 5 seconds per image.
  • No other datasets and no weights pre-trained on other datasets.
  • Output format must be compatible with predict_all_bounding_boxes; the model must inherit from nn.Module.
Format
Stage I (online), 17 February – 22 March 2025; up to 100 points per task (500 in total). Evaluated automatically on the Competition Platform (Platforma Konkursowa) on a hidden test set; points are rounded to an integer, and a notebook that fails the requirements or does not run scores 0.

Details

Year
2025, Online
Round
Stage I · Task 1
Language
Polish; English translation by SOTA
License
Not stated by the source