Polyp segmentation on Kvasir-SEG with a Tsetlin machine¶
Notebook 07 segmented generated street scenes, where the layout does much of the work: sky on
top, road at the bottom. This notebook takes SegmentationTsetlinMachine to real medical
images. Kvasir-SEG (Jha et al., MMM 2020) is 1 000
colonoscopy frames, each with a hand-drawn mask of a polyp. A polyp can be anywhere in the
frame and any size, the lighting comes from the endoscope, and only about 15% of the pixels are
polyp. There is no layout to lean on.
- downloading and loading the data, and booleanizing the colour;
- the floors: predicting nothing, and predicting where polyps usually are;
- a single-scale model, with early stopping on a validation split;
- context from an OR-pooled pyramid and position literals;
- class-balanced feedback for a minority class;
- is the decision threshold the problem? Tuning it on the validation split;
- where the model fails, image by image;
- why a pixel was called polyp.
Read the numbers as what a per-pixel Tsetlin machine on thermometer-coded colour reaches at 64 × 64, not as a competitive polyp segmenter: the gap to a deep encoder–decoder is large, and section 6 shows where it comes from.
# Setup: on Google Colab this installs torchtsetlin from PyPI (torch, torchvision, scikit-learn,
# matplotlib and pandas are already there). Elsewhere: `pip install torchtsetlin`, or
# `pip install -e ".[dev]"` in a checkout of the repository.
import sys
IN_COLAB = "google.colab" in sys.modules
if IN_COLAB:
%pip install -q "torchtsetlin>=0.7"
import os
import time
import urllib.request
import zipfile
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import torch
from PIL import Image
import torchtsetlin as tt
SEED = 0
tt.seed_everything(SEED)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
DATA_ROOT = Path("data" if IN_COLAB else "../../data")
CLASSES = ["background", "polyp"]
print("torchtsetlin", tt.__version__, "| torch", torch.__version__, "| device", device)
if device.type == "cuda":
print("gpu:", torch.cuda.get_device_name(0))
1. The data¶
The archive from the dataset page holds Kvasir-SEG/images/*.jpg and, under the same file
names, Kvasir-SEG/masks/*.jpg. The cell below downloads and unpacks it into DATA_ROOT once.
If you already have a copy, point the environment variable KVASIR_SEG_DIR at the folder that
contains images/ and masks/. Check the dataset page
for its terms of use, and cite Jha et al. (2020) if you use it.
URL = "https://datasets.simula.no/downloads/kvasir-seg.zip"
def kvasir_seg_dir(root=DATA_ROOT):
"""The Kvasir-SEG folder, downloading and unpacking the archive on first use."""
if os.environ.get("KVASIR_SEG_DIR"):
return Path(os.environ["KVASIR_SEG_DIR"])
folder = Path(root) / "Kvasir-SEG"
if not (folder / "images").is_dir():
Path(root).mkdir(parents=True, exist_ok=True)
archive = Path(root) / "kvasir-seg.zip"
if not archive.exists():
print("downloading", URL)
urllib.request.urlretrieve(URL, archive)
with zipfile.ZipFile(archive) as zf:
zf.extractall(root)
return folder
KVASIR = kvasir_seg_dir()
files = sorted((KVASIR / "images").glob("*.jpg"))
print(len(files), "images in", KVASIR)
The frames come in many sizes (from about 330 × 490 to 1 350 × 1 920). We resize every image
and mask to SIZE × SIZE. The images are resized bilinearly; the masks are resized bilinearly
too and then thresholded at one half, because they are stored as JPEG and their edges carry
compression noise. Kvasir-SEG has no official split, so we draw a seeded one: 780 images to
train on, 100 to validate (early stopping, threshold tuning) and 120 to test.
A Tsetlin machine classifies every pixel as one example, so the resolution sets the cost: 64 × 64 is 4 096 examples per image, about 3.2 million per training epoch.
SIZE = 64
def load_kvasir(paths, size):
images, masks = [], []
for f in paths:
img = Image.open(f).convert("RGB").resize((size, size), Image.BILINEAR)
mask = Image.open(f.parent.parent / "masks" / f.name).convert("L").resize((size, size), Image.BILINEAR)
images.append(np.asarray(img))
masks.append(np.asarray(mask) > 127)
rgb = torch.from_numpy(np.stack(images)).permute(0, 3, 1, 2).float() / 255.0
return rgb, torch.from_numpy(np.stack(masks)).long()
t0 = time.time()
rgb, labels = load_kvasir(files, SIZE)
rgb, labels = rgb.to(device), labels.to(device)
print(f"images {tuple(rgb.shape)} masks {tuple(labels.shape)} [{time.time() - t0:.0f} s]")
perm = torch.randperm(len(files), generator=torch.Generator().manual_seed(SEED)).to(device)
idx_tr, idx_va, idx_te = perm[:780], perm[780:880], perm[880:]
y_tr, y_va, y_te = labels[idx_tr], labels[idx_va], labels[idx_te]
rgb_te = rgb[idx_te]
area = labels.float().mean((1, 2))
print(f"polyp pixels: {labels.float().mean():.3f} of all pixels; "
f"per image median {area.median():.3f}, min {area.min():.3f}, max {area.max():.3f}")
fig, axes = plt.subplots(2, 6, figsize=(13, 4.6))
for k, i in enumerate([0, 1, 2, 3, 4, 5]):
axes[0, k].imshow(rgb_te[i].permute(1, 2, 0).cpu())
axes[1, k].imshow(y_te[i].cpu(), cmap="gray", interpolation="nearest")
axes[1, k].set_title(f"polyp {y_te[i].float().mean():.0%}", fontsize=9)
for ax in axes.flat:
ax.axis("off")
plt.tight_layout()
plt.show()
plt.figure(figsize=(5, 2.6))
plt.hist(area.cpu(), bins=40)
plt.xlabel("fraction of the image that is polyp")
plt.ylabel("images")
plt.tight_layout()
plt.show()
Booleanizing colour¶
As in notebook 07: a thermometer with 4 levels per channel, so plane k of a channel says "this
channel is at least the k-th level". Three channels give 12 Boolean planes, 0–3 red, 4–7 green
and 8–11 blue. The features are kept as bool tensors: the pyramid in section 4 has 540 planes
per pixel, which as float32 would not fit on a T4 for all 1 000 images.
enc = tt.data.ColorThermometerEncoder(n_bits=4, value_range=(0.0, 1.0)).to(device)
x = enc(rgb).bool()
x_tr, x_va, x_te = x[idx_tr], x[idx_va], x[idx_te]
print("Boolean planes", tuple(x.shape), "| fraction ON", round(x.float().mean().item(), 3))
fig, axes = plt.subplots(1, 5, figsize=(12, 2.6))
axes[0].imshow(rgb_te[0].permute(1, 2, 0).cpu())
axes[0].set_title("image")
for ax, k in zip(axes[1:], (0, 2, 5, 9)):
ax.imshow(x_te[0, k].cpu(), cmap="gray", interpolation="nearest")
ax.set_title(f"plane {k}")
for ax in axes:
ax.axis("off")
plt.tight_layout()
plt.show()
2. The floors¶
Polyp segmentation is scored by the Dice coefficient and the IoU of the polyp class; mean IoU averages the polyp and background IoU. Two baselines that never look at a pixel's colour set the floor:
- all background: predicts no polyp anywhere. It is right on about 85% of the pixels and scores 0 Dice.
- where polyps usually are: the fraction of training masks that cover each pixel, thresholded at the value that maximises Dice on the training set. Endoscopists tend to centre the polyp, so this is a blob in the middle of the frame.
def polyp_scores(pred, target):
"""Dice and IoU of the polyp class, plus mean IoU and pixel accuracy, from label maps."""
m = tt.metrics.segmentation_metrics(pred, target, 2, CLASSES)
iou = m["iou_polyp"]
return {"dice": 2 * iou / (1 + iou), "iou": iou, "mean_iou": m["mean_iou"],
"pixel_accuracy": m["pixel_accuracy"]}
freq = y_tr.float().mean(0) # (H, W): how often a pixel is polyp
best_th = max(torch.linspace(0.05, 0.6, 56).tolist(),
key=lambda th: polyp_scores((freq > th).long().expand_as(y_tr), y_tr)["dice"])
RESULTS = {
"all background": polyp_scores(torch.zeros_like(y_te), y_te),
"where polyps usually are": polyp_scores((freq > best_th).long().expand_as(y_te), y_te),
}
print(f"prior threshold {best_th:.2f}")
pd.DataFrame(RESULTS).T.round(3)
fig, axes = plt.subplots(1, 2, figsize=(6.5, 3))
axes[0].imshow(freq.cpu(), cmap="magma")
axes[0].set_title("P(polyp) per pixel, train")
axes[1].imshow((freq > best_th).cpu(), cmap="gray")
axes[1].set_title(f"> {best_th:.2f}")
for ax in axes:
ax.axis("off")
plt.tight_layout()
plt.show()
In our run all background is right on 85.7% of the pixels and finds no polyp (Dice 0). The location prior is the floor to beat: knowing nothing but where polyps tend to be, it reaches a polyp Dice of about 0.43 (IoU 0.27). A model has to beat that before it can claim to read the image at all.
3. A single-scale model¶
SegmentationTsetlinMachine with the settings of notebook 07: each pixel is classified from its
3 × 3 neighbourhood (12 × 9 = 108 features), 300 clauses per class, T=60, s=10, weighted
clauses. batch_size counts images, so a batch of 8 is 32 768 pixel-examples, committed 128 at a
time (patches_per_commit, see notebook 07).
On this data the test score moves by several points from one epoch to the next: the model swings
between calling too much and too little polyp. So we train with a validation split and
EarlyStopping(restore_best=True), which keeps the epoch with the best validation polyp IoU,
and report the test score of that epoch only.
EPOCHS = 4
def train(name, xt, xv, xs, patch_size=3, position=False, class_p=None, epochs=EPOCHS):
tt.seed_everything(SEED)
model = tt.SegmentationTsetlinMachine(
2, n_clauses=300, T=60, s=10.0, patch_size=patch_size,
position_encoding=position, weighted=True, n_states=100,
).to(device)
model.class_names = CLASSES
if class_p is not None:
model.set_class_feedback_p(class_p)
stop = tt.EarlyStopping(monitor="val_iou_polyp", patience=epochs, restore_best=True)
trainer = tt.Trainer(model, device=device, batch_size=8, verbose=False, callbacks=[stop])
t0 = time.time()
history = trainer.fit((xt, y_tr), epochs=epochs, val_data=(xv, y_va))
seconds = time.time() - t0
model.eval() # empty clauses are False when predicting: never skip this
res = polyp_scores(model.predict(xs), y_te)
res["boundary_f1"] = tt.metrics.boundary_f1(model.predict(xs), y_te, tolerance=2)
res["best_epoch"] = stop.best_epoch + 1
res["seconds"] = seconds
RESULTS[name] = res
val = [round(v, 3) for v in history["val_iou_polyp"]]
print(f"{name:<28} val polyp IoU per epoch {val} -> test Dice {res['dice']:.3f} "
f"IoU {res['iou']:.3f} [{seconds:.0f} s]")
return model
single = train("single scale 3x3", x_tr, x_va, x_te)
def show(model, xs, ids, title=None):
model.eval()
pred = model.predict(xs[ids])
fig, axes = plt.subplots(3, len(ids), figsize=(2.1 * len(ids), 6.6))
for k, i in enumerate(ids):
d = polyp_scores(pred[k:k + 1], y_te[i:i + 1])["dice"]
axes[0, k].imshow(rgb_te[i].permute(1, 2, 0).cpu())
axes[1, k].imshow(y_te[i].cpu(), cmap="gray", interpolation="nearest")
axes[2, k].imshow(pred[k].cpu(), cmap="gray", interpolation="nearest")
axes[2, k].set_title(f"Dice {d:.2f}", fontsize=9)
for ax in axes.flat:
ax.axis("off")
axes[0, 0].set_ylabel("image")
if title:
plt.suptitle(title)
plt.tight_layout()
plt.show()
SHOW = [0, 1, 2, 3, 4, 5]
show(single, x_te, SHOW, "single scale 3x3: image / mask / prediction")
In our run the single-scale model reaches a polyp Dice of about 0.41: below the location prior. The validation IoU per epoch shows the instability, about 0.24, 0.23, 0.00, 0.17: after the third epoch the model predicted almost no polyp. Early stopping kept the first epoch.
The figure shows what the model learned: pale, bright tissue is polyp. That finds the polyp
when it is the brightest thing in the frame (fourth column), and also marks the brightly lit
folds close to the endoscope and the rim of the lumen (first two columns). A pale polyp on pale
mucosa (last column) is missed. A 3 × 3 window of colour cannot separate them.
4. Context: a pyramid and position literals¶
A 3 × 3 window of a 64-pixel image sees about 5% of its width. Whether a pinkish, shiny patch is
polyp or the colon wall next to it depends on what is around it: a rounded edge, a change in
texture, the dark lumen. Notebook 07 added context in two Boolean ways, and we do the same:
tt.data.PyramidEncoder(scales=(1, 2, 4, 8, 16), patch=3)OR-pools the planes to 1/2 … 1/16 resolution and gives each pixel the3 × 3patch at every level: at the coarsest level that patch covers most of the frame. That is12 × 9 × 5 = 540planes, and the model usespatch_size=1.position_encoding=8adds 8 thermometer bits per axis, so a clause can restrict itself to a region of the frame.
The pyramid is encoded in chunks of 50 images and stored as bool.
pyr = tt.data.PyramidEncoder(scales=(1, 2, 4, 8, 16), patch=3)
px = torch.cat([pyr(x[i:i + 50]) for i in range(0, len(x), 50)]).bool()
px_tr, px_va, px_te = px[idx_tr], px[idx_va], px[idx_te]
print("pyramid planes", tuple(px.shape), f"{px.numel() / 2**30:.1f} GB as bool")
pyramid = train("pyramid + position", px_tr, px_va, px_te, patch_size=1, position=8)
5. Class-balanced feedback¶
Only about 15% of the pixels are polyp, so most of the feedback the machine receives is about
background. A Tsetlin machine has no loss to reweight; the lever is which pixels give feedback.
tt.data.balanced_class_probabilities sets the probability that a pixel of each class contributes
(here: every polyp pixel, and a matching fraction of background pixels).
class_p = tt.data.balanced_class_probabilities(y_tr, 2, floor=0.05)
print("feedback probability:", dict(zip(CLASSES, [round(p, 3) for p in class_p.tolist()])))
balanced = train("pyramid + position + balanced", px_tr, px_va, px_te, patch_size=1, position=8,
class_p=class_p)
cols = ["dice", "iou", "mean_iou", "pixel_accuracy", "boundary_f1", "best_epoch", "seconds"]
pd.DataFrame(RESULTS).T.reindex(columns=cols).round(3)
In our run:
- The pyramid and position literals alone do not help: Dice about 0.39, against 0.41 for the single-scale model. Pixel accuracy and mean IoU rise (fewer false positives on the background), but the polyp itself is found less often. Its validation IoU swings even more: about 0.20, 0.12, 0.24, 0.07 per epoch.
- With class-balanced feedback the same model reaches Dice about 0.45 (IoU 0.29, mean IoU 0.54), the only model here that beats the location prior, and its validation curve is the steadiest (about 0.28, 0.28, 0.24, 0.23). Every polyp pixel now gives feedback, and only about one background pixel in five.
- Boundary F1 stays around 0.3 for every model: the outlines are not right, even where the region roughly is.
- The three models train in about the same time, roughly 2 minutes for 4 epochs on an RTX 3090.
In our runs with other settings, every variation we tried ended up close to the same plateau,
a test Dice of 0.40–0.47 after 4 epochs: 600 clauses, T=150, s=3 or s=20, an extra
1/32 pyramid level, 8-bit colour, patches_per_commit=32 (4× slower), and
pixel_order="shuffle". Class balancing was the one setting that consistently made training
steadier. The earlier, longer study in experiments/segmentation/ of the repository found the
same ceiling at 128 × 128 (mean IoU about 0.54 for the best Tsetlin model, 0.75 for a U-Net).
show(balanced, px_te, SHOW, "pyramid + position + balanced: image / mask / prediction")
6. Is it the threshold?¶
predict takes the class with the larger vote sum, i.e. it calls a pixel polyp when the margin
votes[polyp] - votes[background] is above 0. If the model ranked pixels well but its margin were
shifted, moving that threshold would fix it. We tune the threshold on the validation split, and
also report the best threshold chosen on the test set itself: an upper bound that no threshold
can beat, not a result.
def margins(model, xs):
model.eval()
out = [model.vote_map(xs[i:i + 20]) for i in range(0, len(xs), 20)]
v = torch.cat(out)
return v[:, 1] - v[:, 0] # (B, H, W) polyp minus background votes
m_va, m_te = margins(balanced, px_va), margins(balanced, px_te)
grid = torch.quantile(m_va.flatten()[::7].float(), torch.linspace(0.3, 0.99, 70, device=device)).tolist()
dice_va = [polyp_scores((m_va > th).long(), y_va)["dice"] for th in grid]
dice_te = [polyp_scores((m_te > th).long(), y_te)["dice"] for th in grid]
th_va = grid[int(np.argmax(dice_va))]
print(f"threshold 0 (argmax): test Dice {polyp_scores((m_te > 0).long(), y_te)['dice']:.3f}")
print(f"threshold {th_va:7.1f} (validation): test Dice {polyp_scores((m_te > th_va).long(), y_te)['dice']:.3f}")
print(f"best threshold on test itself: test Dice {max(dice_te):.3f} (upper bound)")
plt.figure(figsize=(5.5, 3))
plt.plot(grid, dice_va, label="validation")
plt.plot(grid, dice_te, label="test")
plt.axvline(0, color="gray", lw=0.8, ls="--")
plt.xlabel("margin threshold (polyp - background votes)")
plt.ylabel("polyp Dice")
plt.legend()
plt.tight_layout()
plt.show()
In our run the validation-tuned threshold moves the test Dice from 0.451 to 0.454, and even the best threshold picked on the test set reaches only 0.457. The threshold is not the problem. The model's margin does not rank polyp pixels above background pixels much better than its argmax already shows. What limits it is the information in the features, not how its votes are turned into a label.
7. Where it fails¶
A dataset-level Dice hides how the errors are distributed. Per test image, against the size of the polyp:
balanced.eval()
pred_te = balanced.predict(px_te)
per_image = torch.tensor([polyp_scores(pred_te[i:i + 1], y_te[i:i + 1])["dice"] for i in range(len(y_te))])
size_te = y_te.float().mean((1, 2)).cpu()
plt.figure(figsize=(5.5, 3.2))
plt.scatter(size_te, per_image, s=12)
plt.xlabel("polyp area (fraction of image)")
plt.ylabel("Dice of the image")
plt.tight_layout()
plt.show()
small = size_te < size_te.median()
print(f"median per-image Dice {per_image.median():.3f}; "
f"smaller half of the polyps {per_image[small].mean():.3f}, larger half {per_image[~small].mean():.3f}")
print(f"images with Dice < 0.1: {(per_image < 0.1).sum().item()} of {len(per_image)}")
order = per_image.argsort()
picks = [int(i) for i in (order[:2].tolist() + order[len(order) // 2 - 1:len(order) // 2 + 1].tolist()
+ order[-2:].tolist())]
show(balanced, px_te, picks, "worst two, middle two, best two")
In our run Dice depends strongly on the size of the polyp: about 0.25 on average for the smaller half of the polyps and 0.52 for the larger half (median per image 0.37). 14 of 120 test images score below 0.1. The figure shows why. In the worst images a small polyp sits away from the centre and the model paints a blob of bright mucosa in the middle of the frame instead: it has learned the location prior and "pale and bright" together. The best images are the ones where those two cues happen to coincide with the polyp: a large, pale polyp in the middle of a darker frame.
8. Why was a pixel called polyp?¶
Every pixel's label is the sum of votes of clauses, and every clause is an AND of literals
about named pixels. tt.interpret.explain_pixel lists the clauses that decided one pixel. The
single-scale model reads 12 planes per pixel, which is what keeps its clauses readable, so we
ask it about the polyp pixel it is most confident about in one test image: p[k,r,c] is
"plane k at pixel (r, c) is on".
single.eval()
SCENE = int(order[-1]) # the image the models do best on
v = single.vote_map(x_te[SCENE:SCENE + 1])[0]
margin = (v[1] - v[0]).masked_fill(y_te[SCENE] == 0, -float("inf"))
ROW, COL = divmod(int(margin.argmax()), SIZE)
expl = tt.interpret.explain_pixel(single, x_te[SCENE], ROW, COL, target=int(y_te[SCENE, ROW, COL]),
max_clauses=4)
print(str(expl)[:700], "...\n") # the full rules are long: 40-60 literals each
LEVELS = (0.2, 0.4, 0.6, 0.8) # thermometer level of planes 0-3 (red), 4-7 (green), 8-11 (blue)
def describe(model, j):
"""A 3x3 clause, per colour channel: the level each of its 9 pixels must reach (>=) or stay below (<)."""
patch = model.clause_patch(j).view(3, 4, 9).cpu() # channel, level, pixel of the window
parts = []
for c, name in enumerate(("red", "green", "blue")):
lo = [max((LEVELS[k] for k in range(4) if patch[c, k, p] == 1), default=None) for p in range(9)]
hi = [min((LEVELS[k] for k in range(4) if patch[c, k, p] == -1), default=None) for p in range(9)]
terms = [f">={v} at {lo.count(v)}/9 px" for v in sorted({b for b in lo if b is not None})]
terms += [f"<{v} at {hi.count(v)}/9 px" for v in sorted({b for b in hi if b is not None})]
parts.append(f"{name}: " + (", ".join(terms) if terms else "any"))
return " | ".join(parts)
for m in expl.matched:
print(f"clause {m.clause} (weight {m.weight:.0f}) {describe(single, m.clause)}")
Planes 0–3 are red at levels ≥ 0.2, 0.4, 0.6 and 0.8; 4–7 green and 8–11 blue likewise. Read raw, the rules are walls of literals, so the second part of the output decodes each one per colour channel.
In our run all four clauses say the same thing in slightly different words: across the whole
3 × 3 window, red is at least 0.8 at every pixel, green at least 0.6 and blue at least 0.4 at
(nearly) every pixel, and no literal says "must be off". That is bright, pale pink: what the polyp in this image
looks like, and also what a brightly lit fold of healthy mucosa looks like. The explanation
names the shortcut that sections 3 and 7 found from the outside.
model.clause_map shows where single clauses fire over the whole image. Below: the two clauses
with the largest weight among those voting for polyp, on the same image.
# Clauses are owned by classes, 300 each; within a class the first 150 vote for it.
ids = torch.arange(300, 450, device=device)
top = ids[single.weights[ids].argsort(descending=True)[:2]].tolist()
fires = single.clause_map(x_te[SCENE:SCENE + 1], clauses=top)[0].cpu()
fig, axes = plt.subplots(1, 4, figsize=(10.5, 2.8))
axes[0].imshow(rgb_te[SCENE].permute(1, 2, 0).cpu())
axes[0].set_title("image")
axes[1].imshow(y_te[SCENE].cpu(), cmap="gray", interpolation="nearest")
axes[1].set_title("polyp mask")
for ax, j, m in zip(axes[2:], top, fires):
ax.imshow(m, cmap="gray", vmin=0, vmax=1, interpolation="nearest")
ax.set_title(f"clause {j} (weight {int(single.weights[j])})", fontsize=9)
for ax in axes:
ax.axis("off")
plt.tight_layout()
plt.show()
In our run the strongest polyp clause (left) fires on the bright core of the polyp and nowhere else in this image. The second (right) fires on the lit rims of the colon folds, which are healthy tissue, with almost the same weight. A Tsetlin machine's mistakes can be read off as single rules: that is the argument for using one, even when its scores are not competitive.
Takeaways¶
- Beat the location prior first. On Kvasir-SEG, "where polyps usually are" reaches a polyp Dice of about 0.43. Two of the three Tsetlin models here do not beat it, and the best one (0.45) beats it only narrowly.
- Balance the feedback for a minority class. With 15% polyp pixels, class-balanced feedback was the one change that both raised Dice and kept training from collapsing between epochs. Use a validation split and early stopping either way.
- The limit is the features, not the decision rule. A tuned threshold adds about 0.003 Dice. A per-pixel model on thermometer-coded colour learns "bright and pale", and context from an OR-pooled pyramid does not change that here.
- The explanations show the shortcut. Decoded per channel, the strongest polyp clauses are a colour range, and a clause map shows them firing on healthy, brightly lit folds. That is the most useful thing this notebook produces: a precise statement of what the model gets wrong.
- Deep networks are far ahead on this data. The Kvasir-SEG paper reports a Dice of about
0.79 for its ResUNet baseline, at a much higher resolution. Closing that gap needs features
that encode shape and texture (edges, curvature, local contrast) or a learned spatial
composition; it does not come from tuning
T,sor the number of clauses.
Further reading: notebook 07 and the
segmentation guide for the
API (patches_per_commit, ignore_index, multi-label masks), and
Segmentation for the
algorithm.
Reference. D. Jha, P. H. Smedsrud, M. A. Riegler, P. Halvorsen, T. de Lange, D. Johansen and H. D. Johansen, Kvasir-SEG: A Segmented Polyp Dataset, MultiMedia Modeling (MMM) 2020, doi:10.1007/978-3-030-37734-2_37.