import cv2 import gettext from itertools import count import os from send2trash import send2trash import signal import sys import subprocess import time import tomllib # TODO : remove tkinter dep import tkinter as tk from tkinter import filedialog, messagebox import numpy as np 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'] next_letter = 'A' # 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': '', # ~ 'project_letter': 'A' '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) # Create blank image with 0s blank_image = np.zeros((project_settings['screen_h'], project_settings['screen_w'],3), np.uint8) # Set all pixels to light grey blank_image[:,0:] = 200 font = cv2.FONT_HERSHEY_SIMPLEX cv2.putText(blank_image,_("Pas d'image"),(int(project_settings['screen_w']/3),int(project_settings['screen_h']/2)), font, 4, (100, 100, 100), 8, cv2.LINE_AA) def find_letter_after(letter:str): if letter in alphabet and alphabet.index(letter) < len(alphabet) - 1: return alphabet[alphabet.index(letter) + 1] else: return False 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(): 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 dir in dir_list: if len(dir) == 1 and dir in alphabet: sessions_list.append(dir) # 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) if next_letter is False: return False # A previous session folder was found; ask the user if they wish to resume session 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: next_letter = 'A' 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 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_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_index, update_image(img_list, img_index) else: return 0, blank_image def playback_animation(img_list, img_index): # save onionskin state if onionskin: onionskin = False onionskin_was_on = True # ~ index = 0 # Play all frames # ~ while index < len(img_list): # ~ print(img_list[index]) # ~ img = update_image(img_list, index) # ~ cv2.imshow("StopiCV", img) # ~ time.sleep(.5) # ~ index += 1 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 if not testDevice(0): print(_("No camera device found. Exiting...")) return 1 cam = cv2.VideoCapture(0) cam.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cam.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) 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) frame = get_onionskin_frame(savepath, index) while True: 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) time.sleep(1.0/project_settings['framerate']) 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 # Resize preview overlay = cv2.resize(overlay, (project_settings['screen_w'], project_settings['screen_h'])) # Apply onionskin overlay = cv2.addWeighted(frame, 1.0, overlay, project_settings['onionskin_alpha_default'], 0) # Flip preview (0 = vert; 1 = hor) if project_settings['vflip'] or project_settings['hflip']: if project_settings['vflip'] and project_settings['hflip']: flip_dir = -1 elif project_settings['vflip']: flip_dir = 0 elif project_settings['hflip']: flip_dir = 1 overlay = cv2.flip(overlay, flip_dir) 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 left elif (k%256 == 81) or (k%256 == 52): # Displau previous frame print("Left") if len(img_list): if playback: playback = False index, frame = previous_frame(index) print("L: {}".format(index)) elif (k%256 == 83) or (k%256 == 54): # Displau next frame print("Right") if len(img_list): if playback: playback = False index, frame = next_frame(index) print("R: {}".format(index)) # 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") 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 elif (k%256 == 32) or (k%256 == 48): # SPACE pressed 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] == 'A.-001.png'): img_list[index] = img_name else: index += 1 frame = get_onionskin_frame(savepath, index) # TODO : go back to last frame ? # REMOVE : Debug print keycode elif k==-1: # normally -1 returned,so don't print it continue else: print(k) # else print its value if 'ffmpeg_process' in locals(): if ffmpeg_process.poll() is None: print("Ffmpeg is still running. Waiting for task to complete.") try: ffmpeg_process.wait(timeout=1) except: print("Terminating running process...") ffmpeg_process.terminate() cam.release() cv2.destroyAllWindows() ctrlc_pressed = False projects_folder = project_settings['projects_folder'] img_list = [] savepath = get_session_folder() onionskin = project_settings['onion_skin_onstartup'] playback = False playhead = 0 loop_playback = True 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:]))