wikillm

Paper · 2021-01-18 · cs.CV

HarDNet-MSEG: A Simple Encoder-Decoder Polyp Segmentation Neural Network that Achieves over 0.9 Mean Dice and 86 FPS

HarDNet-MSEG segments polyps in colonoscopy images with a HarDNet68 encoder and a small decoder that refines three feature levels with RFB blocks and dense aggregation. It reports 0.904 mean Dice on Kvasir-SEG at 86.7 FPS on an RTX 2080 Ti, and 0.912 under the PraNet protocol.

Semantic segmentationEfficient vision backbonesMulti-scale feature fusionSegmentation evaluation (scoring foreground, saliency and segmentation masks)medical-imagingsegmentationefficient-backbone

arXivPDFCodeMarkdown15 min read

AI-compiled by claude-haiku-5-5 on 2026-10-18, not yet reviewed by a human. Every claim cites its source; check it when a detail matters.

In plain words

Polyps are small growths in the colon that can turn into cancer. A doctor looking at a colonoscopy video has to spot them by eye, and older image methods miss many of them. Polyp segmentation means labelling, pixel by pixel, which part of an image is polyp and which is not.

HarDNet-MSEG is a neural network with two parts. The first part, the encoder, reads the image with a fast, memory-friendly network called HarDNet68. The second part, the decoder, takes features from three levels of that encoder, each at a different zoom, and merges them into one label map. The parts are chosen for speed, and the design avoids attention modules.

The authors report that it is more accurate and faster than the networks they compare with on five public datasets. On Kvasir-SEG it reaches a mean Dice of 0.904 at 86.7 frames per second on one GeForce RTX 2080 Ti GPU. Dice measures how much the predicted mask overlaps the true mask, where 1 means a perfect overlap.

TL;DR

HarDNet-MSEG is a polyp segmentation network with a HarDNet68 encoder and a decoder built from RFB blocks and a dense aggregation step. The decoder drops the highest-resolution features and uses no attention modules §3, §5. Under the Kvasir-SEG 880/120 split it reports 0.904 mean Dice at 86.7 FPS, and under the PraNet protocol it reports 0.912 mean Dice tab. 1 tab. 2 §5. Those figures are author-reported and use two different training setups. Several training details that matter for reproduction (loss, optimizer for each setup, checkpoint selection) are either missing from the paper or differ from the code.

Problem

Manual polyp detection depends on the endoscopist's skill, and earlier feature-based segmentation methods miss many polyps §1. Deep U-Net-style networks and their variants do well on most polyps, but they still cut off boundaries, miss small polyps and break up large regions. They are also slow to run and slow to train §1. The authors aim for a network that is accurate on polyp benchmarks and fast at inference, without extra attention or context modules §1 §5.

Method

HarDNet-MSEG has an encoder (HarDNet68 backbone) and a decoder §3. The decoder follows the design of the Cascaded Partial Decoder §3.2. The paper describes the components in prose and figures; the pseudocode below adds the concrete layer choices read from the released code, and each such line cites the code.

Backbone §3.1. HarDNet is a dense-style CNN whose blocks use fewer shortcut connections than DenseNet, wider key layers, and a few 1x1 convolutions. The authors say this lowers memory traffic and inference time compared with DenseNet and ResNet §3.1. They use HarDNet68 as the backbone because FC-HarDNet70 is strong on Cityscapes §3.1. The decoder does not use HarDBlocks §3.2.

Decoder §3.2. The paper argues that shallow features are high-resolution and expensive, and that deeper features already carry spatial detail, so the decoder discards the shallow features and computes on the deeper ones §3.2. Each kept level passes through an RFB block §3.2.1 fig. 4. The levels are then fused by upsampling and element-wise multiplication §3.2.2 fig. 5.

Metrics §4.3. The paper gives the following formulas. They are unnumbered in the source.

\mathrm{Dice} = \frac{2\,tp}{2\,tp + fp + fn} §4.3

\mathrm{mIoU} = \frac{tp}{tp + fp + fn} §4.3

\mathrm{Recall} = \frac{tp}{tp + fn}, \qquad \mathrm{Precision} = \frac{tp}{tp + fp} §4.3

F_2 = \frac{5\,p\,r}{4p + r} §4.3

\mathrm{Acc.} = \frac{tp + tn}{tp + tn + fp + fn} §4.3

Model forward pass, as implemented in the released code:

Pseudocode
# HarDNet-MSEG forward pass  [§3]  [code: lib/HarDMSEG.py#L142-L160]
# Input:  images (B, 3, H, W)
# Output: logits (B, 1, H, W)

def forward(images):
    # Encode with HarDNet68 and keep four feature maps  [§3.1]
    # Strides 4, 8, 16, 32; channels 128, 320, 640, 1024
    # [code: lib/hardnet_68.py#L186-L189]
    _, feat8, feat16, feat32 = hardnet68(images)
    # Reduce each kept map to 32 channels with an RFB block  [§3.2.1] [fig. 4]
    # [code: lib/HarDMSEG.py#L152-L154]
    small8 = rfb8(feat8)          # (B, 32, H/8, W/8)
    small16 = rfb16(feat16)       # (B, 32, H/16, W/16)
    small32 = rfb32(feat32)       # (B, 32, H/32, W/32)
    # Fuse the three levels, deepest first  [§3.2.2] [fig. 5]
    # [code: lib/HarDMSEG.py#L156]
    coarse = dense_aggregation(small32, small16, small8)   # (B, 1, H/8, W/8)
    # Upsample by 8 to the input resolution  [code: lib/HarDMSEG.py#L158]
    return upsample_bilinear(coarse, scale=8)

RFB block, as implemented in the released code (the "RFB" of §3.2.1):

Pseudocode
# RFB block  [§3.2.1] [fig. 4]  [code: lib/HarDMSEG.py#L27-L63]
# Input: x (B, C_in, h, w); output (B, 32, h, w)
# conv_bn = conv + batch norm, no ReLU  [code: lib/HarDMSEG.py#L15-L24]

def rfb(x):
    # Branch 0: a single 1x1 conv  [code: lib/HarDMSEG.py#L31-L33]
    branches = [conv_bn(x, kernel=1)]
    # Branches 1 to 3: 1x1 conv, then 1xk and kx1 convs, then a 3x3 conv
    # with dilation k, for k = 3, 5, 7  [code: lib/HarDMSEG.py#L34-L51]
    for k in [3, 5, 7]:
        y = conv_bn(x, kernel=1)
        y = conv_bn(y, kernel=(1, k))
        y = conv_bn(y, kernel=(k, 1))
        branches.append(conv_bn(y, kernel=3, dilation=k))
    # Concatenate the four branches and fuse with a 3x3 conv  [code: L52, L60]
    fused = conv_bn(concat(branches), kernel=3)
    # Add a 1x1 projection of the input, then ReLU  [code: L53, L62]
    return relu(fused + conv_bn(x, kernel=1))

Dense aggregation, as implemented in the released code:

Pseudocode
# Dense aggregation of three 32-channel levels  [§3.2.2] [fig. 5]
# [code: lib/HarDMSEG.py#L85-L100]
# deep is the coarsest map, shallow the finest

def dense_aggregation(deep, middle, shallow):
    # Upsample the deep map and multiply it into the middle level  [code: L87]
    middle_mix = conv_a(upsample(deep)) * middle
    # Upsample the deep map twice and the middle map once; multiply all
    # three into the shallow level  [code: lib/HarDMSEG.py#L88-L89]
    shallow_mix = (conv_b(upsample(upsample(deep)))
                   * conv_c(upsample(middle))
                   * shallow)
    # Concatenate with the upsampled deep map, then fuse  [code: L91-L92]
    middle_out = conv_concat2(
        concat(middle_mix, conv_d(upsample(deep))))
    # Upsample the middle output to the shallow level, fuse  [code: L94-L95]
    shallow_out = conv_concat3(
        concat(shallow_mix, conv_e(upsample(middle_out))))
    # A 3x3 conv, then a 1x1 conv down to one channel  [code: L97-L98]
    return conv_1x1(conv_3x3(shallow_out))

Training step, as implemented in the released code. The paper does not state the loss, the batch size or the multi-scale schedule; see the Hyperparameters and Paper vs code sections.

Pseudocode
# One training batch  [code: Train.py#L73-L99]
# images (B, 3, T, T) and masks (B, 1, T, T), with T = trainsize

def train_batch(images, masks):
    # Train at three scales: 0.75x, 1x and 1.25x of T, each rounded to a
    # multiple of 32  [code: Train.py#L73, L83]
    for scale in [0.75, 1.0, 1.25]:
        size = round(T * scale / 32) * 32
        scaled_images = resize_bilinear(images, size)
        scaled_masks = resize_bilinear(masks, size)
        # Clear gradients, run the model and compute the loss  [code: L77-L91]
        optimizer.zero_grad()
        logits = model(scaled_images)
        loss = structure_loss(logits, scaled_masks)
        # Backprop, then clamp each gradient element to +/-0.5  [code: L97-L98]
        loss.backward()
        clamp_gradients(optimizer, 0.5)
        # One optimizer step per scale  [code: Train.py#L99]
        optimizer.step()

Loss used by the code (not described in the paper):

Pseudocode
# Structure loss: weighted BCE plus weighted soft IoU  [code: Train.py#L14-L25]
# Computed per image over height and width, then averaged over the batch

def structure_loss(logits, masks):
    # Pixels near mask edges get a larger weight: 1 + 5 * |local mean - mask|
    # with a 31x31 window  [code: Train.py#L16]
    weight = 1 + 5 * abs(avg_pool(masks, 31, stride=1, pad=15) - masks)
    # Weighted binary cross-entropy, normalised by the total weight  [L17-L18]
    wbce = sum(weight * bce_with_logits(logits, masks)) / sum(weight)
    probs = sigmoid(logits)
    # Weighted soft IoU  [code: Train.py#L20-L23]
    inter = sum(probs * masks * weight)
    union = sum((probs + masks) * weight)
    wiou = 1 - (inter + 1) / (union - inter + 1)
    return mean(wbce + wiou)

Inference, as implemented in Test.py:

Pseudocode
# Prediction for one image  [code: Test.py#L22-L42]
# [code: utils/dataloader.py#L130-L131] for the ImageNet normalisation
# image: RGB; gt_size: (h, w) of the ground-truth mask

def predict(image, gt_size):
    # Resize to 352 (code default) and normalise  [code: Test.py#L10]
    inputs = normalise(resize(image, 352))
    # Upsample the logit map to the ground-truth size  [code: Test.py#L38]
    logits = upsample_bilinear(model(inputs), size=gt_size)
    # Sigmoid, then min-max normalise the map  [code: Test.py#L39-L40]
    probs = sigmoid(logits)
    return (probs - min(probs)) / (max(probs) - min(probs) + 1e-8)

Hyperparameters

The paper gives two training setups §4.2. The code's defaults match the second one. Values marked "code" are not stated in the paper.

NameValueWhereNotes
Training setup A: data880 Kvasir-SEG images for training, 120 for testing§4.2split taken from jha2020real; the code does not build this split
Training setup A: input size512x512§4.2code default is 352, see Paper vs code
Training setup A: augmentationrandom rotation, horizontal flip, vertical flip§4.2rotation angle range not stated; code uses up to ±90° dataloader.py
Training setup A: optimizerSGD§4.2momentum and weight decay not stated; code's SGD branch uses momentum 0.9 and weight decay 1e-4 Train.py
Training setup A: learning rate1e-2§4.2
Training setup A: epochs100§4.2code runs 99, see Paper vs code
Training setup B: data1450 training images (900 Kvasir-SEG, 550 CVC-ClinicDB), no augmentation§4.2PraNet protocol
Training setup B: input size312x312§4.2 tab. 2code default is 352, see Paper vs code
Training setup B: optimizerAdam§4.2code default Train.py
Training setup B: learning rate1e-4§4.2code default Train.py
Training setup B: epochs100§4.2code runs 99 Train.py
Speed test size312x312 for setup Btab. 2metric test size not stated; code default 352 Test.py
Batch sizenot statedcode default 16 Train.py
Weight decay (Adam)not statedcode: none Train.py
Gradient clippingnot statedcode: each gradient element clamped to ±0.5 Train.py utils.py
Learning-rate schedulenot statedcode: multiply by 0.1 every 200 epochs, so no decay within 100 epochs Train.py utils.py
Training scalesnot statedcode: 0.75x, 1x, 1.25x of trainsize, one optimizer step each Train.py
Lossnot statedcode: structure loss on the final map only Train.py
Input normalisationnot statedcode: ImageNet mean (0.485, 0.456, 0.406), std (0.229, 0.224, 0.225) dataloader.py
Backbone pretrainingnot stated§3.1code loads a hardnet68.pth file from a hard-coded absolute path hardnet_68.py; README says to download it from the Pytorch-HarDNet repo and edit that line README.md
Random seednot statedcode sets no seed in Train.py
Decoder widthnot statedcode: 32 channels per decoder level HarDMSEG.py
HarDNet68 stemnot stated§3.1code: conv3x3 stride 2 with 32 channels, conv3x3 with 64 channels, max-pool 3x3 stride 2 hardnet_68.py
HarDNet68 blocksnot stated§3.1code: growth rates [14, 16, 20, 40, 160], layers per block [8, 16, 16, 16, 4], channel multiplier 1.7, output channels [128, 256, 320, 640, 1024] hardnet_68.py
Upsampling in decodernot statedcode: bilinear, align_corners=True inside the aggregation; final x8 bilinear with the default align_corners HarDMSEG.py
Metric threshold and aggregationnot stated§4.3authors' MATLAB evaluation averages over thresholds 1 to 0 in steps of 1/255 and also reports the maximum main.m
HardwareGeForce RTX 2080 Ti, Intel i9-9900K§4.4FPS measured on this GPU
SoftwarePyTorch 1.6, CUDA 10.2§4.4README asks for Python 3.6 and PyTorch 1.1 README.md

Results

Author-reported. Everything below is copied from the paper's tables; the baseline values in Table 1 are taken by the authors from jha2020real [tab. 1 caption]. FPS is measured on a GeForce RTX 2080 Ti.

Setup A, Kvasir-SEG 880/120 split §4.2 tab. 1: training at 512x512, SGD with learning rate 1e-2 for 100 epochs, with augmentation.

ModelmIoUmDiceF2PrecisionRecallAcc.FPS
U-Net0.4710.5970.5980.6720.6170.89411
ResUNet0.5720.6900.6990.7450.7250.91715
ResUNet++0.6130.7140.7200.7840.7420.9177
FCN80.7370.8310.8250.8820.8350.95225
HRNet0.7590.8450.8470.8780.8590.95212
DoubleUNet0.7330.8130.8200.8610.8400.9497.5
PSPNet0.7440.8410.8310.8900.8360.95317
DeepLabv3+ [ResNet50]0.7760.8570.8550.8910.86160.96128
DeepLabv3+ [ResNet101]0.7860.8640.8570.9060.8590.96117
U-Net [ResNet34]0.8100.8760.8620.9440.8600.96835
HarDNet-MSEG0.8480.9040.9150.9070.9230.96986.7

Setup B, PraNet protocol §4.2 tab. 2 tab. 3: 1450 training images from Kvasir-SEG and CVC-ClinicDB, no augmentation, training at 312x312 with Adam (learning rate 1e-4) for 100 epochs, and testing on five datasets. The FPS in Table 2 is at 312x312.

Kvasir-SEG tab. 2:

ModelmDicemIoUwFmSmMAEmaxEmFPS
U-Net0.8180.7460.7940.8580.0550.89353
U-Net++0.8210.7430.8080.8620.0480.91025
ResUNet-mod0.791n/an/an/an/an/an/a
ResUNet++0.8130.793n/an/an/an/an/a
SFA0.7230.6110.670.7820.0750.84940
PraNet0.8980.8400.8850.9150.0300.94866
HarDNet-MSEG0.9120.8570.9030.9230.0250.95888

ClinicDB, ColonDB, ETIS and CVC-T (CVC-T is the test set of EndoScene) tab. 3:

ModelClinicDB mDiceClinicDB mIoUColonDB mDiceColonDB mIoUETIS mDiceETIS mIoUCVC-T mDiceCVC-T mIoU
U-Net0.8230.7550.5120.4440.3980.3350.710.627
U-Net++0.7940.7290.4830.4100.4010.3440.7070.624
ResUNet-mod0.779n/an/an/an/an/an/an/a
ResUNet++0.7960.796n/an/an/an/an/an/a
SFA0.7000.6070.4690.3470.2970.2170.4670.329
PraNet0.8990.8490.7090.6400.6280.5670.8710.797
HarDNet-MSEG0.9320.8820.7310.6600.6770.6130.8870.821

Headline claims §5: HarDNet-MSEG is the only network in the comparison to pass 0.90 mean Dice on Kvasir-SEG, at 0.912 under setup B and 0.904 under setup A. The authors report it as about 1.3 times faster than PraNet and more than twice as fast as the other compared models. The abstract's 0.904 and 86.7 FPS are the setup A figures tab. 1.

Paper vs code

Compared with the released code at aac1c4b5d2fdbc80bc8fe21a5377af2d202a0348. The official repository is the one linked from the abstract.

  • Training loss. The paper states no loss §4.2. The code uses the structure loss (weighted BCE plus weighted IoU) on the final map only, and its auxiliary losses are commented out Train.py.
  • Optimizer for setup A. The paper states SGD with learning rate 1e-2 §4.2. The code's default is Adam with learning rate 1e-4 Train.py, which matches setup B. SGD is available through the flags, with momentum 0.9 and weight decay 1e-4 Train.py. The paper does not say which configuration produced the setup A numbers.
  • Input size. The paper uses 512x512 for setup A and 312x312 for setup B §4.2 tab. 2. The code defaults to 352 for training and for testing Train.py Test.py.
  • Epochs. The paper says 100 §4.2. The code loops range(1, opt.epoch), which runs 99 epochs Train.py.
  • Learning-rate schedule. The paper states none. The code multiplies the learning rate by 0.1 every 200 epochs, which means a constant rate over 100 epochs Train.py utils.py.
  • Multi-scale training. The paper does not mention it. The code runs each batch at 0.75x, 1x and 1.25x of the training size, with one optimizer step per scale Train.py. At the default size of 352, the three sizes are 256, 352 and 448 (derived from the rounding in Train.py#L83).
  • Gradient clipping. The paper states none. The code clamps each gradient element to ±0.5, not the gradient norm Train.py utils.py.
  • Batch size. Not in the paper. The code defaults to 16 Train.py.
  • Augmentation. The paper lists random rotation and horizontal and vertical flips for setup A §4.2. The code applies rotations of up to ±90° and flips with probability 0.5 dataloader.py. It enables augmentation only when the flag is exactly the string True dataloader.py, and it is off by default Train.py.
  • Decoder fusion. The paper describes upsampling and element-wise multiplication §3.2.2 fig. 5. The code also concatenates features and applies 3x3 convolutions between the steps HarDMSEG.py.
  • Which backbone levels are used. The paper says the shallow features are discarded §3.2 but does not say how many levels. In the code, the backbone outputs are taken at strides 4, 8, 16 and 32 (128, 320, 640 and 1024 channels). The decoder uses only the last three, so the stride-4 output is dropped hardnet_68.py HarDMSEG.py. The strides are derived from the stem and pooling layers in hardnet_68.py.
  • ReLU in convolution blocks. The paper does not state this. BasicConv2d defines a ReLU, but its forward pass returns only convolution and batch norm. ReLU is applied only at the end of each RFB block HarDMSEG.py HarDMSEG.py.
  • Unused modules. The reverse-attention branches and the layers conv2 to conv6 are created in __init__ but never used in forward HarDMSEG.py HarDMSEG.py. They still appear in model.parameters().
  • Pretrained backbone. The paper does not state it §3.1. The code loads a hardnet68.pth file from a hard-coded absolute path on the author's machine hardnet_68.py. The README says to download the file from the Pytorch-HarDNet repository and edit that line README.md.
  • Software. The paper states PyTorch 1.6 and CUDA 10.2 §4.4. The README asks for Python 3.6 and PyTorch 1.1 README.md.
  • Checkpoint selection. The paper does not state it. The code saves the checkpoint with the highest mean Dice on the folder given by --test_path, which defaults to the Kvasir test folder Train.py Train.py. See Pitfalls.
  • Setup A split. The paper cites jha2020real for the 880/120 split §4.2. The code does not create this split. It reads whatever folders are passed in Train.py.

Pitfalls

  • Checkpoint selection on the test set. The training script evaluates the mean Dice on the test folder after every epoch and keeps the best checkpoint Train.py. The reported Kvasir numbers may therefore be selected on the test set. The paper does not mention this. A fair reproduction should fix the epoch count in advance or use a separate validation split.
  • "Mean Dice" is not one number. The paper gives per-pixel Dice formulas §4.3 but does not say whether the tables average per image, per dataset, or over thresholds. The authors' MATLAB evaluation averages Dice over 256 thresholds (1 to 0 in steps of 1/255) and also reports the best threshold. The per-image map is first min-max normalised main.m main.m main.m. The README says the report uses this MATLAB code README.md. The Python script in eval_Kvasir.py is said to differ from the report by about 0.002 README.md.
  • Python Dice in Train.py is not the table metric. The test loop in Train.py computes a soft Dice with smoothing 1 on min-max normalised probabilities. It averages over 100 hard-coded iterations, not over the actual test set size Train.py. Its values are for checkpoint selection, not for comparison with the tables.
  • F2 formula. The paper prints F2 as 5pr/(4p+r) without defining p and r §4.3. The usual reading is precision and recall, but the paper does not say so.
  • Speed numbers depend on undocumented inputs. The paper gives the input size only for setup B tab. 2. It does not say whether FPS includes the upsampling to ground-truth size or the sigmoid §4.4. The code's default size of 352 is not the 312 that the speed claim in Table 2 uses.
  • Multi-scale training costs about three times the compute per batch. Each batch makes three forward and backward passes and three optimizer steps Train.py. Epoch counts and wall-clock times from the paper therefore do not transfer directly to a single-scale run.
  • Silent sample dropping. The training loader drops image and mask pairs whose sizes differ, without any warning dataloader.py.
  • Hard-coded paths. The pretrained weight path hardnet_68.py, the dataset defaults Train.py and the test folders Test.py point to the authors' machine. Test.py loops over five dataset names, including CVC-300, and expects their folder layout.
  • Deprecated calls. Test.py saves maps with scipy.misc.imsave Test.py Test.py, which current SciPy releases no longer provide. Train.py imports torchstat Train.py, which the README does not list.
  • Single runs. The paper reports one run per model and gives no variance or seeds §4. Train.py sets no seed Train.py.
  • Baselines come from other papers. The setup A caption says the other models' scores are taken from jha2020real tab. 1 caption, so they were not retrained by the authors. The caption does not say whether the baseline FPS values were measured by the authors.
  • Two headline numbers. The abstract's 0.904 and 86.7 FPS come from setup A, while 0.912 and 88 FPS come from setup B at 312x312 §5 tab. 2. The two setups differ in input size, optimizer, augmentation and training data, so they should not be treated as one benchmark.

Lineage

  • Builds on: HarDNet, the backbone, with its own low-memory-traffic design §3.1. The paper cites it as chao2019hardnet; no card exists yet.
  • Builds on: Cascaded Partial Decoder, whose decoder design the paper follows §3.2. The paper cites it as wu2019cascaded; no card exists yet.
  • Builds on: Receptive Field Block, used for each decoder input §3.2.1. The paper cites it as liu2018receptive; no card exists yet.
  • Trained with the protocols of PraNet §4.2 (setup B). PraNet has no card yet.
  • Uses the setup A protocol of jha2020real §4.2.
  • Compared against: PraNet, U-Net and its variants, DoubleU-Net, ResUNet and ResUNet++, FCN8, HRNet, PSPNet, DeepLabv3+, SFA, and others tab. 1 tab. 2 tab. 3.
  • Superseded by: not known from the content of this wiki.

Source and license

Adapted from “HarDNet-MSEG: A Simple Encoder-Decoder Polyp Segmentation Neural Network that Achieves over 0.9 Mean Dice and 86 FPS” by Chien-Hsiang Huang, Hung-Yu Wu, Youn-Long Lin (arXiv:2101.07172), used under CC BY-SA 4.0. Changes: condensed and restructured for implementation, with tables, equations and algorithms taken from the paper and its code, each cited to its source. Not affiliated with or endorsed by the authors. This page is licensed under CC BY-SA 4.0. Code citations refer to https://github.com/james128333/HarDNet-MSEG at commit aac1c4b5d2fd (Apache-2.0). Any quoted code is shown under that license.