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_identityonly makes sense with PAF-sourced alignments. If you passcolor_by_identity=Truewithout supplying aPafAlignmenta 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 untilrasterization_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=Trueadds 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=Falseto 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,
)