import cv2 import gettext from itertools import count import os # Needed for utf-8 text from PIL import ImageFont, ImageDraw, Image from send2trash import send2trash import signal import sys import subprocess import time from timeit import default_timer as timer import tomllib import numpy as np import serialutils running_from_folder = os.path.realpath(__file__) alphabet = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'] # l10n LOCALE = os.getenv('LANG', 'en_EN') _ = gettext.translation('template', localedir='locales', languages=[LOCALE]).gettext # Config # defaults project_settings_defaults = { # gphoto2 = 0, picam = 1, webcam = 2 'camera_type': 2, 'file_extension':'JPG', 'trigger_mode': 'key', 'projects_folder': '', 'use_date_for_folder': False, 'onion_skin_onstartup' : False, 'onionskin_alpha_default' : 0.4, 'onionskin_fx' : False, 'fullscreen_bool' : True, 'screen_w' : 1920, 'screen_h' : 1080, 'framerate' : 16, 'vflip' : False, 'hflip' : False, 'ffmpeg_path' : None, 'export_options' : 'scale=1920:-1,crop=1920:1080:0:102', 'cache_images' : False, 'liveview' : False, } # Load from file config_locations = ["./", "~/.", "~/.config/"] config_found_msg = _("No configuration file found, using defaults.") project_settings = project_settings_defaults for location in config_locations: # Optional config files, ~ is expanded to $HOME on *nix, %USERPROFILE% on windows if os.path.exists( os.path.expanduser(os.path.join(location, 'config.toml'))): with open(os.path.expanduser(location + 'config.toml'), 'rb') as config_file: project_settings = tomllib.load(config_file) if 'CAMERA' in project_settings: camera_settings = project_settings['CAMERA'] if 'DEFAULT' in project_settings: project_settings = project_settings['DEFAULT'] config_found_msg = _("Found configuration file in {}").format(os.path.expanduser(location)) print(config_found_msg) def reset_wb_exp(): # Flip auto-exposure, white-balance on to adapt to light environment serialutils.send_serial_cmd(serialutils.find_cam_port(), 'v4l2-ctl -d /dev/video0 -c auto_exposure=0 -c white_balance_auto_preset=1') # Give some time to the captor to adapt time.sleep(1) # Flip back, wb 3 = fluorescent serialutils.send_serial_cmd(serialutils.find_cam_port(), 'v4l2-ctl -d /dev/video0 -c auto_exposure=1 -c white_balance_auto_preset=2') def generate_text_image(text:str, screen_w, screen_h, bullets=False): text_image = Image.new('RGB', (screen_w, screen_h), (0,0,0) ) # ~ text_image = np.zeros((screen_h,screen_w,3),np.uint8) # ~ text_image_pil = Image.fromarray(text_image) text_image_draw = ImageDraw.Draw(text_image) if text is not None: font = ImageFont.truetype("Tuffy_Bold.ttf", (screen_w/32)) font_len = font.getlength(text.split('\n')[0]) text_image_draw.multiline_text((screen_w/2 - font_len/2, screen_h/2 ), text, fill=(255, 255, 255), font=font, align='center', spacing=20 ) if bullets: dot_radius = screen_w/24 x_unit = (screen_w/32) y_unit = (screen_h/32) green_dot = (x_unit*14, y_unit*24) red_dot = (green_dot[0]+x_unit*4, green_dot[1]) # Green dot text_image_draw.circle(green_dot, dot_radius, fill=(0,255,0), outline=None, width=1 ) text_image_draw.text(green_dot, _("Yes"), fill=(0, 0, 0), font=font, anchor="mm", spacing=20 ) # Red dot text_image_draw.circle(red_dot, dot_radius, fill=(0,0,255), outline=None, width=1 ) text_image_draw.text(red_dot, _("No"), fill=(0, 0, 0), font=font, anchor="mm", spacing=20 ) text_image = np.array(text_image) return text_image def askyesno(text:str): blank = generate_text_image(text, project_settings['screen_w'], project_settings['screen_h'], bullets=True) cv2.imshow("StopiCV", blank) # Wait for input to continue answer = cv2.waitKey(0) # Space pressed == yes if answer%256 == 32 or answer%256 == 48 : return True # Any other key == no else: return False def find_letter_after(letter:str, date=False): if letter in alphabet and alphabet.index(letter) < len(alphabet) - 1 and not date: letter = alphabet[alphabet.index(letter) + 1] else: # Create folder with date year,mon,day,hour,minute,sec,wd,yd,dst = time.localtime() letter = '{}-{}-{}_{}-{}-{}'.format(year,mon,day,hour,minute,sec) return letter def get_projects_folder(): if len(projects_folder): project_folder = projects_folder else: # Get user folder project_folder = os.path.expanduser('~') # If a project folder is defined in settings, use it if project_settings['projects_folder'] != '': subfolder = project_settings['projects_folder'] else: # If it doesn't exist, use a default name subfolder = 'Stopmotion Projects' project_folder = os.path.join(project_folder, subfolder) # Create folder if it doesn't exist if os.path.exists(project_folder) == False: os.mkdir(project_folder) else: if not os.path.isdir(project_folder): # If file exists but is not a folder, can't create it, abort return False return project_folder def get_session_folder(): global next_letter project_folder = get_projects_folder() if project_folder: sessions_list = [] dir_list = os.listdir(project_folder) # Filter folders with name only one char long for folder in dir_list: if len(folder) == 1 and folder in alphabet: sessions_list.append(folder) # If folders exist, find last folder in alphabetical order if len(sessions_list): sessions_list.sort() last_letter = sessions_list[-1] # By default, find next letter for a new session next_letter = find_letter_after(last_letter, project_settings['use_date_for_folder']) if next_letter is False: return False # A previous session folder was found; ask the user if they wish to resume session if not project_settings['use_date_for_folder']: resume_session = askyesno(_("A previous session was found in {},\n resume shooting ?").format(os.path.join(project_folder, last_letter))) # ~ resume_session = tk.messagebox.askyesno(_("Resume session?"), _("A previous session was found in {}, resume shooting ?").format(os.path.join(project_folder, last_letter))) if resume_session: next_letter = last_letter else: if not project_settings['use_date_for_folder']: next_letter = 'A' else: next_letter = find_letter_after('A', project_settings['use_date_for_folder']) if os.path.exists(os.path.join(project_folder, next_letter)) is False: os.mkdir(os.path.join(project_folder, next_letter)) print(_("Using {} as session folder.").format(os.path.join(project_folder, next_letter))) return os.path.join(project_folder, next_letter) return False def get_frames_list(folder:str): # Get JPG files list in current directory existing_animation_files = img_list file_list = os.listdir(folder) for file in file_list: if (file.startswith(project_letter) and file.endswith(project_settings['file_extension'])): if file not in existing_animation_files: existing_animation_files.append(file) if len(existing_animation_files) == 0: # If no images were found, return fake name set to -001 to init file count to 000 return ["{}.{:04d}.{}".format(next_letter, -1, project_settings['file_extension'])] existing_animation_files.sort() return existing_animation_files def get_last_frame(folder:str): # Refresh file list existing_animation_files = get_frames_list(folder) # Get last file # Filename pattern is A.0001.JPG return existing_animation_files[-1].split('.') def get_onionskin_frame(folder:str, index=None): prev_image = get_last_frame(folder) prev_image = '.'.join(prev_image) if os.path.exists( os.path.expanduser(os.path.join(savepath, prev_image))): frm = cv2.imread(os.path.join(savepath, prev_image)) frm = cv2.resize(frm, (project_settings['screen_w'], project_settings['screen_h'])) # Imge does not exist, load blank image else: frm = blank_image return frm def return_next_frame_number(last_frame_name): prefix, filecount, ext = last_frame_name filename = '.{:04d}.'.format(int(filecount)+1) # ~ filename = (".%04i." % x for x in count(int(filecount) + 1)) # ~ return prefix + next(filename) + ext return prefix + filename + ext def update_image(img_list, img_index): if len(img_list) == 0: return 0 img_filename = img_list[img_index] if os.path.exists( os.path.expanduser(os.path.join(savepath, img_filename))): img = cv2.imread(os.path.join(savepath, img_filename)) img = cv2.resize(img, (project_settings['screen_w'], project_settings['screen_h'])) else: img = blank_image return img def next_frame(img_index, loop=True): img_index = check_range(img_index+1, loop) return img_index, update_image(img_list, img_index) def previous_frame(img_index): img_index = check_range(img_index-1) return img_index, update_image(img_list, img_index) def last_frame(img_index): img_index = len(img_list)-1 return img_index, update_image(img_list, img_index) def first_frame(img_index): img_index = 0 return img_index, update_image(img_list, img_index) def clean_img_list(folder_path): # Check file in dict exists, else remove it file_list = os.listdir(folder_path) # Iterate over copy of dict to avoid OOR error img_list_copy = img_list for file in img_list_copy: if file not in file_list: img_list.remove(file) def check_range(x, loop=True): if x < 0: if loop: return len(img_list)-1 else: return 0 elif x >= len(img_list)-1: if loop: return 0 else: return len(img_list)-1 else: return x def batch_rename(folder:str): # initialize counter to 0 frame_list = get_frames_list(folder) counter = (".%04i." % x for x in count(0)) # ~ for i in range(len(frame_list)): for i in frame_list: # ~ if os.path.exists(os.path.realpath(frame_list[i])): if os.path.exists(os.path.join(folder, i)): # ~ os.rename(os.path.realpath(frame_list[i]), os.path.realpath("{}{}{}".format(project_settings['project_letter'], next(counter), project_settings['file_extension']))) os.rename(os.path.join(folder, i), os.path.join(folder, "{}{}{}".format(project_letter, next(counter), project_settings['file_extension']))) # ~ print(os.path.join(folder, "{}{}{}".format(project_letter, next(counter), project_settings['file_extension']))) else: print(_("{} does not exist").format(str(i))) return get_frames_list(folder) def offset_dictvalues(from_index=0): dict_copy = dict(img_list) for i in range(from_index, len(dict_copy)): if i < len(img_list)-1: img_list[list(img_list.keys())[i]] = list(img_list.values())[i+1] else: img_list[list(img_list.keys())[i]] = None def remove_frame(img_list, img_index): if len(img_list): folder_path = os.path.realpath(savepath) frame_name = img_list[img_index] # ~ frame_path = os.path.realpath(frame_name) frame_path = os.path.join(folder_path, frame_name) if not os.path.exists(frame_path): return img_list, img_index, blank_image print(_("Removing {}").format(frame_path)) # trash file send2trash(frame_path) # remove entry from dict img_list.remove(frame_name) # offset cached images # ~ offset_dictvalues(img_index) # rename files and get new list img_list = batch_rename(folder_path) clean_img_list(folder_path) # update index if possible img_index = check_range(img_index, False) # update display return img_list, img_index, update_image(img_list, img_index) else: return img_list, 0, blank_image # ~ def playback_animation(img_list, img_index): # ~ # save onionskin state # ~ if onionskin: # ~ onionskin = False # ~ onionskin_was_on = True # ~ for file in img_list: # ~ img = update_image(img_list, img_list.index(file)) # ~ print(str(img_list.index(file)) + " : " + file ) # ~ cv2.imshow("StopiCV", img) # ~ time.sleep(.5) # ~ # Restore previous frame # ~ print(img_index) # ~ frame_before_playback = update_image(img_list, img_index) # ~ cv2.imshow("StopiCV", img) # ~ # Restore onionskin # ~ if 'onionskin_was_on' in locals(): # ~ onionskin = True # ~ # Restore index # ~ return img_index, frame_before_playback def testDevice(source): cap = cv2.VideoCapture(source) if cap is None or not cap.isOpened(): print(_("Warning: unable to open video source: {}").format(source)) return False cap.release() return True def signal_handler(sig, frame): global ctrlc_pressed ctrlc_pressed = True def parse_export_options(options:str, vflip:bool=False, hflip:bool=False): if vflip: options += ',vflip' if hflip: options += ',hflip' return options def export_animation(input_filename, export_filename): input_format, framerate = input_options if project_settings['ffmpeg_path'] is None: return False ffmpeg_process = subprocess.Popen([ project_settings['ffmpeg_path'], '-v','quiet', '-y', '-f', input_format, '-r', framerate, '-i', input_filename, '-vf', output_options, export_filename, ]) return ffmpeg_process def main(args): global onionskin, playback, loop_playback, playhead, index, img_list if not testDevice(0): print(_("No camera device found. Exiting...")) return 1 cam = cv2.VideoCapture(0) cam.set(cv2.CAP_PROP_FRAME_WIDTH, 1600) cam.set(cv2.CAP_PROP_FRAME_HEIGHT, 900) frame = get_onionskin_frame(savepath, index) # TODO : (re)set camera settings on startup # Using serial to send v4l2-ctl cmd to the camera # Use v4l-ctl to flip frame # v4l2-ctl -d /dev/video0 -c vertical_flip=1 -c control=value # DEPRECATED : Using config file and loading with camera-ctl # Write config file to /tmp/blah # ~ write_config_file = '''rm /tmp/blah && echo "power_line_frequency=2 # ~ sharpness=50 # ~ video_bitrate=25000000 # ~ auto_exposure=1 # ~ auto_exposure_bias=2 # ~ white_balance_auto_preset=3 # ~ exposure_metering_mode=1 # ~ " > /tmp/blah''' # Apply config # ~ apply_config = '/opt/camera-control/camera-ctl -c /tmp/blah -v /dev/video0 -i repeat_sequence_header -i h264_i_frame_period -i h264_level -i h264_profile -i compression_quality\rl\rq\r' # ~ serialutils.send_serial_cmd(serialutils.find_cam_port(), write_config_file) # ~ serialutils.send_serial_cmd(serialutils.find_cam_port(), apply_config) # ~ User Controls # ~ brightness 0x00980900 (int) : min=0 max=100 step=1 default=50 value=50 flags=slider # ~ contrast 0x00980901 (int) : min=-100 max=100 step=1 default=0 value=-2 flags=slider # ~ saturation 0x00980902 (int) : min=-100 max=100 step=1 default=0 value=5 flags=slider # ~ red_balance 0x0098090e (int) : min=1 max=7999 step=1 default=1000 value=1000 flags=slider # ~ blue_balance 0x0098090f (int) : min=1 max=7999 step=1 default=1000 value=1000 flags=slider # ~ horizontal_flip 0x00980914 (bool) : default=0 value=0 # ~ vertical_flip 0x00980915 (bool) : default=0 value=0 # ~ power_line_frequency 0x00980918 (menu) : min=0 max=3 default=1 value=1 # ~ sharpness 0x0098091b (int) : min=-100 max=100 step=1 default=0 value=10 flags=slider # ~ color_effects 0x0098091f (menu) : min=0 max=15 default=0 value=0 # ~ rotate 0x00980922 (int) : min=0 max=360 step=90 default=0 value=0 flags=modify-layout # ~ color_effects_cbcr 0x0098092a (int) : min=0 max=65535 step=1 default=32896 value=32896 # ~ Codec Controls # ~ video_bitrate_mode 0x009909ce (menu) : min=0 max=1 default=0 value=0 flags=update # ~ video_bitrate 0x009909cf (int) : min=25000 max=25000000 step=25000 default=10000000 value=25000000 # ~ repeat_sequence_header 0x009909e2 (bool) : default=0 value=0 # ~ h264_i_frame_period 0x00990a66 (int) : min=0 max=2147483647 step=1 default=60 value=60 # ~ h264_level 0x00990a67 (menu) : min=0 max=11 default=11 value=11 # ~ h264_profile 0x00990a6b (menu) : min=0 max=4 default=4 value=4 # ~ Camera Controls # ~ auto_exposure 0x009a0901 (menu) : min=0 max=3 default=0 value=0 # ~ exposure_time_absolute 0x009a0902 (int) : min=1 max=10000 step=1 default=1000 value=1000 # ~ exposure_dynamic_framerate 0x009a0903 (bool) : default=0 value=0 # ~ auto_exposure_bias 0x009a0913 (intmenu): min=0 max=24 default=12 value=2 # ~ white_balance_auto_preset 0x009a0914 (menu) : min=0 max=9 default=1 value=1 # ~ image_stabilization 0x009a0916 (bool) : default=0 value=0 # ~ iso_sensitivity 0x009a0917 (intmenu): min=0 max=4 default=0 value=0 # ~ iso_sensitivity_auto 0x009a0918 (menu) : min=0 max=1 default=1 value=1 # ~ exposure_metering_mode 0x009a0919 (menu) : min=0 max=3 default=0 value=0 # ~ scene_mode 0x009a091a (menu) : min=0 max=13 default=0 value=0 # ~ JPEG Compression Controls # ~ compression_quality 0x009d0903 (int) : min=1 max=100 step=1 default=30 value=30 # Make sure we're using max bitrate serialutils.send_serial_cmd(serialutils.find_cam_port(), 'v4l2-ctl -d /dev/video0 -c video_bitrate=25000000') # Flip auto-exposure, white-balance on and off to adapt to light environment reset_wb_exp() # Flip preview (0 = vert; 1 = hor) if project_settings['vflip']: serialutils.send_serial_cmd(serialutils.find_cam_port(), 'v4l2-ctl -d /dev/video0 -c vertical_flip=1') else: serialutils.send_serial_cmd(serialutils.find_cam_port(), 'v4l2-ctl -d /dev/video0 -c vertical_flip=0') if project_settings['hflip']: serialutils.send_serial_cmd(serialutils.find_cam_port(), 'v4l2-ctl -d /dev/video0 -c horizontal_flip=1') else: serialutils.send_serial_cmd(serialutils.find_cam_port(), 'v4l2-ctl -d /dev/video0 -c horizontal_flip=0') time.sleep(.5) loop_delta = 0 while True: start = timer() if playback: if onionskin: onionskin = False onionskin_was_on = True # Play all frames if playhead < len(img_list)-1: playhead, img = next_frame(playhead, loop_playback) cv2.imshow("StopiCV", img) # Calculate framerate according to loop execution time frame_interval = 1.0/project_settings['framerate']-loop_delta if frame_interval < 0: frame_interval = 0 time.sleep(frame_interval) else: playhead = index img = update_image(img_list, index) playback = False # Restore onionskin if 'onionskin_was_on' in locals(): onionskin = True loop_playback = False if onionskin: ret, overlay = cam.read() og_frame = overlay.copy() # Resize preview overlay = cv2.resize(overlay, (project_settings['screen_w'], project_settings['screen_h'])) # Apply onionskin alpha = project_settings['onionskin_alpha_default'] beta = (1.0 - alpha) overlay = cv2.addWeighted(frame, alpha, overlay, beta, 0) if not ret: print(_("Failed to grab frame.")) break cv2.imshow("StopiCV", overlay) if not playback and not onionskin: cv2.imshow("StopiCV", frame) k = cv2.waitKey(1) # Key o if (k%256 == 111) or (k%256 == 47): # Toggle onionskin onionskin = not onionskin # Key up elif (k%256 == 82) or (k%256 == 56): if len(img_list): if playback: playback = False index, frame = last_frame(index) # Key down elif (k%256 == 84) or (k%256 == 50): if len(img_list): if playback: playback = False index, frame = first_frame(index) print(index) # Key left elif (k%256 == 81) or (k%256 == 52): # Displau previous frame if len(img_list): if playback: playback = False index, frame = previous_frame(index) elif (k%256 == 83) or (k%256 == 54): # Displau next frame if len(img_list): if playback: playback = False index, frame = next_frame(index) # Key r / keypad 9 - reset wb,exp elif (k%256 == 114) or (k%256 == 57): reset_wb_exp() # Key e / keypad * elif (k%256 == 101) or (k%256 == 42): print(_("Export")) ffmpeg_process = export_animation(input_filename, export_filename) # Key Return elif (k%256 == 13): print(_("Playback")) playhead = index loop_playback = True playback = not playback # Key remove frame - backspace, del, numpad_minus elif (k%256 == 8) or (k%256 == 45) or (k == 255): # Remove frame print(_("Remove frame")) img_list, index, frame = remove_frame(img_list, index) # Quit app elif k%256 == 27: # ESC pressed print(_("Escape hit, exiting...")) break elif ctrlc_pressed: print(_("Ctrl-C hit, exiting...")) break elif cv2.getWindowProperty("StopiCV", cv2.WND_PROP_VISIBLE) < 1: print(_("Window was closed, exiting...")) break # Take pic # SPACE or numpad 0 pressed elif (k%256 == 32) or (k%256 == 48): img_name = return_next_frame_number(get_last_frame(savepath)) img_path = os.path.join(savepath, img_name) cv2.imwrite(img_path, og_frame) print(_("File {} written.").format(img_path)) if len(img_list) and (img_list[index] == '{letter}.-001.{ext}'.format(letter=project_letter, ext=project_settings['file_extension'])): img_list[index] = img_name else: index += 1 frame = get_onionskin_frame(savepath, index) # REMOVE : Debug print keycode elif k==-1: # normally -1 returned,so don't print it continue else: print(k) # else print its value end = timer() loop_delta = end - start print(loop_playback) if 'ffmpeg_process' in locals(): if ffmpeg_process.poll() is None: print(_("Ffmpeg is still running.\n Waiting for task to complete.")) msg = generate_text_image(_("Ffmpeg is still running.\n Waiting for task to complete."), project_settings['screen_w'], project_settings['screen_h'] ) cv2.imshow("StopiCV", msg) # Force window refresh cv2.pollKey() try: ffmpeg_process.wait(timeout=20) except: print(_("Terminating running process...")) ffmpeg_process.terminate() cam.release() cv2.destroyAllWindows() cv2.namedWindow("StopiCV", cv2.WINDOW_GUI_NORMAL) cv2.setWindowProperty("StopiCV", cv2.WND_PROP_OPENGL, cv2.WINDOW_OPENGL) cv2.setWindowProperty("StopiCV", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN) cv2.setWindowProperty("StopiCV", cv2.WND_PROP_ASPECT_RATIO, cv2.WINDOW_KEEPRATIO) ctrlc_pressed = False projects_folder = project_settings['projects_folder'] next_letter = 'Z' img_list = [] savepath = get_session_folder() onionskin = project_settings['onion_skin_onstartup'] playback = False playhead = 0 loop_playback = True blank_image = generate_text_image(_("No images yet! Start shooting..."), project_settings['screen_w'], project_settings['screen_h']) if len(savepath): project_letter = savepath.split(os.sep)[-1] else: project_letter = 'A' img_list = get_frames_list(savepath) index = len(img_list)-1 print(project_letter) # Export settings input_filename = "{folder}{letter}{sep}{letter}.%04d.{ext}".format(folder=projects_folder, sep=os.sep, letter=project_letter, ext=project_settings['file_extension']) print(input_filename) input_options = ["image2", str(project_settings['framerate'])] # ~ output_filename = "{folder}{sep}{filename}.mp4".format(folder=projects_folder, sep=os.sep, filename=savepath.split(os.sep)[-1]) output_filename = "{filename}.mp4".format(filename=project_letter) output_options = parse_export_options(project_settings['export_options'], project_settings['vflip'], project_settings['hflip'] ) export_filename = os.path.join(savepath, output_filename) if __name__ == '__main__': signal.signal(signal.SIGINT, signal_handler) sys.exit(main(sys.argv[1:]))