HardU-Net

Complete U-Net Network

U-Net: Convolutional Networks for Biomedical Image Segmentation

Hard

Problem

Implement a compact numerical U-Net forward pass in NHWC layout. Apply an encoder block, save its skip tensor, max-pool into a two-convolution bottleneck, upsample and project the bottleneck, concatenate the centered skip tensor, apply two decoder convolutions, and finish with a per-pixel linear output projection. The weights dictionary contains enc_kernel1, enc_bias1, enc_kernel2, enc_bias2, bridge_kernel1, bridge_bias1, bridge_kernel2, bridge_bias2, W_up, b_up, dec_kernel1, dec_bias1, dec_kernel2, dec_bias2, W_out, and b_out. Return raw segmentation logits as a float64 NumPy array.

Theory

The U-Net is a fully convolutional encoder-decoder architecture for semantic segmentation, introduced by Ronneberger, Fischer, and Brox (2015). Its defining feature is a symmetric structure where a contracting encoder captures context at multiple scales, a bottleneck processes the deepest representation, and an expanding decoder restores spatial resolution while fusing high-resolution features through skip connections. With the standard 572x572 input, the network produces a 388x388 segmentation map.


What It Is

The complete U-Net is a single end-to-end network that takes an image and produces a dense pixel-wise classification map. It consists of 9 processing blocks arranged in a U shape: 4 encoder blocks form the descending left arm, 1 bottleneck sits at the base, and 4 decoder blocks form the ascending right arm. A final 1x1 convolution maps the last 64 feature channels to N_{\text{classes}} output channels.

The architecture was designed for biomedical image segmentation where labeled data is extremely scarce. The encoder captures "what" is in the image at the cost of spatial precision, while the decoder recovers "where" things are by combining upsampled deep features with fine-grained spatial details from the encoder via skip connections. Every convolution in the original U-Net is unpadded (valid convolution), meaning each 3x3 convolution reduces spatial dimensions by 2 pixels. This creates a cumulative spatial loss that makes the output smaller than the input.


Key Equations

Encoder block (applied 4 times with channel doubling). Two unpadded 3x3 convolutions with ReLU, then 2x2 max pooling:

x_{\text{conv1}} = \text{ReLU}(\text{Conv3x3}(x_{\text{in}})), \quad H_{\text{conv1}} = H_{\text{in}} - 2

x_{\text{conv2}} = \text{ReLU}(\text{Conv3x3}(x_{\text{conv1}})), \quad H_{\text{conv2}} = H_{\text{conv1}} - 2

x_{\text{pool}} = \text{MaxPool2x2}(x_{\text{conv2}}), \quad H_{\text{pool}} = H_{\text{conv2}} / 2

The skip connection stores x_{\text{conv2}} at spatial resolution H_{\text{conv2}} \times H_{\text{conv2}}, captured after convolutions but before pooling.

Bottleneck (applied once at the deepest level). Two unpadded 3x3 convolutions with ReLU, no pooling:

x_{\text{bn}} = \text{ReLU}(\text{Conv3x3}(\text{ReLU}(\text{Conv3x3}(x_{\text{in}}))))

Decoder block (applied 4 times with channel halving). Up-convolution, skip concatenation, then two unpadded 3x3 convolutions:

x_{\text{up}} = \text{UpConv2x2}(x_{\text{in}}), \quad H_{\text{up}} = 2 \times H_{\text{in}}

x_{\text{cat}} = \text{Concat}(\text{CenterCrop}(x_{\text{skip}}, H_{\text{up}}), \; x_{\text{up}})

x_{\text{out}} = \text{ReLU}(\text{Conv3x3}(\text{ReLU}(\text{Conv3x3}(x_{\text{cat}})))), \quad H_{\text{out}} = H_{\text{up}} - 4

The skip feature map is always spatially larger than the upsampled map. Center cropping trims the skip to match the upsampled dimensions before concatenation along the channel axis.

Output layer (1x1 convolution):

\text{output} = \text{Conv1x1}(x_{\text{final}}), \quad C_{\text{out}} = N_{\text{classes}}

Maps 64 channels to N_{\text{classes}} without changing spatial dimensions. No activation is applied.

Channel progression through the full network:

1 \xrightarrow{E_1} 64 \xrightarrow{E_2} 128 \xrightarrow{E_3} 256 \xrightarrow{E_4} 512 \xrightarrow{BN} 1024 \xrightarrow{D_4} 512 \xrightarrow{D_3} 256 \xrightarrow{D_2} 128 \xrightarrow{D_1} 64 \xrightarrow{1 \times 1} N_{\text{classes}}


The U-Shape Architecture

The name "U-Net" comes from the U-shaped diagram in the original paper. The encoder descends on the left, the decoder ascends on the right, and horizontal skip connections bridge them. This shape encodes three design principles.

The contracting path (left arm) progressively reduces spatial resolution while increasing feature channels. Each encoder block halves spatial dimensions via pooling and doubles channels. Shallow blocks detect edges and textures at high resolution; deep blocks recognize complex structures at low resolution with richer semantic content. By the bottleneck, spatial precision is minimal but abstract understanding is maximal.

The expanding path (right arm) reverses the process. Each decoder block doubles spatial dimensions via up-convolution and halves channels. However, upsampling alone produces blurry results because fine spatial details were destroyed during pooling.

Skip connections (horizontal bridges) solve this. Each decoder block receives the feature map from the corresponding encoder block at the same depth. These encoder features retain the fine-grained spatial information that pooling destroyed. By concatenating encoder and decoder features, each block accesses both deep semantic context and precise spatial detail. This is what Ronneberger et al. call "precise localization."


The Full Dimension Trace

The standard U-Net takes a 1 \times 572 \times 572 input and produces an N_{\text{classes}} \times 388 \times 388 output. Every intermediate spatial dimension follows two rules: unpadded 3x3 convolution subtracts 2, and 2x2 pooling or up-convolution halves or doubles the size.

Encoder Block 1 (1 \to 64 channels): 572 \to 570 \to 568. Skip: 64 \times 568 \times 568. Pool: 64 \times 284 \times 284.

Encoder Block 2 (64 \to 128): 284 \to 282 \to 280. Skip: 128 \times 280 \times 280. Pool: 128 \times 140 \times 140.

Encoder Block 3 (128 \to 256): 140 \to 138 \to 136. Skip: 256 \times 136 \times 136. Pool: 256 \times 68 \times 68.

Encoder Block 4 (256 \to 512): 68 \to 66 \to 64. Skip: 512 \times 64 \times 64. Pool: 512 \times 32 \times 32.

Bottleneck (512 \to 1024): 32 \to 30 \to 28. Output: 1024 \times 28 \times 28.

Decoder Block 4 (1024 \to 512): Up 28 \to 56. Skip (512, 64, 64) cropped to (512, 56, 56). Concat: (1024, 56, 56). Convs: 54 \to 52. Output: 512 \times 52 \times 52.

Decoder Block 3 (512 \to 256): Up 52 \to 104. Skip (256, 136, 136) cropped to (256, 104, 104). Concat: (512, 104, 104). Convs: 102 \to 100. Output: 256 \times 100 \times 100.

Decoder Block 2 (256 \to 128): Up 100 \to 200. Skip (128, 280, 280) cropped to (128, 200, 200). Concat: (256, 200, 200). Convs: 198 \to 196. Output: 128 \times 196 \times 196.

Decoder Block 1 (128 \to 64): Up 196 \to 392. Skip (64, 568, 568) cropped to (64, 392, 392). Concat: (128, 392, 392). Convs: 390 \to 388. Output: 64 \times 388 \times 388.

Output layer: 1x1 conv maps 64 \to N_{\text{classes}}. Final: N_{\text{classes}} \times 388 \times 388.


Why 572 Input and 388 Output

The input size 572 is not arbitrary. It ensures every pooling operation receives an even spatial dimension. If any pooling input were odd, floor division would lose information asymmetrically. Starting from the requirement that the bottleneck input be 32 \times 32, back-calculate through the encoder: each block needs an even dimension after two convolutions (which subtract 4). Working backward: $ \to 68 \to 140 \to 284 \to 572$.

Each 3x3 unpadded convolution loses 2 pixels. The network has 18 convolutions (2 per block across 9 blocks), but spatial losses compound differently at each scale. Losses at deeper levels are magnified by upsampling when mapped back to input resolution. The net effect: 572 input pixels become 388 output pixels, a loss of 184 total (92 per side).

The output represents the valid central region where every pixel has full receptive field context. The paper handles borders by mirror-padding the input so the segmentation covers the entire original image.


Paper Context

Ronneberger et al. published "U-Net: Convolutional Networks for Biomedical Image Segmentation" for the ISBI 2015 cell tracking challenge. The paper addressed segmentation tasks where only a handful of annotated training images are available, since medical annotation requires expert pathologists.

The U-Net won the ISBI challenge by a large margin, trained on just 30 annotated images. The skip connections were the key architectural innovation: earlier encoder-decoder architectures like FCN (Long et al., 2015) used additive skip connections, but U-Net used concatenation, preserving the full encoder features rather than adding a correction signal.

Heavy data augmentation was critical, particularly elastic deformations. The authors applied random elastic transformations using smooth displacement fields generated from random vectors on a coarse grid. This taught invariance to realistic tissue deformations. The paper also introduced a weighted cross-entropy loss giving higher importance to pixels near cell boundaries, encouraging the network to learn thin separation lines between touching cells.


The Channel Pattern

The U-Net follows a strict doubling-halving channel progression: 64 \to 128 \to 256 \to 512 \to 1024 \to 512 \to 256 \to 128 \to 64 \to N_{\text{classes}}. This pattern is tied to spatial resolution changes. When pooling halves spatial dimensions, the number of activations per feature map drops by 4\times. Doubling channels compensates, keeping total activations roughly constant across levels.

At the decoder, up-convolution halves channels while doubling spatial dimensions. The up-convolution output has C/2 channels and the skip contributes C/2 channels. After concatenation the combined tensor has C channels, which the two convolutions reduce to C/2.

For decoder block 4: the bottleneck outputs 1024 channels, up-convolution produces 512, encoder block 4's skip has 512, concatenation gives 1024, and the two convolutions output 512. The same pattern applies at every decoder level.


Numerical Example

Full shape trace with N_{\text{classes}} = 2 in (C, H, W) format:

Input: (1, 572, 572)

Encoder path:

Bottleneck: (512, 32, 32) \xrightarrow{\text{conv}} (1024, 30, 30) \xrightarrow{\text{conv}} (1024, 28, 28).

Decoder path:

Output: 1x1 conv: (64, 388, 388) \to (2, 388, 388).


U-Net in Modern Deep Learning

Diffusion models. The denoising network in nearly all modern diffusion models (DDPM, Stable Diffusion, DALL-E 2, Imagen) uses a U-Net backbone. The encoder downsamples the noisy image, the bottleneck processes the deepest features, and the decoder upsamples back. Skip connections preserve fine spatial details during denoising, and timestep embeddings are injected at each level.

Medical imaging. U-Net remains the default for medical image segmentation. Variants like 3D U-Net (volumetric CT/MRI), Attention U-Net (attention gates on skip connections), and nnU-Net (self-configuring framework) dominate organ segmentation and tumor detection benchmarks.

Modern padding. Most current implementations use padded convolutions (padding=1 for 3x3 kernels) instead of valid convolutions. This preserves spatial dimensions, makes output equal to input size, and eliminates center cropping at skip connections.


Pitfalls


Examples

Example 1

Input
x = [[[[1],[0]],[[-1],[2]]]], weights = {"enc_kernel1":[[[[1]]]],"enc_bias1":[0],"enc_kernel2":[[[[1]]]],"enc_bias2":[0],"bridge_kernel1":[[[[2]]]],"bridge_bias1":[0],"bridge_kernel2":[[[[0.5]]]],"bridge_bias2":[0],"W_up":[[1]],"b_up":[0],"dec_kernel1":[[[[1],[1]]]],"dec_bias1":[0],"dec_kernel2":[[[[0.5]]]],"dec_bias2":[0],"W_out":[[1,-1]],"b_out":[0.1,-0.1]}
Output
[[[[1.6,-1.6],[1.1,-1.1]],[[1.1,-1.1],[2.1,-2.1]]]]
Explanation
The forward pass connects the high-resolution skip path with the pooled bottleneck path before producing pixel logits.

Example 2

Input
x = [[[[0.5],[1]],[[1.5],[2]]]], weights = {"enc_kernel1":[[[[1]]]],"enc_bias1":[0.2],"enc_kernel2":[[[[0.5]]]],"enc_bias2":[0],"bridge_kernel1":[[[[1]]]],"bridge_bias1":[-0.1],"bridge_kernel2":[[[[1]]]],"bridge_bias2":[0],"W_up":[[1]],"b_up":[0],"dec_kernel1":[[[[0.25],[0.5]]]],"dec_bias1":[0.1],"dec_kernel2":[[[[1]]]],"dec_bias2":[0],"W_out":[[2]],"b_out":[-0.2]}
Output
[[[[1.175],[1.3]],[[1.425],[1.55]]]]

Example 3

Input
x = [[[[1,-1],[0,1]],[[2,0],[-1,2]]]], weights = {"enc_kernel1":[[[[1],[0.5]]]],"enc_bias1":[0],"enc_kernel2":[[[[1]]]],"enc_bias2":[0],"bridge_kernel1":[[[[1]]]],"bridge_bias1":[0],"bridge_kernel2":[[[[1]]]],"bridge_bias2":[0],"W_up":[[1]],"b_up":[0],"dec_kernel1":[[[[0.5],[1]]]],"dec_bias1":[0],"dec_kernel2":[[[[1]]]],"dec_bias2":[0],"W_out":[[1]],"b_out":[0]}
Output
[[[[2.25],[2.25]],[[3],[2]]]]

Hints

  1. Keep the encoder skip tensor before applying 2 by 2 max pooling.
  2. Reuse the same convolution operation in the encoder, bottleneck, and decoder.
  3. Apply the final W_out projection without ReLU.

Requirements

Constraints

Starter Code

import numpy as np

def unet_forward(x: np.ndarray, weights: dict) -> np.ndarray:
    """
    Returns float64 per-pixel segmentation logits in NHWC layout.
    """
    pass

Test Cases

CaseMatches
Compact U-Netpublic
Biased networkpublic
Two input channelspublic