Discord

Checklist OAI 2024 Stage I · Task 4

SOTA-AI Editorial

Colour Quantisation

Quantise each image to exactly 37 colours with a clustering method that minimises a weighted sum of reconstruction error and a cost that favours vivid colours.

Written
8 October 2026
Result
Mean validation cost 6,950.8 with 37 colors, locally measured below the 8,000 full-credit threshold (100/100).
Points
100/100 (see result for validation and provenance)
Olympiad
Polish Olympiad in Artificial Intelligence (Olimpiada Sztucznej Inteligencji), 2024 · Stage I

Hints

Hint 1

Compute the ordinary 37-color K-means baseline and inspect each term in the score.

Hint 2

Which weighted term can change substantially while only modestly affecting reconstruction error?

Hint 3

Move each centroid toward its nearest allowed vivid corner; the weighted objective gives a 10.5-unit proximal shift.

Hint 4

Cap the largest palette color cost and verify that all 37 colors are actually used.

The solution in one line

Use per-image MiniBatch K-means, shrink each centroid toward a simple RGB corner, and cap palette cost at 100.

The editorial

Color Quantization — Stage I (2024)

Problem name: Color Quantization (kwantyzacja_kolorow)

Domain: Unsupervised image compression, color clustering

Metric: Per-image 2 × MSE + 21 × max_color_cost + 42 × mean_color_cost; lower is better. The output must contain exactly 37 RGB colors. The organizer awards full credit below 8,000 and zero above 8,900.

Abridged statement

For each 512×512 RGB image, produce a 37-color reconstruction that balances pixelwise squared error against the distance of the chosen colors from the eight RGB cube corners (black, white, red, green, blue, yellow, magenta, cyan). Each image is its own fitting set; no pretrained weights are used.

Dataset analysis and EDA

The local mirror contains 15 training images and five released validation images. The starter resizes each JPEG to 512×512 and measures the score in 8-bit RGB. Images are evaluated independently, with no labels or paired training targets. The official starter's ordinary 37-cluster K-means baseline reports per-image validation scores of 9,004.565, 8,084.809, 9,634.555, 10,236.788, and 8,945.656, for a mean of 9,181.275.

Experiments

  • Organizer K-means baseline: mean validation score 9,181.275; it optimizes MSE but ignores the color-cost terms.
  • Per-image MiniBatch K-means plus the analytic 10.5-unit shrink toward the nearest simple color: mean 8,441.3 on the released validation images. This reduced the mean color cost but did not reach the full-credit boundary.
  • Same shrink with a palette cost cap of 180: mean 8,398.8. The maximum-cost term fell slightly, but the mean remained above 8,000.
  • Same shrink with a palette cost cap of 140: per-image validation scores 8,507.6, 8,063.2, 7,208.9, 6,974.3, and 7,981.7; mean 7,747.1.
  • Same shrink with a palette cost cap of 100: per-image validation scores 8,121.5, 7,099.6, 6,307.2, 6,101.4, and 7,124.6; mean 6,950.8. This is the selected candidate. The 15 training images averaged 6,935.3 with the same settings, versus 7,607.7 for cap 140 and 8,460.2 for cap 180.
  • Stronger 20-unit shrink: with cap 100 it scored 7,362.8 mean over the 15 training images, worse than the 10.5-unit update; it was not selected.
  • A wider-cap exploratory run (radius 200/220) with an earlier slower initialization also averaged about 8,462.5; it was superseded by the faster, repeatable MiniBatch version.

The cluster-centroid update follows the objective: minimizing 2 * squared_error + 42 * color_cost shifts a mean centroid by 42 / 4 = 10.5 RGB units toward its closest cube corner. A 140-unit cap limits the largest active palette color cost, trading additional MSE for a substantial reduction in the 21-weighted maximum-cost term. Empty palette entries are activated by a single-pixel reassignment so the official 37-color assertion is met.

Selected solution

Run MiniBatch K-means on 25,000 sampled pixels from each input image, then perform six assignment/centroid updates using the exact weighted per-pixel terms, the analytic 10.5-unit shrink, and the 100-unit palette-cost cap. Return exactly 37 distinct uint8 RGB colors. Code is in color_quantization.py; it reads only the supplied image and does not copy the dataset.

Validation score and evidence

The best measured released-validation mean is 6,950.8, below 8,000 (1.5/1.5 task points, 100/100). All five per-image scores are listed above. The selected source settings took 22.66 seconds on CPU for all five validation images. No hidden-test result is claimed.

Compute and runtime

The candidate uses scikit-learn MiniBatch K-means and NumPy/SciPy distance calculations on CPU. The selected implementation took 22.66 seconds for five images, well below the three-minute Google Colab GPU limit. It uses one 25,000-pixel sample for palette initialization and six full-image assignment passes.

Alternatives

Ordinary K-means is the organizer's MSE-only baseline. The selected constrained codebook improves the full objective. Median-cut, octree quantization, perceptual-space clustering, and stronger/softer palette caps are plausible alternatives but were not measured.

Code

Solution code solution.py · 160 lines
"""Constrained color quantization for the Polish AI Olympiad 2024 task.

The function uses only the current image as its per-image training set. It
returns exactly 37 RGB colors, as required by the official scorer.
"""

from __future__ import annotations

from pathlib import Path
import numpy as np
from PIL import Image
from scipy.spatial.distance import cdist
from sklearn.cluster import MiniBatchKMeans


N_COLORS = 37
SIMPLE_COLORS = np.asarray(
    [
        [0, 0, 0],
        [0, 0, 255],
        [0, 255, 0],
        [0, 255, 255],
        [255, 0, 0],
        [255, 0, 255],
        [255, 255, 0],
        [255, 255, 255],
    ],
    dtype=np.float32,
)


def _simple_color_cost(colors: np.ndarray) -> np.ndarray:
    """Distance of each RGB color to the nearest of the task's eight corners."""
    return cdist(np.asarray(colors, dtype=np.float32), SIMPLE_COLORS).min(axis=1)


def your_quantization_algorithm(
    img: np.ndarray,
    *,
    seed: int = 0,
    max_cost: float = 100.0,
    proximal_shift: float = 10.5,
    iterations: int = 6,
) -> np.ndarray:
    """Return a 37-color image optimized for the task's weighted objective.

    The objective's per-pixel terms are ``2 * squared_error`` and ``42 *
    color_cost``. For a fixed cluster, their proximal update moves the color
    centroid 42/4 = 10.5 RGB units toward its closest simple color. A maximum
    palette-cost cap further trades some MSE for a much smaller ``21 *
    max_color_cost`` term.
    """
    source = np.asarray(img)
    if source.ndim != 3 or source.shape[2] != 3:
        raise ValueError(f"Expected an RGB image with shape (H, W, 3), got {source.shape}")
    if source.dtype != np.uint8:
        raise TypeError("The official evaluator requires uint8 RGB images")
    if source.size == 0:
        raise ValueError("Image must contain at least one pixel")

    pixels = source.reshape(-1, 3).astype(np.float32)
    pixel_sq = np.sum(pixels * pixels, axis=1, keepdims=True)
    rng = np.random.RandomState(seed)
    sample = pixels[rng.choice(len(pixels), min(25_000, len(pixels)), replace=False)]
    kmeans = MiniBatchKMeans(
        n_clusters=N_COLORS,
        random_state=seed,
        batch_size=4096,
        max_iter=80,
        n_init=1,
        init_size=15_000,
        reassignment_ratio=0.01,
    ).fit(sample)
    centers = kmeans.cluster_centers_.astype(np.float32)

    for _ in range(iterations):
        center_sq = np.sum(centers * centers, axis=1, keepdims=True).T
        squared_distances = np.maximum(0.0, pixel_sq + center_sq - 2.0 * pixels @ centers.T)
        center_cost = _simple_color_cost(centers)
        labels = np.argmin(2.0 * squared_distances + 42.0 * center_cost[None, :], axis=1)

        counts = np.bincount(labels, minlength=N_COLORS).astype(np.float32)
        sums = np.stack(
            [np.bincount(labels, weights=pixels[:, channel], minlength=N_COLORS) for channel in range(3)],
            axis=1,
        )
        nonempty = counts > 0
        means = centers.copy()
        means[nonempty] = sums[nonempty] / counts[nonempty, None]

        nearest = np.argmin(cdist(means, SIMPLE_COLORS), axis=1)
        vertices = SIMPLE_COLORS[nearest]
        displacement = means - vertices
        distance = np.linalg.norm(displacement, axis=1)
        target_distance = np.minimum(np.maximum(distance - proximal_shift, 0.0), max_cost)
        centers = vertices + displacement * np.divide(
            target_distance,
            distance,
            out=np.zeros_like(target_distance),
            where=distance > 0,
        )[:, None]

        # Keep every palette entry active when the vividness prior empties a
        # cluster. The final one-pixel reassignment is only a cardinality fix.
        if not np.all(nonempty):
            current_error = squared_distances[np.arange(len(pixels)), labels]
            reserved: set[int] = set()
            for cluster in np.flatnonzero(~nonempty):
                candidate = np.sum((pixels - centers[cluster]) ** 2, axis=1) - current_error
                if reserved:
                    candidate[np.fromiter(reserved, dtype=np.int64)] = np.inf
                pixel_idx = int(np.argmin(candidate))
                reserved.add(pixel_idx)
                centers[cluster] = pixels[pixel_idx]

    # Compute the final task-specific assignment with the stabilized palette.
    center_sq = np.sum(centers * centers, axis=1, keepdims=True).T
    squared_distances = np.maximum(0.0, pixel_sq + center_sq - 2.0 * pixels @ centers.T)
    center_cost = _simple_color_cost(centers)
    labels = np.argmin(2.0 * squared_distances + 42.0 * center_cost[None, :], axis=1)

    palette = np.clip(np.rint(centers), 0, 255).astype(np.uint8)
    seen: set[tuple[int, int, int]] = set()
    for i in range(N_COLORS):
        color = [int(channel) for channel in palette[i]]
        while tuple(color) in seen:
            for channel in range(3):
                if color[channel] < 255:
                    color[channel] += 1
                    break
                color[channel] = 0
        palette[i] = color
        seen.add(tuple(color))

    quantized = palette[labels].reshape(source.shape)
    reserved_pixels: set[int] = set()
    current_error = squared_distances[np.arange(len(pixels)), labels]
    for cluster in range(N_COLORS):
        if np.any(labels == cluster):
            continue
        candidate = np.sum((pixels - palette[cluster].astype(np.float32)) ** 2, axis=1) - current_error
        if reserved_pixels:
            candidate[np.fromiter(reserved_pixels, dtype=np.int64)] = np.inf
        pixel_idx = int(np.argmin(candidate))
        reserved_pixels.add(pixel_idx)
        quantized.reshape(-1, 3)[pixel_idx] = palette[cluster]

    return quantized.astype(np.uint8, copy=False)


def evaluate_directory(data_dir: str | Path) -> tuple[float, list[float]]:
    """Evaluate the task score on a directory of JPEGs without copying data."""
    scores: list[float] = []
    for path in sorted(Path(data_dir).glob("*.jpg")):
        with Image.open(path) as image:
            original = np.asarray(image.convert("RGB").resize((512, 512)), dtype=np.uint8)
        quantized = your_quantization_algorithm(original)
        if len(np.unique(quantized.reshape(-1, 3), axis=0)) != N_COLORS:
            raise RuntimeError(f"{path.name}: expected exactly {N_COLORS} output colors")
        mse = np.mean((original.astype(np.float32) - quantized.astype(np.float32)) ** 2)
        unique_colors, counts = np.unique(quantized.reshape(-1, 3), axis=0, return_counts=True)
        color_cost = _simple_color_cost(unique_colors)
        mean_cost = float(np.sum(color_cost * counts) / counts.sum())
        maximum_cost = float(color_cost.max())
        score = float(2.0 * mse + 21.0 * maximum_cost + 42.0 * mean_cost)
        scores.append(score)
        print(
            f"{path.name}: MSE={mse:.3f}, max_color_cost={maximum_cost:.3f}, "
            f"mean_color_cost={mean_cost:.3f}, score={score:.3f}"
        )
    if not scores:
        raise ValueError(f"No .jpg images found in {data_dir}")
    mean_score = float(np.mean(scores))
    print(f"Mean score: {mean_score:.3f}")
    return mean_score, scores


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("data_dir", help="Task train_data or valid_data folder")
    args = parser.parse_args()
    evaluate_directory(args.data_dir)

Download solution.py

Editorials are written by members of the SOTA community, not by the olympiad. The official statement and scoring win wherever an editorial differs.