Skip to content

Instantly share code, notes, and snippets.

@luandro
Created August 20, 2025 02:34
Show Gist options
  • Save luandro/4ebaeb200d0718b807bf4af57beb936b to your computer and use it in GitHub Desktop.
Save luandro/4ebaeb200d0718b807bf4af57beb936b to your computer and use it in GitHub Desktop.
Generate SMP from QGIS XYZ
#!/bin/bash
# List all folders except numeric folders
options=($(ls -d */ | grep -Ev '^[0-9]+/$' | grep -v 'generated_smp/'))
echo "Select a folder:"
select selected_folder in "${options[@]}"; do
if [[ -n $selected_folder ]]; then
# Remove trailing slash
selected_folder=${selected_folder%/}
# Find highest numbered folder (max zoom)
max_zoom=$(ls -d [0-9]*/ 2>/dev/null | sort -V | tail -1 | tr -d '/')
if [[ -z $max_zoom ]]; then
echo "Error: No numbered folders found"
exit 1
fi
# Debug: show detected folders
echo "Available numbered folders: $(ls -d [0-9]*/ 2>/dev/null | tr -d '/' | tr '\n' ' ')"
# Prepare variables
current_year=$(date +%Y)
folder_name="$selected_folder"
# Prompt user for custom name
echo -n "Enter a name for the SMP file (or press Enter for default): "
read -r custom_name
if [[ -n "$custom_name" ]]; then
# Use custom name with selected folder and max zoom
name="${custom_name}-${folder_name}-${current_year}-z${max_zoom}"
else
# Use default name pattern
name="tiles-${folder_name}-${current_year}-z${max_zoom}"
fi
tmp_dir="temp_${name}_$(date +%s)"
output_dir="generated_smp"
echo "Processing folder: $selected_folder"
echo "Max zoom level: $max_zoom"
echo "Output name: $name"
# Create temp directory structure
mkdir -p "$tmp_dir/s/0"
# Copy all numbered folders to s/0
echo "Copying numbered folders (0-$max_zoom) to s/0..."
for i in $(seq 0 $max_zoom); do
if [[ -d "$i" ]]; then
echo " Copying folder: $i"
cp -r "$i" "$tmp_dir/s/0/"
else
echo " Skipping missing folder: $i"
fi
done
# Merge selected folder content into s/0
echo "Merging $selected_folder content into s/0..."
if [[ -d "$selected_folder" ]]; then
if ls "$selected_folder"/* > /dev/null 2>&1; then
cp -r "$selected_folder"/* "$tmp_dir/s/0/" 2>/dev/null || true
echo " Content merged successfully"
else
echo " Selected folder is empty"
fi
else
echo " Warning: Selected folder does not exist"
fi
# Process JSON files to create multiple sources
echo "Creating style.json with multiple sources from template..."
# Function to convert EPSG:3857 to WGS84
convert_bounds() {
local extent="$1"
# Extract coordinates from EXTENT format: "xmin,xmax,ymin,ymax [EPSG:3857]"
local coords=$(echo "$extent" | sed 's/ \[EPSG:3857\]//g')
local xmin=$(echo "$coords" | cut -d',' -f1)
local xmax=$(echo "$coords" | cut -d',' -f2)
local ymin=$(echo "$coords" | cut -d',' -f3)
local ymax=$(echo "$coords" | cut -d',' -f4)
# Convert from EPSG:3857 to WGS84 (simplified conversion)
# For longitude: lon = x / 20037508.34 * 180
# For latitude: lat = atan(sinh(y / 20037508.34 * pi)) * 180 / pi
local lon_min=$(echo "scale=8; $xmin / 20037508.34 * 180" | bc -l)
local lon_max=$(echo "scale=8; $xmax / 20037508.34 * 180" | bc -l)
# Simplified latitude conversion (good enough for bounds)
local lat_min=$(echo "scale=8; 2 * a(e($ymin / 20037508.34 * 3.14159265)) * 57.2958 - 90" | bc -l 2>/dev/null || echo "scale=6; $ymin * 0.000000009" | bc)
local lat_max=$(echo "scale=8; 2 * a(e($ymax / 20037508.34 * 3.14159265)) * 57.2958 - 90" | bc -l 2>/dev/null || echo "scale=6; $ymax * 0.000000009" | bc)
echo "[$lon_min, $lat_min, $lon_max, $lat_max]"
}
# Function to calculate center from bounds
calculate_center() {
local extent="$1"
local coords=$(echo "$extent" | sed 's/ \[EPSG:3857\]//g')
local xmin=$(echo "$coords" | cut -d',' -f1)
local xmax=$(echo "$coords" | cut -d',' -f2)
local ymin=$(echo "$coords" | cut -d',' -f3)
local ymax=$(echo "$coords" | cut -d',' -f4)
# Calculate center in Web Mercator
local x_center=$(echo "scale=2; ($xmin + $xmax) / 2" | bc -l)
local y_center=$(echo "scale=2; ($ymin + $ymax) / 2" | bc -l)
# Convert center to WGS84
local lon_center=$(echo "scale=8; $x_center / 20037508.34 * 180" | bc -l)
local lat_center=$(echo "scale=8; 2 * a(e($y_center / 20037508.34 * 3.14159265)) * 57.2958 - 90" | bc -l 2>/dev/null || echo "scale=6; $y_center * 0.000000009" | bc)
echo "[$lon_center, $lat_center]"
}
# Initialize variables for style generation
declare -A json_data
sources_json=""
layers_json=""
source_folders_json=""
global_bounds="[-73.740234375, -7.231698708367133, -69.3896484375, -4.346411275333186]"
center_coords="[-71.56494140625, -5.7890549918501595]"
highest_zoom=0
highest_zoom_extent=""
source_count=0
# Process JSON files from current directory
for json_file in *.json; do
if [[ -f "$json_file" && "$json_file" != "style.json" && "$json_file" != "style_template.json" ]]; then
echo "Processing JSON file: $json_file"
# Extract data from JSON
minzoom=$(jq -r '.inputs.ZOOM_MIN // 0' "$json_file" 2>/dev/null || echo "0")
maxzoom=$(jq -r '.inputs.ZOOM_MAX // 13' "$json_file" 2>/dev/null || echo "13")
extent=$(jq -r '.inputs.EXTENT // ""' "$json_file" 2>/dev/null || echo "")
# Check if this is the highest zoom level so far
if [[ $maxzoom -gt $highest_zoom ]]; then
highest_zoom=$maxzoom
highest_zoom_extent="$extent"
fi
# Generate source name from filename
source_name="source-$(basename "$json_file" .json)"
# Convert bounds if extent is available
if [[ -n "$extent" && "$extent" != "null" ]]; then
bounds=$(convert_bounds "$extent" 2>/dev/null || echo "$global_bounds")
else
bounds="$global_bounds"
fi
# Add comma if not first source
if [[ $source_count -gt 0 ]]; then
sources_json="$sources_json,"
source_folders_json="$source_folders_json,"
fi
# Add source to JSON
sources_json="$sources_json
\"$source_name\": {
\"format\": \"jpg\",
\"name\": \"$name-$source_name\",
\"version\": \"2.0\",
\"type\": \"raster\",
\"minzoom\": $minzoom,
\"maxzoom\": $maxzoom,
\"scheme\": \"xyz\",
\"bounds\": $bounds,
\"center\": [0, 0, 6],
\"tiles\": [\"smp://maps.v1/s/0/{z}/{x}/{y}.jpg\"]
}"
# Add layer to JSON (comma handled separately)
if [[ $source_count -eq 0 ]]; then
layers_json="$layers_json
{
\"id\": \"raster-$source_name\",
\"type\": \"raster\",
\"source\": \"$source_name\",
\"paint\": {
\"raster-opacity\": 1
}
}"
else
layers_json="$layers_json,
{
\"id\": \"raster-$source_name\",
\"type\": \"raster\",
\"source\": \"$source_name\",
\"paint\": {
\"raster-opacity\": 1
}
}"
fi
# Add to source folders metadata
source_folders_json="$source_folders_json
\"$source_name\": \"0\""
source_count=$((source_count + 1))
fi
done
# Process JSON files from selected directory if they exist
if [[ -d "$selected_folder" ]]; then
for json_file in "$selected_folder"/*.json; do
if [[ -f "$json_file" ]]; then
echo "Processing JSON file from selected folder: $json_file"
# Extract data from JSON
minzoom=$(jq -r '.inputs.ZOOM_MIN // 0' "$json_file" 2>/dev/null || echo "0")
maxzoom=$(jq -r '.inputs.ZOOM_MAX // 13' "$json_file" 2>/dev/null || echo "13")
extent=$(jq -r '.inputs.EXTENT // ""' "$json_file" 2>/dev/null || echo "")
# Check if this is the highest zoom level so far
if [[ $maxzoom -gt $highest_zoom ]]; then
highest_zoom=$maxzoom
highest_zoom_extent="$extent"
fi
# Generate source name from filename and folder
source_name="source-$selected_folder-$(basename "$json_file" .json)"
# Convert bounds if extent is available
if [[ -n "$extent" && "$extent" != "null" ]]; then
bounds=$(convert_bounds "$extent" 2>/dev/null || echo "$global_bounds")
else
bounds="$global_bounds"
fi
# Add comma if not first source
if [[ $source_count -gt 0 ]]; then
sources_json="$sources_json,"
source_folders_json="$source_folders_json,"
fi
# Add source to JSON
sources_json="$sources_json
\"$source_name\": {
\"format\": \"jpg\",
\"name\": \"$name-$source_name\",
\"version\": \"2.0\",
\"type\": \"raster\",
\"minzoom\": $minzoom,
\"maxzoom\": $maxzoom,
\"scheme\": \"xyz\",
\"bounds\": $bounds,
\"center\": [0, 0, 6],
\"tiles\": [\"smp://maps.v1/s/0/{z}/{x}/{y}.jpg\"]
}"
# Add layer to JSON
layers_json="$layers_json,
{
\"id\": \"raster-$source_name\",
\"type\": \"raster\",
\"source\": \"$source_name\",
\"paint\": {
\"raster-opacity\": 1
}
}"
# Add to source folders metadata
source_folders_json="$source_folders_json
\"$source_name\": \"0\""
source_count=$((source_count + 1))
fi
done
fi
# Calculate center from highest zoom level extent
if [[ -n "$highest_zoom_extent" && "$highest_zoom_extent" != "null" ]]; then
center_coords=$(calculate_center "$highest_zoom_extent" 2>/dev/null || echo "$center_coords")
echo "Using center from highest zoom level ($highest_zoom): $center_coords"
fi
# Calculate overall bounds from highest zoom or use global bounds
if [[ -n "$highest_zoom_extent" && "$highest_zoom_extent" != "null" ]]; then
global_bounds=$(convert_bounds "$highest_zoom_extent" 2>/dev/null || echo "$global_bounds")
fi
# Fallback: if no JSON files found, create default source
if [[ $source_count -eq 0 ]]; then
echo "No JSON files found, creating default source..."
sources_json="
\"mbtiles-source\": {
\"format\": \"jpg\",
\"name\": \"$name\",
\"version\": \"2.0\",
\"type\": \"raster\",
\"minzoom\": 0,
\"maxzoom\": $max_zoom,
\"scheme\": \"xyz\",
\"bounds\": $global_bounds,
\"center\": [0, 0, 6],
\"tiles\": [\"smp://maps.v1/s/0/{z}/{x}/{y}.jpg\"]
}"
layers_json="
{
\"id\": \"raster\",
\"type\": \"raster\",
\"source\": \"mbtiles-source\",
\"paint\": {
\"raster-opacity\": 1
}
}"
source_folders_json="
\"mbtiles-source\": \"0\""
fi
# Generate style.json directly (avoiding template replacement complexity)
echo "Generating style.json..."
# Calculate default zoom
default_zoom=$(echo "scale=0; $highest_zoom - 3" | bc 2>/dev/null || echo "11")
if [[ $default_zoom -lt 1 ]]; then
default_zoom=11
fi
# Create the complete style.json
cat > "$tmp_dir/style.json" << EOF
{
"version": 8,
"name": "$name",
"sources": {$sources_json
},
"layers": [
{
"id": "background",
"type": "background",
"paint": {
"background-color": "white"
}
},$layers_json
],
"metadata": {
"smp:bounds": $global_bounds,
"smp:maxzoom": $max_zoom,
"smp:sourceFolders": {$source_folders_json
}
},
"center": $center_coords,
"zoom": $default_zoom
}
EOF
# Create output directory
mkdir -p "$output_dir"
# Create the SMP file (zip with .smp extension)
echo "Creating SMP file..."
# Show what we're about to zip
echo "Contents in temp directory:"
find "$tmp_dir" -type f | wc -l
echo "files total"
# Create zip from inside the temp directory to avoid path issues
cd "$tmp_dir"
if zip -r "../${output_dir}/${name}.smp" * > /dev/null 2>&1; then
echo "ZIP creation successful"
else
echo "Error: ZIP creation failed"
cd - > /dev/null
rm -rf "$tmp_dir"
exit 1
fi
cd - > /dev/null
# Verify file was created before cleanup
if [[ -f "${output_dir}/${name}.smp" ]]; then
echo "Process completed successfully!"
echo "Output file: ${output_dir}/${name}.smp"
echo "File size: $(du -h "${output_dir}/${name}.smp" | cut -f1)"
else
echo "Error: Output file was not created"
rm -rf "$tmp_dir"
exit 1
fi
# Clean up temporary directory
rm -rf "$tmp_dir"
break
else
echo "Invalid selection."
fi
done
{
"version": 8,
"name": "{{NAME}}",
"sources": {{SOURCES}},
"layers": [
{
"id": "background",
"type": "background",
"paint": {
"background-color": "white"
}
}{{LAYERS}}
],
"metadata": {
"smp:bounds": {{BOUNDS}},
"smp:maxzoom": {{MAX_ZOOM}},
"smp:sourceFolders": {{SOURCE_FOLDERS}}
},
"center": {{CENTER}},
"zoom": {{DEFAULT_ZOOM}}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment