diff --git a/photobooth.py b/photobooth.py index 8fb5634..49b38d9 100755 --- a/photobooth.py +++ b/photobooth.py @@ -173,39 +173,54 @@ def capture_label(cfg): return "space" if cfg.key_capture == " " else cfg.key_capture -def a4_landscape_chars(max_lines, max_cols): - """(cols, rows) to capture/preview for an A4 landscape printout. +def box_dims_for(term_rows, term_cols): + return max(term_rows - 2, 3), max(term_cols, 10) - txt2printr.sh prints on a normally-fed (portrait) page and rotates the - ascii art 90 degrees in software; the reader turns the finished sheet - 90 degrees to view it. That second turn cancels the software rotation, - so this buffer's own dimensions equal the final visual result - but its - width becomes the printed *line count* after rotation (bounded by - max_lines, the page's real per-page line capacity) and its height - becomes each printed line's length (bounded by max_cols, the real - per-line character capacity). Both are measured directly against the - printer with pagetest.sh rather than derived from paper/pitch math, - which doesn't reliably match real-world margins. + +_CHAR_ASPECT = 0.5 # width:height ratio of a typical monospace glyph cell + + +def visual_aspect(cols, rows): + """Physical width:height ratio an ascii grid of cols x rows renders as.""" + return (cols / rows) * _CHAR_ASPECT if rows else 1.0 + + +def crop_to_aspect(frame, aspect): + """Center-crop a (h, w, ...) frame to the given width:height ratio.""" + h, w = frame.shape[:2] + if w / h > aspect: + new_w = max(1, round(h * aspect)) + x0 = (w - new_w) // 2 + return frame[:, x0:x0 + new_w] + new_h = max(1, round(w / aspect)) + y0 = (h - new_h) // 2 + return frame[y0:y0 + new_h, :] + + +def prepare_print_frame(frame, cfg, inner_cols, inner_rows): + """Crop (and rotate, for A4) a raw frame ahead of the print conversion. + + Order matters for intelligibility: crop to roughly the right aspect + ratio *before* rotating, then rotate the already-correctly-shaped image, + and only then hand it to ascii-image-converter - rather than converting + first and mangling already-rendered ascii text, which doesn't account + for non-square character cells and reads as visibly distorted. """ - return max_lines, max_cols - - -def box_dims_for(term_rows, term_cols, a4_target): - box_rows, box_cols = max(term_rows - 2, 3), max(term_cols, 10) - if a4_target: - a4_cols, a4_rows = a4_target - box_rows = min(box_rows, a4_rows + 2) - box_cols = min(box_cols, a4_cols + 2) - return box_rows, box_cols + if cfg.printer_a4: + cols, rows = cfg.printer_max_cols, cfg.printer_max_lines + # Cropping happens before the 90-degree rotation below, which swaps + # width and height, so crop to the inverse of the final aspect ratio. + frame = crop_to_aspect(frame, 1 / visual_aspect(cols, rows)) + frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) + else: + cols, rows = inner_cols, inner_rows + frame = crop_to_aspect(frame, visual_aspect(cols, rows)) + return frame, cols, rows def main(stdscr): cfg = Config() conv_flags = cfg.converter_flags() - a4_target = ( - a4_landscape_chars(cfg.printer_max_lines, cfg.printer_max_cols) - if cfg.printer_a4 else None - ) curses.curs_set(0) stdscr.nodelay(True) @@ -225,7 +240,7 @@ def main(stdscr): frame_times = [] message, message_until = "", 0.0 rows, cols = stdscr.getmaxyx() - box_dims = box_dims_for(rows, cols, a4_target) + box_dims = box_dims_for(rows, cols) box_win = curses.newwin(box_dims[0], box_dims[1], 0, 0) try: @@ -239,13 +254,8 @@ def main(stdscr): 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 = box_dims_for(rows, cols, a4_target) + box_rows, box_cols = box_dims_for(rows, cols) inner_rows, inner_cols = max(box_rows - 2, 1), max(box_cols - 2, 1) if (box_rows, box_cols) != box_dims: @@ -253,9 +263,16 @@ def main(stdscr): box_dims = (box_rows, box_cols) stdscr.erase() + # Live preview: always upright, cropped to roughly the box's own + # aspect ratio - independent of PRINTER_A4, whose rotated/resized + # capture is done separately at capture time (see below). + preview_frame = crop_to_aspect(frame, visual_aspect(inner_cols, inner_rows)) + _, buf = cv2.imencode( + ".jpg", preview_frame, [cv2.IMWRITE_JPEG_QUALITY, cfg.jpeg_quality] + ) 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) + ascii_lines = convert_to_ascii(buf.tobytes(), width, height, conv_flags) box_win.erase() box_win.box() @@ -312,11 +329,33 @@ def main(stdscr): 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") + + print_frame, print_cols, print_rows = prepare_print_frame( + frame, cfg, inner_cols, inner_rows + ) + _, print_buf = cv2.imencode( + ".jpg", print_frame, [cv2.IMWRITE_JPEG_QUALITY, cfg.jpeg_quality] + ) + print_jpg_bytes = print_buf.tobytes() + print_width = cfg.width or str(print_cols) + print_height = cfg.height or str(print_rows) + print_ascii_lines = convert_to_ascii( + print_jpg_bytes, print_width, print_height, conv_flags + ) + with open(photo_path, "wb") as f: - f.write(jpg_bytes) + f.write(print_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]) + f.write("\n".join(print_ascii_lines) + "\n") + + env = os.environ.copy() + if cfg.printer_a4: + # The image was already rotated above; tell txt2printr.sh + # not to also apply its own text-transpose rotation. + env["ALREADY_ROTATED"] = "true" + subprocess.run( + [os.path.join(SCRIPT_DIR, "txt2printr.sh"), ascii_path], env=env + ) message = f"Saved {os.path.basename(photo_path)}" message_until = time.time() + 2 finally: diff --git a/txt2printr.sh b/txt2printr.sh index 1cd0112..e168577 100755 --- a/txt2printr.sh +++ b/txt2printr.sh @@ -3,7 +3,11 @@ # Sends an ascii-art text file to a parallel port needle/dot-matrix printer. # # Usage: ./txt2printr.sh [ascii-art.txt] -# Env: PRINTER_DEV - printer device node (default: /dev/lp0) +# Env: PRINTER_DEV - printer device node (default: /dev/lp0) +# ALREADY_ROTATED - skip the text-rotation below because the caller +# already rotated the source image itself before +# converting it to ascii (photobooth.py does this +# for better quality; set by it automatically) # Reads printer.conf for PRINTER_A4 (landscape rotation) and PRINTER_MAX_LINES # (per-page line capacity, measured with pagetest.sh). @@ -11,6 +15,7 @@ dir="$(dirname "$0")" [ -f "$dir/printer.conf" ] && source "$dir/printer.conf" : "${PRINTER_A4:=false}" : "${PRINTER_MAX_LINES:=64}" +: "${ALREADY_ROTATED:=false}" infile="${1:-ascii-img.txt}" # Source - https://stackoverflow.com/a/638980 @@ -40,8 +45,12 @@ fi # Rotates the ascii art 90 degrees clockwise so it reads correctly once the # printed page itself is turned 90 degrees counter-clockwise - the printer's -# scan/feed directions are fixed, so this is the only way to get a landscape -# reading orientation out of it. +# scan/feed directions are fixed, so this is a fallback for callers (e.g. the +# bash webcam2ascii.sh/exec-cycle.sh pipeline) that can't rotate the source +# image itself first. Rearranging already-rendered ascii characters ignores +# non-square character cells and reads as distorted, so photobooth.py instead +# rotates the source image before conversion and sets ALREADY_ROTATED to +# skip this. rotate90() { awk ' { if (length($0) > maxlen) maxlen = length($0); lines[NR] = $0 } @@ -60,7 +69,7 @@ rotate90() { } body() { - if [ "$PRINTER_A4" = "true" ]; then + if [ "$PRINTER_A4" = "true" ] && [ "$ALREADY_ROTATED" != "true" ]; then rotate90 else cat "$infile"