add photobooth.py: curses TUI rewrite of the live preview loop
Replaces per-frame fswebcam process spawns with a persistent OpenCV/v4l2 capture handle for the highest achievable refresh rate, while still shelling out to ascii-image-converter for conversion and txt2printr.sh for printing. Renders a bordered live preview with a status/keybind-help bar via curses, works on a plain TTY with no graphical environment. Reuses webcam.conf/ascii.conf/keybinds.conf, parsed as plain key=value (not sourced, so a config file can no longer execute arbitrary code). Brightness/exposure now go through v4l2-ctl with percentage mapped onto each control's actual min/max range; added DEVICE/BRIGHTNESS_CTRL_ID/ EXPOSURE_CTRL_ID to webcam.conf and KEY_QUIT to keybinds.conf for this, kept separate from the bash-oriented keys so the existing bash scripts are unaffected. Bash scripts are left in place as a fallback. New deps: python-opencv, v4l-utils.master
parent
1d77f411fa
commit
e295910234
|
|
@ -4,3 +4,4 @@
|
||||||
**.jpeg
|
**.jpeg
|
||||||
**.txt
|
**.txt
|
||||||
**.ghs.sh
|
**.ghs.sh
|
||||||
|
__pycache__/
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#/bin/bash
|
#/bin/bash
|
||||||
./yay-installer.sh
|
./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
|
# lp group membership grants write access to /dev/lp0 for txt2printr.sh
|
||||||
if ! groups "$USER" | grep -qw lp; then
|
if ! groups "$USER" | grep -qw lp; then
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# ASCII Photobooth keybindings
|
# ASCII Photobooth keybindings
|
||||||
# Sourced by exec-cycle.sh. Each value must be a single character;
|
# Sourced by exec-cycle.sh (bash) and photobooth.py (python). Each value
|
||||||
# matching is case-insensitive. Ctrl+C always quits and isn't
|
# must be a single character; matching is case-insensitive. Ctrl+C always
|
||||||
# configurable here.
|
# quits both and isn't configurable here.
|
||||||
|
|
||||||
KEY_CAPTURE=" "
|
KEY_CAPTURE=" "
|
||||||
KEY_BRIGHTNESS_UP=+
|
KEY_BRIGHTNESS_UP=+
|
||||||
|
|
@ -9,3 +9,6 @@ KEY_BRIGHTNESS_DOWN=-
|
||||||
KEY_EXPOSURE_UP=.
|
KEY_EXPOSURE_UP=.
|
||||||
KEY_EXPOSURE_DOWN=,
|
KEY_EXPOSURE_DOWN=,
|
||||||
KEY_RESET=r
|
KEY_RESET=r
|
||||||
|
|
||||||
|
# photobooth.py (python) only - bash's exec-cycle.sh only quits on Ctrl+C
|
||||||
|
KEY_QUIT=q
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
18
webcam.conf
18
webcam.conf
|
|
@ -1,12 +1,12 @@
|
||||||
# ASCII Photobooth webcam configuration
|
# 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
|
# Capture settings
|
||||||
RESOLUTION=640x480
|
RESOLUTION=640x480
|
||||||
JPEG_QUALITY=85
|
JPEG_QUALITY=85
|
||||||
|
|
||||||
# Starting brightness/exposure (0-100, or "auto" for the camera default)
|
# 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_BRIGHTNESS=auto
|
||||||
DEFAULT_EXPOSURE=auto
|
DEFAULT_EXPOSURE=auto
|
||||||
|
|
||||||
|
|
@ -14,6 +14,18 @@ DEFAULT_EXPOSURE=auto
|
||||||
STEP=5
|
STEP=5
|
||||||
|
|
||||||
# v4l2 control names as reported by: fswebcam --list-controls
|
# 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"
|
BRIGHTNESS_CONTROL="Brightness"
|
||||||
EXPOSURE_CONTROL="Exposure (Absolute)"
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue