Skip to content

Instantly share code, notes, and snippets.

@MaxXSoft
Created December 18, 2024 14:54
Show Gist options
  • Save MaxXSoft/965713c7efa693f20feabe7ab7a3420b to your computer and use it in GitHub Desktop.
Save MaxXSoft/965713c7efa693f20feabe7ab7a3420b to your computer and use it in GitHub Desktop.
MaxXing's PV tools, again.
#!/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')
#!/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