Compare commits

...

10 Commits

Author SHA1 Message Date
ABelliqueux 31f02680d9 New playback method 2024-04-09 09:11:32 +02:00
ABelliqueux 3eb3d6054f Fix link 2024-03-29 18:54:52 +01:00
ABelliqueux 4ddfa390ad Add readme 2024-03-29 18:50:09 +01:00
ABelliqueux 420754ed32 Add cache_images option 2024-03-04 16:21:50 +01:00
ABelliqueux cd30da3426 Remove prints, change key mapping 2024-03-01 14:34:28 +01:00
ABelliqueux b795713018 Update l10n 2024-02-27 11:51:59 +01:00
ABelliqueux 588d301f13 Cancel export if esc used 2024-02-27 11:50:28 +01:00
ABelliqueux 1856d42ab4 Video export ask for filename, check cam type != 0 2024-02-27 11:05:22 +01:00
ABelliqueux b6cf074362 Picamera2 support 2024-02-26 18:32:18 +01:00
ABelliqueux bf156cb6e2 Fix settings not being applied 2024-02-23 18:02:48 +01:00
8 changed files with 204 additions and 76 deletions

View File

@ -1,4 +1,5 @@
[DEFAULT]
camera_type = 0
file_extension = 'JPG'
trigger_mode = 'key'
projects_folder = ''
@ -13,8 +14,12 @@ framerate = 16
vflip = false
hflip = false
export_options = 'scale=1920:-1,crop=1920:1080:0:102'
cache_images = false
[CAMERA]
# Nikon D40x
# Add meter mode to center, focus mode to fixed selection
# /main/capturesettings/autofocusarea to 0
# /main/capturesettings/focusmetermode to 1
capturemode = 3 # use IR remote
imagesize = 2 # use size S (1936x1296)
whitebalance = 1 # Natural light

Binary file not shown.

View File

@ -75,3 +75,5 @@ msgstr ""
msgid "Exporting to {}"
msgstr ""
msgid "Mp4 files"
msgstr "Fichier Mp4"

Binary file not shown.

View File

@ -84,3 +84,5 @@ msgstr "Terminaison du processus."
msgid "Exporting to {}"
msgstr "Exportation dans {}"
msgid "Mp4 files"
msgstr "Fichier Mp4"

189
main_c.py
View File

@ -30,7 +30,7 @@ from io import BytesIO
from itertools import count
import locale
import os
from PIL import Image, ImageTk, ImageFilter, ImageDraw, ImageFont
from PIL import Image, ImageTk, ImageFilter, ImageDraw, ImageOps, ImageFont
import sys
import threading
import time
@ -60,8 +60,10 @@ from send2trash import send2trash
# X Allow opening and exporting without a camera connected
# o Better settings names
# o webcam support (pygame, win and linux only)
# o picam support (picamera2)
# X picam support (picamera2)
# o notify export ending
# o Use try/except for picam and pygame lib import
# o picam liveview
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']
@ -73,6 +75,8 @@ _ = gettext.translation('template', localedir='locales', languages=[LOCALE]).get
# Config
# defaults
project_settings_defaults = {
# DSLR = 0, picam = 1, webcam = 2
'camera_type': 0,
'file_extension':'JPG',
'trigger_mode': 'key',
'projects_folder': '',
@ -87,6 +91,7 @@ project_settings_defaults = {
'vflip' : False,
'hflip' : False,
'export_options' : 'scale=1920:-1,crop=1920:1080:0:102',
'cache_images' : False,
}
# Camera Settings (Nikon D40x)
@ -102,12 +107,12 @@ for location in config_locations:
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 'DEFAULT' in project_settings:
project_settings = project_settings['DEFAULT']
if 'CHECK' in project_settings:
camera_status = project_settings['CHECK']
if 'CAMERA' in project_settings:
camera_settings = project_settings['CAMERA']
if 'CHECK' in project_settings:
camera_settings = project_settings['CHECK']
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)
@ -115,6 +120,8 @@ class KISStopmo(tk.Tk):
def __init__(self, *args, **kargs):
self.check_config()
if project_settings['camera_type'] != 0:
from picamera2 import Picamera2
# Default config
# Set script settings according to config file
self.onion_skin = project_settings['onion_skin_onstartup']
@ -123,9 +130,8 @@ class KISStopmo(tk.Tk):
self.fullscreen_bool = project_settings['fullscreen_bool']
self.screen_w, self.screen_h = project_settings['screen_w'], project_settings['screen_h']
self.framerate = project_settings['framerate']
self.playback = False
# ~ for setting in camera_settings:
# ~ print(setting)
# ~ self.photo = None
self.end_thread = False
# Window setup
@ -157,51 +163,70 @@ class KISStopmo(tk.Tk):
self.img_index = self.check_range(len(self.img_list)-1, False)
self.splash_text = _("No images yet! Start shooting...")
# Camera setup
self.camera = gp.check_result(gp.gp_camera_new())
try:
gp.check_result(gp.gp_camera_init(self.camera))
# get configuration tree
self.current_camera_config = gp.check_result(gp.gp_camera_get_config(self.camera))
self.apply_camera_settings(self.camera, self.current_camera_config)
if self.check_status(self.camera, self.current_camera_config) is False:
print(_("Warning: Some settings are not set to the recommended value!"))
except:
self.splash_text += _("\nCamera not found or busy.")
if project_settings['camera_type'] != 0:
try:
self.camera = Picamera2()
self.picam_conf_full = self.camera.create_still_configuration(main={"size":(1920,1080)}, lores={"size":(800,600)})
self.camera.configure(self.picam_conf_full)
# Autofocus, get lens position and switch to manual mode
# Set Af mode to Manual (1). Default is Continuous (2), Auto is 1
# TODO: lock exposure, wb
self.camera.set_controls({'AfMode':1})
self.camera.start(show_preview=False)
self.camera.autofocus_cycle()
self.camera_lenspos = self.camera.capture_metadata()['LensPosition']
self.camera.set_controls({'AfMode':0, 'AwbEnable': False, 'AeEnable': False})
self.camera.stop()
except:
self.camera = False
self.splash_text += _("\nCamera not found or busy.")
else:
self.camera = gp.check_result(gp.gp_camera_new())
try:
gp.check_result(gp.gp_camera_init(self.camera))
# get configuration tree
self.current_camera_config = gp.check_result(gp.gp_camera_get_config(self.camera))
self.apply_camera_settings(self.camera, self.current_camera_config)
if self.check_status(self.camera, self.current_camera_config) is False:
print(_("Warning: Some settings are not set to the recommended value!"))
except:
self.camera = False
self.splash_text += _("\nCamera not found or busy.")
self.timeout = 3000 # milliseconds
self.splashscreen = self.generate_splashscreen()
image = self.update_image()
if image:
self.image = None
self.update_image()
if self.image:
if self.onion_skin:
if self.img_index:
photo = self.apply_onionskin(image, self.onionskin_alpha_default, self.onionskin_fx)
photo = self.apply_onionskin(self.image, self.onionskin_alpha_default, self.onionskin_fx)
else:
photo = ImageTk.PhotoImage(image)
photo = ImageTk.PhotoImage(self.image)
self.label.configure(image=photo)
self.label.image = photo
if project_settings['trigger_mode'] == 'event':
root.after(1000, self.trigger_bg_loop)
# ~ root.after(1000, self.wait_for_capture)
# Key binding
root.bind("<Escape>", lambda event: root.attributes("-fullscreen", False))
root.bind("<f>", lambda event: root.attributes("-fullscreen", True))
root.bind("<n>", self.next_frame)
root.bind("<b>", self.previous_frame)
root.bind("<o>", self.toggle_onionskin)
root.bind("<p>", self.preview_animation)
root.bind("<e>", self.trigger_export_animation)
root.bind("<e>", self.next_frame)
root.bind("<j>", self.previous_frame)
root.bind("<J>", self.toggle_onionskin)
root.bind("<N>", self.preview_animation)
root.bind("<E>", self.trigger_export_animation)
root.bind("<d>", self.remove_frame)
root.bind("<a>", self.print_imglist)
if project_settings['trigger_mode'] != 'event':
root.bind("<j>", self.capture_image)
root.bind("<n>", self.capture_image)
def check_config(self):
global project_settings
for setting in project_settings_defaults:
@ -319,7 +344,6 @@ class KISStopmo(tk.Tk):
existing_animation_files = self.img_list
# ~ existing_animation_files =
file_list = os.listdir(folder)
# ~ print(file_list)
for file in file_list:
if (file.startswith(self.project_letter) and file.endswith(project_settings['file_extension'])):
if file not in existing_animation_files:
@ -353,7 +377,6 @@ class KISStopmo(tk.Tk):
frame_list = self.get_frames_list(folder)
counter = (".%04i." % x for x in count(0))
# ~ for i in range(len(frame_list)):
# ~ print(frame_list)
for i in frame_list.keys():
# ~ if os.path.exists(os.path.realpath(frame_list[i])):
if os.path.exists(os.path.join(folder, i)):
@ -385,7 +408,6 @@ class KISStopmo(tk.Tk):
if not os.path.exists(frame_path):
return 0
# ~ print(self.img_list)
print(_("Removing {}").format(frame_path))
# trash file
send2trash(frame_path)
@ -395,15 +417,10 @@ class KISStopmo(tk.Tk):
self.offset_dictvalues(self.img_index)
# rename files and get new list
self.img_list = self.batch_rename(folder_path)
print(self.img_list)
self.clean_img_list(folder_path)
# ~ print(self.img_list)
# update index if possible
# ~ print(self.img_index)
self.img_index = self.check_range(self.img_index, False)
# ~ print(self.img_index)
# update display
# ~ print(self.img_index)
self.update_image(None, self.img_index)
# ~ def open_jpg(self, filepath:str, w:int, h:int):
@ -411,28 +428,22 @@ class KISStopmo(tk.Tk):
# If pic not cached
if filetuple[-1] is None:
try:
# ~ image = Image.open(filepath)
# ~ print( filetuple[0] + "is not in cache")
image = Image.open(os.path.join(self.savepath, filetuple[0]))
image = image.resize((w, h))
image = ImageOps.fit(image, (w, h))
if vflip:
image = image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
if hflip:
image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
self.img_list[filetuple[0]] = image
if project_settings['cache_images']:
# TODO : Do not cache image to preserve memory
self.img_list[filetuple[0]] = image
except FileNotFoundError:
return False
else:
# ~ print( filetuple[0] + "is in cache")
image = filetuple[-1]
if project_settings['cache_images']:
image = filetuple[-1]
return image
# ~ def inc(self, x):
# ~ if x >= len(self.img_list)-1:
# ~ return 0
# ~ else:
# ~ return x + 1
def check_range(self, x, loop=True):
if x < 0:
@ -465,6 +476,7 @@ class KISStopmo(tk.Tk):
def update_image(self, event=None, index=None):
# TODO : check event mode still works
if event is not None:
self.img_index = self.check_range(self.img_index+1)
if index is None:
@ -484,10 +496,12 @@ class KISStopmo(tk.Tk):
if self.onion_skin:
if index:
photo = self.apply_onionskin(new_image, project_settings['onionskin_alpha_default'], project_settings['onionskin_fx'])
# ~ print(photo)
self.label.configure(image=photo)
self.label.image = photo
return new_image
# ~ return new_image
self.image = new_image
# ~ new_image.close()
return True
else:
return False
@ -502,17 +516,31 @@ class KISStopmo(tk.Tk):
self.update_image(None, self.img_index)
def playback_animation(self):
while self.img_index < len(self.img_list)-1:
self.img_index += 1
root.after(62, self.update_image(None, self.img_index))
root.update_idletasks()
def preview_animation(self, event):
# save OS state
if self.onion_skin:
self.onion_skin = False
onion_skin_was_on = True
# playback
# TODO : Use async function for playback
# ~ self.playback = not self.playback
# ~ self.img_index = 0
# ~ self.playback_animation()
for img in self.img_list:
# ~ self.update_image(None, self.img_list.index(img))
# ~ if self.playback:
self.update_image(None, list(self.img_list.keys()).index(img))
root.update_idletasks()
time.sleep(1/self.framerate)
# ~ else:
# ~ break
# ~ self.update_image(None, self.img_index)
self.display_last_frame()
# restore OS state
@ -632,31 +660,32 @@ class KISStopmo(tk.Tk):
print(speed)
print(str(current_lightmeter_value) + " - " + str(current_shutterspeed_value))
return True
# ~ print(str(current_lightmeter_value) + " - " + str(current_shutterspeed_value))
def capture_image(self, event=None, event_data=None):
# get net file name based on prefix, file count and extension
next_filename = self.return_next_frame_number(self.get_last_frame(self.savepath))
# build full path to file
target_path = os.path.join(self.savepath, next_filename)
# ~ print(target_path)
# Get file from camera
if event_data is None:
# ~ print("j pressed")
new_frame_path = self.camera.capture(gp.GP_CAPTURE_IMAGE)
# ~ self.camera.trigger_capture()
new_frame = self.camera.file_get(
new_frame_path.folder, new_frame_path.name, gp.GP_FILE_TYPE_NORMAL)
print(_("Saving {}{}").format(new_frame_path.folder, new_frame_path.name))
if project_settings['camera_type'] != 0:
self.camera.start(show_preview=False)
self.camera.set_controls({"LensPosition": self.camera_lenspos})
self.camera.capture_file(target_path, 'main', format='jpeg')
self.camera.stop()
else:
print(_("Getting file {}").format(event_data.name))
new_frame = self.camera.file_get(
event_data.folder, event_data.name, gp.GP_FILE_TYPE_NORMAL)
new_frame.save(target_path)
# Get file from DSLR camera
if event_data is None:
new_frame_path = self.camera.capture(gp.GP_CAPTURE_IMAGE)
# ~ self.camera.trigger_capture()
new_frame = self.camera.file_get(
new_frame_path.folder, new_frame_path.name, gp.GP_FILE_TYPE_NORMAL)
print(_("Saving {}{}").format(new_frame_path.folder, new_frame_path.name))
else:
print(_("Getting file {}").format(event_data.name))
new_frame = self.camera.file_get(
event_data.folder, event_data.name, gp.GP_FILE_TYPE_NORMAL)
new_frame.save(target_path)
# ~ next_filename = prefix+next(filename)+ext
# ~ print(self.img_list)
if '{letter}.-001.JPG'.format(letter=self.project_letter) in self.img_list:
# ~ self.img_list.pop('A.-001.JPG')
self.img_list.pop('{letter}.-001.JPG'.format(letter=self.project_letter))
# Display new frame
self.display_last_frame()
@ -668,7 +697,6 @@ class KISStopmo(tk.Tk):
return
event_type, event_data = self.camera.wait_for_event(self.timeout)
if event_type == gp.GP_EVENT_FILE_ADDED:
# ~ print("file added")
self.capture_image(None, event_data)
# ~ root.after(self.timeout, self.wait_for_capture)
self.wait_for_capture()
@ -683,7 +711,8 @@ class KISStopmo(tk.Tk):
self.input_filename,
self.input_options)
.output(
self.output_filename,
# ~ self.output_filename,
self.export_filename,
self.output_options
)
)
@ -691,14 +720,22 @@ class KISStopmo(tk.Tk):
await ffmpeg.execute()
def trigger_export_animation(self, event):
output_folder = filedialog.askdirectory()
self.output_filename = "{folder}{sep}{filename}".format(folder=output_folder, sep=os.sep, filename=self.output_filename)
print(_("Exporting to {}").format(self.output_filename))
# ~ output_folder = filedialog.askdirectory()
self.export_filename = ()
self.export_filename = filedialog.asksaveasfilename(defaultextension='.mp4', filetypes=((_("Mp4 files"), '*.mp4'),))
if not self.export_filename:
# ~ self.export_filename = "{folder}{sep}{filename}".format(folder=os.getcwd(), sep=os.sep, filename=self.output_filename)
return False
print(_("Exporting to {}").format(self.export_filename))
self.export_task = asyncio.run(self.export_animation())
# check with self.export_task.done() == True ? https://stackoverflow.com/questions/69350645/proper-way-to-retrieve-the-result-of-tasks-in-asyncio
def end_bg_loop(KISStopmo):
KISStopmo.camera.exit()
if KISStopmo.camera is not False:
if project_settings['camera_type'] != 0:
KISStopmo.camera.stop()
else:
KISStopmo.camera.exit()
KISStopmo.end_thread = True
root = tk.Tk()

78
readme.md Normal file
View File

@ -0,0 +1,78 @@
# Stopi
A python stop-motion script that keeps things simple and allows to use a DSLR or raspicam for capture.
* Full screen display of the last picture taken
* Optional Onion Skinning with the second to last picture
* No gui : everything is done with keyboard keys (or a [homemade rpi pico based remote](https://forge.aquilenet.fr/benjamin.duchenne/picoti)) and a config file
* Auto configuration of the DSLR on startup
* Key or event mode ; use your DSLR's trigger button or IR remote and images are automatically downloaded after capture
* Preview playback
* Full HD export with ffmpeg
* Uses translations
## Setup
0. (Windows users only) Setup WSL2 on your (P)OS and install a Debian based distro (Debian, Mint, Ubuntu...)
1. Install dependencides : `sudo apt install --no-install-recommends --no-install-suggests git ffmpeg gphoto2 python3-libcamera python3-picamera2 python3-tk`
(Optional) If you want a minimal graphical environment : `sudo apt install --no-install-recommends --no-install-suggests openbox xserver-xorg xinit pcmanfm gmrun lxterminal hsetroot unclutter`
2. Clone the repo : `git clone https://`
3. Change to directory : `cd stopimotion`
4. Create Python venv : `python -m venv ./` (If planning to use a raspicam, you need to also pass the `--system-site-packages` parameter to be able to import the GPIO module.)
5. Install dependencies : `pip install -r requirements.txt`
6. Plug your DSLR/setup your raspicam
7. Set Execution bit on script : `chmod +x stopi.sh`
8. Launch script : `./stopi.sh`
## Todo / Fix me
* UI freezes when exporting
* Better settings names ; currently they're kinda cryptic and sucky.
* Notify ffmpeg export ending
## Planned features
* Liveview (when I get a DSLR that supports it :))
* Webcam support (e.g; using pygame)
## Raspberry Pi image
For convenience, a disk image is available here for RPI users.
For advanced users, the steps for preparing RaspiOS for a minimal kiosk-like experience are these :
1. Flash Raspi OS bookworm lite version to a SD card, enabling SSH, Wifi, etc if needed.
2. Follow the steps in the 'Setup' section above.
3. Use the 'raspi-config' utility to enable console auto-login.
4. Add this content to '~/.bash_login' :
```
if [[ -z $DISPLAY ]] && [[ $(tty) = /dev/tty1 ]]; then
startx
fi
```
5. Add this content to '~/.xinitrc' :
```
#!/bin/sh
# /etc/X11/xinit/xinitrc
#
# global xinitrc file, used by all X sessions started by xinit (startx)
# invoke global X session script
. /etc/X11/Xsession
exec openbox-session
```
6. Add this content to '~/.config/openbox/autostart.sh' :
```
#!/bin/env bash
# Change X keyboard mapping
setxkbmap fr
# Set background color
hsetroot -solid "#8393CC"
# Hide mouse after 0.2 seconds
unclutter -idle 0.2 &
# Start script
/home/$USER/stopi.sh &
```
When you reboot, the X session should launch automatically, and then the script.

4
stopi.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/env bash
cd "$(dirname "$0")"
source bin/activate
python main_c.py