Spaces:
Running
Running
| """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__) | |
| 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 | |