Last active
November 20, 2023 19:09
-
-
Save mcorrigan/faf55cd8b0d183e0aaab5c40db6ffd92 to your computer and use it in GitHub Desktop.
Preview Raspberry Pi Camera via Color ASCII Preview [for headless Pi]
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from picamera import PiCamera | |
from picamera.array import PiRGBArray | |
import sys, curses, time, cv2 | |
import numpy as np | |
import warnings | |
import collections | |
# TODO: resolve these at some point | |
warnings.filterwarnings(action='ignore', message='Mean of empty slice') | |
warnings.filterwarnings(action='ignore', message='invalid value encountered in double_scalars') | |
# 70 levels of gray -- http://paulbourke.net/dataformats/asciiart/ | |
gscale = "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,\"^`'. " | |
color_index = {} # dict of tuples => color_addr: all possible RGB term colors using RGB index | |
use_color = False # if supported, should we try to use color ASCII (reduces preview FPS but color helps be able to see) | |
capture_destination = "dataset/Mike/image_{}.jpg" | |
class FPS: | |
# https://stackoverflow.com/a/54539292 | |
def __init__(self,avarageof=50): | |
self.frametimestamps = collections.deque(maxlen=avarageof) | |
def __call__(self): | |
self.frametimestamps.append(time.time()) | |
if(len(self.frametimestamps) > 1): | |
return len(self.frametimestamps)/(self.frametimestamps[-1]-self.frametimestamps[0]) | |
else: | |
return 0.0 | |
def setupColors(): | |
# we have to define the std 256 colors again since I don't know the RGB values making each color | |
# maybe there is a better way | |
global color_index | |
i = 1 # 0 = is reserved for white on black (65536 max) | |
for r in range(0, 1001, 200): | |
for g in range(0, 1001, 200): | |
for b in range(0, 1001, 200): | |
curses.init_color(i, r, g, b) | |
curses.init_pair(i, i, curses.COLOR_BLACK) | |
color_index[(r, g, b)] = i | |
i += 1 | |
def printFrameToAscii(image_arr, cols, win): | |
global gscale, color_index, use_color | |
scale = .43 # assume the frame is 4:3 | |
frame_width,frame_height,rgb_dim = image_arr.shape # store dimensions | |
tile_width = frame_width / cols # compute frame_width of tile | |
# compute tile frame_height based on aspect ratio and scale | |
tile_height = tile_width / scale | |
rows = int(frame_height / tile_height) | |
if cols > frame_width or rows > frame_height: | |
raise Exception("Image too small for specified cols!") | |
# generate list of dimensions | |
for r in range(rows): | |
y1 = int(r * tile_height) | |
y2 = int((r + 1) * tile_height) | |
y2 = frame_height if r == rows - 1 else y2 # correct last tile | |
for c in range(cols): | |
x1 = int(c * tile_width) | |
x2 = int((c + 1) * tile_width) | |
x2 = frame_width if c == cols - 1 else x2 # correct last tile | |
tile = image_arr[y1:y2, x1:x2] # get pixel RGB (col, row) | |
try: | |
# might improve w/ formula (0.2989 * R + 0.5870 * G + 0.1140 * B) - (or just ingest the data as YUV420, 3rd channel Y [luminance]) | |
# https://stackoverflow.com/questions/42905779/is-there-any-difference-between-y-component-of-yuv-and-converted-grey-component | |
avg = int(np.average(tile)) # get average luminance | |
gsval = gscale[int((avg*69)/255)] # look up ascii char | |
_color_pair = 0 # default to white on black color pair | |
# only worry about rgb if terminal supports it | |
if curses.has_colors() and use_color: | |
# collapse the two axis to get color info as mean | |
d = np.mean(tile, axis=(0, 1)) | |
# convert from 256 color values to 0,1000 range (https://docs.python.org/3.6/library/curses.html#curses.init_color) | |
d = np.interp(d, [0, 255], [0, 1000]).astype(int) | |
d = np.around(d / 200, decimals = 0) * 200 # we need each color to be an increment of 200 | |
# look up the existing color we created | |
_color_pair = color_index[(d[0], d[1], d[2])] | |
win.addch(r, c, gsval, curses.color_pair(_color_pair)) | |
except: | |
pass | |
def main(stdscr): | |
global use_color | |
stdscr.nodelay(True) | |
curses.curs_set(False) | |
curses.noecho() | |
save_colors = [] | |
if curses.has_colors() and use_color: | |
curses.start_color() | |
save_colors = [curses.color_content(i) for i in range(curses.COLORS)] | |
setupColors() | |
# stdscr.keypad(True) # enable keypad? does it restore on exit? | |
# instructions window | |
instructWin_x = 0 | |
instructWin_y = 0 | |
instructWin_height = 3 | |
instructWin = curses.newwin(instructWin_height, curses.LINES, instructWin_y, instructWin_x) | |
# frame window | |
frameWin_x = 0 | |
frameWin_y = instructWin_height | |
frameWin_height = curses.LINES - instructWin_height | |
frameWin_width = 80 | |
frameWin = curses.newwin(frameWin_height, curses.COLS, frameWin_y, frameWin_x) | |
sb_instruction = "Press SPACEBAR to capture camera image or Q to quit" | |
# print keyboard prompt | |
mid = int((frameWin_width - len(sb_instruction)) / 2) - 1 | |
instructWin.addstr(1, 0, sb_instruction) | |
with PiCamera() as cam: | |
# https://picamera.readthedocs.io/en/release-1.13/_modules/picamera/camera.html?highlight=capture_continuous | |
cam.resolution = (640, 480) | |
cam.framerate = 10 | |
# cam.contrast = 0 # -100 <-> 100 | |
# cam.iso = 200 # 100, 200, 320, 400, 500, 640, 800 | |
# cam.saturation | |
# cam.rotation = 0 # 0, 90, 180, 270 | |
# cam.shutter_speed = 0 # 0 for autoexposure , or number of microseconds between shutters | |
# cam.exposure_mode = 'off # 'off', (PiCamera.EXPOSURE_MODE - https://picamera.readthedocs.io/en/release-1.13/_modules/picamera/camera.html) | |
# cam.awb_mode = 'off' # 'off', (PiCamera.AWB_MODES - https://picamera.readthedocs.io/en/release-1.13/_modules/picamera/camera.html) | |
# cam.awb_gains # 0.0 and 8.0 (typically between 0.9 and 1.9) | |
# cam.sharpness # -100 <-> 100 | |
# cam.brightness # 0 <-> 100 | |
# cam.video_denoise # True or False | |
# cam.drc_strength = 'off' # 'off | |
# cam.meter_mode = | |
# cam.video_stabilization = False # True or False | |
# cam.exposure_compensation = 0 # Each increment represents 1/6th of a stop. Hence setting the attribute to 6 increases exposure by 1 stop. | |
# cam.flash_mode = 'off' # (PiCamera.FLASH_MODES - https://picamera.readthedocs.io/en/release-1.13/_modules/picamera/camera.html) - requires more pins | |
# cam.image_effect # (PiCamera. - https://picamera.readthedocs.io/en/release-1.13/_modules/picamera/camera.html?highlight=IMAGE_EFFECTS#) | |
# cam.image_effect_params # (see doc) | |
# cam.color_effects # None, tuple(u,v) u,v = 0 <-> 255 | |
# cam.zoom = # (x, y, w, h) | |
# cam.overlays | |
# temp camera settings | |
frame_rotation = 0 # 0, 90, 180, 270 | |
frame_contrast = 0 # -100 <-> 100 | |
img_counter = 0 # used for exports (will overwrite existing images with the same name - no warning) | |
fps = FPS() | |
output = np.empty((480, 640, 3), dtype=np.uint8) | |
for frame in cam.capture_continuous(output, format="bgr", use_video_port=True): | |
frameWin.clear() | |
instructWin.clear() | |
# normal_color_pair = 0 if not use_color else color_index[(1000, 1000, 1000)] | |
normal_color_pair = 0 | |
if use_color: | |
normal_color_pair = color_index[(1000, 1000, 1000)] | |
# listen for keypress actions | |
c = stdscr.getch() | |
if c == ord(' '): | |
# SPACE pressed - take photo | |
img_name = capture_destination.format(img_counter) | |
cv2.imwrite(img_name, frame) | |
print("{} written!".format(img_name)) | |
img_counter += 1 | |
elif c == ord('+'): | |
if frameWin_width < 120: | |
frameWin_width += 15 | |
elif c == ord('-'): | |
if frameWin_width > 80: | |
frameWin_width -= 15 | |
elif c == curses.KEY_UP: | |
# rotate through cam rotations | |
if frame_rotation == 0: | |
frame_rotation = 90 | |
elif frame_rotation == 90: | |
frame_rotation = 180 | |
elif frame_rotation == 180: | |
frame_rotation = 270 | |
else: | |
frame_rotation = 0 | |
cam.rotation = frame_rotation | |
elif c == curses.KEY_DOWN: | |
# not yet implemented | |
pass | |
elif c == curses.KEY_LEFT: | |
# not yet implemented | |
pass | |
elif c == curses.KEY_RIGHT: | |
# not yet implemented | |
pass | |
elif c == ord('q') or c == ord('Q') or c == 27: | |
# some of these might be supported by curses by default (i.e. "q") | |
break | |
instructWin.addstr(1, 0, sb_instruction, curses.color_pair(normal_color_pair)) | |
# print ASCII frame - could window this, so we only redraw it | |
printFrameToAscii(frame, frameWin_width, frameWin) | |
_fps = fps() | |
instructWin.addstr(0, 0, "FPS: %s" % (round(_fps, 2)), curses.color_pair(normal_color_pair)) | |
frameWin.refresh() | |
instructWin.refresh() | |
# if used, restore TERM original coloring | |
if curses.has_colors() and len(save_colors) > 0: | |
for i in range(curses.COLORS): | |
curses.init_color(i, *save_colors[i]) | |
# wrap terminal control so we can restore everything on quit | |
curses.wrapper(main) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Added a first draft of color ASCII - comes with a performance hit in terms of FPS. Added FPS.