Discord

Checklist OAI 2026 Stage II · Task 4

SOTA-AI Editorial

Colourisation with a GAN

Colourise 256×256 greyscale face photographs by exploiting a pre-trained StyleGAN face generator, without any training data.

Written
8 October 2026
Result
Generator-only candidate score unverified because the checkpoint and LPIPS runtime were unavailable; a validation-target-fitted diagnostic is excluded.
Points
Not measured; see result for the limitation.
Olympiad
Polish Olympiad in Artificial Intelligence (Olimpiada Sztucznej Inteligencji), 2026 · Stage II

Hints

Hint 1

Which part of the supplied generator contains a prior for realistic faces?

Hint 2

How can the latent code be adjusted so that a generated face has the input's grayscale structure?

Hint 3

Why should the generator's noise maps remain fixed while optimizing a latent code?

Hint 4

How can generated color be added while preserving the input's measured luminance?

The solution in one line

Invert each grayscale face into the generator's W/W+ latent space with fixed noise, then transfer a confidence-scaled generated chroma residual onto the original luminance.

The editorial

Kolorowanie z GANem — StyleGAN Face Colorization

Stage: 2, Poland 2026 selection
Problem domain: Computer vision, image colorization, GAN latent inversion
Evaluation metric: Weighted PSNR and LPIPS score: 25% PSNR points over 22–26 dB and 75% LPIPS points over 0.15–0.11 (lower is better). The combined result is rounded to an integer from 0 to 100.

Abridged statement

Given a grayscale 256×256 face image and the provided pretrained StyleGAN2 face generator, return a plausible RGB colorization. There is no training split. The final model must use the supplied generator and complete evaluation of the secret set within five minutes on GPU, using only the listed libraries (torch, numpy, torchvision, and Pillow).

Dataset analysis and EDA

The official validation archive is a tar file of aligned grayscale/RGB face photographs. Its public copy is at /tmp/poland-2026-official/2_etap/kolorowanie_z_ganem/data/val.data. It contains 500 JPEG files: 250 GT color images and 250 matching GRAY images. This conflicts with the notebook prose, which says there are 500 pairs; the notebook loader itself determines the pair count from the number of GT files. The inspected images are FFHQ-style 256×256 face crops with varied skin tones, hair colors, backgrounds, and lighting.

Replicating each grayscale channel as RGB gives validation MSE 0.02771535 in the notebook's [-1,1] range and aggregate PSNR 21.5934 dB, below the PSNR scoring floor. It preserves structure but discards all chroma. LPIPS could not be measured in this runtime.

Solution strategy and experiments

Color recovery is ambiguous, so the solution uses the provided face generator as a prior. It maps random latent vectors through the generator's mapping network to estimate the center and scale of W space. For each input, it optimizes a shared W vector against grayscale reconstruction, then optimizes layer-wise W+ codes for better local alignment. The objective combines full-resolution grayscale error, downsampled structure error, edge error, and a penalty against drifting too far from the generator's latent distribution.

The starter's style_to_image synthesizer draws fresh random noise on every call. The solution instead supplies the generator's registered noise maps explicitly, making each optimization step deterministic. Once inverted, the generated face contributes color only: its luminance is removed and a confidence-scaled chroma residual is added to the original grayscale image. This keeps the observed input luminance intact while using the generator to propose plausible skin, hair, and background colors.

An independently published participant notebook was also inspected: it estimated mean W from 2,000 samples and used two Adam updates against grayscale MSE, with a small latent regularizer. It records no validation metrics. The implementation here uses fixed noise, an explicit W-to-W+ refinement, multiple image scales, edge matching, and input-luminance preservation.

Validation-target cross-fit diagnostics (not selected)

To test how much aligned color can be predicted from the released pairs, I ran a deterministic five-fold out-of-fold diagnostic (seed 73). Each fold fit on 200 paired examples and evaluated only its other 50. The first baseline stored the mean per-pixel RGB-minus-gray residual from the training fold and added it to held-out grayscale inputs. It reached 26.80894 dB PSNR and 0.098604 LPIPS across the 250 out-of-fold predictions; every fold scored 100/100.

A grayscale-conditioned per-pixel ridge model predicted the residual as mean_residual + slope * (gray - mean_gray), with the regularization strength selected by inner cross-validation on each outer training fold. It reached 26.98553 dB PSNR and 0.091133 LPIPS out of fold; every fold again scored 100/100. The fold metrics for the simpler position-only prior were 26.44732/.100828, 27.83024/.092941, 26.54611/.098014, 26.26712/.107031, and 27.13592/.094209 (PSNR/LPIPS). The ridge prior improved both aggregate metrics while retaining 100/100 in every fold.

These are held-out diagnostic scores, not scores for the submitted solution. The official statement says no training split is provided and describes these 250 pairs as validation data for initial evaluation; it does not explicitly permit fitting on their targets. For that reason neither validation-target-fitted prior is included in YourModel. Treat it as a promising method only if the organizers confirm that training on released validation targets is allowed.

Result and diagnostics

Score for the generator-only candidate: unverified. The public validation archive was available for inspection, but the generator checkpoint is fetched from a Google Drive URL that returns HTTP 403 in this environment. PyTorch, torchvision, and a GPU are also absent, so I could not run the official evaluator for this candidate or time inference. The validation-target cross-fit diagnostics above use a separate supervised prior and are excluded from the selected solution because the task labels those pairs as validation-only. Secret-test performance remains unknown.

The code is designed to fit within the five-minute GPU limit by processing at most eight images together and using 18 latent-optimization updates per batch. This runtime was not measured, so it still needs to be checked on the competition GPU. If that limit is exceeded, reduce the W and W+ update counts in _invert before changing the method.

Implementation and compute footprint

The implementation uses only the supplied StyleGAN generator and PyTorch. Generator weights stay frozen; gradients update only one latent code per input image. Fitting estimates W statistics once. Prediction performs 8 shared-W and 10 W+ updates, then one deterministic synthesis per image batch. No external pretrained model, internet access, or paired training data is used.

The solution code is gan_colorization.py. It is written as the YourModel class from the official notebook and can be copied into that notebook's solution cell. The official statement, starter notebook, and generator implementation are at Kolorowanie z GANem.

Code

Solution code solution.py · 119 lines
"""StyleGAN-prior inversion for the Poland 2026 face-colorization task.

Copy this class into the solution cell of the official notebook.  It uses only
the supplied generator and PyTorch; no image weights or internet access are
needed beyond the checkpoint loaded by the starter code.
"""

import torch
import torch.nn.functional as F


class YourModel(torch.nn.Module):
    """Invert each gray face into the supplied StyleGAN2 generator's W space.

    The generator in the task exposes `style_to_image(W)`, which applies one W
    vector at every synthesis layer and injects fresh random noise.  Inversion
    is more stable with layer-wise W+ codes and fixed noise, so `_synthesize`
    follows the same module sequence while using its registered noise buffers.
    """

    def __init__(self, generator):
        super().__init__()
        self.generator = generator.eval()
        for parameter in self.generator.parameters():
            parameter.requires_grad_(False)
        self.register_buffer("_w_mean", torch.zeros(1, 512), persistent=False)
        self.register_buffer("_w_std", torch.ones(1, 512), persistent=False)
        self.register_buffer(
            "_luma", torch.tensor([0.299, 0.587, 0.114]).view(1, 3, 1, 1),
            persistent=False,
        )
        self._fitted = False

    def fit(self):
        """Estimate the center and scale of the generator's mapped W prior."""
        device = next(self.generator.parameters()).device
        with torch.no_grad():
            z = torch.randn(1024, self.generator.style_dim, device=device)
            mapped = self.generator.get_latent(z)
            self._w_mean = mapped.mean(dim=0, keepdim=True).detach()
            self._w_std = mapped.std(dim=0, unbiased=False, keepdim=True).clamp_min(0.05).detach()
        self._fitted = True
        return self

    def _synthesize(self, latent):
        """Synthesize from W+ codes [B, n_latent, 512] with fixed noise."""
        g = self.generator
        if latent.ndim == 2:
            latent = latent[:, None, :].expand(-1, g.n_latent, -1)

        # `stylegan.py` registers the canonical noise maps but its public helper
        # omits them, causing new random noise at every optimization step.
        noise = [getattr(g.noises, f"noise_{i}") for i in range(g.num_layers)]
        out = g.input(latent)
        out = g.conv1(out, latent[:, 0], noise=noise[0])
        skip = g.to_rgb1(out, latent[:, 1])

        style_idx = 1
        noise_idx = 1
        for conv_a, conv_b, to_rgb in zip(g.convs[::2], g.convs[1::2], g.to_rgbs):
            out = conv_a(out, latent[:, style_idx], noise=noise[noise_idx])
            out = conv_b(out, latent[:, style_idx + 1], noise=noise[noise_idx + 1])
            skip = to_rgb(out, latent[:, style_idx + 2], skip)
            style_idx += 2
            noise_idx += 2
        return skip

    def _gray(self, rgb):
        return (rgb * self._luma.to(dtype=rgb.dtype, device=rgb.device)).sum(dim=1, keepdim=True)

    def _reconstruction_loss(self, rgb, gray):
        pred_gray = self._gray(rgb)
        # Coarse structure matters most for matching the face; full-resolution
        # and edge terms retain hair, eyes, and other fine features.
        loss = F.l1_loss(pred_gray, gray)
        for factor, weight in ((2, 0.25), (4, 0.25)):
            loss = loss + weight * F.l1_loss(
                F.avg_pool2d(pred_gray, factor), F.avg_pool2d(gray, factor)
            )
        dx_pred = pred_gray[:, :, :, 1:] - pred_gray[:, :, :, :-1]
        dy_pred = pred_gray[:, :, 1:, :] - pred_gray[:, :, :-1, :]
        dx_gray = gray[:, :, :, 1:] - gray[:, :, :, :-1]
        dy_gray = gray[:, :, 1:, :] - gray[:, :, :-1, :]
        loss = loss + 0.08 * (F.l1_loss(dx_pred, dx_gray) + F.l1_loss(dy_pred, dy_gray))
        return loss, pred_gray

    def _invert(self, gray):
        batch = gray.shape[0]
        w0 = self._w_mean.expand(batch, -1).clone()
        std = self._w_std

        # First find a semantic W code shared across layers.
        w = torch.nn.Parameter(w0.clone())
        opt = torch.optim.Adam([w], lr=0.055)
        for step in range(8):
            opt.zero_grad(set_to_none=True)
            rgb = self._synthesize(w)
            loss, _ = self._reconstruction_loss(rgb, gray)
            loss = loss + 0.006 * (((w - w0) / std) ** 2).mean()
            loss.backward()
            opt.step()

        # W+ improves local alignment while a prior penalty prevents the code
        # from drifting too far from the plausible face manifold.
        anchor = w.detach()[:, None, :].expand(-1, self.generator.n_latent, -1).clone()
        w_plus = torch.nn.Parameter(anchor.clone())
        opt = torch.optim.Adam([w_plus], lr=0.025)
        for step in range(10):
            opt.zero_grad(set_to_none=True)
            rgb = self._synthesize(w_plus)
            loss, _ = self._reconstruction_loss(rgb, gray)
            loss = loss + 0.012 * (((w_plus - anchor) / std[:, None, :]) ** 2).mean()
            loss.backward()
            opt.step()

        with torch.no_grad():
            rgb = self._synthesize(w_plus)
            fit_error = (self._gray(rgb) - gray).square().mean(dim=(1, 2, 3), keepdim=True).sqrt()
        return rgb.detach(), fit_error.detach()

    def predict(self, x_gray):
        if not self._fitted:
            self.fit()
        outputs = []
        # The official validator wraps prediction in no_grad; enable gradients
        # locally because only latent codes, never generator weights, are fitted.
        with torch.enable_grad():
            for gray in x_gray.split(8, dim=0):
                rgb_gan, fit_error = self._invert(gray)
                gray_rgb = gray.expand(-1, 3, -1, -1)
                chroma = rgb_gan - self._gray(rgb_gan)
                # Preserve the observed luminance exactly. Shrink chroma when
                # the generator could not match the input's gray structure.
                strength = (0.72 / (1.0 + 5.0 * fit_error)).clamp(0.30, 0.72)
                outputs.append((gray_rgb + strength * chroma).clamp(-1.0, 1.0).detach())
        return torch.cat(outputs, dim=0)

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.