diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index a226ae0..9858a21 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -10,12 +10,12 @@ jobs: pre_commit_checks: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ github.head_ref }} - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v6 with: python-version: '3.10' diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..fa4a699 --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,102 @@ +name: Build, validate & Release + +on: + push: + tags: [ 'v*.*.*' ] + pull_request: + branches: [ main, public ] + types: [ labeled, opened, edited, synchronize, reopened ] + +jobs: + test: + name: Test / smoke (matrix) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ "3.10", "3.11", "3.12" ] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install tools + run: | + python -m pip install --upgrade pip + python -m pip install build twine wheel "packaging>=24.2" + + - name: Build distributions (sdist + wheel) + run: python -m build + + - name: Inspect dist + run: | + ls -lah dist/ + echo "sdist contents (first ~200 entries):" + tar -tf dist/*.tar.gz | sed -n '1,200p' + + - name: Twine metadata & README check + run: python -m twine check dist/* + + - name: Install from wheel & smoke test + run: | + python -m pip install dist/*.whl + python - <<'PY' + import importlib + pkg_name = "dlclivegui" + m = importlib.import_module(pkg_name) + print("Imported:", m.__name__, "version:", getattr(m, "__version__", "n/a")) + PY + + if ! command -v dlclivegui >/dev/null 2>&1; then + echo "CLI entry point 'dlclivegui' not found in PATH; skipping CLI smoke test." + else + if command -v dlclivegui >/dev/null 2>&1; then + echo "Running 'dlclivegui --help' smoke test..." + if ! dlclivegui --help >/dev/null 2>&1; then + echo "::error::'dlclivegui --help' failed; this indicates a problem with the installed CLI package." + exit 1 + fi + + build: + name: Build release artifacts (single) + runs-on: ubuntu-latest + needs: test + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Build distributions (sdist + wheel) + run: | + python -m pip install --upgrade pip + python -m pip install build twine wheel "packaging>=24.2" + python -m build + python -m twine check dist/* + + - name: Upload dist artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/* + if-no-files-found: error + + publish: + name: Publish to PyPI (OIDC) + runs-on: ubuntu-latest + needs: build + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + environment: pypi + permissions: + id-token: write + steps: + - name: Download dist artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/ci.yml b/.github/workflows/testing-ci.yml similarity index 100% rename from .github/workflows/ci.yml rename to .github/workflows/testing-ci.yml diff --git a/dlclivegui/__init__.py b/dlclivegui/__init__.py index 9cc2640..f302f5b 100644 --- a/dlclivegui/__init__.py +++ b/dlclivegui/__init__.py @@ -24,3 +24,4 @@ "CameraConfigDialog", "main", ] +__version__ = "2.0.0rc0" # PLACEHOLDER diff --git a/dlclivegui/assets/ascii_art.py b/dlclivegui/assets/ascii_art.py new file mode 100644 index 0000000..51a8d2f --- /dev/null +++ b/dlclivegui/assets/ascii_art.py @@ -0,0 +1,430 @@ +""" +Utilities to generate ASCII (optionally ANSI-colored) art for the user's terminal. + +Cross-platform and CI-safe: +- Detects terminal width using shutil.get_terminal_size (portable across OSes). +- Respects NO_COLOR and a color mode (auto|always|never). +- Enables ANSI color on Windows PowerShell/cmd via os.system("") when needed. +- Supports transparent PNGs (alpha) by compositing over a chosen background color. +- Optional crop-to-content using alpha or a background heuristic when no alpha. + +Dependencies: opencv-python, numpy +""" + +# dlclivegui/assets/ascii_art.py +from __future__ import annotations + +import os +import shutil +import sys +from collections.abc import Iterable +from typing import Literal + +import numpy as np + +from dlclivegui.gui.theme import LOGO_ALPHA + +try: + import cv2 as cv +except Exception as e: # pragma: no cover + raise RuntimeError( + "OpenCV (opencv-python) is required for dlclivegui.assets.ascii_art.\nInstall with: pip install opencv-python" + ) from e + +# Character ramps (dense -> sparse) +ASCII_RAMP_SIMPLE = "@%#*+=-:. " +ASCII_RAMP_FINE = "@$B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. " + +ColorMode = Literal["auto", "always", "never"] + +# ----------------------------- +# Terminal / ANSI capabilities +# ----------------------------- + + +def enable_windows_ansi_support() -> None: + """Enable ANSI escape support in Windows terminals. + Safe to call on any OS; no-op on non-Windows. + """ + if os.name == "nt": + # This call toggles the console mode to enable VT processing in many hosts + # Always leave the string empty. + os.system("") # This is a known, safe workaround to enable ANSI support on Windows. + + +def get_terminal_width(default: int = 80) -> int: + """Return terminal width in columns, or a fallback if stdout is not a TTY.""" + try: + if not sys.stdout.isatty(): + return default + return shutil.get_terminal_size((default, 24)).columns + except Exception: + return default + + +def should_use_color(mode: ColorMode = "auto") -> bool: + """Determine if colored ANSI output should be emitted. + + - 'never': never use color + - 'always': always use color (even when redirected) + - 'auto': use color only when stdout is a TTY and NO_COLOR is not set + """ + if mode == "never": + return False + if mode == "always": + return True + # auto + if os.environ.get("NO_COLOR"): + return False + return sys.stdout.isatty() + + +def terminal_is_wide_enough(min_width: int = 60) -> bool: + if not sys.stdout.isatty(): + return False + return get_terminal_width() >= min_width + + +# ----------------------------- +# Image helpers +# ----------------------------- + + +def _to_bgr(img: np.ndarray) -> np.ndarray: + """Ensure an image array is 3-channel BGR.""" + if img.ndim == 2: + return cv.cvtColor(img, cv.COLOR_GRAY2BGR) + if img.ndim == 3 and img.shape[2] == 3: + return img + if img.ndim == 3 and img.shape[2] == 4: + # Caller should composite first; keep as-is for now + b, g, r, a = cv.split(img) + return cv.merge((b, g, r)) + raise ValueError(f"Unsupported image shape for BGR conversion: {img.shape!r}") + + +def composite_over_color(img: np.ndarray, bg_bgr: tuple[int, int, int] = (255, 255, 255)) -> np.ndarray: + """If img has alpha (BGRA), alpha-composite over a solid BGR color and return BGR.""" + if img.ndim == 3 and img.shape[2] == 4: + b, g, r, a = cv.split(img) + af = (a.astype(np.float32) / 255.0)[..., None] # (H,W,1) + bgr = cv.merge((b, g, r)).astype(np.float32) + bg = np.empty_like(bgr, dtype=np.float32) + bg[..., 0] = bg_bgr[0] + bg[..., 1] = bg_bgr[1] + bg[..., 2] = bg_bgr[2] + out = af * bgr + (1.0 - af) * bg + return np.clip(out, 0, 255).astype(np.uint8) + return _to_bgr(img) + + +def crop_to_content_alpha(img_bgra: np.ndarray, alpha_thresh: int = 1, pad: int = 0) -> np.ndarray: + """Crop to bounding box of pixels where alpha > alpha_thresh. Returns BGRA.""" + if not (img_bgra.ndim == 3 and img_bgra.shape[2] == 4): + return img_bgra + a = img_bgra[..., 3] + mask = a > alpha_thresh + if not mask.any(): + return img_bgra + ys, xs = np.where(mask) + y0, y1 = ys.min(), ys.max() + x0, x1 = xs.min(), xs.max() + if pad: + h, w = a.shape + y0 = max(0, y0 - pad) + x0 = max(0, x0 - pad) + y1 = min(h - 1, y1 + pad) + x1 = min(w - 1, x1 + pad) + return img_bgra[y0 : y1 + 1, x0 : x1 + 1, :] + + +def crop_to_content_bg( + img_bgr: np.ndarray, bg: Literal["white", "black"] = "white", tol: int = 10, pad: int = 0 +) -> np.ndarray: + """Heuristic crop when no alpha: assume uniform white or black background. + Returns BGR. + """ + if not (img_bgr.ndim == 3 and img_bgr.shape[2] == 3): + img_bgr = _to_bgr(img_bgr) + if bg == "white": + dist = 255 - img_bgr.max(axis=2) # darker than white + mask = dist > tol + else: + dist = img_bgr.max(axis=2) # brighter than black + mask = dist > tol + if not mask.any(): + return img_bgr + ys, xs = np.where(mask) + y0, y1 = ys.min(), ys.max() + x0, x1 = xs.min(), xs.max() + if pad: + h, w = mask.shape + y0 = max(0, y0 - pad) + x0 = max(0, x0 - pad) + y1 = min(h - 1, y1 + pad) + x1 = min(w - 1, x1 + pad) + return img_bgr[y0 : y1 + 1, x0 : x1 + 1, :] + + +def resize_for_terminal(img: np.ndarray, width: int | None, aspect: float | None) -> np.ndarray: + """Resize image for terminal display. + + Parameters + ---------- + width: target character width (None -> current terminal width) + aspect: character cell height/width ratio; default 0.5 is good for many fonts. + """ + h, w = img.shape[:2] + if width is None: + width = get_terminal_width(100) + width = max(20, int(width)) + if aspect is None: + # Allow override by env var, else default 0.5 + try: + aspect = float(os.environ.get("DLCLIVE_ASCII_ASPECT", "0.5")) + except ValueError: + aspect = 0.5 + new_h = max(1, int((h / w) * width * aspect)) + return cv.resize(img, (width, new_h), interpolation=cv.INTER_AREA) + + +# ----------------------------- +# Rendering +# ----------------------------- + + +def _map_luminance_to_chars(gray: np.ndarray, fine: bool) -> Iterable[str]: + ramp = ASCII_RAMP_FINE if fine else ASCII_RAMP_SIMPLE + ramp_arr = np.array(list(ramp), dtype=" Iterable[str]: + ramp = ASCII_RAMP_FINE if fine else ASCII_RAMP_SIMPLE + # ramp is ASCII; encode once + ramp_bytes = [c.encode("utf-8") for c in ramp] + + reset = b"\x1b[0m" + + # Luminance (same coefficients you used; keep exact behavior) + b = img_bgr[..., 0].astype(np.float32) + g = img_bgr[..., 1].astype(np.float32) + r = img_bgr[..., 2].astype(np.float32) + lum = 0.0722 * b + 0.7152 * g + 0.2126 * r + if invert: + lum = 255.0 - lum + + idx = (lum / 255.0 * (len(ramp) - 1)).astype(np.uint16) + + # Pack color into 0xRRGGBB for fast comparisons + rr = img_bgr[..., 2].astype(np.uint32) + gg = img_bgr[..., 1].astype(np.uint32) + bb = img_bgr[..., 0].astype(np.uint32) + color_key = (rr << 16) | (gg << 8) | bb # (H,W) uint32 + + # Cache SGR prefixes by packed color + # e.g. 0xRRGGBB -> b"\x1b[38;2;R;G;Bm" + prefix_cache: dict[int, bytes] = {} + + h, w = idx.shape + lines: list[str] = [] + + for y in range(h): + ba = bytearray() + + ck_row = memoryview(color_key[y]) + idx_row = memoryview(idx[y]) + + prev_ck: int | None = None + + for x in range(w): + ck = int(ck_row[x]) + + # Emit new color code only when color changes + if ck != prev_ck: + prefix = prefix_cache.get(ck) + if prefix is None: + rr_i = (ck >> 16) & 255 + gg_i = (ck >> 8) & 255 + bb_i = ck & 255 + prefix = f"\x1b[38;2;{rr_i};{gg_i};{bb_i}m".encode("ascii") + prefix_cache[ck] = prefix + ba.extend(prefix) + prev_ck = ck + + ba.extend(ramp_bytes[int(idx_row[x])]) + + # Reset once per line to prevent color bleed into subsequent terminal output + ba.extend(reset) + + lines.append(ba.decode("utf-8", errors="strict")) + + return lines + + +# ----------------------------- +# Public API +# ----------------------------- + + +def generate_ascii_lines( + image_path: str, + *, + width: int | None = None, + aspect: float | None = None, + color: ColorMode = "auto", + fine: bool = False, + invert: bool = False, + crop_content: bool = False, + crop_bg: Literal["none", "white", "black"] = "none", + alpha_thresh: int = 1, + crop_pad: int = 0, + bg_bgr: tuple[int, int, int] = (255, 255, 255), +) -> Iterable[str]: + """Load an image and return ASCII art lines sized for the user's terminal. + + Parameters + ---------- + image_path: path to the input image + width: output width in characters (None -> detect terminal width) + aspect: character cell height/width ratio (None -> 0.5 or env override) + color: 'auto'|'always'|'never' color mode + fine: use a finer 70+ character ramp + invert: invert luminance mapping + crop_content: crop to non-transparent content (alpha) if present + crop_bg: when no alpha, optionally crop assuming a uniform 'white' or 'black' background + alpha_thresh: threshold for alpha-based crop (0-255) + crop_pad: pixels of padding around detected content + bg_bgr: background color used for alpha compositing (default white) + """ + enable_windows_ansi_support() + + if not os.path.isfile(image_path): + raise FileNotFoundError(image_path) + + # Load preserving alpha if present + img = cv.imread(image_path, cv.IMREAD_UNCHANGED) + if img is None: + raise RuntimeError(f"Failed to load image with OpenCV: {image_path}") + + # Crop prior to compositing/resizing + if crop_content and img.ndim == 3 and img.shape[2] == 4: + img = crop_to_content_alpha(img, alpha_thresh=alpha_thresh, pad=crop_pad) + elif crop_content and (img.ndim != 3 or img.shape[2] != 4) and crop_bg in ("white", "black"): + img = crop_to_content_bg(_to_bgr(img), bg=crop_bg, tol=10, pad=crop_pad) + + # Composite transparency to solid background for correct visual result + img_bgr = composite_over_color(img, bg_bgr=bg_bgr) + + # Resize for terminal cell ratio + img_bgr = resize_for_terminal(img_bgr, width=width, aspect=aspect) + + use_color = should_use_color(color) + + if use_color: + return _color_ascii_lines(img_bgr, fine=fine, invert=invert) + else: + gray = cv.cvtColor(img_bgr, cv.COLOR_BGR2GRAY) + if invert: + gray = 255 - gray + return _map_luminance_to_chars(gray, fine=fine) + + +def print_ascii( + image_path: str, + *, + width: int | None = None, + aspect: float | None = None, + color: ColorMode = "auto", + fine: bool = False, + invert: bool = False, + crop_content: bool = False, + crop_bg: Literal["none", "white", "black"] = "none", + alpha_thresh: int = 1, + crop_pad: int = 0, + bg_bgr: tuple[int, int, int] = (255, 255, 255), + output: str | None = None, +) -> None: + """Convenience: generate and print ASCII art; optionally write it to a file.""" + lines = list( + generate_ascii_lines( + image_path, + width=width, + aspect=aspect, + color=color, + fine=fine, + invert=invert, + crop_content=crop_content, + crop_bg=crop_bg, + alpha_thresh=alpha_thresh, + crop_pad=crop_pad, + bg_bgr=bg_bgr, + ) + ) + + # Print to stdout + for line in lines: + print(line) + + # Optionally write raw ANSI/plain text to a file + if output: + with open(output, "w", encoding="utf-8", newline="\n") as f: + for line in lines: + f.write(line) + f.write("\n") + + +# ----------------------------- +# Optional: Help banner helpers +# ----------------------------- + + +def build_help_description( + static_banner: str | None = None, *, desc=None, color: ColorMode = "auto", min_width: int = 60, max_width: int = 120 +) -> str: + """Return a help description string that conditionally includes a colored ASCII banner. + + - If stdout is a TTY and wide enough, returns banner + description. + - Otherwise returns a plain, single-line description. + - If static_banner is None, uses ASCII_BANNER (empty by default). + """ + enable_windows_ansi_support() + desc = "DeepLabCut-Live GUI — launch the graphical interface." if desc is None else desc + if not sys.stdout.isatty() and terminal_is_wide_enough(min_width=min_width): + return desc + + banner: str | None + if static_banner is not None: + banner = static_banner + else: + try: + term_width = get_terminal_width(default=max_width) + width = max(min(term_width, max_width), min_width) + banner = "\n".join( + generate_ascii_lines( + str(LOGO_ALPHA), + width=width, + aspect=0.5, + color=color, + fine=True, + invert=False, + crop_content=True, + crop_bg="white", + alpha_thresh=1, + crop_pad=1, + bg_bgr=(255, 255, 255), + ) + ) + except (FileNotFoundError, RuntimeError, OSError): + banner = None + + if banner: + if should_use_color(color): + banner = f"\x1b[36m{banner}\x1b[0m" + return banner + "\n" + desc + return desc diff --git a/dlclivegui/gui/theme.py b/dlclivegui/gui/theme.py index cdc9ace..cc6db38 100644 --- a/dlclivegui/gui/theme.py +++ b/dlclivegui/gui/theme.py @@ -10,7 +10,7 @@ from PySide6.QtWidgets import QApplication # ---- Splash screen config ---- -SHOW_SPLASH = True +SHOW_SPLASH = False SPLASH_SCREEN_WIDTH = 600 SPLASH_SCREEN_HEIGHT = 400 SPLASH_SCREEN_DURATION_MS = 1000 diff --git a/dlclivegui/main.py b/dlclivegui/main.py index 35494f7..eb444aa 100644 --- a/dlclivegui/main.py +++ b/dlclivegui/main.py @@ -1,6 +1,7 @@ # dlclivegui/gui/main.py from __future__ import annotations +import argparse import logging import signal import sys @@ -9,6 +10,7 @@ from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication +from dlclivegui.assets import ascii_art as art from dlclivegui.gui.main_window import DLCLiveMainWindow from dlclivegui.gui.misc.splash import SplashConfig, show_splash from dlclivegui.gui.theme import ( @@ -42,22 +44,54 @@ def _sigint_handler(_signum, _frame) -> None: signal.signal(signal.SIGINT, _sigint_handler) # Keepalive timer to allow Python to handle signals while Qt is running. - sig_timer = QTimer(app) + sig_timer = QTimer() sig_timer.setInterval(100) # 50–200ms typical; keep low overhead sig_timer.timeout.connect(lambda: None) sig_timer.start() - if not hasattr(app, "_sig_timer"): - app._sig_timer = sig_timer + if hasattr(app, "_sig_timer"): + app._sig_timer.stop() # Stop any existing timer to avoid duplicates + app._sig_timer = sig_timer # Store on app to keep it alive and allow cleanup on exit + + +def parse_args(argv=None): + if argv is None: + argv = sys.argv[1:] + + default_desc = "Welcome to DeepLabCut-Live GUI!" + no_art_flag = "--no-art" in argv + wants_help = any(a in ("-h", "--help") for a in argv) + + # Only build banner description if we're about to print help + if wants_help and not no_art_flag: + try: + desc = art.build_help_description() + except Exception as e: + logging.warning(f"Failed to build ASCII art for help description: {e}") + desc = default_desc else: - raise RuntimeError("QApplication already has _sig_timer attribute, which is reserved for SIGINT handling.") + desc = default_desc + + parser = argparse.ArgumentParser( + description=desc, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--no-art", action="store_true", help="Disable ASCII art in help and when launching.") + return parser.parse_known_args(argv) def main() -> None: - # signal.signal(signal.SIGINT, signal.SIG_DFL) + args, _unknown = parse_args() + + logging.info("Starting DeepLabCut-Live GUI...") - # HiDPI pixmaps - always enabled in Qt 6 so no need to set it explicitly - # QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True) + # If you want a startup banner, PRINT it (not log), and only in TTY contexts. + if not args.no_art and sys.stdout.isatty() and art.terminal_is_wide_enough(): + try: + print(art.build_help_description(desc="Welcome to DeepLabCut-Live GUI!")) + except Exception: + # Keep startup robust; don't fail if banner fails + pass app = QApplication(sys.argv) app.setWindowIcon(QIcon(LOGO)) diff --git a/pyproject.toml b/pyproject.toml index 9dac41d..7fba79a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = [ "setuptools>=68" ] [project] name = "deeplabcut-live-gui" -description = "PySide6-based GUI to run real time DeepLabCut experiments" +description = "PySide6-based GUI to run real time pose estimation experiments with DeepLabCut" readme = "README.md" keywords = [ "deep learning", "deeplabcut", "gui", "pose estimation", "real-time" ] license-files = [ "LICENSE" ] @@ -21,10 +21,10 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Topic :: Scientific/Engineering :: Artificial Intelligence", ] -dynamic = [ "version" ] +dynamic = [ "version" ] # version is set in dlclivegui/__init__.py dependencies = [ "cv2-enumerate-cameras", - "deeplabcut-live>=2", # might be missing timm and scipy + "deeplabcut-live==1.1", "matplotlib", "numpy", "opencv-python", @@ -34,8 +34,11 @@ dependencies = [ "vidgear[core]", ] [[project.authors]] -name = "A. & M. Mathis Labs" -email = "adim@deeplabcut.org" +name = "M-Lab of Adaptive Intelligence" +email = "mackenzie@deeplabcut.org" +[[project.authors]] +name = "Mathis Group for Computational Neuroscience and AI" +email = "alexander@deeplabcut.org" [project.optional-dependencies] all = [ "harvesters", "pypylon" ] basler = [ "pypylon" ] @@ -49,7 +52,7 @@ dev = [ ] gentl = [ "harvesters" ] pytorch = [ - "deeplabcut-live[pytorch]>=2", # this includes timm and scipy + "deeplabcut-live[pytorch]==1.1", # includes timm and scipy ] test = [ "hypothesis>=6", @@ -59,19 +62,21 @@ test = [ "pytest-qt>=4.2", ] tf = [ - "deeplabcut-live[tf]>=2", + "deeplabcut-live[tf]==1.1", ] [project.scripts] dlclivegui = "dlclivegui:main" [project.urls] "Bug Tracker" = "https://github.com/DeepLabCut/DeepLabCut-live-GUI/issues" -Documentation = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" +Documentation = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" # FIXME @C-Achard replace once docs are up Homepage = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" Repository = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" +[tool.setuptools.dynamic] +version = { attr = "dlclivegui.__version__" } + # [tool.setuptools] # include-package-data = true - [tool.setuptools.package-data] "dlclivegui.assets" = [ "*.png" ] [tool.setuptools.packages] diff --git a/tests/gui/test_app_entrypoint.py b/tests/gui/test_app_entrypoint.py index f28ddc3..0a68bb2 100644 --- a/tests/gui/test_app_entrypoint.py +++ b/tests/gui/test_app_entrypoint.py @@ -16,8 +16,20 @@ def _import_fresh(): return importlib.import_module(MODULE_UNDER_TEST) +@pytest.fixture +def set_use_splash_true(monkeypatch): + # Ensure theme.py SHOW_SPLASH is True for tests that rely on it, without affecting other tests + monkeypatch.setattr("dlclivegui.gui.theme.SHOW_SPLASH", True) + + +@pytest.fixture +def set_use_splash_false(monkeypatch): + # Ensure theme.py SHOW_SPLASH is False for tests that rely on it, without affecting other tests + monkeypatch.setattr("dlclivegui.gui.theme.SHOW_SPLASH", False) + + @pytest.mark.gui -def test_main_with_splash(monkeypatch): +def test_main_with_splash(monkeypatch, set_use_splash_true): appmod = _import_fresh() # --- Patch Qt app & icon in the entry module's namespace --- @@ -87,7 +99,7 @@ def immediate_single_shot(ms, fn): @pytest.mark.gui -def test_main_without_splash(monkeypatch): +def test_main_without_splash(monkeypatch, set_use_splash_false): appmod = _import_fresh() # Patch Qt app creation & window icon @@ -97,9 +109,6 @@ def test_main_without_splash(monkeypatch): monkeypatch.setattr(appmod, "QApplication", QApplication_cls) monkeypatch.setattr(appmod, "QIcon", MagicMock(name="QIcon")) - # Force the no-splash branch - appmod.SHOW_SPLASH = False - # show_splash should not be called show_splash_mock = MagicMock(name="show_splash") monkeypatch.setattr(appmod, "show_splash", show_splash_mock) diff --git a/tests/gui/test_ascii_art.py b/tests/gui/test_ascii_art.py new file mode 100644 index 0000000..50d702c --- /dev/null +++ b/tests/gui/test_ascii_art.py @@ -0,0 +1,278 @@ +import os +import sys +from pathlib import Path + +import numpy as np +import pytest + +try: + import cv2 as cv +except Exception as e: + raise ImportError("OpenCV (cv2) is required for these tests. Please install the main package dependencies.") from e + +import dlclivegui.assets.ascii_art as ascii_mod + +# ------------------------- +# Fixtures & small helpers +# ------------------------- + + +@pytest.fixture +def tmp_png_gray(tmp_path: Path): + """Create a simple 16x8 gray gradient PNG without alpha.""" + h, w = 8, 16 + # Horizontal gradient from black to white in BGR + x = np.linspace(0, 255, w, dtype=np.uint8) + img = np.tile(x, (h, 1)) + bgr = cv.cvtColor(img, cv.COLOR_GRAY2BGR) + p = tmp_path / "gray.png" + assert cv.imwrite(str(p), bgr) + return p + + +@pytest.fixture +def tmp_png_bgra_logo(tmp_path: Path): + """Create a small BGRA image with a transparent border and opaque center.""" + h, w = 10, 20 + bgra = np.zeros((h, w, 4), dtype=np.uint8) + # Opaque blue rectangle in center + bgra[2:8, 5:15, 0] = 255 # B + bgra[2:8, 5:15, 3] = 255 # A + p = tmp_path / "logo_bgra.png" + assert cv.imwrite(str(p), bgra) + return p + + +def _force_isatty(monkeypatch, obj, value: bool): + """ + Ensure obj.isatty() returns value. + Try instance patch first; if the object disallows attribute assignment, + patch the method on its class. + """ + try: + # Try patching the instance directly + monkeypatch.setattr(obj, "isatty", lambda: value, raising=False) + except Exception: + # Fallback: patch the class method + cls = type(obj) + monkeypatch.setattr(cls, "isatty", lambda self: value, raising=True) + + +@pytest.fixture +def force_tty(monkeypatch): + """ + Pretend stdout is a TTY and provide a stable terminal size inside the + module-under-test namespace (and the actual sys). + """ + # NO_COLOR must be unset for should_use_color("auto") + monkeypatch.delenv("NO_COLOR", raising=False) + + # Make whatever stdout object exists report TTY=True + _force_isatty(monkeypatch, sys.stdout, True) + _force_isatty(monkeypatch, ascii_mod.sys.stdout, True) + + # Ensure terminal size used by the module is deterministic + monkeypatch.setattr( + ascii_mod.shutil, + "get_terminal_size", + lambda fallback=None: os.terminal_size((80, 24)), + raising=True, + ) + return sys.stdout # not used directly, but handy + + +@pytest.fixture +def force_notty(monkeypatch): + """ + Pretend stdout is not a TTY. + """ + _force_isatty(monkeypatch, sys.stdout, False) + _force_isatty(monkeypatch, ascii_mod.sys.stdout, False) + return sys.stdout + + +# ------------------------- +# Terminal / ANSI behavior +# ------------------------- + + +def test_get_terminal_width_tty(force_tty): + width = ascii_mod.get_terminal_width(default=123) + assert width == 80 + + +def test_get_terminal_width_notty(force_notty): + width = ascii_mod.get_terminal_width(default=123) + assert width == 123 + + +def test_should_use_color_auto_tty(force_tty, monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + assert ascii_mod.should_use_color("auto") is True + + +def test_should_use_color_auto_no_color(force_tty, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + assert ascii_mod.should_use_color("auto") is False + + +def test_should_use_color_modes(force_notty): + assert ascii_mod.should_use_color("never") is False + assert ascii_mod.should_use_color("always") is True + + +def test_terminal_is_wide_enough(force_tty): + assert ascii_mod.terminal_is_wide_enough(60) is True + assert ascii_mod.terminal_is_wide_enough(100) is False + + +# ------------------------- +# Image helpers +# ------------------------- + + +def test__to_bgr_converts_gray(): + gray = np.zeros((5, 7), dtype=np.uint8) + bgr = ascii_mod._to_bgr(gray) + assert bgr.shape == (5, 7, 3) + assert bgr.dtype == np.uint8 + + +def test_composite_over_color_bgra(tmp_png_bgra_logo): + img = cv.imread(str(tmp_png_bgra_logo), cv.IMREAD_UNCHANGED) + assert img.shape[2] == 4 + bgr = ascii_mod.composite_over_color(img, bg_bgr=(10, 20, 30)) + assert bgr.shape[2] == 3 + # Transparent border should become the bg color + assert tuple(bgr[0, 0]) == (10, 20, 30) + # Opaque center should keep blue channel high + assert bgr[5, 10, 0] == 255 + + +def test_crop_to_content_alpha(tmp_png_bgra_logo): + img = cv.imread(str(tmp_png_bgra_logo), cv.IMREAD_UNCHANGED) + cropped = ascii_mod.crop_to_content_alpha(img, alpha_thresh=1, pad=0) + h, w = cropped.shape[:2] + assert h == 6 # 2..7 -> 6 rows + assert w == 10 # 5..14 -> 10 cols + assert cropped[..., 3].min() == 255 + + +def test_crop_to_content_bg_white(tmp_path): + # Create white background with a black rectangle + h, w = 12, 20 + bgr = np.full((h, w, 3), 255, dtype=np.uint8) + bgr[3:10, 4:15] = 0 + p = tmp_path / "white_bg.png" + assert cv.imwrite(str(p), bgr) + cropped = ascii_mod.crop_to_content_bg(bgr, bg="white", tol=10, pad=0) + assert cropped.shape[0] == 7 # 3..9 -> 7 rows + assert cropped.shape[1] == 11 # 4..14 -> 11 cols + + +def test_resize_for_terminal_aspect_env(monkeypatch): + img = np.zeros((100, 200, 3), dtype=np.uint8) + monkeypatch.setenv("DLCLIVE_ASCII_ASPECT", "0.25") + resized = ascii_mod.resize_for_terminal(img, width=80, aspect=None) + # new_h = (h/w) * width * aspect = (100/200)*80*0.25 = 10 + assert resized.shape[:2] == (10, 80) + + +# ------------------------- +# Rendering +# ------------------------- + + +def test_map_luminance_to_chars_simple(): + gray = np.array([[0, 127, 255]], dtype=np.uint8) + lines = list(ascii_mod._map_luminance_to_chars(gray, fine=False)) + assert len(lines) == 1 + # First char should be the densest in the simple ramp '@', last should be space + assert lines[0][0] == ascii_mod.ASCII_RAMP_SIMPLE[0] + assert lines[0][-1] == ascii_mod.ASCII_RAMP_SIMPLE[-1] + + +def test_color_ascii_lines_basic(): + # Small 2x3 color blocks + img = np.zeros((2, 3, 3), dtype=np.uint8) + img[:] = (10, 20, 30) + lines = list(ascii_mod._color_ascii_lines(img, fine=False, invert=False)) + assert len(lines) == 2 + # Expect ANSI 24-bit color sequence present + assert "\x1b[38;2;" in lines[0] + # Reset code present + assert lines[0].endswith("\x1b[0m" * 3) is False # individual chars have resets, but line won't end with triple + + +# ------------------------- +# Public API: generate & print +# ------------------------- + + +@pytest.mark.parametrize("use_color", ["never", "always"]) +def test_generate_ascii_lines_gray(tmp_png_gray, use_color, force_tty): + lines = list( + ascii_mod.generate_ascii_lines( + str(tmp_png_gray), + width=40, + aspect=0.5, + color=use_color, + fine=False, + invert=False, + crop_content=False, + crop_bg="none", + ) + ) + assert len(lines) > 0 + # Width equals number of characters per line + assert all(len(line) == 40 or ("\x1b[38;2;" in line and len(_strip_ansi(line)) == 40) for line in lines) + + +def _strip_ansi(s: str) -> str: + import re + + return re.sub(r"\x1b\[[0-9;]*m", "", s) + + +def test_generate_ascii_lines_crop_alpha(tmp_png_bgra_logo, force_tty): + lines_no_crop = list( + ascii_mod.generate_ascii_lines(str(tmp_png_bgra_logo), width=40, aspect=0.5, color="never", crop_content=False) + ) + lines_crop = list( + ascii_mod.generate_ascii_lines(str(tmp_png_bgra_logo), width=40, aspect=0.5, color="never", crop_content=True) + ) + # Both are non-empty; height may change either way depending on aspect ratio + assert len(lines_no_crop) > 0 and len(lines_crop) > 0 + # Cropping should affect the generated ASCII content + assert lines_crop != lines_no_crop + + +def test_print_ascii_writes_file(tmp_png_gray, force_tty, tmp_path): + out_path = tmp_path / "out.txt" + ascii_mod.print_ascii( + str(tmp_png_gray), + width=30, + aspect=0.5, + color="never", + output=str(out_path), + ) + assert out_path.exists() + text = out_path.read_text(encoding="utf-8") + # Expect multiple lines of length 30 + lines = [ln for ln in text.splitlines() if ln] + assert len(lines) > 0 + assert all(len(ln) == 30 for ln in lines) + + +def test_build_help_description_tty(tmp_png_bgra_logo, monkeypatch, force_tty): + monkeypatch.setattr(ascii_mod, "ASCII_IMAGE_PATH", Path(tmp_png_bgra_logo)) + desc = ascii_mod.build_help_description(static_banner=None, color="always", min_width=60) + assert "DeepLabCut-Live GUI" in desc + assert "\x1b[36m" in desc # cyan wrapper now present since TTY is mocked correctly + + +def test_build_help_description_notty(tmp_png_bgra_logo, monkeypatch, force_notty): + monkeypatch.setattr(ascii_mod, "ASCII_IMAGE_PATH", Path(tmp_png_bgra_logo)) + desc = ascii_mod.build_help_description(static_banner=None, color="always", min_width=60) + # Not a TTY -> no banner, just the plain description + assert desc.strip() == "DeepLabCut-Live GUI — launch the graphical interface."