Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.6 1cc05cf6e9 Simplify label positioning to clearance-threshold approach
Replace Gaussian-weighted blend with simpler algorithm: find all pixels with
>= 70% of max possible clearance, pick closest to centroid. This guarantees
labels stay well inside province boundaries while remaining centered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:38:38 -08:00
adminandClaude Opus 4.6 aeb9e09924 Fix province label positions using pole-of-inaccessibility algorithm
Replace geometric centroid (mean of pixels) with a distance-transform-based
approach that finds the point inside each province maximally distant from
edges, weighted by proximity to the visual center. This fixes labels that
bled into neighboring provinces or extended over water (Berkorszag, Chia,
Pozia).

Also adds --update-centroids mode to generate_map.py for re-running label
positioning without losing hand-tuned styling fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 14:10:43 -08:00
2 changed files with 196 additions and 99 deletions
@@ -3,8 +3,8 @@
{
"id": 4,
"name": "Berkorszag",
"centroid_x": 1757.8,
"centroid_y": 1231.9,
"centroid_x": 1737.0,
"centroid_y": 1296.0,
"area": 247272,
"orientation": -20.1884829564446,
"principal_length": 873.5,
@@ -14,8 +14,8 @@
{
"id": 27,
"name": "Chapellia",
"centroid_x": 3076.8,
"centroid_y": 508.9,
"centroid_x": 3077.0,
"centroid_y": 509.0,
"area": 37546,
"orientation": -70.52505264723656,
"principal_length": 297.6,
@@ -25,8 +25,8 @@
{
"id": 28,
"name": "Kezonoria",
"centroid_x": 2204.7,
"centroid_y": 1263.1,
"centroid_x": 2195.0,
"centroid_y": 1226.0,
"area": 126885,
"orientation": -35.386786918677814,
"principal_length": 753.6,
@@ -36,7 +36,7 @@
{
"id": 29,
"name": "Mesh",
"centroid_x": 2832.3,
"centroid_x": 2832.0,
"centroid_y": 1017.0,
"area": 197788,
"orientation": -26.173956537897066,
@@ -47,8 +47,8 @@
{
"id": 36,
"name": "Pemeria",
"centroid_x": 2839.4,
"centroid_y": 1290.4,
"centroid_x": 2835.0,
"centroid_y": 1282.0,
"area": 36356,
"orientation": -44.16012147839107,
"principal_length": 457.1,
@@ -58,8 +58,8 @@
{
"id": 37,
"name": "Yuetia",
"centroid_x": 896.1,
"centroid_y": 1227.1,
"centroid_x": 902.0,
"centroid_y": 1132.0,
"area": 362211,
"orientation": 0.6747576339005641,
"principal_length": 1261.0,
@@ -69,8 +69,8 @@
{
"id": 39,
"name": "Kojaria",
"centroid_x": 3233.1,
"centroid_y": 678.6,
"centroid_x": 3234.0,
"centroid_y": 678.0,
"area": 37489,
"orientation": -25.04939634407932,
"principal_length": 301.9,
@@ -80,8 +80,8 @@
{
"id": 43,
"name": "Chia",
"centroid_x": 1134.5,
"centroid_y": 1519.3,
"centroid_x": 1172.0,
"centroid_y": 1499.0,
"area": 75627,
"orientation": -35.91298964206631,
"principal_length": 441.6,
@@ -91,8 +91,8 @@
{
"id": 18,
"name": "Alah",
"centroid_x": 3057.6,
"centroid_y": 1330.3,
"centroid_x": 3056.0,
"centroid_y": 1330.0,
"area": 35076,
"orientation": -25.515287772906877,
"principal_length": 285.6,
@@ -102,8 +102,8 @@
{
"id": 34,
"name": "Turton",
"centroid_x": 2586.7,
"centroid_y": 1506.5,
"centroid_x": 2598.0,
"centroid_y": 1500.0,
"area": 37458,
"orientation": -89.27834794069042,
"principal_length": 295.6,
@@ -113,8 +113,8 @@
{
"id": 41,
"name": "Parnia",
"centroid_x": 397.9,
"centroid_y": 962.3,
"centroid_x": 398.0,
"centroid_y": 963.0,
"area": 184733,
"orientation": 80.31028460303715,
"principal_length": 816.0,
@@ -124,8 +124,8 @@
{
"id": 7,
"name": "Oryslia",
"centroid_x": 2494.9,
"centroid_y": 1104.4,
"centroid_x": 2476.0,
"centroid_y": 1058.0,
"area": 108679,
"orientation": 53.601133897917535,
"principal_length": 673.2,
@@ -135,7 +135,7 @@
{
"id": 6,
"name": "Soria",
"centroid_x": 611.2,
"centroid_x": 611.0,
"centroid_y": 587.0,
"area": 37567,
"orientation": 73.04044050523646,
@@ -146,7 +146,7 @@
{
"id": 26,
"name": "Reigia",
"centroid_x": 1538.6,
"centroid_x": 1538.0,
"centroid_y": 824.0,
"area": 105738,
"orientation": -56.461232070203295,
@@ -157,8 +157,8 @@
{
"id": 21,
"name": "Sigkarl",
"centroid_x": 1776.1,
"centroid_y": 688.7,
"centroid_x": 1776.0,
"centroid_y": 688.0,
"area": 52475,
"orientation": -80.26596706568765,
"principal_length": 411.4,
@@ -168,8 +168,8 @@
{
"id": 42,
"name": "Grytrand",
"centroid_x": 1064.1,
"centroid_y": 369.5,
"centroid_x": 1056.0,
"centroid_y": 373.0,
"area": 37511,
"orientation": 61.866124085696136,
"principal_length": 315.1,
@@ -179,8 +179,8 @@
{
"id": 13,
"name": "Al Raala",
"centroid_x": 3164.8,
"centroid_y": 1142.9,
"centroid_x": 3165.0,
"centroid_y": 1142.0,
"area": 64624,
"orientation": 1.6109698686653782,
"principal_length": 417.0,
@@ -190,8 +190,8 @@
{
"id": 3,
"name": "Laufarvia",
"centroid_x": 770.1,
"centroid_y": 437.8,
"centroid_x": 770.0,
"centroid_y": 437.0,
"area": 37521,
"orientation": -42.64914861683819,
"principal_length": 253.6,
@@ -201,7 +201,7 @@
{
"id": 8,
"name": "Atfordia",
"centroid_x": 2473.6,
"centroid_x": 2474.0,
"centroid_y": 527.0,
"area": 78637,
"orientation": -15.123132625107454,
@@ -212,8 +212,8 @@
{
"id": 33,
"name": "Usvol",
"centroid_x": 939.2,
"centroid_y": 417.9,
"centroid_x": 939.0,
"centroid_y": 418.0,
"area": 37617,
"orientation": 72.87731176765703,
"principal_length": 329.4,
@@ -223,8 +223,8 @@
{
"id": 9,
"name": "Bolve",
"centroid_x": 1554.3,
"centroid_y": 567.8,
"centroid_x": 1554.0,
"centroid_y": 568.0,
"area": 37519,
"orientation": -28.254514479055615,
"principal_length": 297.1,
@@ -234,8 +234,8 @@
{
"id": 17,
"name": "Hofolen",
"centroid_x": 1193.4,
"centroid_y": 496.5,
"centroid_x": 1192.0,
"centroid_y": 497.0,
"area": 37530,
"orientation": -40.73421792639953,
"principal_length": 321.3,
@@ -245,8 +245,8 @@
{
"id": 15,
"name": "Pieksa",
"centroid_x": 3373.6,
"centroid_y": 752.0,
"centroid_x": 3372.0,
"centroid_y": 753.0,
"area": 37474,
"orientation": -42.30480488812397,
"principal_length": 302.7,
@@ -256,8 +256,8 @@
{
"id": 1,
"name": "Shumal",
"centroid_x": 2841.2,
"centroid_y": 1426.4,
"centroid_x": 2841.0,
"centroid_y": 1426.0,
"area": 35197,
"orientation": -26.08271079122629,
"principal_length": 286.9,
@@ -267,8 +267,8 @@
{
"id": 5,
"name": "Musland",
"centroid_x": 2776.1,
"centroid_y": 685.8,
"centroid_x": 2776.0,
"centroid_y": 686.0,
"area": 88353,
"orientation": -67.33176588364896,
"principal_length": 410.3,
@@ -278,8 +278,8 @@
{
"id": 19,
"name": "Hella",
"centroid_x": 809.4,
"centroid_y": 600.1,
"centroid_x": 810.0,
"centroid_y": 600.0,
"area": 37632,
"orientation": -29.964829271657674,
"principal_length": 292.4,
@@ -289,7 +289,7 @@
{
"id": 24,
"name": "Tatoros",
"centroid_x": 2452.8,
"centroid_x": 2453.0,
"centroid_y": 1508.0,
"area": 37547,
"orientation": 64.24404335610743,
@@ -300,8 +300,8 @@
{
"id": 10,
"name": "Vovimaria",
"centroid_x": 608.9,
"centroid_y": 818.4,
"centroid_x": 613.0,
"centroid_y": 807.0,
"area": 37474,
"orientation": 42.325246247463234,
"principal_length": 451.9,
@@ -311,8 +311,8 @@
{
"id": 14,
"name": "Onmaa",
"centroid_x": 3464.3,
"centroid_y": 864.9,
"centroid_x": 3465.0,
"centroid_y": 876.0,
"area": 48302,
"orientation": -33.08899725807026,
"principal_length": 465.4,
@@ -323,7 +323,7 @@
"id": 32,
"name": "Ingia",
"centroid_x": 445.0,
"centroid_y": 614.2,
"centroid_y": 615.0,
"area": 37475,
"orientation": 40.330942997303495,
"principal_length": 270.3,
@@ -333,8 +333,8 @@
{
"id": 11,
"name": "Garholtia",
"centroid_x": 768.3,
"centroid_y": 805.8,
"centroid_x": 768.0,
"centroid_y": 807.0,
"area": 37509,
"orientation": 73.25523522382795,
"principal_length": 275.4,
@@ -344,8 +344,8 @@
{
"id": 2,
"name": "Motcia",
"centroid_x": 2989.1,
"centroid_y": 672.1,
"centroid_x": 2989.0,
"centroid_y": 674.0,
"area": 37541,
"orientation": 71.01019565314077,
"principal_length": 340.1,
@@ -355,8 +355,8 @@
{
"id": 20,
"name": "Elekes",
"centroid_x": 2354.4,
"centroid_y": 1346.4,
"centroid_x": 2355.0,
"centroid_y": 1346.0,
"area": 37570,
"orientation": -4.693928802826447,
"principal_length": 300.8,
@@ -366,8 +366,8 @@
{
"id": 16,
"name": "Oscasland",
"centroid_x": 2033.5,
"centroid_y": 740.9,
"centroid_x": 2033.0,
"centroid_y": 741.0,
"area": 92595,
"orientation": -7.875650568974214,
"principal_length": 519.3,
@@ -377,8 +377,8 @@
{
"id": 30,
"name": "Tegrot",
"centroid_x": 1736.5,
"centroid_y": 1018.5,
"centroid_x": 1736.0,
"centroid_y": 1018.0,
"area": 122426,
"orientation": -20.247381662688625,
"principal_length": 568.2,
@@ -388,8 +388,8 @@
{
"id": 40,
"name": "Faluria",
"centroid_x": 976.7,
"centroid_y": 744.3,
"centroid_x": 976.0,
"centroid_y": 746.0,
"area": 80344,
"orientation": -62.2520755175581,
"principal_length": 494.3,
@@ -399,8 +399,8 @@
{
"id": 25,
"name": "Fluria",
"centroid_x": 1245.7,
"centroid_y": 835.6,
"centroid_x": 1245.0,
"centroid_y": 836.0,
"area": 155157,
"orientation": -66.31259041323624,
"principal_length": 561.7,
@@ -410,8 +410,8 @@
{
"id": 35,
"name": "Tumala",
"centroid_x": 3211.2,
"centroid_y": 1409.8,
"centroid_x": 3210.0,
"centroid_y": 1409.0,
"area": 25474,
"orientation": 33.16003147165417,
"principal_length": 283.2,
@@ -421,8 +421,8 @@
{
"id": 31,
"name": "Nikemi",
"centroid_x": 3200.5,
"centroid_y": 882.0,
"centroid_x": 3200.0,
"centroid_y": 881.0,
"area": 40737,
"orientation": 57.09128390939083,
"principal_length": 378.6,
@@ -432,8 +432,8 @@
{
"id": 22,
"name": "Chipingia",
"centroid_x": 2206.4,
"centroid_y": 580.2,
"centroid_x": 2209.0,
"centroid_y": 588.0,
"area": 40656,
"orientation": -48.11179717863961,
"principal_length": 286.5,
@@ -443,8 +443,8 @@
{
"id": 23,
"name": "Eivikia",
"centroid_x": 1359.5,
"centroid_y": 506.6,
"centroid_x": 1360.0,
"centroid_y": 506.0,
"area": 37555,
"orientation": -49.357592556477876,
"principal_length": 285.3,
@@ -454,8 +454,8 @@
{
"id": 38,
"name": "Wichel",
"centroid_x": 2399.6,
"centroid_y": 792.4,
"centroid_x": 2395.0,
"centroid_y": 781.0,
"area": 122281,
"orientation": -26.154786547398853,
"principal_length": 627.0,
@@ -465,8 +465,8 @@
{
"id": 12,
"name": "Pozia",
"centroid_x": 2950.6,
"centroid_y": 338.6,
"centroid_x": 2960.0,
"centroid_y": 395.0,
"area": 56289,
"orientation": -65.77001402153415,
"principal_length": 522.5,
@@ -474,4 +474,4 @@
"curvature": -9.3
}
]
}
}
+115 -18
View File
@@ -9,6 +9,7 @@ Usage:
python generate_map.py --analyze # Analyze current map, show province sizes
python generate_map.py --generate # Generate new equalized map
python generate_map.py --visualize # Generate visualization of current vs new
python generate_map.py --update-centroids # Update label positions in centroids.json
"""
import argparse
@@ -89,6 +90,39 @@ def load_province_data() -> dict[int, dict]:
return provinces
def find_label_position(mask: np.ndarray) -> tuple[float, float]:
"""Find the best label position for a province.
Finds all pixels with at least 70% of the province's maximum possible
clearance from edges, then picks the one closest to the geometric centroid.
This guarantees good distance from borders/coastlines while keeping labels
as visually centered as possible.
Returns (x, y) in pixel coordinates.
"""
# Geometric centroid (visual center of mass)
y_coords, x_coords = np.where(mask)
mean_x = np.mean(x_coords)
mean_y = np.mean(y_coords)
# Distance from nearest edge for every province pixel
dist = ndimage.distance_transform_edt(mask)
max_clearance = np.max(dist)
# Require at least 70% of the best possible clearance
candidates = dist >= max_clearance * 0.7
if np.any(candidates):
cand_ys, cand_xs = np.where(candidates)
dists_to_centroid = (cand_xs - mean_x)**2 + (cand_ys - mean_y)**2
best = np.argmin(dists_to_centroid)
return float(cand_xs[best]), float(cand_ys[best])
# Fallback: pole of inaccessibility
max_idx = np.unravel_index(np.argmax(dist), dist.shape)
return float(max_idx[1]), float(max_idx[0])
def analyze_provinces(raw_gray: np.ndarray, province_data: dict) -> list[Province]:
"""Calculate centroid and area for each province."""
provinces = []
@@ -102,10 +136,7 @@ def analyze_provinces(raw_gray: np.ndarray, province_data: dict) -> list[Provinc
print(f"Warning: Province {province_id} ({data['name']}) has no pixels")
continue
# Calculate centroid (center of mass)
y_coords, x_coords = np.where(mask)
centroid_x = np.mean(x_coords)
centroid_y = np.mean(y_coords)
centroid_x, centroid_y = find_label_position(mask)
provinces.append(Province(
id=province_id,
@@ -530,26 +561,85 @@ def generate_visualization(
def save_centroids(provinces: list[Province], output_path: Path):
"""Save province centroids to JSON for Unity label positioning."""
data = {
"provinces": [
{
"id": int(p.id),
"name": p.name,
"centroid_x": float(p.centroid[0]),
"centroid_y": float(p.centroid[1]),
"area": int(p.area)
}
for p in provinces
]
}
"""Save province centroids to JSON for Unity label positioning.
Preserves hand-tuned fields (orientation, principal_length, etc.)
from existing centroids.json if present.
"""
# Load existing data to preserve hand-tuned fields
existing_by_id = {}
for candidate in [output_path, UNITY_ASSETS / "centroids.json"]:
if candidate.exists():
with open(candidate, 'r') as f:
existing_by_id = {p['id']: p for p in json.load(f)['provinces']}
break
province_list = []
for p in provinces:
entry = {
"id": int(p.id),
"name": p.name,
"centroid_x": round(float(p.centroid[0]), 1),
"centroid_y": round(float(p.centroid[1]), 1),
"area": int(p.area)
}
# Preserve hand-tuned fields from existing file
if p.id in existing_by_id:
for field in ("orientation", "principal_length", "perpendicular_width", "curvature"):
if field in existing_by_id[p.id]:
entry[field] = existing_by_id[p.id][field]
province_list.append(entry)
data = {"provinces": province_list}
with open(output_path, 'w') as f:
json.dump(data, f, indent=2)
f.write('\n')
print(f"Saved centroids to {output_path}")
def update_centroids(raw_gray: np.ndarray, province_data: dict):
"""Update centroids.json with pole-of-inaccessibility positions.
Only updates centroid_x and centroid_y, preserving all hand-tuned fields
(orientation, principal_length, perpendicular_width, curvature).
"""
centroids_path = UNITY_ASSETS / "centroids.json"
with open(centroids_path, 'r') as f:
data = json.load(f)
existing_by_id = {p['id']: p for p in data['provinces']}
updated = 0
for province_id, pdata in province_data.items():
mask = raw_gray == province_id
area = np.sum(mask)
if area == 0:
print(f"Warning: Province {province_id} ({pdata['name']}) has no pixels")
continue
lx, ly = find_label_position(mask)
new_x = round(lx, 1)
new_y = round(ly, 1)
if province_id in existing_by_id:
entry = existing_by_id[province_id]
old_x, old_y = entry['centroid_x'], entry['centroid_y']
if old_x != new_x or old_y != new_y:
print(f" {pdata['name']:20s}: ({old_x}, {old_y}) -> ({new_x}, {new_y})")
entry['centroid_x'] = new_x
entry['centroid_y'] = new_y
updated += 1
with open(centroids_path, 'w') as f:
json.dump(data, f, indent=2)
f.write('\n')
print(f"Updated {updated} centroids in {centroids_path}")
def save_raw_gray(province_map: np.ndarray, output_path: Path, compress: bool = True):
"""Save province map as raw bytes (optionally gzip compressed)."""
# Flip for Unity (bottom-left origin)
@@ -572,6 +662,8 @@ def main():
parser.add_argument('--analyze', action='store_true', help='Analyze current map')
parser.add_argument('--generate', action='store_true', help='Generate new equalized map')
parser.add_argument('--visualize', action='store_true', help='Generate visualization')
parser.add_argument('--update-centroids', action='store_true',
help='Update centroid positions in centroids.json (preserves hand-tuned fields)')
parser.add_argument('--target-ratio', type=float, default=0.5,
help='Target minimum province size as ratio of average (default: 0.5)')
parser.add_argument('--output-dir', type=Path, default=SCRIPT_DIR / 'output',
@@ -579,7 +671,7 @@ def main():
args = parser.parse_args()
if not any([args.analyze, args.generate, args.visualize]):
if not any([args.analyze, args.generate, args.visualize, args.update_centroids]):
args.analyze = True # Default to analyze
print("Loading province data...")
@@ -590,6 +682,11 @@ def main():
raw_gray = load_raw_gray()
print(f"Map dimensions: {raw_gray.shape[1]} x {raw_gray.shape[0]}")
if args.update_centroids:
print("Updating centroids with pole-of-inaccessibility positions...")
update_centroids(raw_gray, province_data)
return
print("Analyzing provinces...")
provinces = analyze_provinces(raw_gray, province_data)