Created
December 18, 2024 14:54
-
-
Save MaxXSoft/965713c7efa693f20feabe7ab7a3420b to your computer and use it in GitHub Desktop.
MaxXing's PV tools, again.
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
#!/usr/bin/env python3 | |
''' | |
Tool for making piano roll frames from MIDI files. | |
Written by MaxXing, licensed under GPL-v3. | |
''' | |
from enum import Enum, auto | |
from dataclasses import dataclass | |
from mido import MidiFile | |
from PIL import Image, ImageDraw | |
from os import path | |
import math | |
class WidthMode(Enum): | |
''' | |
MIDI roll width mode. | |
''' | |
FRAME = auto() # `width` specifies frame width. | |
NOTE = auto() # `width` specifies note width. | |
@dataclass(frozen=True) | |
class Config: | |
''' | |
Main configurations. | |
''' | |
height: int | |
width_mode: WidthMode | |
width: int | |
h_gap: float # Horizontal gap (width%) between notes. | |
fps: int | |
speed: int # Speed (pixel per frame). | |
def frame_width(self, notes: 'Notes') -> float: | |
''' | |
Returns the frame width in pixel. | |
''' | |
if self.width_mode == WidthMode.FRAME: | |
return self.width | |
else: | |
width_notes = (notes.max_pitch + 1) * self.width | |
width_gaps = (notes.max_pitch + 2) * self.width * self.h_gap | |
return width_notes + width_gaps | |
def note_width(self, notes: 'Notes') -> float: | |
''' | |
Returns the note width in pixel. | |
''' | |
if self.width_mode == WidthMode.FRAME: | |
return self.width / (notes.max_pitch * (1 + self.h_gap) + 2 * self.h_gap + 1) | |
else: | |
return self.width | |
def frame_time_us(self) -> float: | |
''' | |
Returns the time of one frame in microseconds. | |
''' | |
return 1e6 / self.fps | |
def pre_time_us(self) -> float: | |
''' | |
Returns the time before any notes touch the bottom border in microseconds. | |
''' | |
return self.height * 1e6 / (self.speed * self.fps) | |
def dist_to_bottom(self, cur_time_us: float, pixel_time_us: float) -> float: | |
''' | |
Returns the distance to the bottom border of the pixel at the current time. | |
''' | |
delta = pixel_time_us - cur_time_us | |
return self.speed * delta * self.fps * 1e-6 | |
def draw_frame(self, cur_time_us: float, notes: 'Notes') -> Image.Image: | |
''' | |
Draws the frame at the current time to the given canvas. | |
''' | |
image = Image.new('1', (int(self.frame_width(notes)), self.height)) | |
draw = ImageDraw.Draw(image) | |
note_width = self.note_width(notes) | |
left_gap = note_width * self.h_gap | |
for note in notes.notes: | |
bottom = min(self.height - self.dist_to_bottom(cur_time_us, note.start_us), | |
self.height) | |
if bottom < 0: | |
break | |
top = max(0, self.height - self.dist_to_bottom(cur_time_us, note.end_us)) | |
if top > self.height: | |
continue | |
left = left_gap + (notes.max_pitch - note.pitch) * \ | |
(1 + self.h_gap) * note_width | |
right = left + note_width | |
draw.rectangle(((left, top), (right, bottom)), fill='white') | |
return image | |
def draw_frames(self, notes: 'Notes', dir: str) -> None: | |
''' | |
Draws all frames and saves them to the given directory. | |
''' | |
i = 0 | |
frame_time_us = self.frame_time_us() | |
pre_time_us = self.pre_time_us() | |
time = -pre_time_us - pre_time_us * 0.5 | |
digits = math.ceil(math.log10((notes.end_us - time) / frame_time_us)) | |
while time <= notes.end_us + pre_time_us * 0.5: | |
image = self.draw_frame(time, notes) | |
image.save(path.join(dir, f'frame-{i:>0{digits}}.png')) | |
i += 1 | |
time += frame_time_us | |
@dataclass(frozen=True) | |
class Note: | |
''' | |
Representation of a note. | |
''' | |
start_us: float | |
end_us: float | |
pitch: int | |
@dataclass(frozen=True) | |
class Notes: | |
''' | |
Representation of notes. | |
''' | |
notes: list[Note] | |
end_us: float | |
max_pitch: int | |
def get_tempo(mid: MidiFile) -> int: | |
''' | |
Returns the tempo of the given MIDI file. | |
''' | |
for track in mid.tracks: | |
for m in track: | |
if m.type == 'set_tempo': | |
return m.tempo | |
raise RuntimeError('tempo not found') | |
def tick_to_us(tick: int, ticks_per_beat: int, tempo: int) -> float: | |
''' | |
Converts absolute time in ticks to micro seconds. | |
''' | |
scale = tempo / ticks_per_beat | |
return tick * scale | |
def triple_to_note(t: tuple[int, int, int], base_tick: int, base_pitch: int, ticks_per_beat: int, tempo: int) -> Note: | |
''' | |
Converts the `(start_tick, end_tick, pitch)` triple to note. | |
''' | |
start_us = tick_to_us(t[0] - base_tick, ticks_per_beat, tempo) | |
end_us = tick_to_us(t[1] - base_tick, ticks_per_beat, tempo) | |
return Note(start_us, end_us, t[2] - base_pitch) | |
def read_notes(mid: MidiFile) -> Notes: | |
''' | |
Reads notes from the given MIDI file. | |
''' | |
# Collect notes in tracks. | |
notes = [] | |
for track in mid.tracks: | |
cur_notes = {} | |
tick = 0 | |
for m in track: | |
match m.type: | |
case 'channel_prefix': | |
tick = m.time | |
case 'note_on': | |
tick += m.time | |
cur_notes[m.note] = tick | |
case 'note_off': | |
tick += m.time | |
notes.append((cur_notes.pop(m.note), tick, m.note)) | |
# Create notes. | |
base_tick = min(map(lambda x: x[0], notes)) | |
base_pitch = min(map(lambda x: x[2], notes)) | |
tempo = get_tempo(mid) | |
notes = list(map( | |
lambda x: triple_to_note( | |
x, base_tick, base_pitch, mid.ticks_per_beat, tempo), | |
notes)) | |
notes.sort(key=lambda x: x.start_us) | |
return Notes(notes, max(map(lambda x: x.end_us, notes)), max(map(lambda x: x.pitch, notes))) | |
if __name__ == '__main__': | |
raise RuntimeError('You should rewrite the following configurations!') | |
with MidiFile('end.mid') as mid: | |
notes = read_notes(mid) | |
config = Config(640, WidthMode.FRAME, 640, 0.5, 120, 15) | |
config.draw_frames(notes, 'frames') |
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
#!/usr/bin/env python3 | |
''' | |
Tool for capturing stop-motion frames from a camera/webcam. | |
Written by MaxXing, licensed under GPL-v3. | |
''' | |
import os | |
import cv2 | |
import dearpygui.dearpygui as dpg | |
import numpy as np | |
from typing import Any | |
class App: | |
''' | |
The main application class. | |
''' | |
def __init__(self, files: list[str], device: int, | |
width_height: tuple[int, int] | None = None) -> None: | |
self.__files = files | |
self.__cur_file = 0 | |
self.__cap = cv2.VideoCapture(device) | |
if width_height is not None: | |
width, height = width_height | |
self.__cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) | |
self.__cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) | |
self.__capture_camera() | |
self.__image_alpha = 0.5 | |
self.__image_crop = 1 | |
self.__image_h_offset = 0 | |
self.__image_v_offset = 0 | |
self.__load_image() | |
self.__camera_texture_id = 0 | |
self.__image_texture_id = 0 | |
self.__window_id = 0 | |
self.__status_text_id = 0 | |
self.__camera_image_id = 0 | |
self.__image_image_id = 0 | |
def __enter__(self) -> 'App': | |
return self | |
def __exit__(self, exc_type, exc_value, traceback) -> None: | |
self.__cap.release() | |
def __capture_camera(self) -> None: | |
''' | |
Captures a frame from the camera. | |
''' | |
ret, frame = self.__cap.read() | |
assert ret, 'Failed to read frame from camera.' | |
self.__camera = frame | |
@property | |
def __camera_width(self) -> int: | |
''' | |
Returns the width of the camera frame. | |
''' | |
return self.__camera.shape[1] | |
@property | |
def __camera_height(self) -> int: | |
''' | |
Returns the height of the camera frame. | |
''' | |
return self.__camera.shape[0] | |
def __load_image(self) -> None: | |
''' | |
Loads the current image from disk. | |
''' | |
self.__image = cv2.imread(self.__files[self.__cur_file]) | |
width, height = self.__image_width, self.__image_height | |
camera_width, camera_height = self.__camera_width, self.__camera_height | |
# Crop image to same aspect ratio as the camera frame. | |
if width / height > camera_width / camera_height: | |
new_width = int(height * camera_width / camera_height) | |
new_height = height | |
else: | |
new_width = width | |
new_height = int(width * camera_height / camera_width) | |
new_width = int(new_width * self.__image_crop) | |
new_height = int(new_height * self.__image_crop) | |
new_left = int((width - new_width) / 2 * (1 + self.__image_h_offset)) | |
new_top = int((height - new_height) / 2 * (1 + self.__image_v_offset)) | |
self.__image = self.__image[new_top:new_top + new_height, | |
new_left:new_left + new_width] | |
self.__image = cv2.resize(self.__image, (width, height)) | |
@property | |
def __image_width(self) -> int: | |
''' | |
Returns the width of the current image. | |
''' | |
return self.__image.shape[1] | |
@property | |
def __image_height(self) -> int: | |
''' | |
Returns the height of the current image. | |
''' | |
return self.__image.shape[0] | |
def __camera_to_raw(self) -> Any: | |
''' | |
Converts the current camera frame to raw texture data of Dear PyGui. | |
''' | |
frame_rgba = cv2.cvtColor(self.__camera, cv2.COLOR_BGR2RGBA) | |
raw = np.array(frame_rgba, dtype=np.float32).ravel() / 255 | |
return raw | |
def __image_to_raw(self) -> Any: | |
''' | |
Converts the current image frame to raw texture data of Dear PyGui. | |
''' | |
frame_rgba = cv2.cvtColor(self.__image, cv2.COLOR_BGR2RGBA) | |
raw = np.array(frame_rgba, dtype=np.float32).ravel() / 255 | |
raw[3::4] = self.__image_alpha | |
return raw | |
def __update_camera(self) -> None: | |
''' | |
Captures a frame from the camera and updates the texture. | |
''' | |
self.__capture_camera() | |
dpg.set_value(self.__camera_texture_id, self.__camera_to_raw()) | |
def __update_image(self) -> None: | |
''' | |
Updates the image texture. | |
''' | |
dpg.set_value(self.__image_texture_id, self.__image_to_raw()) | |
def __snapshot_path(self) -> str: | |
''' | |
Returns the path to the snapshot file. | |
''' | |
dir_name, file_name = os.path.split(self.__files[self.__cur_file]) | |
return os.path.join(dir_name, f'sm-{file_name}') | |
def __take_snapshot(self) -> None: | |
''' | |
Saves the current camera frame to disk. | |
''' | |
cv2.imwrite(self.__snapshot_path(), self.__camera) | |
self.__update_status_text() | |
def __update_status_text(self) -> None: | |
''' | |
Updates the status text. | |
''' | |
file_name = os.path.basename(self.__files[self.__cur_file]) | |
done = os.path.exists(self.__snapshot_path()) | |
dpg.set_value(self.__status_text_id, f'Current: {file_name}\nDone: {done}') | |
def __prev_image(self) -> None: | |
''' | |
Switches to the previous image. | |
''' | |
self.__cur_file -= 1 | |
if self.__cur_file < 0: | |
self.__cur_file = len(self.__files) - 1 | |
self.__load_image() | |
self.__update_image() | |
self.__update_status_text() | |
def __next_image(self) -> None: | |
''' | |
Switches to the next image. | |
''' | |
self.__cur_file += 1 | |
if self.__cur_file >= len(self.__files): | |
self.__cur_file = 0 | |
self.__load_image() | |
self.__update_image() | |
self.__update_status_text() | |
def __set_image_alpha(self, _: int | str, alpha: float) -> None: | |
''' | |
Sets the alpha value of the image. | |
''' | |
self.__image_alpha = alpha | |
self.__update_image() | |
def __set_image_crop(self, _: int | str, crop: float) -> None: | |
''' | |
Sets the crop value of the image. | |
''' | |
self.__image_crop = crop | |
self.__load_image() | |
self.__update_image() | |
def __set_image_h_offset(self, _: int | str, offset: float) -> None: | |
''' | |
Sets the horizontal offset of the image. | |
''' | |
self.__image_h_offset = offset | |
self.__load_image() | |
self.__update_image() | |
def __set_image_v_offset(self, _: int | str, offset: float) -> None: | |
''' | |
Sets the vertical offset of the image. | |
''' | |
self.__image_v_offset = offset | |
self.__load_image() | |
self.__update_image() | |
def __resize(self) -> None: | |
''' | |
Resizes the camera frame to fit the window. | |
''' | |
window_width, window_height = dpg.get_item_rect_size(self.__window_id) | |
camera_width, camera_height = self.__camera_width, self.__camera_height | |
camera_image_left, camera_image_top = dpg.get_item_pos( | |
self.__camera_image_id) | |
# Update camera image's width and height. | |
area_width = window_width - camera_image_left - camera_image_top * 2 | |
area_height = window_height - camera_image_top * 2 | |
if area_width / area_height > camera_width / camera_height: | |
new_width = int(area_height * camera_width / camera_height) | |
new_height = area_height | |
else: | |
new_width = area_width | |
new_height = int(area_width * camera_height / camera_width) | |
dpg.set_item_width(self.__camera_image_id, new_width) | |
dpg.set_item_height(self.__camera_image_id, new_height) | |
# Update image image's position, width and height. | |
dpg.set_item_pos(self.__image_image_id, | |
[camera_image_left, camera_image_top]) | |
dpg.set_item_width(self.__image_image_id, new_width) | |
dpg.set_item_height(self.__image_image_id, new_height) | |
def run(self) -> None: | |
''' | |
Runs the application. | |
''' | |
# Create Dear PyGui context. | |
dpg.create_context() | |
# Create texture from camera frame. | |
width, height = self.__camera_width, self.__camera_height | |
with dpg.texture_registry(show=False): | |
self.__camera_texture_id = dpg.add_raw_texture(width=width, height=height, | |
default_value=self.__camera_to_raw(), | |
format=dpg.mvFormat_Float_rgba) | |
self.__image_texture_id = dpg.add_raw_texture(width=self.__image_width, height=self.__image_height, | |
default_value=self.__image_to_raw(), | |
format=dpg.mvFormat_Float_rgba) | |
# Create Dear PyGui window. | |
with dpg.window() as window: | |
self.__window_id = window | |
with dpg.group(horizontal=True): | |
with dpg.child_window(width=160): | |
with dpg.group(horizontal=True): | |
dpg.add_button(label='Take', callback=self.__take_snapshot) | |
dpg.add_button(label='Prev', callback=self.__prev_image) | |
dpg.add_button(label='Next', callback=self.__next_image) | |
dpg.add_spacer(height=15) | |
dpg.add_slider_float(label='Alpha', width=80, min_value=0, max_value=1, | |
default_value=self.__image_alpha, callback=self.__set_image_alpha) | |
dpg.add_slider_float(label='Crop', width=80, min_value=0.01, max_value=1, | |
default_value=self.__image_crop, callback=self.__set_image_crop) | |
dpg.add_slider_float(label='H-offset', width=80, min_value=-1, max_value=1, | |
default_value=self.__image_h_offset, callback=self.__set_image_h_offset) | |
dpg.add_slider_float(label='V-offset', width=80, min_value=-1, max_value=1, | |
default_value=self.__image_v_offset, callback=self.__set_image_v_offset) | |
dpg.add_spacer(height=15) | |
self.__status_text_id = dpg.add_text() | |
self.__update_status_text() | |
dpg.add_spacer(height=15) | |
dpg.add_text(f'Width: {width}\nHeight: {height}') | |
self.__camera_image_id = dpg.add_image(self.__camera_texture_id) | |
self.__image_image_id = dpg.add_image(self.__image_texture_id) | |
# Create viewport. | |
dpg.create_viewport(title='Max\'s Stop Motion Gadget', | |
width=800, height=600) | |
dpg.setup_dearpygui() | |
dpg.show_viewport() | |
dpg.set_primary_window(self.__window_id, True) | |
dpg.set_viewport_resize_callback(self.__resize) | |
# Run render loop. | |
while dpg.is_dearpygui_running(): | |
self.__update_camera() | |
dpg.render_dearpygui_frame() | |
# Cleanup. | |
dpg.destroy_context() | |
if __name__ == '__main__': | |
import argparse | |
import os | |
parser = argparse.ArgumentParser() | |
parser.add_argument('frames', type=str, | |
help='Directory of pre-rendered stop motion frames.') | |
parser.add_argument('device', type=int, help='Camera device index.') | |
parser.add_argument('-W', '--width', type=int, default=None, | |
help='Camera frame width.') | |
parser.add_argument('-H', '--height', type=int, default=None, | |
help='Camera frame height.') | |
args = parser.parse_args() | |
# Get file list from directory. | |
files = [os.path.join(args.frames, f) for f in os.listdir(args.frames) | |
if f.endswith('.png')] | |
files.sort() | |
if args.width is None or args.height is None: | |
width_height = None | |
else: | |
width_height = args.width, args.height | |
with App(files, args.device, width_height) as app: | |
app.run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment