OpenRA-Bench / openra_bench /minimap.py
Xiaochuang Yuan
Merge origin/main into pr13-revised
d4d38ed
Raw
History Blame Contribute Delete
22.1 kB
"""Self-contained minimap PNG (no training-repo / terrain_png dep).
The bench engine emits an ASCII fog grid (`#`=unknown, `.`=explored)
plus unit/building cell lists — enough to draw a real, legible colour
minimap the model can actually see. Pure; returns base64 PNG, or None
if Pillow is missing or there's nothing to draw (graceful text-only
fallback).
"""
from __future__ import annotations
import base64
import io
import logging
import zipfile
from functools import lru_cache
logger = logging.getLogger(__name__)
@lru_cache(maxsize=32)
def terrain_png_for(base_map: str) -> bytes | None:
"""Raw `map.png` bytes from the resolved `.oramap` (a zip), cached.
None when the map can't be resolved — caller falls back."""
if not base_map:
return None
try:
from .scenarios.loader import resolve_map_path
p = resolve_map_path(base_map)
if not p:
return None
with zipfile.ZipFile(p, "r") as zf:
if "map.png" in zf.namelist():
return zf.read("map.png")
except Exception as e: # noqa: BLE001
logger.debug("terrain extract failed for %s: %s", base_map, e)
return None
def render_b64(render_state: dict, terrain_png: bytes | None = None) -> str | None:
"""Preferred minimap: the training renderer (real terrain + an
embedded legend the model can read) when terrain is available;
otherwise the self-contained bench fallback. Either way a *valid*
base64 PNG, or None for graceful text-only."""
if terrain_png:
try:
from openra_rl_training.training.minimap_renderer import (
render_minimap,
)
b64 = render_minimap(
terrain_png=terrain_png,
map_width=int(render_state.get("map_width", 64) or 64),
map_height=int(render_state.get("map_height", 64) or 64),
bounds_x=int(render_state.get("bounds_x", 0) or 0),
bounds_y=int(render_state.get("bounds_y", 0) or 0),
own_units=render_state.get("units_summary", []) or [],
enemy_units=render_state.get("enemy_summary", []) or [],
ascii_minimap=render_state.get("minimap", "") or "",
)
if b64:
return b64
except Exception as e: # noqa: BLE001 — fall back below
logger.debug("training minimap failed, using fallback: %s", e)
return render_png_b64(render_state)
CELL = 6 # px per map cell (≈768×240 for a 128×40 map — legible)
# (R,G,B)
_BG_UNKNOWN = (18, 18, 22) # '#' fog / never seen
_BG_EXPLORED = (70, 74, 82) # '.' revealed terrain
_OWN = (60, 200, 90) # your units
_OWN_BLD = (60, 130, 230) # your buildings
_ENEMY = (225, 60, 55) # enemy units
_ENEMY_BLD = (240, 160, 40) # enemy buildings
_OBJECTIVE = (255, 218, 70) # objective / target region
def _draw_cell(px, w: int, h: int, cx: int, cy: int, rgb, r: int = 1) -> None:
for yy in range(max(0, cy - r), min(h, cy + r + 1)):
for xx in range(max(0, cx - r), min(w, cx + r + 1)):
for sy in range(CELL):
for sx in range(CELL):
px[xx * CELL + sx, yy * CELL + sy] = rgb
def render_png_b64(render_state: dict) -> str | None:
try:
from PIL import Image
except Exception: # noqa: BLE001 — vision optional
return None
ascii_mm = render_state.get("minimap") or ""
rows = [r for r in ascii_mm.split("\n") if r]
if not rows:
return None
h = len(rows)
w = max(len(r) for r in rows)
if w == 0 or w * h > 200_000: # sanity cap
return None
img = Image.new("RGB", (w * CELL, h * CELL), _BG_UNKNOWN)
px = img.load()
for y, row in enumerate(rows):
for x, ch in enumerate(row):
if ch != "#": # explored / visible
for sy in range(CELL):
for sx in range(CELL):
px[x * CELL + sx, y * CELL + sy] = _BG_EXPLORED
def _plot(items, rgb_unit, rgb_bld):
for it in items or []:
if not isinstance(it, dict):
continue
cx, cy = int(it.get("cell_x", 0)), int(it.get("cell_y", 0))
if 0 <= cx < w and 0 <= cy < h:
is_b = bool(it.get("is_building"))
_draw_cell(px, w, h, cx, cy, rgb_bld if is_b else rgb_unit,
r=1 if is_b else 0)
_plot(render_state.get("units_summary"), _OWN, _OWN_BLD)
_plot(render_state.get("own_buildings"), _OWN_BLD, _OWN_BLD)
_plot(render_state.get("enemy_summary"), _ENEMY, _ENEMY_BLD)
try:
buf = io.BytesIO()
img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
except Exception as e: # noqa: BLE001
logger.debug("minimap encode failed: %s", e)
return None
# ── Tactical minimap: per-type shapes + overlap counts + legend ───────
# A richer renderer than render_png_b64 — distinguishes unit TYPES by
# shape, shows a COUNT when units stack on one cell (otherwise they
# render as a single dot), draws a coordinate GRID, and an embedded
# LEGEND. Used by the human Play tab; reusable for the model's view.
_INFANTRY_TYPES = {"e1", "e2", "e3", "e4", "e6", "e7", "medi", "mech",
"spy", "thf", "dog", "engineer"}
def _unit_category(actor_type: str, is_building: bool) -> str:
"""Coarse class for shape selection: infantry / harvester /
building / vehicle."""
t = (actor_type or "").strip().lower()
if is_building:
return "building"
if t.startswith("harv"):
return "harvester"
if t in _INFANTRY_TYPES or (
len(t) == 2 and t[0] == "e" and t[1].isdigit()
):
return "infantry"
return "vehicle"
def _minimap_font(size: int):
"""A legible TrueType font (falls back to a scaled default)."""
from PIL import ImageFont
for path in (
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
):
try:
return ImageFont.truetype(path, size)
except Exception: # noqa: BLE001
continue
try:
return ImageFont.load_default(size=size)
except Exception: # noqa: BLE001
return ImageFont.load_default()
# Distinct SHAPE per unit TYPE — so e.g. 1tnk and 2tnk are visually
# different on the minimap, not both "a vehicle". Types not listed fall
# back by category (infantry→circle, harvester→tridown, else→square).
_TYPE_SHAPE = {
"e1": "circle", "e2": "circle", "e3": "circle", "e4": "circle",
"e6": "circle", "e7": "circle", "medi": "circle", "mech": "circle",
"spy": "circle", "thf": "circle", "dog": "circle", "engineer": "circle",
"1tnk": "square", "2tnk": "diamond", "3tnk": "hexagon",
"4tnk": "triangle", "harv": "tridown",
"jeep": "pentagon", "apc": "pentagon", "mcv": "pentagon",
"arty": "star", "v2rl": "star", "ftrk": "star",
}
def _unit_shape(actor_type: str, is_building: bool) -> str:
"""Shape key for a unit/building — distinct per unit TYPE."""
if is_building:
return "building"
t = (actor_type or "").strip().lower()
if t in _TYPE_SHAPE:
return _TYPE_SHAPE[t]
cat = _unit_category(t, False)
if cat == "infantry":
return "circle"
if cat == "harvester":
return "tridown"
return "square"
def _shape_points(shape, x0, y0, x1, y1):
"""Polygon vertices for `shape` in the box; None for an ellipse."""
import math
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
rx, ry = (x1 - x0) / 2, (y1 - y0) / 2
if shape == "circle":
return None
if shape == "diamond":
return [(mx, y0), (x1, my), (mx, y1), (x0, my)]
if shape == "triangle":
return [(mx, y0), (x1, y1), (x0, y1)]
if shape == "tridown":
return [(x0, y0), (x1, y0), (mx, y1)]
if shape in ("hexagon", "pentagon", "star"):
n = {"hexagon": 6, "pentagon": 5, "star": 5}[shape]
pts = []
if shape == "star":
for i in range(2 * n):
ang = -math.pi / 2 + i * math.pi / n
r = 1.0 if i % 2 == 0 else 0.42
pts.append((mx + r * rx * math.cos(ang),
my + r * ry * math.sin(ang)))
else:
for i in range(n):
ang = -math.pi / 2 + i * 2 * math.pi / n
pts.append((mx + rx * math.cos(ang),
my + ry * math.sin(ang)))
return pts
return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] # square / building
def _draw_shape(draw, x0, y0, x1, y1, shape, color):
"""Draw `shape` filling the box (x0,y0)-(x1,y1) in `color`."""
outline = (15, 15, 18)
if shape == "circle":
draw.ellipse([x0, y0, x1, y1], fill=color, outline=outline)
elif shape == "building":
draw.rectangle([x0, y0, x1, y1], fill=color,
outline=outline, width=2)
else:
draw.polygon(_shape_points(shape, x0, y0, x1, y1),
fill=color, outline=outline)
def _draw_unit_shape(draw, cx, cy, cp, shape, color):
"""Draw `shape` filling ~70% of the cp-px cell at grid (cx, cy)."""
m = cp * 0.16
_draw_shape(draw, cx * cp + m, cy * cp + m,
(cx + 1) * cp - m, (cy + 1) * cp - m, shape, color)
def _draw_objective_region(draw, region, cp, w, h, index):
try:
cx = int(region["x"])
cy = int(region["y"])
radius = float(region.get("radius", 3))
except (KeyError, TypeError, ValueError):
return
if not (0 <= cx < w and 0 <= cy < h):
return
x0 = max(0, (cx - radius) * cp)
y0 = max(0, (cy - radius) * cp)
x1 = min(w * cp - 1, (cx + radius + 1) * cp - 1)
y1 = min(h * cp - 1, (cy + radius + 1) * cp - 1)
width = max(3, cp // 7)
draw.ellipse([x0, y0, x1, y1], outline=_OBJECTIVE, width=width)
mid_x, mid_y = (cx + 0.5) * cp, (cy + 0.5) * cp
arm = max(cp * 0.45, width * 2)
draw.line([(mid_x - arm, mid_y), (mid_x + arm, mid_y)],
fill=_OBJECTIVE, width=width)
draw.line([(mid_x, mid_y - arm), (mid_x, mid_y + arm)],
fill=_OBJECTIVE, width=width)
label = str(region.get("label") or f"OBJ {index}")
font = _minimap_font(max(11, int(cp * 0.7)))
draw.text(
(min(w * cp - cp * 4, max(2, x0 + cp * 0.2)), max(2, y0 - cp)),
label, fill=_OBJECTIVE, font=font,
stroke_width=max(2, cp // 12), stroke_fill=(0, 0, 0),
)
def _draw_move_arrow(draw, fx, fy, tx, ty, cp, color):
"""Arrow from cell (fx,fy) centre to cell (tx,ty) centre — a unit's
move/attack destination link."""
import math
x0, y0 = (fx + 0.5) * cp, (fy + 0.5) * cp
x1, y1 = (tx + 0.5) * cp, (ty + 0.5) * cp
width = max(2, cp // 8)
draw.line([(x0, y0), (x1, y1)], fill=color, width=width)
# Arrowhead at the destination.
ang = math.atan2(y1 - y0, x1 - x0)
head = cp * 0.7
spread = math.radians(26)
p1 = (x1 - head * math.cos(ang - spread),
y1 - head * math.sin(ang - spread))
p2 = (x1 - head * math.cos(ang + spread),
y1 - head * math.sin(ang + spread))
draw.polygon([(x1, y1), p1, p2], fill=color)
def render_tactical_minimap(
render_state: dict,
scale: int = 4,
grid: bool = True,
legend: bool = True,
selected=None,
arrows=None,
unit_labels=None,
):
"""A legible tactical minimap as a PIL RGB image:
* per-type SHAPES — ● infantry, ■ vehicle, ▲ harvester, ◆ building;
* COUNT badge when >1 unit stacks on a cell (so overlapping units
are not silently rendered as one dot);
* colour by side — green = you, red = enemy;
* a coordinate GRID with axis labels every 10 cells, and a LEGEND
strip beneath the map.
`scale` multiplies the 6px base cell. Returns None if Pillow is
missing or there is nothing to draw.
`unit_labels` (id-str → handle, e.g. `{"1004": "tank-1"}`) drives
the image-primary perception channel: every actor is tagged with
its legible handle so the model can identify and command units
from the picture alone (the text briefing carries no positions).
When set, the per-cell count badge is replaced by the labels."""
try:
from PIL import Image, ImageDraw
except Exception: # noqa: BLE001
return None
rows = [r for r in (render_state.get("minimap") or "").split("\n")
if r]
if not rows:
return None
h = len(rows)
w = max(len(r) for r in rows)
if w == 0 or w * h > 200_000:
return None
cp = max(1, CELL * scale)
legend_h = cp * 2 if legend else 0
img = Image.new("RGB", (w * cp, h * cp + legend_h), _BG_UNKNOWN)
draw = ImageDraw.Draw(img)
# Explored terrain.
for y, row in enumerate(rows):
for x, ch in enumerate(row):
if ch != "#":
draw.rectangle(
[x * cp, y * cp, (x + 1) * cp - 1, (y + 1) * cp - 1],
fill=_BG_EXPLORED,
)
for i, region in enumerate(render_state.get("objective_regions") or [], 1):
if isinstance(region, dict):
_draw_objective_region(draw, region, cp, w, h, i)
# Collect every actor by cell so stacked units can be counted.
by_cell: dict = {}
# Distinct (type, shape) of OWN units seen — drives the legend.
own_types: dict = {}
def _is_bld(shape):
return shape == "building"
def _collect(items, side, force_building):
for it in items or []:
if not isinstance(it, dict):
continue
cx = int(it.get("cell_x", -99))
cy = int(it.get("cell_y", -99))
if not (0 <= cx < w and 0 <= cy < h):
continue
is_b = force_building or bool(it.get("is_building"))
atype = (it.get("actor_type") or it.get("type") or "?")
shape = _unit_shape(atype, is_b)
by_cell.setdefault((cx, cy), []).append(
(side, shape, it.get("id"))
)
if side == "own" and atype != "?":
own_types.setdefault(str(atype).lower(), shape)
_collect(render_state.get("units_summary"), "own", False)
_collect(render_state.get("own_buildings"), "own", True)
_collect(render_state.get("enemy_summary"), "enemy", False)
_collect(
render_state.get("enemy_buildings_summary")
or render_state.get("enemy_buildings"),
"enemy", True,
)
def _color(side, shape):
if side == "own":
return _OWN_BLD if _is_bld(shape) else _OWN
return _ENEMY_BLD if _is_bld(shape) else _ENEMY
badge_font = _minimap_font(max(9, int(cp * 0.62)))
for (cx, cy), occ in by_cell.items():
# Dominant occupant decides the shape; prefer a building.
side, shape, _aid = next(
(o for o in occ if _is_bld(o[1])), occ[0]
)
_draw_unit_shape(draw, cx, cy, cp, shape, _color(side, shape))
# The count badge and per-unit labels are mutually exclusive —
# `unit_labels` (image-primary) names each occupant individually,
# which already disambiguates a stack.
if len(occ) > 1 and not unit_labels:
tx, ty = (cx + 1) * cp - cp * 0.42, cy * cp + 1
draw.text(
(tx, ty), str(len(occ)), fill=(255, 255, 255),
font=badge_font, stroke_width=max(2, cp // 12),
stroke_fill=(0, 0, 0),
)
# Movement arrows — drawn under the selection boxes so a selected
# unit's boundary stays on top. queued = yellow, en-route = cyan.
for ar in (arrows or []):
try:
fx, fy, tx, ty, kind = ar
except (ValueError, TypeError):
continue
col = (255, 230, 90) if kind == "queued" else (90, 220, 245)
_draw_move_arrow(draw, fx, fy, tx, ty, cp, col)
# Selection boundary — a bright white box around each selected
# unit's cell.
if selected:
sel_ids = {str(s) for s in selected}
for u in render_state.get("units_summary", []) or []:
if not isinstance(u, dict):
continue
if str(u.get("id", "")) not in sel_ids:
continue
cx = int(u.get("cell_x", -99))
cy = int(u.get("cell_y", -99))
if 0 <= cx < w and 0 <= cy < h:
inset = max(1, cp // 14)
draw.rectangle(
[cx * cp + inset, cy * cp + inset,
(cx + 1) * cp - inset, (cy + 1) * cp - inset],
outline=(255, 255, 255), width=max(2, cp // 8),
)
# Coordinate grid + axis labels.
if grid:
gcol = (120, 123, 135)
lcol = (255, 246, 120)
gfont = _minimap_font(max(12, int(cp * 0.85)))
map_h = h * cp
for gx in range(0, w + 1, 10):
x = min(w * cp - 1, gx * cp)
draw.line([(x, 0), (x, map_h)], fill=gcol, width=2)
if gx < w:
draw.text(
(x + 3, 2), str(gx), fill=lcol, font=gfont,
stroke_width=3, stroke_fill=(0, 0, 0),
)
for gy in range(0, h + 1, 10):
y = min(map_h - 1, gy * cp)
draw.line([(0, y), (w * cp, y)], fill=gcol, width=2)
if gy < h:
draw.text(
(3, y + 2), str(gy), fill=lcol, font=gfont,
stroke_width=3, stroke_fill=(0, 0, 0),
)
# Per-unit ID labels — the image-primary channel. Each actor's
# legible handle (`tank-1`, `enemy-2`) is placed near its marker
# with greedy collision avoidance (labels nudge clear of one
# another into free space) and a leader line when a label drifts
# off its cell — so the model can identify every unit from the
# picture alone. Drawn last so nothing occludes the text.
if unit_labels:
lab_font = _minimap_font(max(15, int(cp * 0.95)))
img_w, img_h = w * cp, h * cp
# One entry per actor id — an actor can appear in both the unit
# and building lists; dedup so its label is drawn once.
seen: set = set()
actors = [] # (cx, cy, label, side)
for (cx, cy), occ in by_cell.items():
for side, _shape, aid in occ:
key = str(aid)
if aid is None or key in seen or key not in unit_labels:
continue
seen.add(key)
actors.append((cx, cy, unit_labels[key], side))
placed: list = [] # occupied label rects (x0, y0, x1, y1)
def _free(x0, y0, x1, y1):
return all(
x1 < r[0] or x0 > r[2] or y1 < r[1] or y0 > r[3]
for r in placed
)
for cx, cy, lab, side in sorted(actors, key=lambda a: (a[1], a[0])):
try:
bb = draw.textbbox((0, 0), lab, font=lab_font, stroke_width=3)
tw, th = bb[2] - bb[0], bb[3] - bb[1]
except Exception: # noqa: BLE001
tw, th = len(lab) * 9, 16
mx, my = cx * cp + cp // 2, cy * cp + cp // 2
lx = min(max(0, mx + 4), img_w - tw - 1)
ly = my - cp - th
step = th + 3
for _ in range(60): # nudge upward into free space
cand = min(max(1, ly), img_h - th - 1)
if _free(lx, cand, lx + tw, cand + th):
ly = cand
break
ly -= step
else:
ly = min(max(1, my + cp), img_h - th - 1)
placed.append((lx, ly, lx + tw, ly + th))
# Leader line when the label sits away from its marker.
if abs(lx + tw // 2 - mx) > cp or abs(ly + th // 2 - my) > cp:
draw.line(
[(mx, my), (lx + 2, ly + th // 2)],
fill=(165, 167, 178), width=1,
)
col = (175, 255, 175) if side == "own" else (255, 190, 172)
draw.text(
(lx, ly), lab, fill=col, font=lab_font,
stroke_width=3, stroke_fill=(0, 0, 0),
)
# Legend strip — the unit TYPES actually present, each with its
# own shape, so the player can read 1tnk vs 2tnk vs e3 etc.
if legend:
ly = h * cp
draw.rectangle([0, ly, w * cp, ly + legend_h], fill=(24, 24, 30))
lfont = _minimap_font(max(11, int(cp * 0.7)))
sample = cp
m = sample * 0.16
x = int(cp * 0.4)
row_y = ly + int(cp * 0.16)
shown = sorted(own_types.items())[:8] or [("unit", "square")]
for tname, shape in shown:
col = _OWN_BLD if _is_bld(shape) else _OWN
_draw_shape(draw, x + m, row_y + m,
x + sample - m, row_y + sample - m, shape, col)
tx = x + sample + int(cp * 0.18)
draw.text((tx, row_y + sample * 0.2), tname,
fill=(235, 235, 245), font=lfont)
try:
tw = draw.textlength(tname, font=lfont)
except Exception: # noqa: BLE001
tw = len(tname) * cp * 0.5
x = int(tx + tw + cp * 0.7)
help_txt = (
"green = you red/orange = enemy yellow ring = objective "
"label = unit id (pass it to your tools) "
"scout the dark area to reveal more"
if unit_labels else
"green = you red/orange = enemy yellow ring = objective "
"number = units stacked white box = selected arrow = order"
)
draw.text(
(int(cp * 0.4), ly + int(cp * 1.05)), help_txt,
fill=(200, 202, 212), font=lfont,
)
return img