Hi! We come from here:
viewtopic.php?t=90654
For some time, I’ve wanted to represent Spain on an 8K map for OpenTTD, but when I tried it in the previous Project Iberia, I couldn’t achieve the results I wanted. I ran into the issue of having to manually place each city and town, which made the task extremely tedious. On top of that, once I started placing cities, the scenario became locked with whatever patches I had decided to apply.
That’s why I changed my approach to the problem. I created an 8K heightmap, and thanks to ChatGPT (I have no knowledge of programming), I developed some Python scripts that can automatically place the cities I input. Then, I run them through a second script that scales the population based on real population data.
One of the biggest challenges I faced was that the projection is so large that there’s a slight distortion. This made the calculations much more complex, leading to days of trial and error. In the end, I had to settle for using a projection that doesn’t include Portugal or part of Galicia, but even so, the result is impressive.
Of course, the process isn’t perfect, but maybe it can serve as a base for other users to develop an improved version. That’s why I’ll upload all the code so that those who know programming can use it and improve it if they want.
And well, the result is what you see—an automatically populated map of Spain, so everyone can create their own scenario and use whichever NewGRFs they prefer. Here are some screenshots:
Script in python wich can place every city on their coordinates:
- [+] Spoiler
- #!/usr/bin/env python3
import json
import time
import random
from geopy.geocoders import Nominatim
from geopy.exc import GeocoderServiceError
# Constantes del mapa y parámetros de conversión
CENTER_LAT = 40.1452893 # Latitud del centro
CENTER_LON = -2.197265625 # Longitud del centro
CENTER_PIXEL = 4096 # Centro en un mapa de 8192x8192
RES_V = 0.001047 # Resolución vertical base (grados/px)
RES_H = 0.001373 # Resolución horizontal (grados/px)
CORR_FACTOR = 0.00767 # Factor de corrección en la conversión vertical
PIXEL_MAX = 8192 # Tamaño del mapa (para normalizar)
def lat_to_pixel_y(lat):
"""
Convierte la latitud a la coordenada vertical (en píxeles).
"""
denom = RES_V * (1 - CORR_FACTOR * (lat - CENTER_LAT))
offset = (lat - CENTER_LAT) / denom
pixel_y = CENTER_PIXEL - offset
return pixel_y
def lon_to_pixel_x(lon):
"""
Convierte la longitud a la coordenada horizontal (en píxeles).
"""
offset = (lon - CENTER_LON) / RES_H
pixel_x = CENTER_PIXEL + offset
return pixel_x
def compute_normalized_coordinates(lat, lon):
"""
Calcula las coordenadas normalizadas.
"""
pixel_x = lon_to_pixel_x(lon)
pixel_y = lat_to_pixel_y(lat)
norm_x = pixel_y / PIXEL_MAX # Nota: ¿Es correcto usar pixel_y para norm_x?
norm_y = pixel_x / PIXEL_MAX # Nota: ¿Es correcto usar pixel_x para norm_y?
return norm_x, norm_y
def is_valid_normalized_coords(norm_x, norm_y):
"""
Valida que las coordenadas normalizadas estén en el rango [0, 1].
"""
return 0.0 <= norm_x <= 1.0 and 0.0 <= norm_y <= 1.0
def get_city_coordinates(city_name, geolocator, max_retries=3):
"""
Obtiene las coordenadas de una ciudad con reintentos en caso de fallo.
"""
retries = 0
while retries < max_retries:
try:
location = geolocator.geocode(city_name, timeout=15)
if location:
return location.latitude, location.longitude
else:
print(f"Advertencia: No se encontró la ciudad '{city_name}'.")
return None, None
except GeocoderServiceError as e:
print(f"Error al obtener '{city_name}': {e}. Reintentando...")
retries += 1
time.sleep(2 + random.uniform(0, 2))
print(f"Error persistente con '{city_name}', omitiéndola.")
return None, None
def main():
cities = [
"Gibraltar"
]
geolocator = Nominatim(user_agent="city_mapper", timeout=10)
output = []
for city in cities:
lat, lon = get_city_coordinates(city, geolocator)
if lat is not None and lon is not None:
norm_x, norm_y = compute_normalized_coordinates(lat, lon)
if is_valid_normalized_coords(norm_x, norm_y):
output.append({
"name": city,
"population": 1000,
"city": True,
"x": norm_x,
"y": norm_y
})
else:
print(f"Advertencia: Coordenadas fuera de rango para '{city}'. Omitiendo.")
time.sleep(5 + random.uniform(0, 3))
with open("cities.json", "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=4)
print(json.dumps(output, ensure_ascii=False, indent=4))
if __name__ == "__main__":
main()
Script in python that check the real population and scales it from 3 to 1000. If it is bigger than 500 it will set it as ''City''.
- [+] Spoiler
- import json
import time
import sys
from SPARQLWrapper import SPARQLWrapper, JSON
def get_population_from_wikidata(municipality_name):
"""
Consulta Wikidata para obtener la población (propiedad P1082) del municipio cuyo
rdfs:label en español coincida exactamente con municipality_name.
Devuelve la población como entero o None si no se encuentra.
"""
endpoint_url = "https://query.wikidata.org/sparql"
sparql = SPARQLWrapper(endpoint_url)
query = f"""
SELECT ?population WHERE {{
?municipality rdfs:label "{municipality_name}"@es.
?municipality wdt:P1082 ?population.
}} ORDER BY DESC(?population) LIMIT 1
"""
sparql.setQuery(query)
sparql.setReturnFormat(JSON)
try:
results = sparql.query().convert()
bindings = results["results"]["bindings"]
if bindings:
return int(bindings[0]["population"]["value"])
else:
return None
except Exception as e:
print(f"Error al consultar Wikidata para '{municipality_name}': {e}")
return None
def main(input_file, output_file):
# Cargar el JSON de entrada
with open(input_file, "r", encoding="utf-8") as f:
data = json.load(f)
fetched_populations = []
for item in data:
name = item["name"]
print(f"Buscando población para: {name}")
pop = get_population_from_wikidata(name)
if pop is None:
print(f" No se encontró población para '{name}', se usará el valor por defecto 1000.")
pop = 1000
else:
print(f" Población encontrada: {pop}")
# Guardamos la población real en un campo temporal (opcional)
item["real_population"] = pop
fetched_populations.append(pop)
# Pausa para evitar saturar el endpoint
time.sleep(1)
# Determinar el mínimo y máximo de la población real obtenida
min_pop = min(fetched_populations)
max_pop = max(fetched_populations)
print(f"\nPoblación mínima: {min_pop} / máxima: {max_pop}")
def scale_population(real_value):
if max_pop == min_pop:
return 1000
# Transformación lineal para mapear de [min_pop, max_pop] a [3, 1000]
scaled = 3 + ((real_value - min_pop) * 997) / (max_pop - min_pop)
return round(scaled)
# Actualizar cada municipio en la lista
for item in data:
real_value = item["real_population"]
scaled_value = scale_population(real_value)
item["population"] = scaled_value
item["city"] = True if scaled_value > 500 else False
# Opcional: eliminar el campo temporal
del item["real_population"]
# Guardar el resultado en un archivo de salida
with open(output_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
print(f"\nProceso completado. Archivo generado: {output_file}")
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Uso: python transform.py input.json output.json")
else:
main(sys.argv[1], sys.argv[2])
FILES. Heighmap, scenario, .json and scripts.
- [+] Spoiler
- https://www.transfernow.net/dl/20250226f4G3Dgup
https://limewire.com/d/0ada0c00-5f97-4d ... fcZ90KsHcQ
DOWNLOADS on next post.