Atlas and Map Series Automation
An atlas is not a folder of maps. It is a single publication whose sheets have to agree with one another: a town must be labelled identically wherever it appears, a class break must mean the same value on every page, adjacent sheets must overlap enough that nothing falls between them, and the page numbers printed on each sheet must match the index. Every one of those is a cross-sheet invariant, and every one of them is invisible to a per-sheet quality check, because each sheet is internally correct.
This page covers generating that publication deterministically: deriving the page grid, freezing the decisions that span boundaries, rendering sheets independently without losing the run to a single failure, and assembling the index and neighbour references that make the volume navigable.
Prerequisites and Environment Configuration
python==3.11
geopandas==0.14.4
shapely==2.0.4
pyproj==3.6.1
celery==5.4.0
pypdf==4.2.0
Three inputs define an atlas, and all three belong in a specification file rather than in code:
- The coverage geometry. The area the atlas must cover, as a polygon or multipolygon in the atlas CRS. This is not the same as the bounding box of the data: an atlas of a coastal county covers the county, not the rectangle containing it, and generating sheets for the empty sea quarter of that rectangle wastes a quarter of the print run.
- The sheet ground size. The extent one page covers, which follows from the page frame size and the atlas scale. The frame size in turn comes from the layout computation described in Map Layout and Composition Automation, so an atlas whose legend grows acquires a smaller frame and therefore a different page grid.
- The overlap. How far adjacent sheets extend into each other’s territory, in ground units. This has a floor set by label geometry, discussed below, and it must be recorded rather than chosen per run.
The CRS must be projected and metric. An atlas grid computed in degrees produces sheets that narrow toward the poles, so the northern sheets of a national atlas cover less ground than the southern ones at the same stated scale.
Conceptual Foundation: Which Decisions Are Per-Sheet and Which Are Not
The whole difficulty of atlas generation is in this partition. Some decisions genuinely belong to a sheet, and some belong to the publication but get made per sheet by accident.
Per-sheet by nature. The extent, the inset position, the neighbour references and the page number are properties of one page and can be computed independently.
Publication-wide, and frequently computed per sheet by mistake. Class breaks, label priority cut-offs, symbol size scales, legend contents and the colour ramp are all properties of the atlas. Computing any of them from the features visible on one sheet produces a volume whose pages cannot be compared with each other — the same value shades differently on two pages, the same town is labelled at two sizes, the legend gains and loses entries as the reader turns pages.
The fix is architectural rather than incremental: resolve the publication-wide decisions once, over the whole coverage, serialise them, and have every sheet render from that frozen artefact. A sheet renderer that has no access to the underlying data — only to the decisions — cannot make a publication-wide decision by accident.
Step-by-Step Implementation
Step 1: Derive the page grid
from shapely.geometry import box
from shapely.ops import unary_union
def page_grid(coverage, sheet_w: float, sheet_h: float, overlap: float):
"""Tile a coverage polygon into overlapping sheet extents.
coverage : shapely geometry in a projected, metric CRS
sheet_w : ground width one sheet covers, in CRS units
overlap : ground units each sheet extends into its neighbours
"""
if overlap * 2 >= min(sheet_w, sheet_h):
raise ValueError("overlap consumes the sheet; check the units")
step_x, step_y = sheet_w - overlap, sheet_h - overlap
minx, miny, maxx, maxy = coverage.bounds
cells, y = [], miny
while y < maxy:
x = minx
while x < maxx:
cell = box(x, y, x + sheet_w, y + sheet_h)
if cell.intersects(coverage): # skip empty sea and margin
cells.append(cell)
x += step_x
y += step_y
return cells
The intersects test is what keeps the volume from containing forty blank pages of open water. It is also the reason the coverage must be the real area of interest rather than its bounding box.
Step 2: Derive the overlap from label geometry
The overlap is not a stylistic choice. Its floor is set by the largest label the atlas will place: a feature whose label extends beyond the sheet edge must be fully readable on the neighbour, or it is readable nowhere.
def minimum_overlap(max_label_mm: float, scale_denominator: float,
safety: float = 1.5) -> float:
"""Ground-unit overlap that guarantees the longest label fits on a neighbour."""
label_ground = (max_label_mm / 1000.0) * scale_denominator
return label_ground * safety
Measure max_label_mm from the rendered text extents across the whole coverage, using the same font metrics the renderer will use — the measurement approach set out in Typography Rules for Maps. A guessed value of two or three millimetres is almost always too small, because it was chosen by thinking about the map rather than about the longest place name in the dataset.
Step 3: Number the sheets stably
def number_sheets(cells) -> list:
"""Assign page numbers in a documented, reproducible traversal."""
# North to south, then west to east within each row: the convention most
# printed atlases use, and — more importantly — a total order.
ordered = sorted(cells, key=lambda c: (-round(c.bounds[3], 3),
round(c.bounds[0], 3)))
return [{"page": i + 1, "extent": c} for i, c in enumerate(ordered)]
Rounding the sort keys matters. Floating-point coordinates that differ in the fifteenth decimal place will sort inconsistently between platforms, and a re-run that renumbers pages invalidates every cross-reference in an already printed volume.
Step 4: Render sheets without losing the run
from dataclasses import dataclass, field
@dataclass
class RunReport:
rendered: list = field(default_factory=list)
failed: list = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.failed
def render_atlas(sheets, decisions, render_sheet) -> RunReport:
"""Render every sheet, recording failures instead of aborting."""
report = RunReport()
for sheet in sheets:
try:
path = render_sheet(sheet, decisions)
except Exception as exc: # noqa: BLE001 — recorded, not swallowed
report.failed.append({"page": sheet["page"], "error": repr(exc)})
else:
report.rendered.append({"page": sheet["page"], "path": path})
return report
The broad exception clause is deliberate and is the opposite of swallowing an error: every failure is recorded with its page number and re-raised into the report, and publication is gated on report.ok. What it prevents is a legend overflow on page 217 discarding 216 completed renders — a real cost, since those renders are the expensive part.
Performance Optimization Patterns
Resolve once, render many. The decision-freezing pass reads the whole coverage and is the only stage that must. Every per-sheet render then reads a small serialised artefact, which makes the sheets genuinely independent and therefore trivially parallel — the fan-out pattern in Batch Queue Orchestration.
Route sheets by estimated cost. Sheet render times in an atlas vary by an order of magnitude between a dense city page and an open moorland page. Estimating cost from feature count and routing to separate queues stops two city sheets from blocking thirty rural ones.
Cache the shared layout. Every sheet shares a page size and a legend, so the entire box computation is identical across the run. Compute it once and pass it in.
Assemble the PDF incrementally. Concatenating four hundred single-page PDFs at the end holds every page in memory at once. Append as each sheet completes, or assemble in groups and merge the groups.
Common Pitfalls and Debugging
A feature falls between two sheets. The overlap is smaller than the feature’s label plus leader. Recompute it from the measured maximum label extent and regenerate the grid; this changes the page count, so it must be settled before anything is printed.
Page numbers change between runs. The sort key was not total, or was not rounded, so ties resolved differently. Make the traversal a total order over rounded coordinates.
The legend gains and loses entries across pages. The legend was built from features present on each sheet. Build it from the union of classes across the coverage, as argued in Dynamic Legend Generation.
Sheet 1 is correct and later sheets inherit stale state. A renderer that mutates shared layout or style objects between iterations. Snapshot the state before the loop and restore it at the top of each iteration.
The run aborts on one bad sheet. Convert the failure into a report entry. The atlas is not publishable, but 399 completed renders are worth keeping while the one is fixed.
Frequently Asked Questions
How much overlap should adjacent atlas sheets have?
Enough that any feature crossing a boundary is fully readable on at least one sheet, which in practice means the overlap must exceed the longest label plus its leader line. Measured against real place names that is usually between five and ten millimetres of page — noticeably more than the two or three millimetres that feels sufficient when looking at a single sheet. Derive the value from the maximum rendered label extent across the whole coverage rather than choosing it, record it in the atlas specification, and recompute it whenever the type size changes, because a one-point increase in the label scale can push the longest name past the current overlap on a handful of sheets.
Why do the same towns get different label sizes on adjacent sheets?
Because each sheet ran its own priority cut-off against only the features inside its own extent. A market town that is the largest place on a rural sheet and the fourth largest on the neighbouring urban sheet is assigned two different tiers, and a reader turning the page sees it change weight. The fix is to resolve label decisions against the whole coverage before any sheet renders and freeze them, so the tier becomes a property of the feature rather than of the page it happens to appear on. No per-sheet validation catches this, because both sheets are internally consistent.
Should page numbers follow the grid or the reading order?
The grid, in a documented traversal — conventionally north to south by row and west to east within each row. What matters far more than the specific convention is that the numbering is reproducible: a re-run over the same coverage must yield the same numbers, or every cross-reference printed in the volume and every neighbour stamp becomes wrong. Derive numbers from rounded grid geometry rather than from the order in which sheets finished rendering, which in a parallel run is not deterministic at all.
What should happen when one sheet in a 400-sheet run fails?
Record it and keep going. The most common causes — a legend that will not fit, a label with no valid placement, a missing basemap tile — are local to one page, and aborting discards every completed render. Collect failures into a report with page numbers and error detail, gate publication on that report being empty, and re-run only the failed pages once each cause is addressed. Treating the run’s exit code as the quality signal is what turns a two-page problem into a full re-render.
Conclusion
Atlas automation is mostly about deciding what belongs to the publication and what belongs to a page, then making it structurally impossible to confuse the two. Freeze the publication-wide decisions before any sheet renders, derive the overlap from measured label geometry rather than from taste, number the sheets by a total order over rounded coordinates, and let a failing page fail alone. What remains — the per-sheet extent, inset and neighbour stamps — is genuinely local and can be computed independently and in parallel.
Related
- Generating Atlas Page Grids with Guaranteed Overlap — deriving the grid and the overlap floor from label geometry.
- Building a Map Series Index Sheet with Page Numbers — the locator diagram and neighbour stamps.
- Debugging Inconsistent Extents Across Atlas Pages — state leaking between iterations of the sheet loop.
- Batch Queue Orchestration — the fan-out that renders the pages once the decisions are frozen.