Skip to content

Commit 40ca81b

Browse files
committed
Use disk-backed memmap for tile stitching to avoid OOM
1 parent 2dc304f commit 40ca81b

1 file changed

Lines changed: 35 additions & 29 deletions

File tree

scripts/tile_composer.py

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import math
2+
import os
23
import requests
4+
import numpy as np
35
from PIL import Image
46
from io import BytesIO
57
from pathlib import Path
@@ -96,10 +98,15 @@ def download_and_save(lat_max, lat_min, lon_min, lon_max, zoom, image_name):
9698
num_y = y_max - y_min + 1
9799

98100

99-
# Replace it with these:
100-
stitched_image = None
101101
tile_size = None
102102
headers = {'User-Agent': 'Mozilla/5.0'}
103+
mmap_path = f"{image_name}_canvas.dat"
104+
105+
# Clean up stale memmap from a previous run
106+
if os.path.exists(mmap_path):
107+
os.remove(mmap_path)
108+
109+
canvas = None
103110

104111
image_min_lat = 90.0
105112
image_min_long = 180.0
@@ -108,23 +115,24 @@ def download_and_save(lat_max, lat_min, lon_min, lon_max, zoom, image_name):
108115

109116
for i, x in enumerate(range(x_min, x_max + 1)):
110117
for j, y in enumerate(range(y_min, y_max + 1)):
111-
url = get_satellite_url(x, y, zoom) # Add provider="mapbox" etc. if using your updated function
118+
url = get_satellite_url(x, y, zoom)
112119
response = requests.get(url, headers=headers)
113-
120+
114121
if response.status_code == 200:
115122
tile = Image.open(BytesIO(response.content))
116-
117-
# --- LAZY CANVAS INITIALIZATION ---
118-
# If this is the first valid tile, measure it and build the canvas
119-
if stitched_image is None:
120-
tile_size = tile.width # This will automatically be 256 or 512
123+
124+
# Lazy init: measure first tile and create disk-backed canvas
125+
if canvas is None:
126+
tile_size = tile.width
121127
print(f"Detected tile size: {tile_size}x{tile_size}px")
122-
stitched_image = Image.new('RGB', (num_x * tile_size, num_y * tile_size))
123-
124-
# Paste using the dynamic tile_size instead of 256 or 512
125-
stitched_image.paste(tile, (i * tile_size, j * tile_size))
128+
print(f"Canvas: {num_x * tile_size}x{num_y * tile_size}px (memmap)")
129+
canvas = np.memmap(mmap_path, dtype=np.uint8, mode='w+',
130+
shape=(num_y * tile_size, num_x * tile_size, 3))
131+
132+
tile_arr = np.array(tile.convert('RGB'))
133+
canvas[j * tile_size:(j + 1) * tile_size,
134+
i * tile_size:(i + 1) * tile_size] = tile_arr
126135

127-
# Get corners
128136
corners = get_tile_corners(x, y, zoom)
129137
for _, val in corners.items():
130138
lat = val[0]
@@ -140,34 +148,32 @@ def download_and_save(lat_max, lat_min, lon_min, lon_max, zoom, image_name):
140148
else:
141149
print(f"Error: Could not download tile {x},{y}")
142150

143-
# Failsafe in case the entire grid failed to download
144-
if stitched_image is None:
151+
if canvas is None:
145152
print("Error: Could not download any tiles. Canvas was never created.")
146153
return None
147-
148-
# Determine the global pixel coordinate of the top-left corner of your stitched image
149-
# (Which is the NW corner of tile x_min, y_min)
154+
150155
ref_pixel_x = x_min * tile_size
151156
ref_pixel_y = y_min * tile_size
152157

153-
# Get the global pixel coordinates for your requested bounding box
154158
upper_left_x, upper_left_y = latlon_to_pixel(lat_max, lon_min, zoom, tile_size)
155159
lower_right_x, lower_right_y = latlon_to_pixel(lat_min, lon_max, zoom, tile_size)
156160

157-
# Calculate the crop boundaries relative to the stitched image (0,0)
158-
left = upper_left_x - ref_pixel_x
159-
top = upper_left_y - ref_pixel_y
160-
right = lower_right_x - ref_pixel_x
161-
bottom = lower_right_y - ref_pixel_y
161+
left = int(upper_left_x - ref_pixel_x)
162+
top = int(upper_left_y - ref_pixel_y)
163+
right = int(lower_right_x - ref_pixel_x)
164+
bottom = int(lower_right_y - ref_pixel_y)
162165

163-
# Crop the output image
164166
print(f"Cropping image to: {left, top, right, bottom}")
165-
stitched_image = stitched_image.crop((left, top, right, bottom))
167+
cropped = Image.fromarray(canvas[top:bottom, left:right])
166168

167169
output_path = Path(f"{image_name}.jpg")
168170
output_path.parent.mkdir(parents=True, exist_ok=True)
169-
stitched_image.save(output_path, "JPEG", quality=90)
170-
171+
cropped.save(output_path, "JPEG", quality=90)
172+
173+
# Clean up memmap file
174+
del canvas
175+
os.remove(mmap_path)
176+
171177
print(f"Rectangular satellite image saved as '{image_name}.jpg'")
172178
print(f"Bounds: {image_min_lat},{image_min_long} {image_max_lat},{image_max_long}")
173179
return str(output_path)

0 commit comments

Comments
 (0)