EasyU-Net

U-Net Encoder Block

U-Net: Convolutional Networks for Biomedical Image Segmentation

Easy

Problem

Implement one numerical U-Net encoder block in NHWC layout. Apply two same-padded convolutions with ReLU, preserve the result as the skip tensor, then apply non-overlapping 2 by 2 max pooling.

S = \operatorname{ReLU}\left(\operatorname{Conv}_{K_2}(\operatorname{ReLU}(\operatorname{Conv}_{K_1}(X)))\right)

P = \operatorname{MaxPool}_{2\times2}(S)

Here, X is the input, K_1 and K_2 are supplied odd-sized convolution kernels, S is the skip tensor, and P is the pooled tensor. Convolution uses zero padding so height and width are preserved before pooling. Return a Python dictionary with exactly pooled and skip, both float64 NumPy arrays.

Theory

The encoder block is the fundamental repeating unit of the contracting path in U-Net (Ronneberger, Fischer, and Brox, 2015), the fully convolutional network that set new benchmarks for biomedical image segmentation. Each encoder block applies two 3x3 unpadded convolutions followed by a 2x2 max pooling operation, progressively reducing spatial resolution while increasing feature channels. Understanding the exact spatial arithmetic is essential because the unpadded convolutions create dimension mismatches that propagate through the network and directly affect skip connections.


What It Is

The encoder block is the building block of U-Net's contracting path -- the left half of the U-shaped architecture. Each encoder block performs three operations in sequence:

Critically, the encoder block produces two outputs: the pooled tensor (input to the next block) and the pre-pool feature map (saved as a skip connection for the decoder). The paper states: "The contracting path consists of the repeated application of two 3x3 convolutions (unpadded convolutions), each followed by a rectified linear unit (ReLU) and a 2x2 max pooling operation with stride 2 for downsampling." There are four such encoder blocks in the standard U-Net.


Key Equations

The spatial output size of a convolution is governed by the standard formula:

H_{out} = \left\lfloor \frac{H_{in} + 2p - k}{s} \right\rfloor + 1

where H_{in} is the input height, p is padding, k is kernel size, and s is stride. For the U-Net encoder, the convolutions use k = 3, p = 0 (unpadded / valid mode), and s = 1. Substituting:

H_{out} = \left\lfloor \frac{H_{in} + 0 - 3}{1} \right\rfloor + 1 = H_{in} - 2

Each 3x3 unpadded convolution reduces the spatial dimension by exactly 2. Two consecutive convolutions therefore reduce it by 4. The same formula applies to the width dimension W.

For max pooling with kernel size 2 and stride 2:

H_{out} = \left\lfloor \frac{H_{in}}{2} \right\rfloor

Putting the full encoder block together, the shape transformation is:

\begin{array}{rcl} \text{Input} & : & (B,\; H,\; W,\; C_{in}) \\ \xrightarrow{\text{Conv }3{\times}3} & : & (B,\; H{-}2,\; W{-}2,\; C_{out}) \\ \xrightarrow{\text{Conv }3{\times}3} & : & (B,\; H{-}4,\; W{-}4,\; C_{out}) \quad \leftarrow \text{skip connection} \\ \xrightarrow{\text{MaxPool }2{\times}2} & : & \left(B,\; \frac{H{-}4}{2},\; \frac{W{-}4}{2},\; C_{out}\right) \end{array}

The channel dimension changes only at the first convolution (from C_{in} to C_{out}). The second convolution maintains C_{out}, and max pooling preserves channels entirely -- it operates only on spatial dimensions.


Why Unpadded Convolutions

The original U-Net uses valid convolutions (p = 0), meaning the kernel is only applied where it fully overlaps the input. No fabricated border values are introduced, so every output pixel is computed from real data. In biomedical imaging, where each output pixel is a segmentation decision, this avoids border artifacts that could corrupt cell boundary classifications.

The tradeoff is spatial shrinkage at every convolution layer. With two convolutions per block and four encoder blocks, the contracting path alone removes 4 \times 4 = 16 pixels from each spatial side before accounting for pooling. This compounds through the bottleneck and decoder: a 572x572 input produces only a 388x388 output (92 pixels lost per side).

This shrinkage directly affects skip connections. Encoder feature maps are spatially larger than the corresponding decoder maps, so the decoder must center-crop encoder features before concatenation. Modern U-Net implementations switch to same-padding (p = 1) to avoid this, but the original unpadded design is why 572x572 was chosen as input size -- it guarantees even dimensions at every stage.


The Two Outputs

Every encoder block produces two tensors, and both are essential for U-Net to function.

The pooled tensor is the output after max pooling. It has halved spatial dimensions and serves as the input to the next encoder block (or to the bottleneck, for the last encoder block). This tensor carries information deeper into the network, where each successive block operates at lower resolution but captures broader context.

The pre-pool feature map is the output after the second convolution but before max pooling. This tensor is saved and later used as a skip connection in the expanding path. It retains the full spatial resolution at that encoder level, preserving fine-grained localization information that pooling would discard.

Without skip connections, the decoder would reconstruct spatial detail entirely from the low-resolution bottleneck -- fine details are irrecoverably lost. Skip connections give the decoder direct access to the encoder's high-resolution features. During decoding, each decoder block upsamples its input, then crops and concatenates the matching encoder skip connection along the channel axis. The paper describes this as combining "high resolution features from the contracting path" with "the upsampled output."

A practical consequence: an implementation that returns only the pooled tensor (forgetting the pre-pool feature map) will silently break the decoder. Both outputs are required at every encoder level.


The Channel Progression

The original U-Net follows a strict channel doubling pattern through the encoder. Starting from the input image (typically 1 channel for grayscale biomedical images), the four encoder blocks produce progressively more feature channels:

After the four encoder blocks, the bottleneck operates at 512 \to 1024 channels. The decoder then reverses the pattern: 1024 \to 512 \to 256 \to 128 \to 64.

The doubling strategy is a deliberate tradeoff. Reducing spatial size by 2\times in each dimension reduces feature map area by 4\times, so doubling the channel count only increases per-block parameters by roughly 2\times (since convolution weight count scales with C_{in} \times C_{out} \times k^2). The first block's jump (1 \to 64) is where the network transitions from raw pixels to learned representations; the subsequent doublings follow the standard VGGNet pattern.


Paper Context

U-Net was introduced by Ronneberger, Fischer, and Brox in "U-Net: Convolutional Networks for Biomedical Image Segmentation" (2015). The paper addressed biomedical imaging, where training data is extremely scarce yet segmentation demands pixel-level precision. Building on the fully convolutional network (FCN) of Long et al. (2015), U-Net's key contribution was the symmetric encoder-decoder structure with skip connections at every level, dramatically improving localization accuracy.

The contracting path follows what Ronneberger et al. describe as "the typical architecture of a convolutional network." The design mirrors VGGNet's pattern of stacking 3x3 convolutions with 2x2 max pooling. The choice of 3x3 kernels throughout (rather than 5x5 or 7x7) was influenced by the VGGNet finding that stacking small kernels achieves the same receptive field with fewer parameters and more nonlinearities.

U-Net won the ISBI cell tracking challenge 2015 by a large margin (IOU 0.9203 on PhC-U373, 0.7756 on DIC-HeLa) with very limited training data. The paper also introduced the overlap-tile strategy: because unpadded convolutions make the output smaller than the input, adjacent tiles must overlap so the output covers the full image without gaps.


Numerical Example

The standard U-Net input is 572x572x1 (grayscale, single channel). Tracing through the first encoder block with C_{out} = 64:

Input tensor shape: (1, 572, 572, 1)

After first 3x3 unpadded convolution (C_{in}=1, C_{out}=64):

H = 572 - 2 = 570, \quad W = 572 - 2 = 570

Shape: (1, 570, 570, 64). The channel count jumps from 1 to 64.

After second 3x3 unpadded convolution (C_{in}=64, C_{out}=64):

H = 570 - 2 = 568, \quad W = 570 - 2 = 568

Shape: (1, 568, 568, 64). This is the pre-pool feature map -- the skip connection output saved for decoder block 4.

After 2x2 max pooling (stride 2):

H = \frac{568}{2} = 284, \quad W = \frac{568}{2} = 284

Shape: (1, 284, 284, 64). This is the pooled output -- input to encoder block 2. Max pooling does not change the channel count.

So encoder block 1 produces:


The Contracting Path in Full

Tracing the full contracting path from 572x572 through all four encoder blocks. Each block: two 3x3 unpadded convolutions (-2 each) then 2x2 max pool (/2).

Encoder Block 1 (C_{in}=1, C_{out}=64):

(1,\; 572,\; 572,\; 1) \xrightarrow{\text{conv}} (1,\; 570,\; 570,\; 64) \xrightarrow{\text{conv}} (1,\; 568,\; 568,\; 64) \xrightarrow{\text{pool}} (1,\; 284,\; 284,\; 64)

Encoder Block 2 (C_{in}=64, C_{out}=128):

(1,\; 284,\; 284,\; 64) \xrightarrow{\text{conv}} (1,\; 282,\; 282,\; 128) \xrightarrow{\text{conv}} (1,\; 280,\; 280,\; 128) \xrightarrow{\text{pool}} (1,\; 140,\; 140,\; 128)

Encoder Block 3 (C_{in}=128, C_{out}=256):

(1,\; 140,\; 140,\; 128) \xrightarrow{\text{conv}} (1,\; 138,\; 138,\; 256) \xrightarrow{\text{conv}} (1,\; 136,\; 136,\; 256) \xrightarrow{\text{pool}} (1,\; 68,\; 68,\; 256)

Encoder Block 4 (C_{in}=256, C_{out}=512):

(1,\; 68,\; 68,\; 256) \xrightarrow{\text{conv}} (1,\; 66,\; 66,\; 512) \xrightarrow{\text{conv}} (1,\; 64,\; 64,\; 512) \xrightarrow{\text{pool}} (1,\; 32,\; 32,\; 512)

The pooled output of encoder block 4, (1, 32, 32, 512), becomes the input to the bottleneck. The four skip connections at sizes (568, 568), (280, 280), (136, 136), and (64, 64) are later center-cropped and concatenated with the corresponding decoder blocks.

Notice that 572 was specifically chosen so all spatial dimensions remain even at every stage: 568 \to 284, 280 \to 140, 136 \to 68, 64 \to 32. If the input were 570, the first pool would yield (570-4)/2 = 283 (odd), causing rounding problems at subsequent pooling steps.


Pitfalls


Examples

Example 1

Input
x = [[[[1],[-1]],[[2],[0.5]]]], kernel1 = [[[[2,-1]]]], bias1 = [0,0.5], kernel2 = [[[[0.5],[1]]]], bias2 = [0.1]
Output
{"pooled":[[[[2.1]]]],"skip":[[[[1.1],[1.6]],[[2.1],[0.6]]]]}
Explanation
The two convolution stages build the skip features before max pooling halves their spatial dimensions.

Example 2

Input
x = [[[[1],[2]],[[3],[4]]]], kernel1 = [[[[1,-1]]]], bias1 = [0,1], kernel2 = [[[[0.5,0.2],[1,-0.5]]]], bias2 = [0,0.1]
Output
{"pooled":[[[[2,0.9]]]],"skip":[[[[0.5,0.3],[1,0.5]],[[1.5,0.7],[2,0.9]]]]}

Example 3

Input
x = [[[[1],[0]],[[-1],[2]]],[[[0.5],[1.5]],[[2.5],[-0.5]]]], kernel1 = [[[[1]]]], bias1 = [0], kernel2 = [[[[2]]]], bias2 = [-0.5]
Output
{"pooled":[[[[3.5]]],[[[4.5]]]],"skip":[[[[1.5],[0]],[[0],[3.5]]],[[[0.5],[2.5]],[[4.5],[0]]]]}

Hints

  1. Pad only the height and width axes by kernel_size // 2.
  2. Use np.tensordot to contract a spatial patch with a convolution kernel.
  3. Reshape paired spatial positions before reducing them with max.

Requirements

Constraints

Starter Code

import numpy as np

def unet_encoder_block(x: np.ndarray, kernel1: np.ndarray, bias1: np.ndarray,
                       kernel2: np.ndarray, bias2: np.ndarray) -> dict:
    """
    Returns pooled and skip as float64 arrays in a dictionary.
    """
    pass

Test Cases

CaseMatches
Single-channel blockpublic
Two output channelspublic
Batch of twopublic