diff --git a/.gitignore b/.gitignore index ec537e8..72059d5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ **.jpeg **.txt **.ghs.sh +__pycache__/ diff --git a/install-deps.sh b/install-deps.sh index d234e5d..5e0888a 100755 --- a/install-deps.sh +++ b/install-deps.sh @@ -1,6 +1,6 @@ #/bin/bash ./yay-installer.sh -yay -Syu fswebcam ascii-image-converter +yay -Syu fswebcam ascii-image-converter python-opencv v4l-utils # lp group membership grants write access to /dev/lp0 for txt2printr.sh if ! groups "$USER" | grep -qw lp; then diff --git a/keybinds.conf b/keybinds.conf index 4b2ddc1..1f88398 100644 --- a/keybinds.conf +++ b/keybinds.conf @@ -1,7 +1,7 @@ # ASCII Photobooth keybindings -# Sourced by exec-cycle.sh. Each value must be a single character; -# matching is case-insensitive. Ctrl+C always quits and isn't -# configurable here. +# Sourced by exec-cycle.sh (bash) and photobooth.py (python). Each value +# must be a single character; matching is case-insensitive. Ctrl+C always +# quits both and isn't configurable here. KEY_CAPTURE=" " KEY_BRIGHTNESS_UP=+ @@ -9,3 +9,6 @@ KEY_BRIGHTNESS_DOWN=- KEY_EXPOSURE_UP=. KEY_EXPOSURE_DOWN=, KEY_RESET=r + +# photobooth.py (python) only - bash's exec-cycle.sh only quits on Ctrl+C +KEY_QUIT=q diff --git a/photobooth.py b/photobooth.py new file mode 100755 index 0000000..3738e5c --- /dev/null +++ b/photobooth.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +# +# ASCII Photobooth live preview - curses TUI front end. +# +# Grabs frames directly via OpenCV/v4l2 (no fswebcam process-spawn per +# frame) and still shells out to the ascii-image-converter binary for the +# actual conversion, and to txt2printr.sh to print a capture. Reads the +# same webcam.conf/ascii.conf/keybinds.conf used by the bash scripts. + +import curses +import os +import re +import subprocess +import sys +import time +from datetime import datetime + +try: + import cv2 +except ImportError: + sys.exit( + "photobooth.py requires OpenCV's Python bindings " + "(pacman: python-opencv). Install it and retry." + ) + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def load_conf(name): + cfg = {} + path = os.path.join(SCRIPT_DIR, name) + if not os.path.isfile(path): + return cfg + with open(path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key, value = key.strip(), value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + cfg[key] = value + return cfg + + +def as_bool(value, default=False): + if not value: + return default + return value.strip().lower() in ("1", "true", "yes", "on") + + +class Config: + def __init__(self): + webcam = load_conf("webcam.conf") + ascii_ = load_conf("ascii.conf") + keys = load_conf("keybinds.conf") + + self.device = webcam.get("DEVICE", "/dev/video0") + w, _, h = webcam.get("RESOLUTION", "640x480").partition("x") + self.cap_width = int(w or 640) + self.cap_height = int(h or 480) + self.jpeg_quality = int(webcam.get("JPEG_QUALITY", "85")) + self.default_brightness = webcam.get("DEFAULT_BRIGHTNESS", "auto") + self.default_exposure = webcam.get("DEFAULT_EXPOSURE", "auto") + self.step = int(webcam.get("STEP", "5")) + # v4l2-ctl control IDs (snake_case) - a different naming convention + # than BRIGHTNESS_CONTROL/EXPOSURE_CONTROL, which hold fswebcam's + # human-readable names for the bash scripts. + self.brightness_ctrl = webcam.get("BRIGHTNESS_CTRL_ID", "brightness") + self.exposure_ctrl = webcam.get("EXPOSURE_CTRL_ID", "exposure_absolute") + + self.width = ascii_.get("WIDTH", "").strip() + self.height = ascii_.get("HEIGHT", "").strip() + self.complex = as_bool(ascii_.get("COMPLEX")) + self.char_map = ascii_.get("CHAR_MAP", "").strip() + self.grayscale = as_bool(ascii_.get("GRAYSCALE")) + self.negative = as_bool(ascii_.get("NEGATIVE")) + self.flip_x = as_bool(ascii_.get("FLIP_X")) + self.flip_y = as_bool(ascii_.get("FLIP_Y")) + + self.key_capture = keys.get("KEY_CAPTURE", " ") + self.key_brightness_up = keys.get("KEY_BRIGHTNESS_UP", "+") + self.key_brightness_down = keys.get("KEY_BRIGHTNESS_DOWN", "-") + self.key_exposure_up = keys.get("KEY_EXPOSURE_UP", ".") + self.key_exposure_down = keys.get("KEY_EXPOSURE_DOWN", ",") + self.key_reset = keys.get("KEY_RESET", "r") + self.key_quit = keys.get("KEY_QUIT", "q") + + def converter_flags(self): + flags = [] + if self.char_map: + flags += ["--map", self.char_map] + elif self.complex: + flags.append("--complex") + if self.grayscale: + flags.append("--grayscale") + if self.negative: + flags.append("--negative") + if self.flip_x: + flags.append("--flipX") + if self.flip_y: + flags.append("--flipY") + return flags + + +def convert_to_ascii(jpg_bytes, width, height, flags): + proc = subprocess.run( + ["ascii-image-converter", "-", "-d", f"{width},{height}", *flags], + input=jpg_bytes, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + return proc.stdout.decode("utf-8", "replace").splitlines() + + +_ctrl_ranges = {} + + +def ctrl_range(device, name): + if name in _ctrl_ranges: + return _ctrl_ranges[name] + try: + out = subprocess.run( + ["v4l2-ctl", "-d", device, "--list-ctrls"], + capture_output=True, text=True, timeout=2, + ).stdout + except (subprocess.SubprocessError, FileNotFoundError): + out = "" + rng = None + for line in out.splitlines(): + line = line.strip() + if line.startswith(name + " ") or line.startswith(name + "\t"): + m = re.search(r"min=(-?\d+) max=(-?\d+)", line) + if m: + rng = (int(m.group(1)), int(m.group(2))) + break + _ctrl_ranges[name] = rng + return rng + + +def apply_ctrl_pct(device, name, pct): + rng = ctrl_range(device, name) + if rng is None: + return + lo, hi = rng + val = round(lo + (hi - lo) * pct / 100) + subprocess.run( + ["v4l2-ctl", "-d", device, "--set-ctrl", f"{name}={val}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + +def clamp_pct(value): + return max(0, min(100, value)) + + +def adjust(current, delta): + base = 50 if current == "auto" else int(current) + return clamp_pct(base + delta) + + +def matches(key, bound): + return bool(key) and bool(bound) and key.lower() == bound.lower() + + +def capture_label(cfg): + return "space" if cfg.key_capture == " " else cfg.key_capture + + +def main(stdscr): + cfg = Config() + conv_flags = cfg.converter_flags() + + curses.curs_set(0) + stdscr.nodelay(True) + + cap = cv2.VideoCapture(cfg.device, cv2.CAP_V4L2) + cap.set(cv2.CAP_PROP_FRAME_WIDTH, cfg.cap_width) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, cfg.cap_height) + if not cap.isOpened(): + raise SystemExit(f"Could not open camera device {cfg.device}") + + brightness, exposure = cfg.default_brightness, cfg.default_exposure + if brightness != "auto": + apply_ctrl_pct(cfg.device, cfg.brightness_ctrl, int(brightness)) + if exposure != "auto": + apply_ctrl_pct(cfg.device, cfg.exposure_ctrl, int(exposure)) + + frame_times = [] + message, message_until = "", 0.0 + rows, cols = stdscr.getmaxyx() + box_dims = (max(rows - 2, 3), max(cols, 10)) + box_win = curses.newwin(box_dims[0], box_dims[1], 0, 0) + + try: + while True: + ok, frame = cap.read() + if not ok: + continue + + now = time.time() + frame_times.append(now) + frame_times = [t for t in frame_times if now - t < 1.0] + fps = len(frame_times) + + _, buf = cv2.imencode( + ".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, cfg.jpeg_quality] + ) + jpg_bytes = buf.tobytes() + + rows, cols = stdscr.getmaxyx() + box_rows, box_cols = max(rows - 2, 3), max(cols, 10) + inner_rows, inner_cols = max(box_rows - 2, 1), max(box_cols - 2, 1) + + if (box_rows, box_cols) != box_dims: + box_win = curses.newwin(box_rows, box_cols, 0, 0) + box_dims = (box_rows, box_cols) + stdscr.erase() + + width = cfg.width or str(inner_cols) + height = cfg.height or str(inner_rows) + ascii_lines = convert_to_ascii(jpg_bytes, width, height, conv_flags) + + box_win.erase() + box_win.box() + for i, line in enumerate(ascii_lines[:inner_rows]): + try: + box_win.addstr(1 + i, 1, line[:inner_cols]) + except curses.error: + pass + box_win.overwrite(stdscr) + + if now < message_until: + status = f" {message} " + else: + status = f" brightness: {brightness} exposure: {exposure} fps: {fps} " + help_line = ( + f" [{cfg.key_brightness_up}/{cfg.key_brightness_down}] brightness " + f"[{cfg.key_exposure_up}/{cfg.key_exposure_down}] exposure " + f"[{cfg.key_reset}] reset [{capture_label(cfg)}] capture " + f"[{cfg.key_quit}] quit " + ) + try: + stdscr.addstr(box_rows, 0, status[:cols].ljust(cols)) + stdscr.addstr(box_rows + 1, 0, help_line[:cols].ljust(cols - 1)) + except curses.error: + pass + stdscr.refresh() + + ch = stdscr.getch() + if ch == -1: + continue + key = chr(ch) if 0 <= ch < 256 else "" + + if matches(key, cfg.key_quit): + break + elif matches(key, cfg.key_brightness_up): + brightness = adjust(brightness, cfg.step) + apply_ctrl_pct(cfg.device, cfg.brightness_ctrl, brightness) + elif matches(key, cfg.key_brightness_down): + brightness = adjust(brightness, -cfg.step) + apply_ctrl_pct(cfg.device, cfg.brightness_ctrl, brightness) + elif matches(key, cfg.key_exposure_up): + exposure = adjust(exposure, cfg.step) + apply_ctrl_pct(cfg.device, cfg.exposure_ctrl, exposure) + elif matches(key, cfg.key_exposure_down): + exposure = adjust(exposure, -cfg.step) + apply_ctrl_pct(cfg.device, cfg.exposure_ctrl, exposure) + elif matches(key, cfg.key_reset): + brightness, exposure = cfg.default_brightness, cfg.default_exposure + if brightness != "auto": + apply_ctrl_pct(cfg.device, cfg.brightness_ctrl, int(brightness)) + if exposure != "auto": + apply_ctrl_pct(cfg.device, cfg.exposure_ctrl, int(exposure)) + elif matches(key, cfg.key_capture): + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + photo_path = os.path.join(SCRIPT_DIR, f"photo-{timestamp}.jpg") + ascii_path = os.path.join(SCRIPT_DIR, f"ascii-img-{timestamp}.txt") + with open(photo_path, "wb") as f: + f.write(jpg_bytes) + with open(ascii_path, "w") as f: + f.write("\n".join(ascii_lines) + "\n") + subprocess.run([os.path.join(SCRIPT_DIR, "txt2printr.sh"), ascii_path]) + message = f"Saved {os.path.basename(photo_path)}" + message_until = time.time() + 2 + finally: + cap.release() + + +if __name__ == "__main__": + curses.wrapper(main) diff --git a/webcam.conf b/webcam.conf index d74c3fa..dd75447 100644 --- a/webcam.conf +++ b/webcam.conf @@ -1,12 +1,12 @@ # ASCII Photobooth webcam configuration -# Sourced by webcam2ascii.sh and exec-cycle.sh. +# Sourced by webcam2ascii.sh/exec-cycle.sh (bash) and photobooth.py (python). # Capture settings RESOLUTION=640x480 JPEG_QUALITY=85 # Starting brightness/exposure (0-100, or "auto" for the camera default) -# exec-cycle.sh's live +/- and ./, controls adjust away from these at runtime +# The live preview's +/- and ./, controls adjust away from these at runtime DEFAULT_BRIGHTNESS=auto DEFAULT_EXPOSURE=auto @@ -14,6 +14,18 @@ DEFAULT_EXPOSURE=auto STEP=5 # v4l2 control names as reported by: fswebcam --list-controls -# These vary per camera/driver - edit to match your hardware +# Used by webcam2ascii.sh (bash). These vary per camera/driver - edit to +# match your hardware. BRIGHTNESS_CONTROL="Brightness" EXPOSURE_CONTROL="Exposure (Absolute)" + +# --- photobooth.py (python) specific settings --- + +# Camera device node used for OpenCV capture +DEVICE=/dev/video0 + +# v4l2-ctl control IDs (snake_case), as reported by: v4l2-ctl --list-ctrls +# Different naming convention than BRIGHTNESS_CONTROL/EXPOSURE_CONTROL above - +# these vary per camera/driver too. +BRIGHTNESS_CTRL_ID=brightness +EXPOSURE_CTRL_ID=exposure_absolute