Skip to content

Dotplot Visualization Tutorial

This notebook explores all visualisation options provided by the DotPlotter class.

A dot plot (or dotplot) is a classic bioinformatics visualisation that displays all shared subsequences between two sequences. Each dot represents a shared k-mer; diagonal runs of dots indicate conserved regions. Inversions appear as anti-diagonal lines.

import os
import tempfile

import matplotlib.pyplot as plt

from dot_explorer import SequenceIndex
from dot_explorer.dotplot import DotPlotter

1. Build a test index

We create three artificial sequences with different overlap patterns:

# Helper to create a reverse complement
def revcomp(seq):
    table = str.maketrans('ACGTacgt', 'TGCAtgca')
    return seq.translate(table)[::-1]


unit = 'ACGTACGTACGT'  # 12 bp repeat unit
seq_a = unit * 10  # 120 bp  — the reference
seq_b = 'T' + unit * 9 + 'T'  # 120 bp  — shifted by 1
seq_c = revcomp(unit * 5) + unit * 5  # 120 bp  — half inverted

idx = SequenceIndex(k=8)
idx.add_sequence('reference', seq_a)
idx.add_sequence('shifted', seq_b)
idx.add_sequence('partial_inv', seq_c)

print(f'Index: {idx}')
Index: SequenceIndex(k=8, sequences=3)

2. Inline rendering in Jupyter notebooks

Both plot() and plot_single() return a matplotlib.figure.Figure. In a Jupyter notebook the returned figure is automatically displayed inline — no file path is required.

Call matplotlib.pyplot.close(fig) when you are done with the figure to free memory.

plotter = DotPlotter(idx)

# No output_path: the figure is returned and displayed inline in Jupyter
fig = plotter.plot(title='All vs All — inline display')
plt.close(fig)  # free memory when no longer needed
# Inline display for a single pair
fig = plotter.plot_single(
    query_name='reference',
    target_name='partial_inv',
    title='reference vs partial_inv — inline',
)
plt.close(fig)

3. All-vs-all dotplot (default settings)

DotPlotter.plot() without arguments produces an all-vs-all grid using all sequences in the index. Passing output_path saves the figure to disk in addition to returning it.

with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as fh:
    all_vs_all_path = fh.name

fig = plotter.plot(
    output_path=all_vs_all_path,
    title='All vs All',
)
plt.close(fig)
print(f'Saved: {all_vs_all_path}  ({os.path.getsize(all_vs_all_path)} bytes)')
Saved: /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmpf90vdgho.png  (1219303 bytes)

4. Subset: specific query and target sets

Pass query_names and target_names to restrict the grid to a subset of sequences.

with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as fh:
    subset_path = fh.name

plotter.plot(
    query_names=['reference', 'shifted'],
    target_names=['partial_inv'],
    output_path=subset_path,
    title='Reference & Shifted vs Partial Inversion',
)
print(f'Subset plot saved: {subset_path}')
Subset plot saved: /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmpugi13g_r.png

5. Single-pair dotplot

plot_single renders one comparison panel with its own figure size and title.

with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as fh:
    single_path = fh.name

plotter.plot_single(
    query_name='reference',
    target_name='shifted',
    output_path=single_path,
    figsize=(5, 5),
    title='reference vs shifted',
)
print(f'Single-pair plot saved: {single_path}')
Single-pair plot saved: /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmp4_m8i50u.png

6. Customising dot appearance

All plotting methods accept dot_size, cap_style and dot_color to control the appearance of match lines.

cap_style sets the shape of each segment's ends — 'projecting' (square, the default), 'round' or 'butt' (flat). Square and round caps extend the stroke past each endpoint by half the line width, so a match shorter than dot_size still reads as a mark on its own diagonal; with flat caps such a match is drawn wider across the diagonal than along it and looks rotated 90°.

with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as fh:
    styled_path = fh.name

plotter.plot(
    output_path=styled_path,
    dot_size=1.5,
    cap_style='round',
    dot_color='crimson',
    dpi=200,
    title='Custom style: crimson, round caps, dpi=200',
)
print(f'Styled plot saved: {styled_path}')
Styled plot saved: /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmpu2iyd0xx.png

7. Controlling merge behaviour

When merge=True (default), consecutive co-linear k-mer hits are merged into single lines. Set merge=False to display every individual k-mer hit as its own point — useful for inspecting raw k-mer density.

with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as fh:
    unmerged_path = fh.name

plotter.plot_single(
    query_name='reference',
    target_name='shifted',
    output_path=unmerged_path,
    merge=False,
    title='reference vs shifted (unmerged k-mer hits)',
)
print(f'Unmerged plot saved: {unmerged_path}')
Unmerged plot saved: /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmpa7u82i2y.png

8. Output resolution

Use the dpi parameter to control the resolution of the saved image. Higher DPI is better for print-quality figures.

for dpi in [72, 150, 300]:
    with tempfile.NamedTemporaryFile(suffix=f'_dpi{dpi}.png', delete=False) as fh:
        path = fh.name
    plotter.plot_single(
        'reference',
        'shifted',
        output_path=path,
        dpi=dpi,
        title=f'DPI = {dpi}',
    )
    size_kb = os.path.getsize(path) / 1024
    print(f'DPI={dpi:4d}  file size={size_kb:.1f} kB  path={path}')
DPI=  72  file size=94.5 kB  path=/var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmpxff484pi_dpi72.png
DPI= 150  file size=239.3 kB  path=/var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmpz_luld5c_dpi150.png


DPI= 300  file size=762.9 kB  path=/var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmpljyjnk3p_dpi300.png

9. Panel size control

For all-vs-all grids, figsize_per_panel controls the size (in inches) of each subplot panel.

with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as fh:
    large_path = fh.name

plotter.plot(
    output_path=large_path,
    figsize_per_panel=6.0,  # each panel is 6×6 inches
    title='Large panels (6 inches each)',
)
print(f'Large-panel plot saved: {large_path}')
Large-panel plot saved: /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmpny42extt.png

10. Saving to different file formats

dot-explorer passes the format argument directly to matplotlib.savefig, so you can produce PNG, SVG, PDF, or any other matplotlib-supported format.

The simplest approach is to use the matching file extension — matplotlib infers the format automatically. You can also pass format='svg' (or 'pdf', 'png', …) explicitly to override the extension.

Extension / format= Notes
.png / 'png' Raster; good default for screen and web
.svg / 'svg' Vector; infinitely scalable, ideal for publications
.pdf / 'pdf' Vector; embeds cleanly into LaTeX and Word documents

Rendering, resolution and file size. By default (rasterized='auto') the match layer is drawn as true vector — infinitely zoomable in SVG/PDF — until a panel exceeds rasterization_threshold segments, above which just that layer is rasterised at dpi to keep the file small (axes, ticks and labels always stay vector). Force the choice with rasterized=True/False. For dense, genome-scale plots, enable co-linear chaining with chain_gap=<bp> to merge broken diagonals into a few long lines — far fewer segments means faster rendering, smaller files, and true-vector output even at scale.

# PNG (default raster format)
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as fh:
    png_path = fh.name

fig = plotter.plot(output_path=png_path, title='PNG output')
plt.close(fig)
print(f'PNG:  {png_path}  ({os.path.getsize(png_path)} bytes)')

# SVG via file extension
with tempfile.NamedTemporaryFile(suffix='.svg', delete=False) as fh:
    svg_ext_path = fh.name

fig = plotter.plot(output_path=svg_ext_path, title='SVG via extension')
plt.close(fig)
print(f'SVG (ext):     {svg_ext_path}  ({os.path.getsize(svg_ext_path)} bytes)')

# SVG via explicit format parameter (output path need not end in .svg)
with tempfile.NamedTemporaryFile(suffix='.out', delete=False) as fh:
    svg_fmt_path = fh.name

fig = plotter.plot(output_path=svg_fmt_path, format='svg', title='SVG via format param')
plt.close(fig)
print(f'SVG (fmt):     {svg_fmt_path}  ({os.path.getsize(svg_fmt_path)} bytes)')

# PDF — vector format suitable for LaTeX / Word
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as fh:
    pdf_path = fh.name

fig = plotter.plot(output_path=pdf_path, title='PDF output')
plt.close(fig)
print(f'PDF:  {pdf_path}  ({os.path.getsize(pdf_path)} bytes)')

# Verify SVG header
with open(svg_ext_path) as f:
    snippet = f.read(80)
print(f'\nSVG file header: {snippet!r}')
PNG:  /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmpo__ki677.png  (1220388 bytes)


SVG (ext):     /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmprdqtstws.svg  (4875427 bytes)


SVG (fmt):     /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmpjwa_zoht.out  (4875998 bytes)


PDF:  /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmp5gffulip.pdf  (219407 bytes)

SVG file header: '<?xml version="1.0" encoding="utf-8" standalone="no"?>\n<!DOCTYPE svg PUBLIC "-//'

11. Minimum alignment length filter

Pass min_length to suppress alignments shorter than a given number of base pairs. This applies to merged k-mer runs (which may be longer than the original k-mer size after merging) and to any pre-computed PAF alignments that are loaded later.

The filter is applied per match segment; only the length of the query span is checked: query_end - query_start >= min_length.

# Without filtering: all merged hits are drawn
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as fh:
    unfiltered_path = fh.name
plotter.plot_single(
    'reference', 'shifted', output_path=unfiltered_path, title='No min_length filter'
)

# With filtering: only hits of at least 24 bp are drawn
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as fh:
    filtered_path = fh.name
plotter.plot_single(
    'reference',
    'shifted',
    output_path=filtered_path,
    min_length=24,
    title='min_length=24',
)

print(f'Unfiltered: {unfiltered_path}  ({os.path.getsize(unfiltered_path)} bytes)')
print(f'Filtered:   {filtered_path}  ({os.path.getsize(filtered_path)} bytes)')
Unfiltered: /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmphs6tqwws.png  (255579 bytes)
Filtered:   /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmptkp13px0.png  (225927 bytes)

12. Colour alignments by identity (PAF alignments)

When alignments are loaded from a PAF file (e.g. produced by minimap2), each record carries a residue_matches count and an alignment_block_len that together define sequence identity. Pass color_by_identity=True to plot() or plot_single() to render each alignment segment with a colour drawn from the chosen Matplotlib colormap (identity_palette, default 'viridis').

Note: Individual k-mer matches are always 100 % identical (exact matches), so color_by_identity only makes sense with PAF-sourced alignments. If you pass color_by_identity=True without supplying a PafAlignment a warning is logged and the plot falls back to the default strand colours.

Use DotPlotter.plot_identity_colorbar() to generate a standalone colorbar figure for the identity scale.

import random

from dot_explorer.paf_io import PafAlignment, PafRecord

# Build synthetic PAF records with varying identity values to illustrate
# the colour-by-identity feature without needing a real aligner.
random.seed(0)
paf_records = []
for i in range(8):
    block_len = random.randint(10, 30)
    identity = 0.6 + 0.04 * i  # 60 % … 88 %
    residue_matches = round(block_len * identity)
    q_start = i * 12
    paf_records.append(
        PafRecord(
            query_name='reference',
            query_len=144,
            query_start=q_start,
            query_end=q_start + block_len,
            strand='+',
            target_name='shifted',
            target_len=144,
            target_start=q_start,
            target_end=q_start + block_len,
            residue_matches=residue_matches,
            alignment_block_len=block_len,
            mapping_quality=255,
        )
    )

paf_aln = PafAlignment(paf_records)
print(f'{len(paf_aln)} records loaded')

# Create a DotPlotter with the PAF alignment attached
identity_plotter = DotPlotter(idx, paf_alignment=paf_aln)

# ── Plot coloured by identity ─────────────────────────────────────────────
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as fh:
    identity_path = fh.name

fig = identity_plotter.plot_single(
    'reference',
    'shifted',
    output_path=identity_path,
    color_by_identity=True,
    identity_palette='viridis',
    title='Coloured by identity (viridis)',
)
plt.close(fig)
print(f'Identity plot: {identity_path}  ({os.path.getsize(identity_path)} bytes)')

# ── Colorbar (standalone scale figure) ───────────────────────────────────
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as fh:
    colorbar_path = fh.name

fig_cb = identity_plotter.plot_identity_colorbar(
    palette='viridis',
    output_path=colorbar_path,
)
plt.close(fig_cb)
print(f'Colorbar:      {colorbar_path}  ({os.path.getsize(colorbar_path)} bytes)')

# ── Try a different palette ───────────────────────────────────────────────
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as fh:
    plasma_path = fh.name

fig_p = identity_plotter.plot_single(
    'reference',
    'shifted',
    output_path=plasma_path,
    color_by_identity=True,
    identity_palette='plasma',
    title='Coloured by identity (plasma)',
)
plt.close(fig_p)
print(f'Plasma plot:   {plasma_path}  ({os.path.getsize(plasma_path)} bytes)')
8 records loaded


Identity plot: /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmp2gcl2q1d.png  (31977 bytes)
Colorbar:      /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmpbnupzape.png  (8181 bytes)
Plasma plot:   /var/folders/ht/f4psx_9j31934bxvs87wqk1h0000gn/T/tmp8sqfbsxl.png  (31466 bytes)

13. High-resolution vector output (rasterized)

Dotplot matches are drawn as vector line segments. By default (rasterized='auto') the match layer is written as true vector paths — infinitely zoomable in SVG/PDF — until a panel exceeds rasterization_threshold segments (default 50_000), above which only that match layer is rasterised at dpi to keep the file small. Axes, ticks and labels always remain vector.

  • rasterized=False — always true vector (best zoom; larger files when dense).
  • rasterized=True — always rasterise the match layer (smallest, fixed resolution).
  • rasterized='auto' — vector until rasterization_threshold, then rasterise.

To make the differences visible we build a larger, divergent pair: a reference and a 3%-mutated copy. The point mutations break the main diagonal into many short exact-match blocks, giving a segment-rich panel.

import random

# Reference and a 3%-divergent copy.  SNPs break the diagonal into many short
# exact-match k-mer blocks, producing a segment-rich panel.
random.seed(0)
bases = 'ACGT'
ref = ''.join(random.choice(bases) for _ in range(8000))
mut = ''.join(random.choice(bases) if random.random() < 0.03 else b for b in ref)

div_idx = SequenceIndex(k=15)
div_idx.add_sequence('ref', ref)
div_idx.add_sequence('mut', mut)
div_plotter = DotPlotter(div_idx)


def count_segments(fig):
    """Count drawn match segments across all panels of a figure."""
    from matplotlib.collections import LineCollection

    return sum(
        len(c.get_segments())
        for ax in fig.axes
        for c in ax.collections
        if isinstance(c, LineCollection)
    )


def svg_is_vector(path):
    """True when the SVG has no embedded raster <image> (match layer is vector)."""
    with open(path) as fh:
        return '<image' not in fh.read()


# Same data, three rasterization policies (plus an 'auto' with a low threshold
# to show the automatic switch to a rasterised layer).
cases = [
    ('auto (default)', {}),
    ('force vector', {'rasterized': False}),
    ('force raster', {'rasterized': True}),
    ('auto, thresh=100', {'rasterization_threshold': 100}),
]
for label, kwargs in cases:
    with tempfile.NamedTemporaryFile(suffix='.svg', delete=False) as fh:
        path = fh.name
    fig = div_plotter.plot_single('ref', 'mut', output_path=path, **kwargs)
    n = count_segments(fig)
    plt.close(fig)
    kb = os.path.getsize(path) / 1024
    kind = 'vector' if svg_is_vector(path) else 'raster'
    print(f'{label:18s}: {n:6d} segments  {kb:8.1f} kB  {kind}')
auto (default)    :    138 segments      44.9 kB  vector


force vector      :    138 segments      44.9 kB  vector


force raster      :    138 segments      39.1 kB  raster
auto, thresh=100  :    138 segments      39.1 kB  raster

14. Chaining co-linear matches (chain_gap)

Exact k-mer matching breaks a conserved diagonal wherever a SNP or indel occurs, producing many short segments. chain_gap (in bp) joins blocks that lie on the same diagonal and are separated by no more than that many bases into a single line. Larger gaps merge more aggressively, giving fewer segments, smaller files and faster rendering — and make true-vector output practical even for dense, genome-scale plots.

Below, the same divergent pair is rendered as vector SVG across a range of gap lengths. Note how the segment count and file size fall as chain_gap grows.

# Sweep chain_gap; keep rasterized=False so file size tracks the segment count.
for gap in [0, 25, 100, 1000]:
    with tempfile.NamedTemporaryFile(suffix='.svg', delete=False) as fh:
        path = fh.name
    fig = div_plotter.plot_single(
        'ref',
        'mut',
        output_path=path,
        chain_gap=gap,
        rasterized=False,
        title=f'chain_gap = {gap}',
    )
    n = count_segments(fig)
    plt.close(fig)
    kb = os.path.getsize(path) / 1024
    print(f'chain_gap={gap:5d}: {n:6d} segments  {kb:8.1f} kB (vector)')
chain_gap=    0:    138 segments      47.8 kB (vector)


chain_gap=   25:      3 segments      25.2 kB (vector)


chain_gap=  100:      1 segments      24.9 kB (vector)


chain_gap= 1000:      1 segments      25.0 kB (vector)

Summary of DotPlotter parameters

Parameter Default Description
query_names None List of query sequence names (rows); None = all
target_names None List of target sequence names (columns); None = all
output_path None Output file path; None = no file written (inline display only)
format None Output format (e.g. 'svg', 'png', 'pdf'); inferred from extension when None
figsize_per_panel 4.0 Inches per subplot panel (all-vs-all only)
figsize (6, 6) Total figure size for plot_single
dot_size 0.5 Line/marker size for each match
cap_style 'projecting' Line cap for match segments: 'butt', 'round' or 'projecting' (square). Square/round keep sub-linewidth matches on their diagonal
dot_color "blue" Colour of forward-strand match lines
rc_color "red" Colour of reverse-complement match lines
merge True Merge co-linear k-mer runs into blocks
min_length 0 Minimum alignment length to display; 0 = show all
title None Figure title
dpi 150 Output image resolution
chain_gap 0 Chain co-linear matches within this many bp into single lines; 0 = off
rasterized 'auto' Rasterise the match layer: 'auto' (vector until threshold), True, or False
rasterization_threshold 50_000 Segment count above which 'auto' rasterises the match layer
color_by_identity False Colour alignments by identity fraction when a PafAlignment is loaded
identity_palette 'viridis' Matplotlib colormap for identity colouring (any valid colormap name)
query_group / target_group None CrossIndex group labels: look up each axis's sequences from a group (overrides query_names/target_names)
scale_sequences True Scale each panel's width/height by relative sequence length
contig_order None Plot-time ordering: 'length' or 'colinearity'
auto_reverse False With contig_order, flip contigs detected as reverse-oriented
reverse_contigs None Explicit set of query contigs to render reverse-complemented
hide_internal_axes False Remove internal ticks/spines so the grid reads as one plot
identity_colorbar False With color_by_identity, append a 0–100 % identity colour key
annotation None GffAnnotation shaded on self-vs-self diagonal panels
annotation_query / annotation_target None Per-axis annotations for the side tracks
annotation_tracks False Draw side annotation tracks (single-pair plots only)
annotation_track_size 0.6 Side-track thickness in inches
annotation_legend True Add a feature-type colour legend when annotations are drawn

Both plot() and plot_single() return a matplotlib.figure.Figure. In a Jupyter notebook the figure is displayed inline automatically. Call matplotlib.pyplot.close(fig) to release memory when finished. DotPlotter.plot_annotation_legend() renders the feature-type colour legend as a standalone figure.

GFF annotation overlays

Load a GFF3 annotation with GffAnnotation (from a file, text, or raw bytes — gzip is detected automatically) and pass it to plot():

  • annotation= shades features as transparent squares on self-vs-self diagonal panels, drawn behind the alignments;
  • annotation_tracks=True adds side tracks on focused single-pair plots, with strand arrows for gene/mRNA/exon/CDS/ORF features, lane stacking for overlaps, and connector lines through multi-part CDS groups;
  • a feature-type colour legend is added automatically (annotation_legend=False to disable).

HTML reports (to_html) make the diagonal features clickable, showing each feature's name, type, coordinates, strand and parent.

# Build a small demo index and a GFF3 annotation.
import random

from dot_explorer import DotPlotter, SequenceIndex
from dot_explorer.annotation import GffAnnotation

random.seed(11)
seq = "".join(random.choice("ACGT") for _ in range(4000))
ann_idx = SequenceIndex(k=13)
ann_idx.add_sequence("chrA", seq)
ann_idx.add_sequence("chrB", seq[2000:] + seq[:2000])

gff_text = """\
chrA\tdemo\tgene\t201\t1400\t.\t+\t.\tID=gene1;Name=GeneA
chrA\tdemo\tCDS\t201\t600\t.\t+\t0\tID=cds1;Parent=gene1
chrA\tdemo\tCDS\t901\t1400\t.\t+\t0\tID=cds1;Parent=gene1
chrA\tdemo\tgene\t1201\t2400\t.\t-\t.\tID=gene2;Name=GeneB
chrA\tdemo\trepeat_region\t2801\t3600\t.\t.\t.\tID=rep1
chrB\tdemo\tgene\t501\t1800\t.\t-\t.\tID=gene3;Name=GeneC
"""
ann = GffAnnotation.from_text(gff_text)
ann_plotter = DotPlotter(ann_idx)

# Diagonal squares: features shade self-vs-self panels *behind* the
# alignments (one colour per feature type).
fig = ann_plotter.plot(annotation=ann, title="Annotated all-vs-all grid")
# Focused single-pair view with side annotation tracks: lane-packed
# features left of the y axis and below the x axis, direction arrows for
# stranded types and connectors joining multi-part CDS groups.
fig = ann_plotter.plot(
    query_names=["chrB"],
    target_names=["chrA"],
    annotation_query=ann,
    annotation_target=ann,
    annotation_tracks=True,
)