Skip to content

Commit 002e06b

Browse files
committed
Add ASCII art API, CLI flag, and fixes
Introduce dlclivegui.assets.ascii_art: a cross-platform, CI-safe ASCII/ANSI art generator using OpenCV and numpy (alpha compositing, content cropping, resizing, color modes, and terminal detection). Integrate it into main: add argparse with a --no-art flag, print a startup banner (unless disabled), and use the art helper for help text. Fix QApplication SIGINT keepalive timer handling (avoid duplicate timers and stop existing one before replacing). Change default SHOW_SPLASH to False in theme.py and update tests to restore SHOW_SPLASH=True via an autouse fixture so existing splash-dependent tests continue to pass.
1 parent 3ca5d4c commit 002e06b

4 files changed

Lines changed: 397 additions & 7 deletions

File tree

dlclivegui/assets/ascii_art.py

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
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

dlclivegui/gui/theme.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from PySide6.QtWidgets import QApplication
1111

1212
# ---- Splash screen config ----
13-
SHOW_SPLASH = True
13+
SHOW_SPLASH = False
1414
SPLASH_SCREEN_WIDTH = 600
1515
SPLASH_SCREEN_HEIGHT = 400
1616
SPLASH_SCREEN_DURATION_MS = 1000

0 commit comments

Comments
 (0)