Dev

Build a Seamless AI-Styled Isometric Map from 441 Tiles: Spiral Generation, Anchor References, and Python Stitching

Back to Tutorials

Build a Seamless AI-Styled Isometric Map from 441 Tiles

Introduction: What is this and why use it?

Generating one beautiful AI image is easy. Generating 441 separate images that look like they belong to the same world is much harder.

That is the core challenge behind an AI-styled isometric map of London made from a 21 by 21 grid of aerial tiles. Each tile starts as a real overhead map image, gets transformed by an image model into a consistent isometric art style, and is then stitched into one large pannable canvas.

The hard part is not the prompt. The hard part is continuity.

If you generate every tile independently, the model will reinterpret the style each time. One tile may have sharp toy-like buildings, the next may have painterly rooftops, another may shift the road color, and the seams will jump out immediately. Even if each tile looks good alone, the final map looks broken.

The practical fix is to treat image generation as a spatial dependency problem:

  • Start from a central anchor tile.
  • Generate outward in a spiral.
  • For every new tile, pass already-finished neighboring tiles as visual references.
  • Always include the same fixed anchor tile as a global style reference.
  • Blend or crop overlap margins before stitching.

This tutorial walks through a developer-friendly implementation. The examples use Python, Pillow, and a generic image-editing API pattern because providers differ in exact SDK syntax. You can adapt the generation call to OpenAI image editing, Stability AI, Replicate-hosted models, ComfyUI workflows, or another image model that supports image-to-image/reference inputs.

The important concept is model-agnostic: local neighbors preserve edges, and the fixed anchor preserves global style.

Prerequisites

You should be comfortable with basic Python and command-line workflows.

Install the core dependencies:

python -m venv .venv
source .venv/bin/activate
pip install pillow numpy tqdm requests python-dotenv

Optional but useful:

pip install mercantile rasterio shapely

You will also need:

  • A folder of source aerial tiles, such as source_tiles/x_y.png.
  • Permission to use the underlying imagery. Google aerial imagery has licensing restrictions, so use properly licensed data for production work.
  • An image model or workflow that accepts an input image and one or more reference images.
  • Enough disk space. A 21 by 21 grid at 1024 px per tile becomes large quickly.

For this tutorial, we will assume this file layout:

project/
  source_tiles/
    0_0.png
    0_1.png
    ...
  generated_tiles/
  stitched/
  scripts/

The coordinate convention is simple:

  • x increases to the right.
  • y increases downward.
  • The center tile is (10, 10) for a 21 by 21 grid.

Step-by-Step Guide

1. Define the grid and spiral generation order

The generation order matters. If you generate randomly, most tiles will not have completed neighbors. If you generate row by row, horizontal continuity may be decent, but vertical style drift can still accumulate.

A spiral order gives each new tile the best chance of having nearby completed references while keeping the style anchored to the center.

Create scripts/spiral.py:

from typing import Iterator, Tuple

Coord = Tuple[int, int]


def spiral_order(size: int) -> Iterator[Coord]:
    if size % 2 == 0:
        raise ValueError("size must be odd so there is a single center tile")

    center = size // 2
    x = y = center
    yield x, y

    step = 1
    while step < size:
        # move right, then down, then left, then up
        for dx, dy, count in [
            (1, 0, step),
            (0, 1, step),
            (-1, 0, step + 1),
            (0, -1, step + 1),
        ]:
            for _ in range(count):
                x += dx
                y += dy
                if 0 <= x < size and 0 <= y < size:
                    yield x, y
        step += 2

Quick check:

from spiral import spiral_order
print(list(spiral_order(5)))

You should see the center first, then tiles expanding outward.

2. Create a style anchor tile

Pick the most visually representative central source tile. Ideally it contains roads, buildings, parks, water, and dense urban structure. The model will use this as the unchanging reference for the entire run.

Generate this tile manually first. Spend time on it. The anchor determines the visual language of the whole map.

Example prompt:

Transform this aerial map tile into a clean detailed isometric miniature city map. Preserve the real street layout, blocks, parks, river edges, and building footprint positions. Use a consistent 3/4 isometric angle, crisp architectural detail, soft daylight, realistic shadows, muted but readable colors, and no labels. Do not invent major landmarks outside the source geometry. Keep roads connected exactly at the tile edges.

Negative prompt if your tool supports it:

No text labels, no fantasy buildings, no floating objects, no border, no frame, no vignette, no dramatic perspective shift, no inconsistent road colors, no cropped edge decoration.

Save the result as:

generated_tiles/10_10.png

This anchor never changes. Every later generation call receives it as a reference image.

3. Find completed neighbors for each tile

For a tile (x, y), useful neighbors are the already-generated tiles touching its sides and corners. Side neighbors matter most because seams usually appear along shared edges.

Create scripts/neighbors.py:

from pathlib import Path
from typing import List, Tuple

Coord = Tuple[int, int]

NEIGHBOR_OFFSETS = [
    (0, -1),  # north
    (1, 0),   # east
    (0, 1),   # south
    (-1, 0),  # west
    (1, -1),
    (1, 1),
    (-1, 1),
    (-1, -1),
]


def completed_neighbors(x: int, y: int, generated_dir: Path) -> List[Path]:
    refs = []
    for dx, dy in NEIGHBOR_OFFSETS:
        p = generated_dir / f"{x + dx}_{y + dy}.png"
        if p.exists():
            refs.append(p)
    return refs

In practice, limit references if your image API has upload limits. Use the four side neighbors first, then diagonals if there is room.

4. Add edge strips as stronger seam references

Passing full neighboring images helps with style, but seams need edge-specific information. A useful trick is to crop strips from neighboring tiles and pass them as reference images too.

For example, if you are generating tile (x, y) and the west neighbor (x-1, y) exists, crop the right 128 pixels of that west tile. The new tile should make its left edge compatible with that strip.

Create scripts/edge_refs.py:

from pathlib import Path
from PIL import Image


def crop_edge_reference(tile_path: Path, side: str, out_path: Path, width: int = 128) -> Path:
    img = Image.open(tile_path).convert("RGB")
    w, h = img.size

    boxes = {
        "left": (0, 0, width, h),
        "right": (w - width, 0, w, h),
        "top": (0, 0, w, width),
        "bottom": (0, h - width, w, h),
    }

    crop = img.crop(boxes[side])
    out_path.parent.mkdir(parents=True, exist_ok=True)
    crop.save(out_path)
    return out_path

When generating the new tile, include these edge crops with names like west_neighbor_right_edge.png or north_neighbor_bottom_edge.png. Some models respond better when the prompt explicitly describes what each reference means.

5. Write the generation prompt template

The prompt should be strict about geometry and edges. Do not over-describe decorative style for every tile. The anchor image already carries that. Your tile prompt should focus on transformation rules and continuity.

Use a template like this:

Restyle the source aerial tile into the same isometric city-map style as the fixed anchor reference.

Critical requirements:
- Preserve the source tile's street layout, river boundaries, parks, blocks, and building footprint positions.
- Match the anchor reference's camera angle, lighting, line weight, roof detail, road color, vegetation treatment, and shadow direction.
- Match all provided neighboring references at shared edges.
- Roads, rivers, parks, and building rows must continue cleanly across tile borders.
- Do not add text labels, map pins, legends, borders, frames, or decorative margins.
- Keep the output square and aligned to the source tile.

This tile coordinate is ({x}, {y}) in a larger map. It must visually connect to already completed adjacent tiles.

Notice what is missing: we are not asking for a fresh masterpiece each time. We are asking for disciplined visual translation.

6. Implement the generation loop

The exact API call depends on your image provider. The wrapper below is intentionally shaped so you can plug in your chosen model.

Create scripts/generate_map.py:

from pathlib import Path
from tqdm import tqdm

from spiral import spiral_order
from neighbors import completed_neighbors

SIZE = 21
CENTER = SIZE // 2
SOURCE_DIR = Path("source_tiles")
GENERATED_DIR = Path("generated_tiles")
ANCHOR_PATH = GENERATED_DIR / f"{CENTER}_{CENTER}.png"


def build_prompt(x: int, y: int) -> str:
    return f"""
Restyle the source aerial tile into the same isometric city-map style as the fixed anchor reference.

Critical requirements:
- Preserve the source tile's street layout, river boundaries, parks, blocks, and building footprint positions.
- Match the anchor reference's camera angle, lighting, line weight, roof detail, road color, vegetation treatment, and shadow direction.
- Match all provided neighboring references at shared edges.
- Roads, rivers, parks, and building rows must continue cleanly across tile borders.
- Do not add text labels, map pins, legends, borders, frames, or decorative margins.
- Keep the output square and aligned to the source tile.

This tile coordinate is ({x}, {y}) in a larger map. It must visually connect to already completed adjacent tiles.
""".strip()


def generate_tile_with_references(source_tile: Path, reference_images: list[Path], prompt: str, output_path: Path) -> None:
    """
    Replace this function with your image provider call.

    The provider should receive:
    - the source aerial tile as the main image-to-image input
    - the fixed anchor tile as a style reference
    - completed neighboring tiles as local continuity references
    - the prompt above
    """
    raise NotImplementedError("Connect this to your image model or ComfyUI workflow")


def main() -> None:
    GENERATED_DIR.mkdir(exist_ok=True)

    if not ANCHOR_PATH.exists():
        raise FileNotFoundError(f"Create the anchor tile first: {ANCHOR_PATH}")

    for x, y in tqdm(list(spiral_order(SIZE))):
        output_path = GENERATED_DIR / f"{x}_{y}.png"
        if output_path.exists():
            continue

        source_tile = SOURCE_DIR / f"{x}_{y}.png"
        if not source_tile.exists():
            raise FileNotFoundError(source_tile)

        refs = [ANCHOR_PATH]
        refs.extend(completed_neighbors(x, y, GENERATED_DIR)[:4])

        prompt = build_prompt(x, y)
        generate_tile_with_references(source_tile, refs, prompt, output_path)


if __name__ == "__main__":
    main()

For ComfyUI, this wrapper usually means sending the source tile into an img2img workflow and loading the anchor and neighbor references through IP-Adapter or another reference-conditioning node. For hosted APIs, it usually means uploading the source image and references as multipart files, then saving the returned PNG.

Keep the abstraction boring. You will run this hundreds of times, so retries, logging, and resumability matter more than elegance.

7. Use overlap tiles to reduce visible seams

If you can control how source tiles are extracted, do not generate perfectly adjacent 1024 by 1024 crops. Generate with overlap.

For example:

  • Source crop size: 1152 by 1152
  • Final kept area: center 1024 by 1024
  • Overlap margin: 64 px on every side

This gives the model context beyond the final tile boundary, which improves road and building continuity. After generation, crop each output back to the center.

Create scripts/crop_center.py:

from pathlib import Path
from PIL import Image


def crop_center_to_size(src: Path, dst: Path, size: int = 1024) -> None:
    img = Image.open(src).convert("RGB")
    w, h = img.size
    left = (w - size) // 2
    top = (h - size) // 2
    cropped = img.crop((left, top, left + size, top + size))
    dst.parent.mkdir(parents=True, exist_ok=True)
    cropped.save(dst)

This one change often does more for seams than prompt tweaks.

8. Stitch the final canvas

Once all tiles are generated and cropped to the same size, stitching is straightforward.

Create scripts/stitch.py:

from pathlib import Path
from PIL import Image

SIZE = 21
TILE_SIZE = 1024
GENERATED_DIR = Path("generated_tiles")
OUT_DIR = Path("stitched")


def stitch() -> None:
    OUT_DIR.mkdir(exist_ok=True)
    canvas = Image.new("RGB", (SIZE * TILE_SIZE, SIZE * TILE_SIZE))

    for y in range(SIZE):
        for x in range(SIZE):
            tile_path = GENERATED_DIR / f"{x}_{y}.png"
            tile = Image.open(tile_path).convert("RGB").resize((TILE_SIZE, TILE_SIZE))
            canvas.paste(tile, (x * TILE_SIZE, y * TILE_SIZE))

    canvas.save(OUT_DIR / "london_isometric_map.png", quality=95)


if __name__ == "__main__":
    stitch()

For web viewing, avoid loading a single enormous PNG if the image is very large. Use a tile viewer such as OpenSeadragon or Leaflet with a custom image tile pyramid.

9. Add a simple seam inspection script

Do not inspect 441 joins manually at full resolution. Start with a basic seam-difference score to find problem areas.

Create scripts/seam_score.py:

from pathlib import Path
import numpy as np
from PIL import Image

SIZE = 21
GENERATED_DIR = Path("generated_tiles")
STRIP = 16


def load(path: Path) -> np.ndarray:
    return np.asarray(Image.open(path).convert("RGB"), dtype=np.float32)


def mean_abs_diff(a: np.ndarray, b: np.ndarray) -> float:
    return float(np.mean(np.abs(a - b)))


def main() -> None:
    scores = []

    for y in range(SIZE):
        for x in range(SIZE):
            tile = load(GENERATED_DIR / f"{x}_{y}.png")

            if x + 1 < SIZE:
                right = load(GENERATED_DIR / f"{x+1}_{y}.png")
                score = mean_abs_diff(tile[:, -STRIP:], right[:, :STRIP])
                scores.append((score, (x, y), (x + 1, y), "vertical"))

            if y + 1 < SIZE:
                bottom = load(GENERATED_DIR / f"{x}_{y+1}.png")
                score = mean_abs_diff(tile[-STRIP:, :], bottom[:STRIP, :])
                scores.append((score, (x, y), (x, y + 1), "horizontal"))

    for score, a, b, kind in sorted(scores, reverse=True)[:20]:
        print(f"{score:8.2f} {kind:10s} {a} -> {b}")


if __name__ == "__main__":
    main()

This metric is crude. It will flag real changes like a river crossing a boundary. Still, it is useful for finding the worst mismatches quickly.

Regenerate the worst tiles with stronger neighbor references, especially edge crops.

10. Build a lightweight pannable viewer

A giant map is meant to be explored. Here is a minimal HTML viewer using OpenSeadragon after you create a Deep Zoom Image pyramid.

Install a converter:

pip install deepzoom-tools

Generate tiles:

deepzoom_tile.py -s 256 stitched/london_isometric_map.png stitched/dzi

Then create viewer.html:

<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <title>AI Isometric London Map</title>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/openseadragon/4.1.1/openseadragon.min.js"></script>
  <style>
    html, body, #viewer { margin: 0; width: 100%; height: 100%; background: #111; }
  </style>
</head>
<body>
  <div id="viewer"></div>
  <script>
    OpenSeadragon({
      id: "viewer",
      prefixUrl: "https://cdnjs.cloudflare.com/ajax/libs/openseadragon/4.1.1/images/",
      tileSources: "stitched/dzi/london_isometric_map.dzi"
    });
  </script>
</body>
</html>

Now you can pan and zoom the full generated city map without forcing the browser to decode a huge image at once.

Common Pitfalls

Generating tiles independently

This is the classic failure mode. The model treats every tile as a new art direction. Use the fixed anchor plus completed neighbors every time.

Changing the prompt during the run

Small prompt changes can cause visible style drift. Lock the prompt after the anchor tile looks right. If you must change it, restart from the center.

Using only the anchor tile

The anchor gives global consistency, but it does not know what should happen at a specific edge. Neighboring references are what make roads, parks, and rivers continue properly.

Using only neighboring tiles

Neighbors help locally, but style can drift as the spiral expands. The fixed anchor prevents the outer map from slowly turning into a different illustration style.

Ignoring source imagery overlap

If the source tile stops exactly at the edge, the model has to guess what continues beyond it. Overlap gives it context, and center-cropping removes the unstable outer band.

Asking for too much creativity

Phrases like beautiful fantasy city, cinematic, or highly imaginative are dangerous for map work. You want controlled restyling, not invention.

No resumability

A 441-tile run will fail somewhere. Save each tile immediately, skip existing outputs, log prompts and references, and make regeneration cheap.

Over-blending seams

Feathering can hide color discontinuities, but it can also blur roads and building edges. Prefer better generation inputs first. Use blending as a final polish, not the main fix.

FAQ

Can I do this with Midjourney?

Midjourney can produce strong style references, but automated 441-tile production and exact spatial continuity are harder because the workflow is less API-centered. For a developer pipeline, use a tool that supports repeatable image-to-image calls, reference images, and scripted output handling.

Which image model should I use?

Use a currently available image model or workflow that supports image-to-image transformation and visual references. OpenAI image editing, Stability AI tools, Replicate-hosted image models, and ComfyUI workflows can all fit depending on your requirements. The key is not the brand name; it is reference conditioning plus repeatable automation.

How many neighbor references should I pass?

Start with the fixed anchor plus the four side neighbors. If your provider allows more inputs, add diagonals and cropped edge strips. If you hit input limits, prioritize side neighbors over diagonals.

Why spiral outward instead of rows?

A spiral keeps every new tile close to the established visual center and usually gives it more completed adjacent context. Row-by-row generation can work, but it often creates directional drift where later rows subtly diverge from earlier ones.

Do I need exactly 441 tiles?

No. The same method works for 5 by 5, 9 by 9, 21 by 21, or larger grids. Use odd grid sizes if you want a single center anchor.

How do I make the final map truly isometric?

You have two options. The simpler approach is to ask the image model for an isometric restyle of each aerial tile. The stricter approach is to pre-transform map geometry or use 3D building data, then use AI for stylization. For visual projects, the first option is usually enough. For cartographic accuracy, use real geospatial data and treat AI as a renderer, not a geometry engine.

What is the most important practical lesson?

Consistency is engineered, not wished into existence. For large AI image mosaics, the winning pattern is fixed global style reference, local neighbor conditioning, deterministic generation order, overlap-aware cropping, and automated seam inspection. Once you build that loop, a 441-tile isometric London map becomes a manageable production pipeline instead of a prompt lottery.