Files
eagle0/tools/map_generator/edit_provinces.py
b91113039f Map adjustments: connect Chia island, thicken peninsula, fix Pozia (#5989)
- Chia: Connected the large island to the main peninsula via land bridge
- Chia: Thickened the peninsula by 15 pixels to better fit province label
- Pozia: Assigned 199 previously uncolored pixels in southeast region

- Regenerated map_borders.png (borders only, transparent elsewhere)
- Updated centroids.json with recalculated province centroids
- Added edit_provinces.py tool for province map editing

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 13:41:08 -08:00

482 lines
16 KiB
Python

#!/usr/bin/env python3
"""
Province Map Editor
Edit specific province boundaries in rawGray.gz.bytes
"""
import gzip
import numpy as np
from PIL import Image
from pathlib import Path
from scipy import ndimage
SCRIPT_DIR = Path(__file__).parent
PROJECT_ROOT = SCRIPT_DIR.parent.parent
UNITY_ASSETS = PROJECT_ROOT / "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle"
MAP_WIDTH = 3786
MAP_HEIGHT = 1834
OCEAN_ID = 0
def load_raw_gray() -> np.ndarray:
"""Load and decompress the province ID map."""
raw_gray_path = UNITY_ASSETS / "rawGray.gz.bytes"
with gzip.open(raw_gray_path, 'rb') as f:
data = f.read()
pixels = np.frombuffer(data, dtype=np.uint8).copy()
return pixels.reshape((MAP_HEIGHT, MAP_WIDTH))
def save_raw_gray(province_map: np.ndarray, output_path: Path):
"""Save province map as gzip compressed bytes."""
data = province_map.astype(np.uint8).tobytes()
with gzip.open(output_path, 'wb') as f:
f.write(data)
print(f"Saved province map to {output_path}")
def find_province_bounds(raw_gray: np.ndarray, province_id: int) -> dict:
"""Find bounding box and info for a province."""
mask = raw_gray == province_id
if not np.any(mask):
return None
y_coords, x_coords = np.where(mask)
return {
'min_x': int(np.min(x_coords)),
'max_x': int(np.max(x_coords)),
'min_y': int(np.min(y_coords)),
'max_y': int(np.max(y_coords)),
'area': int(np.sum(mask)),
'centroid': (float(np.mean(x_coords)), float(np.mean(y_coords)))
}
def find_connected_components(raw_gray: np.ndarray, province_id: int) -> list:
"""Find separate landmasses for a province."""
mask = raw_gray == province_id
labeled, num_features = ndimage.label(mask)
components = []
for i in range(1, num_features + 1):
comp_mask = labeled == i
y_coords, x_coords = np.where(comp_mask)
area = len(y_coords)
centroid = (float(np.mean(x_coords)), float(np.mean(y_coords)))
components.append({
'label': i,
'area': area,
'centroid': centroid,
'min_x': int(np.min(x_coords)),
'max_x': int(np.max(x_coords)),
'min_y': int(np.min(y_coords)),
'max_y': int(np.max(y_coords)),
})
return sorted(components, key=lambda x: -x['area'])
def find_unassigned_land(raw_gray: np.ndarray, region: tuple) -> list:
"""Find pixels that are land (non-ocean) but have ID 0 or 255 in a region."""
min_x, max_x, min_y, max_y = region
# Check for pixels that might be unassigned
region_data = raw_gray[min_y:max_y, min_x:max_x]
# Find connected components of ocean pixels
ocean_mask = region_data == OCEAN_ID
labeled, num_features = ndimage.label(ocean_mask)
results = []
for i in range(1, num_features + 1):
comp_mask = labeled == i
area = np.sum(comp_mask)
if area < 5000: # Small "ocean" patches might be unassigned land
y_coords, x_coords = np.where(comp_mask)
results.append({
'area': int(area),
'region_centroid': (float(np.mean(x_coords)) + min_x,
float(np.mean(y_coords)) + min_y),
})
return results
def visualize_province(raw_gray: np.ndarray, province_id: int, output_path: Path,
margin: int = 50, highlight_components: bool = True):
"""Save a visualization of a province and surrounding area."""
bounds = find_province_bounds(raw_gray, province_id)
if not bounds:
print(f"Province {province_id} not found")
return
min_x = max(0, bounds['min_x'] - margin)
max_x = min(MAP_WIDTH, bounds['max_x'] + margin)
min_y = max(0, bounds['min_y'] - margin)
max_y = min(MAP_HEIGHT, bounds['max_y'] + margin)
region = raw_gray[min_y:max_y, min_x:max_x]
# Create RGB image
h, w = region.shape
img = np.zeros((h, w, 3), dtype=np.uint8)
# Ocean is blue
img[region == 0] = [30, 60, 120]
# Target province is green
img[region == province_id] = [50, 200, 50]
# Other provinces are gray
other_mask = (region != 0) & (region != province_id) & (region != 255)
img[other_mask] = [100, 100, 100]
# Border pixels (255) are red
img[region == 255] = [200, 50, 50]
if highlight_components:
# Highlight different connected components of the province
mask = region == province_id
labeled, num_features = ndimage.label(mask)
colors = [
[50, 200, 50], # Green
[200, 200, 50], # Yellow
[50, 200, 200], # Cyan
[200, 50, 200], # Magenta
]
for i in range(1, num_features + 1):
comp_mask = labeled == i
img[comp_mask] = colors[(i-1) % len(colors)]
# Flip for correct orientation (Unity uses bottom-left origin)
img = np.flipud(img)
pil_img = Image.fromarray(img)
pil_img.save(output_path)
print(f"Saved visualization to {output_path}")
print(f" Province {province_id} bounds: x=[{bounds['min_x']}, {bounds['max_x']}], "
f"y=[{bounds['min_y']}, {bounds['max_y']}]")
def connect_chia_island(raw_gray: np.ndarray) -> np.ndarray:
"""Connect Chia's large island to the main peninsula."""
CHIA_ID = 43
components = find_connected_components(raw_gray, CHIA_ID)
print(f"\nChia (id={CHIA_ID}) has {len(components)} connected components:")
for i, comp in enumerate(components):
print(f" Component {i+1}: area={comp['area']:,}, centroid=({comp['centroid'][0]:.0f}, {comp['centroid'][1]:.0f})")
if len(components) < 2:
print(" Only one component, nothing to connect")
return raw_gray
# Find the main landmass (largest) and the large island (second largest)
main = components[0]
island = components[1]
print(f"\n Main landmass: area={main['area']:,}")
print(f" Large island to connect: area={island['area']:,}")
# Find the closest points between main and island
main_mask = raw_gray == CHIA_ID
labeled, _ = ndimage.label(main_mask)
# Get main component label
main_label = labeled[int(main['centroid'][1]), int(main['centroid'][0])]
island_label = labeled[int(island['centroid'][1]), int(island['centroid'][0])]
# Find boundary pixels of each component
main_comp = labeled == main_label
island_comp = labeled == island_label
# Dilate to find boundary
main_dilated = ndimage.binary_dilation(main_comp, iterations=1)
main_boundary = main_dilated & ~main_comp
island_dilated = ndimage.binary_dilation(island_comp, iterations=1)
island_boundary = island_dilated & ~island_comp
# Find closest points
main_boundary_coords = np.array(np.where(main_boundary)).T
island_boundary_coords = np.array(np.where(island_boundary)).T
min_dist = float('inf')
closest_main = None
closest_island = None
# Sample for efficiency
for i in range(0, len(main_boundary_coords), max(1, len(main_boundary_coords) // 500)):
my, mx = main_boundary_coords[i]
for j in range(0, len(island_boundary_coords), max(1, len(island_boundary_coords) // 500)):
iy, ix = island_boundary_coords[j]
dist = ((my - iy) ** 2 + (mx - ix) ** 2) ** 0.5
if dist < min_dist:
min_dist = dist
closest_main = (my, mx)
closest_island = (iy, ix)
print(f" Closest points: main=({closest_main[1]}, {closest_main[0]}), "
f"island=({closest_island[1]}, {closest_island[0]}), distance={min_dist:.1f}")
# Draw a connecting bridge (land bridge between the two)
result = raw_gray.copy()
# Create a thick line between the closest points
y1, x1 = closest_main
y2, x2 = closest_island
# Use Bresenham-like approach for thick line
steps = int(max(abs(x2 - x1), abs(y2 - y1))) + 1
bridge_width = 8 # Width of the land bridge
for t in range(steps):
alpha = t / max(steps - 1, 1)
cx = int(x1 + alpha * (x2 - x1))
cy = int(y1 + alpha * (y2 - y1))
# Fill a circle around each point
for dy in range(-bridge_width, bridge_width + 1):
for dx in range(-bridge_width, bridge_width + 1):
if dx*dx + dy*dy <= bridge_width*bridge_width:
ny, nx = cy + dy, cx + dx
if 0 <= ny < MAP_HEIGHT and 0 <= nx < MAP_WIDTH:
# Only fill if currently ocean
if result[ny, nx] == OCEAN_ID:
result[ny, nx] = CHIA_ID
# Count new pixels
new_pixels = np.sum((result == CHIA_ID) & (raw_gray != CHIA_ID))
print(f" Added {new_pixels} pixels to connect the island")
return result
def thicken_chia(raw_gray: np.ndarray, iterations: int = 5) -> np.ndarray:
"""Thicken Chia peninsula by expanding into ocean."""
CHIA_ID = 43
result = raw_gray.copy()
mask = result == CHIA_ID
print(f"\nThickening Chia by {iterations} pixels...")
initial_area = np.sum(mask)
for i in range(iterations):
# Dilate the mask
dilated = ndimage.binary_dilation(mask, iterations=1)
# Only expand into ocean
new_pixels = dilated & ~mask & (result == OCEAN_ID)
result[new_pixels] = CHIA_ID
mask = result == CHIA_ID
final_area = np.sum(mask)
print(f" Added {final_area - initial_area} pixels to Chia")
return result
def fix_pozia_islands(raw_gray: np.ndarray) -> np.ndarray:
"""Check Pozia for unassigned small islands and assign them."""
POZIA_ID = 12
bounds = find_province_bounds(raw_gray, POZIA_ID)
print(f"\nPozia (id={POZIA_ID}) bounds: x=[{bounds['min_x']}, {bounds['max_x']}], "
f"y=[{bounds['min_y']}, {bounds['max_y']}]")
# Look in the southeast area (lower y values since y=0 is top in our array)
# Pozia centroid is around (2943, 356), so southeast would be higher x, lower y
search_region = (bounds['max_x'] - 200, bounds['max_x'] + 100,
max(0, bounds['min_y'] - 100), bounds['min_y'] + 200)
print(f" Searching region: x=[{search_region[0]}, {search_region[1]}], "
f"y=[{search_region[2]}, {search_region[3]}]")
# Find small ocean patches that might actually be unassigned islands
min_x, max_x, min_y, max_y = search_region
min_x = max(0, min_x)
max_x = min(MAP_WIDTH, max_x)
min_y = max(0, min_y)
max_y = min(MAP_HEIGHT, max_y)
result = raw_gray.copy()
# Look for small isolated ocean areas surrounded by Pozia
ocean_mask = result[min_y:max_y, min_x:max_x] == OCEAN_ID
labeled, num_features = ndimage.label(ocean_mask)
fixed_count = 0
for i in range(1, num_features + 1):
comp_mask = labeled == i
area = np.sum(comp_mask)
# Small patches (potential islands shown in PNG but not in rawGray)
if area < 2000:
# Check if surrounded by Pozia
dilated = ndimage.binary_dilation(comp_mask, iterations=3)
boundary = dilated & ~comp_mask
region = result[min_y:max_y, min_x:max_x]
boundary_vals = region[boundary]
pozia_count = np.sum(boundary_vals == POZIA_ID)
total_land = np.sum((boundary_vals != OCEAN_ID) & (boundary_vals != 255))
if total_land > 0 and pozia_count / total_land > 0.7:
# Mostly surrounded by Pozia - assign to Pozia
y_coords, x_coords = np.where(comp_mask)
for y, x in zip(y_coords, x_coords):
result[min_y + y, min_x + x] = POZIA_ID
fixed_count += area
print(f" Assigned {area} pixels to Pozia")
print(f" Total pixels assigned to Pozia: {fixed_count}")
return result
def generate_map_bw(province_map: np.ndarray, output_path: Path):
"""Generate map_borders.png from province map.
Only draws borders - everything else is transparent:
- Province borders (between different land provinces)
- Coastlines (land/ocean borders)
"""
height, width = province_map.shape
# Create output image - fully transparent by default
img = np.zeros((height, width, 4), dtype=np.uint8)
# Draw borders
for y in range(1, height - 1):
for x in range(1, width - 1):
current = province_map[y, x]
neighbors = [
province_map[y - 1, x],
province_map[y + 1, x],
province_map[y, x - 1],
province_map[y, x + 1],
]
is_border = any(n != current for n in neighbors)
neighbor_is_ocean = any(n == OCEAN_ID for n in neighbors)
current_is_land = current != OCEAN_ID and current != 255
if is_border:
if current_is_land and neighbor_is_ocean:
# Coastline
img[y, x] = [40, 40, 40, 255]
elif current_is_land:
# Internal province border
img[y, x] = [60, 60, 60, 255]
# Flip Y axis for Unity coordinate system
img = np.flipud(img)
pil_img = Image.fromarray(img, mode='RGBA')
pil_img.save(output_path, optimize=True)
print(f"Saved map_borders.png to {output_path}")
def update_centroids(raw_gray: np.ndarray, centroids_path: Path):
"""Update centroids.json with recalculated province centroids."""
import json
with open(centroids_path, 'r') as f:
data = json.load(f)
for province in data['provinces']:
pid = province['id']
mask = raw_gray == pid
if not np.any(mask):
continue
y_coords, x_coords = np.where(mask)
province['centroid_x'] = round(float(np.mean(x_coords)), 1)
province['centroid_y'] = round(float(np.mean(y_coords)), 1)
province['area'] = int(np.sum(mask))
with open(centroids_path, 'w') as f:
json.dump(data, f, indent=2)
print(f"Updated centroids in {centroids_path}")
def main():
import sys
if len(sys.argv) > 1 and sys.argv[1] == '--regenerate-png':
# Just regenerate PNG from existing rawGray
print("Loading raw gray map...")
raw_gray = load_raw_gray()
print(f"Map dimensions: {raw_gray.shape[1]} x {raw_gray.shape[0]}")
print("\nGenerating map_borders.png...")
generate_map_bw(raw_gray, UNITY_ASSETS / "map_borders.png")
return
print("Loading raw gray map...")
raw_gray = load_raw_gray()
print(f"Map dimensions: {raw_gray.shape[1]} x {raw_gray.shape[0]}")
output_dir = SCRIPT_DIR / "output"
output_dir.mkdir(exist_ok=True)
# Visualize Chia before
print("\n" + "="*60)
print("CHIA ANALYSIS")
print("="*60)
visualize_province(raw_gray, 43, output_dir / "chia_before.png")
# Visualize Pozia before
print("\n" + "="*60)
print("POZIA ANALYSIS")
print("="*60)
visualize_province(raw_gray, 12, output_dir / "pozia_before.png", margin=100)
# Check for small islands near Pozia that might be unassigned
print("\nLooking for unassigned islands near Pozia...")
bounds = find_province_bounds(raw_gray, 12)
# Southeast is higher x, lower y in our coordinate system
se_region = (bounds['max_x'] - 300, bounds['max_x'] + 200,
max(0, bounds['min_y'] - 200), bounds['min_y'] + 300)
visualize_province(raw_gray, 12, output_dir / "pozia_se_region.png", margin=200)
# Apply fixes
print("\n" + "="*60)
print("APPLYING FIXES")
print("="*60)
modified = connect_chia_island(raw_gray)
modified = thicken_chia(modified, iterations=15)
modified = fix_pozia_islands(modified)
# Visualize after
visualize_province(modified, 43, output_dir / "chia_after.png")
visualize_province(modified, 12, output_dir / "pozia_after.png", margin=100)
# Save modified rawGray
save_raw_gray(modified, UNITY_ASSETS / "rawGray.gz.bytes")
# Generate map_borders.png
print("\nGenerating map_borders.png...")
generate_map_bw(modified, UNITY_ASSETS / "map_borders.png")
# Update centroids
print("\nUpdating centroids...")
update_centroids(modified, UNITY_ASSETS / "centroids.json")
print("\n" + "="*60)
print("DONE")
print("="*60)
if __name__ == '__main__':
main()