Skip to content

Instantly share code, notes, and snippets.

@mcorrigan
Last active November 20, 2023 19:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mcorrigan/faf55cd8b0d183e0aaab5c40db6ffd92 to your computer and use it in GitHub Desktop.
Save mcorrigan/faf55cd8b0d183e0aaab5c40db6ffd92 to your computer and use it in GitHub Desktop.
Preview Raspberry Pi Camera via Color ASCII Preview [for headless Pi]
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)
@mcorrigan
Copy link
Author

Added a first draft of color ASCII - comes with a performance hit in terms of FPS. Added FPS.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment