mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 23:55:43 +00:00
- 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>
482 lines
16 KiB
Python
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()
|