|
| 1 | +""" |
| 2 | +Utilities to generate ASCII (optionally ANSI-colored) art for the user's terminal. |
| 3 | +
|
| 4 | +Cross-platform and CI-safe: |
| 5 | +- Detects terminal width using shutil.get_terminal_size (portable across OSes). |
| 6 | +- Respects NO_COLOR and a color mode (auto|always|never). |
| 7 | +- Enables ANSI color on Windows PowerShell/cmd via os.system("") when needed. |
| 8 | +- Supports transparent PNGs (alpha) by compositing over a chosen background color. |
| 9 | +- Optional crop-to-content using alpha or a background heuristic when no alpha. |
| 10 | +
|
| 11 | +Dependencies: opencv-python, numpy |
| 12 | +""" |
| 13 | + |
| 14 | +# dlclivegui/assets/ascii.py |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import os |
| 18 | +import shutil |
| 19 | +import sys |
| 20 | +from collections.abc import Iterable |
| 21 | +from importlib import resources |
| 22 | +from typing import Literal |
| 23 | + |
| 24 | +import numpy as np |
| 25 | + |
| 26 | +try: |
| 27 | + import cv2 as cv |
| 28 | +except Exception as e: # pragma: no cover |
| 29 | + raise RuntimeError( |
| 30 | + "OpenCV (opencv-python) is required for dlclivegui.assets.ascii.\nInstall with: pip install opencv-python" |
| 31 | + ) from e |
| 32 | + |
| 33 | +# Character ramps (dense -> sparse) |
| 34 | +ASCII_RAMP_SIMPLE = "@%#*+=-:. " |
| 35 | +ASCII_RAMP_FINE = "@$B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. " |
| 36 | + |
| 37 | +ColorMode = Literal["auto", "always", "never"] |
| 38 | + |
| 39 | +# ----------------------------- |
| 40 | +# Terminal / ANSI capabilities |
| 41 | +# ----------------------------- |
| 42 | + |
| 43 | + |
| 44 | +def enable_windows_ansi_support() -> None: |
| 45 | + """Enable ANSI escape support in Windows terminals. |
| 46 | + Safe to call on any OS; no-op on non-Windows. |
| 47 | + """ |
| 48 | + if os.name == "nt": |
| 49 | + # This call toggles the console mode to enable VT processing in many hosts |
| 50 | + os.system("") |
| 51 | + |
| 52 | + |
| 53 | +def get_terminal_width(default: int = 80) -> int: |
| 54 | + """Return terminal width in columns, or a fallback if stdout is not a TTY.""" |
| 55 | + try: |
| 56 | + if not sys.stdout.isatty(): |
| 57 | + return default |
| 58 | + return shutil.get_terminal_size((default, 24)).columns |
| 59 | + except Exception: |
| 60 | + return default |
| 61 | + |
| 62 | + |
| 63 | +def should_use_color(mode: ColorMode = "auto") -> bool: |
| 64 | + """Determine if colored ANSI output should be emitted. |
| 65 | +
|
| 66 | + - 'never': never use color |
| 67 | + - 'always': always use color (even when redirected) |
| 68 | + - 'auto': use color only when stdout is a TTY and NO_COLOR is not set |
| 69 | + """ |
| 70 | + if mode == "never": |
| 71 | + return False |
| 72 | + if mode == "always": |
| 73 | + return True |
| 74 | + # auto |
| 75 | + if os.environ.get("NO_COLOR"): |
| 76 | + return False |
| 77 | + return sys.stdout.isatty() |
| 78 | + |
| 79 | + |
| 80 | +def terminal_is_wide_enough(min_width: int = 60) -> bool: |
| 81 | + if not sys.stdout.isatty(): |
| 82 | + return False |
| 83 | + return get_terminal_width() >= min_width |
| 84 | + |
| 85 | + |
| 86 | +# ----------------------------- |
| 87 | +# Image helpers |
| 88 | +# ----------------------------- |
| 89 | + |
| 90 | + |
| 91 | +def _to_bgr(img: np.ndarray) -> np.ndarray: |
| 92 | + """Ensure an image array is 3-channel BGR.""" |
| 93 | + if img.ndim == 2: |
| 94 | + return cv.cvtColor(img, cv.COLOR_GRAY2BGR) |
| 95 | + if img.ndim == 3 and img.shape[2] == 3: |
| 96 | + return img |
| 97 | + if img.ndim == 3 and img.shape[2] == 4: |
| 98 | + # Caller should composite first; keep as-is for now |
| 99 | + b, g, r, a = cv.split(img) |
| 100 | + return cv.merge((b, g, r)) |
| 101 | + raise ValueError(f"Unsupported image shape for BGR conversion: {img.shape!r}") |
| 102 | + |
| 103 | + |
| 104 | +def composite_over_color(img: np.ndarray, bg_bgr: tuple[int, int, int] = (255, 255, 255)) -> np.ndarray: |
| 105 | + """If img has alpha (BGRA), alpha-composite over a solid BGR color and return BGR.""" |
| 106 | + if img.ndim == 3 and img.shape[2] == 4: |
| 107 | + b, g, r, a = cv.split(img) |
| 108 | + af = (a.astype(np.float32) / 255.0)[..., None] # (H,W,1) |
| 109 | + bgr = cv.merge((b, g, r)).astype(np.float32) |
| 110 | + bg = np.empty_like(bgr, dtype=np.float32) |
| 111 | + bg[..., 0] = bg_bgr[0] |
| 112 | + bg[..., 1] = bg_bgr[1] |
| 113 | + bg[..., 2] = bg_bgr[2] |
| 114 | + out = af * bgr + (1.0 - af) * bg |
| 115 | + return np.clip(out, 0, 255).astype(np.uint8) |
| 116 | + return _to_bgr(img) |
| 117 | + |
| 118 | + |
| 119 | +def crop_to_content_alpha(img_bgra: np.ndarray, alpha_thresh: int = 1, pad: int = 0) -> np.ndarray: |
| 120 | + """Crop to bounding box of pixels where alpha > alpha_thresh. Returns BGRA.""" |
| 121 | + if not (img_bgra.ndim == 3 and img_bgra.shape[2] == 4): |
| 122 | + return img_bgra |
| 123 | + a = img_bgra[..., 3] |
| 124 | + mask = a > alpha_thresh |
| 125 | + if not mask.any(): |
| 126 | + return img_bgra |
| 127 | + ys, xs = np.where(mask) |
| 128 | + y0, y1 = ys.min(), ys.max() |
| 129 | + x0, x1 = xs.min(), xs.max() |
| 130 | + if pad: |
| 131 | + h, w = a.shape |
| 132 | + y0 = max(0, y0 - pad) |
| 133 | + x0 = max(0, x0 - pad) |
| 134 | + y1 = min(h - 1, y1 + pad) |
| 135 | + x1 = min(w - 1, x1 + pad) |
| 136 | + return img_bgra[y0 : y1 + 1, x0 : x1 + 1, :] |
| 137 | + |
| 138 | + |
| 139 | +def crop_to_content_bg( |
| 140 | + img_bgr: np.ndarray, bg: Literal["white", "black"] = "white", tol: int = 10, pad: int = 0 |
| 141 | +) -> np.ndarray: |
| 142 | + """Heuristic crop when no alpha: assume uniform white or black background. |
| 143 | + Returns BGR. |
| 144 | + """ |
| 145 | + if not (img_bgr.ndim == 3 and img_bgr.shape[2] == 3): |
| 146 | + img_bgr = _to_bgr(img_bgr) |
| 147 | + if bg == "white": |
| 148 | + dist = 255 - img_bgr.max(axis=2) # darker than white |
| 149 | + mask = dist > tol |
| 150 | + else: |
| 151 | + dist = img_bgr.max(axis=2) # brighter than black |
| 152 | + mask = dist > tol |
| 153 | + if not mask.any(): |
| 154 | + return img_bgr |
| 155 | + ys, xs = np.where(mask) |
| 156 | + y0, y1 = ys.min(), ys.max() |
| 157 | + x0, x1 = xs.min(), xs.max() |
| 158 | + if pad: |
| 159 | + h, w = mask.shape |
| 160 | + y0 = max(0, y0 - pad) |
| 161 | + x0 = max(0, x0 - pad) |
| 162 | + y1 = min(h - 1, y1 + pad) |
| 163 | + x1 = min(w - 1, x1 + pad) |
| 164 | + return img_bgr[y0 : y1 + 1, x0 : x1 + 1, :] |
| 165 | + |
| 166 | + |
| 167 | +def resize_for_terminal(img: np.ndarray, width: int | None, aspect: float | None) -> np.ndarray: |
| 168 | + """Resize image for terminal display. |
| 169 | +
|
| 170 | + Parameters |
| 171 | + ---------- |
| 172 | + width: target character width (None -> current terminal width) |
| 173 | + aspect: character cell height/width ratio; default 0.5 is good for many fonts. |
| 174 | + """ |
| 175 | + h, w = img.shape[:2] |
| 176 | + if width is None: |
| 177 | + width = get_terminal_width(100) |
| 178 | + width = max(20, int(width)) |
| 179 | + if aspect is None: |
| 180 | + # Allow override by env var, else default 0.5 |
| 181 | + try: |
| 182 | + aspect = float(os.environ.get("DLCLIVE_ASCII_ASPECT", "0.5")) |
| 183 | + except ValueError: |
| 184 | + aspect = 0.5 |
| 185 | + new_h = max(1, int((h / w) * width * aspect)) |
| 186 | + return cv.resize(img, (width, new_h), interpolation=cv.INTER_AREA) |
| 187 | + |
| 188 | + |
| 189 | +# ----------------------------- |
| 190 | +# Rendering |
| 191 | +# ----------------------------- |
| 192 | + |
| 193 | + |
| 194 | +def _map_luminance_to_chars(gray: np.ndarray, fine: bool) -> Iterable[str]: |
| 195 | + ramp = ASCII_RAMP_FINE if fine else ASCII_RAMP_SIMPLE |
| 196 | + idx = (gray.astype(np.float32) / 255.0 * (len(ramp) - 1)).astype(np.int32) |
| 197 | + lines = ["".join(ramp[i] for i in row) for row in idx] |
| 198 | + return lines |
| 199 | + |
| 200 | + |
| 201 | +def _color_ascii_lines(img_bgr: np.ndarray, fine: bool, invert: bool) -> Iterable[str]: |
| 202 | + ramp = ASCII_RAMP_FINE if fine else ASCII_RAMP_SIMPLE |
| 203 | + b, g, r = cv.split(img_bgr) |
| 204 | + lum = (0.0722 * b + 0.7152 * g + 0.2126 * r).astype(np.float32) |
| 205 | + if invert: |
| 206 | + lum = 255.0 - lum |
| 207 | + idx = (lum / 255.0 * (len(ramp) - 1)).astype(np.int32) |
| 208 | + h, w = idx.shape |
| 209 | + lines = [] |
| 210 | + for y in range(h): |
| 211 | + seg = [] |
| 212 | + for x in range(w): |
| 213 | + ch = ramp[idx[y, x]] |
| 214 | + bb, gg, rr = img_bgr[y, x] |
| 215 | + seg.append(f"\x1b[38;2;{rr};{gg};{bb}m{ch}\x1b[0m") |
| 216 | + lines.append("".join(seg)) |
| 217 | + return lines |
| 218 | + |
| 219 | + |
| 220 | +# ----------------------------- |
| 221 | +# Public API |
| 222 | +# ----------------------------- |
| 223 | + |
| 224 | + |
| 225 | +def generate_ascii_lines( |
| 226 | + image_path: str, |
| 227 | + *, |
| 228 | + width: int | None = None, |
| 229 | + aspect: float | None = None, |
| 230 | + color: ColorMode = "auto", |
| 231 | + fine: bool = False, |
| 232 | + invert: bool = False, |
| 233 | + crop_content: bool = False, |
| 234 | + crop_bg: Literal["none", "white", "black"] = "none", |
| 235 | + alpha_thresh: int = 1, |
| 236 | + crop_pad: int = 0, |
| 237 | + bg_bgr: tuple[int, int, int] = (255, 255, 255), |
| 238 | +) -> Iterable[str]: |
| 239 | + """Load an image and return ASCII art lines sized for the user's terminal. |
| 240 | +
|
| 241 | + Parameters |
| 242 | + ---------- |
| 243 | + image_path: path to the input image |
| 244 | + width: output width in characters (None -> detect terminal width) |
| 245 | + aspect: character cell height/width ratio (None -> 0.5 or env override) |
| 246 | + color: 'auto'|'always'|'never' color mode |
| 247 | + fine: use a finer 70+ character ramp |
| 248 | + invert: invert luminance mapping |
| 249 | + crop_content: crop to non-transparent content (alpha) if present |
| 250 | + crop_bg: when no alpha, optionally crop assuming a uniform 'white' or 'black' background |
| 251 | + alpha_thresh: threshold for alpha-based crop (0-255) |
| 252 | + crop_pad: pixels of padding around detected content |
| 253 | + bg_bgr: background color used for alpha compositing (default white) |
| 254 | + """ |
| 255 | + enable_windows_ansi_support() |
| 256 | + |
| 257 | + if not os.path.isfile(image_path): |
| 258 | + raise FileNotFoundError(image_path) |
| 259 | + |
| 260 | + # Load preserving alpha if present |
| 261 | + img = cv.imread(image_path, cv.IMREAD_UNCHANGED) |
| 262 | + if img is None: |
| 263 | + raise RuntimeError(f"Failed to load image with OpenCV: {image_path}") |
| 264 | + |
| 265 | + # Crop prior to compositing/resizing |
| 266 | + if crop_content and img.ndim == 3 and img.shape[2] == 4: |
| 267 | + img = crop_to_content_alpha(img, alpha_thresh=alpha_thresh, pad=crop_pad) |
| 268 | + elif crop_content and (img.ndim != 3 or img.shape[2] != 4) and crop_bg in ("white", "black"): |
| 269 | + img = crop_to_content_bg(_to_bgr(img), bg=crop_bg, tol=10, pad=crop_pad) |
| 270 | + |
| 271 | + # Composite transparency to solid background for correct visual result |
| 272 | + img_bgr = composite_over_color(img, bg_bgr=bg_bgr) |
| 273 | + |
| 274 | + # Resize for terminal cell ratio |
| 275 | + img_bgr = resize_for_terminal(img_bgr, width=width, aspect=aspect) |
| 276 | + |
| 277 | + use_color = should_use_color(color) |
| 278 | + |
| 279 | + if use_color: |
| 280 | + return _color_ascii_lines(img_bgr, fine=fine, invert=invert) |
| 281 | + else: |
| 282 | + gray = cv.cvtColor(img_bgr, cv.COLOR_BGR2GRAY) |
| 283 | + if invert: |
| 284 | + gray = 255 - gray |
| 285 | + return _map_luminance_to_chars(gray, fine=fine) |
| 286 | + |
| 287 | + |
| 288 | +def print_ascii( |
| 289 | + image_path: str, |
| 290 | + *, |
| 291 | + width: int | None = None, |
| 292 | + aspect: float | None = None, |
| 293 | + color: ColorMode = "auto", |
| 294 | + fine: bool = False, |
| 295 | + invert: bool = False, |
| 296 | + crop_content: bool = False, |
| 297 | + crop_bg: Literal["none", "white", "black"] = "none", |
| 298 | + alpha_thresh: int = 1, |
| 299 | + crop_pad: int = 0, |
| 300 | + bg_bgr: tuple[int, int, int] = (255, 255, 255), |
| 301 | + output: str | None = None, |
| 302 | +) -> None: |
| 303 | + """Convenience: generate and print ASCII art; optionally write it to a file.""" |
| 304 | + lines = list( |
| 305 | + generate_ascii_lines( |
| 306 | + image_path, |
| 307 | + width=width, |
| 308 | + aspect=aspect, |
| 309 | + color=color, |
| 310 | + fine=fine, |
| 311 | + invert=invert, |
| 312 | + crop_content=crop_content, |
| 313 | + crop_bg=crop_bg, |
| 314 | + alpha_thresh=alpha_thresh, |
| 315 | + crop_pad=crop_pad, |
| 316 | + bg_bgr=bg_bgr, |
| 317 | + ) |
| 318 | + ) |
| 319 | + |
| 320 | + # Print to stdout |
| 321 | + for line in lines: |
| 322 | + print(line) |
| 323 | + |
| 324 | + # Optionally write raw ANSI/plain text to a file |
| 325 | + if output: |
| 326 | + with open(output, "w", encoding="utf-8", newline="\n") as f: |
| 327 | + for line in lines: |
| 328 | + f.write(line) |
| 329 | + f.write("\n") |
| 330 | + |
| 331 | + |
| 332 | +# ----------------------------- |
| 333 | +# Optional: Help banner helpers |
| 334 | +# ----------------------------- |
| 335 | +ASCII_IMAGE_PATH = resources.files("dlclivegui.assets") / "logo_transparent.png" |
| 336 | + |
| 337 | + |
| 338 | +def build_help_description( |
| 339 | + static_banner: str | None = None, *, desc=None, color: ColorMode = "auto", min_width: int = 60 |
| 340 | +) -> str: |
| 341 | + """Return a help description string that conditionally includes a colored ASCII banner. |
| 342 | +
|
| 343 | + - If stdout is a TTY and wide enough, returns banner + description. |
| 344 | + - Otherwise returns a plain, single-line description. |
| 345 | + - If static_banner is None, uses ASCII_BANNER (empty by default). |
| 346 | + """ |
| 347 | + enable_windows_ansi_support() |
| 348 | + desc = "DeepLabCut-Live GUI — launch the graphical interface." if desc is None else desc |
| 349 | + if static_banner is None: |
| 350 | + banner = "\n".join( |
| 351 | + generate_ascii_lines( |
| 352 | + str(ASCII_IMAGE_PATH), |
| 353 | + width=shutil.get_terminal_size((80, 24)).columns - 10, |
| 354 | + aspect=0.5, |
| 355 | + color=color, |
| 356 | + fine=True, |
| 357 | + invert=False, |
| 358 | + crop_content=True, |
| 359 | + crop_bg="white", |
| 360 | + alpha_thresh=1, |
| 361 | + crop_pad=1, |
| 362 | + bg_bgr=(255, 255, 255), |
| 363 | + ) |
| 364 | + ) |
| 365 | + else: |
| 366 | + banner = static_banner |
| 367 | + if banner and terminal_is_wide_enough(min_width=min_width): |
| 368 | + if should_use_color(color): |
| 369 | + banner = f"\x1b[36m{banner}\x1b[0m" |
| 370 | + return banner + "\n" + desc |
| 371 | + return desc |
0 commit comments