├── gsbl ├── __init__.py ├── media │ ├── note0.wav │ ├── note1.wav │ ├── note2.wav │ ├── note3.wav │ ├── note4.wav │ ├── note5.wav │ ├── note6.wav │ ├── note7.wav │ ├── note8.wav │ ├── note9.wav │ ├── stick_bug.mp4 │ └── transform.wav ├── __main__.py └── stick_bug.py ├── examples ├── python.gif └── python.mp4 ├── requirements.txt ├── setup.py ├── LICENSE ├── .gitignore └── README.md /gsbl/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/python.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0spaces/get-stick-bugged-lol/HEAD/examples/python.gif -------------------------------------------------------------------------------- /examples/python.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0spaces/get-stick-bugged-lol/HEAD/examples/python.mp4 -------------------------------------------------------------------------------- /gsbl/media/note0.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0spaces/get-stick-bugged-lol/HEAD/gsbl/media/note0.wav -------------------------------------------------------------------------------- /gsbl/media/note1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0spaces/get-stick-bugged-lol/HEAD/gsbl/media/note1.wav -------------------------------------------------------------------------------- /gsbl/media/note2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0spaces/get-stick-bugged-lol/HEAD/gsbl/media/note2.wav -------------------------------------------------------------------------------- /gsbl/media/note3.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0spaces/get-stick-bugged-lol/HEAD/gsbl/media/note3.wav -------------------------------------------------------------------------------- /gsbl/media/note4.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0spaces/get-stick-bugged-lol/HEAD/gsbl/media/note4.wav -------------------------------------------------------------------------------- /gsbl/media/note5.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0spaces/get-stick-bugged-lol/HEAD/gsbl/media/note5.wav -------------------------------------------------------------------------------- /gsbl/media/note6.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0spaces/get-stick-bugged-lol/HEAD/gsbl/media/note6.wav -------------------------------------------------------------------------------- /gsbl/media/note7.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0spaces/get-stick-bugged-lol/HEAD/gsbl/media/note7.wav -------------------------------------------------------------------------------- /gsbl/media/note8.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0spaces/get-stick-bugged-lol/HEAD/gsbl/media/note8.wav -------------------------------------------------------------------------------- /gsbl/media/note9.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0spaces/get-stick-bugged-lol/HEAD/gsbl/media/note9.wav -------------------------------------------------------------------------------- /gsbl/media/stick_bug.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0spaces/get-stick-bugged-lol/HEAD/gsbl/media/stick_bug.mp4 -------------------------------------------------------------------------------- /gsbl/media/transform.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n0spaces/get-stick-bugged-lol/HEAD/gsbl/media/transform.wav -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | moviepy==1.0.3 2 | numpy==1.19.1 3 | Pillow==7.2.0 4 | pylsd-nova==1.2.0 5 | 6 | setuptools>=49.5.0 7 | wheel>=0.35.1 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open('README.md', 'r') as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name='get-stick-bugged-lol', 8 | version='1.0.1', 9 | author='n0spaces', 10 | description="'Get stick bugged' video generator", 11 | long_description=long_description, 12 | long_description_content_type='text/markdown', 13 | url='https://github.com/n0spaces/get_stick_bugged_lol', 14 | packages=setuptools.find_packages(), 15 | package_data={'gsbl': ['media/*.*']}, 16 | entry_points={'console_scripts': ['gsbl=gsbl.__main__:main']}, 17 | install_requires=['pylsd-nova>=1.2.0', 'numpy', 'Pillow', 'moviepy'], 18 | classifiers=[ 19 | 'Programming Language :: Python :: 3', 20 | 'License :: OSI Approved :: MIT License', 21 | "Operating System :: OS Independent", 22 | ], 23 | python_requires='>=3.6', 24 | ) 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Matt Schwartz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /gsbl/__main__.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | import argparse 3 | 4 | parser = argparse.ArgumentParser(prog='gsbl', description="Create a 'get stick bugged lol' video from an image.") 5 | parser.add_argument('input', 6 | help="the image file to be used to generate the video (png, jpg, ...). For best results, make" 7 | "sure the image doesn't have any black or white borders surrounding it.") 8 | parser.add_argument('output', help='the video file to be generated and saved (mp4, webm, ...)') 9 | parser.add_argument('-r --resolution', dest='resolution', nargs=2, type=int, default=[720, 720], 10 | metavar=('WIDTH', 'HEIGHT'), help='width and height of the video (default: 720 720)') 11 | parser.add_argument('--img-bg-color', dest='img_bg_color', nargs=3, type=int, default=[0, 0, 0], 12 | metavar=('R', 'G', 'B'), 13 | help='RGB background color while the image is visible (default: 0 0 0)') 14 | parser.add_argument('--line-color', dest='line_color', nargs=3, type=int, default=[255, 255, 211], 15 | metavar=('R', 'G', 'B'), help='RGB color of line segments (default: 255 255 211)') 16 | parser.add_argument('--line-bg-color', dest='line_bg_color', nargs=3, type=int, default=[125, 115, 119], 17 | metavar=('R', 'G', 'B'), 18 | help='RGB background color after image disappears (default: 125 115 119)') 19 | parser.add_argument('-s --scale', dest='lsd_scale', type=float, default=0.8, metavar='SCALE', 20 | help='the image scale passed to the line segment detector. Slightly lowering this may improve ' 21 | 'results in large images. This does not affect the image scale in the video (try ' 22 | '--resolution instead). (default: 0.8)') 23 | 24 | args = parser.parse_args() 25 | 26 | from gsbl.stick_bug import StickBug 27 | 28 | sb = StickBug(img=args.input, video_resolution=args.resolution, lsd_scale=args.lsd_scale, 29 | img_bg_color=args.img_bg_color, line_color=args.line_color, line_bg_color=args.line_bg_color) 30 | sb.save_video(args.output) 31 | 32 | 33 | if __name__ == '__main__': 34 | main() 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # get-stick-bugged-lol 2 | A Python module and command-line tool that generates a 3 | [Get Stick Bugged Lol](https://knowyourmeme.com/memes/get-stick-bugged-lol) video from any image. 4 | 5 | ## Example 6 | ```commandline 7 | gsbl python.jpg python.mp4 -r 760 475 -s 0.7 8 | ``` 9 | ![Example GIF](examples/python.gif) 10 | 11 | [Example video with sound](examples/python.mp4) 12 | 13 | This script uses [pylsd-nova](https://github.com/AndranikSargsyan/pylsd-nova) to detect line segments in the image, 14 | Pillow to draw the lines as they move to form the stick bug, and MoviePy to create the video. 15 | 16 | ## Requirements 17 | * Python 3.6 or later (any OS) 18 | 19 | ## Installation 20 | This package can be installed using pip: 21 | ```commandline 22 | pip install get-stick-bugged-lol 23 | ``` 24 | 25 | ## Usage 26 | ### In the terminal 27 | Installing the package will register the `gsbl` command in the terminal (or you can use `python -m gsbl`). To use the 28 | image `input.png` to generate the video `output.mp4`: 29 | ```commandline 30 | gsbl input.png output.mp4 31 | ``` 32 | Optional arguments: 33 | * `-h, --help` show this help message and exit 34 | * `-r --resolution WIDTH HEIGHT` width and height of the video (default: 720 720) 35 | * `--img-bg-color R G B` RGB background color while the image is visible (default: 0 0 0) 36 | * `--line-color R G B` RGB color of line segments (default: 255 255 211) 37 | * `--line-bg-color R G B` RGB background color after image disappears (default: 125 115 119) 38 | * `-s --scale SCALE` the image scale passed to the line segment detector. Slightly lowering this may improve results in 39 | large images. This does not affect the image scale in the video (try --resolution instead). (default: 0.8) 40 | 41 | ### In a Python script 42 | 43 | ```python 44 | from PIL import Image 45 | from gsbl.stick_bug import StickBug 46 | 47 | # Create the StickBug object 48 | sb = StickBug(Image.open('example.png')) # parameter can also just be a filepath 49 | 50 | # Change some properties if you want 51 | sb.video_resolution = (1280, 720) 52 | sb.lsd_scale = 0.5 53 | 54 | # That's it! The video will be generated the first time you access it 55 | video = sb.video # MoviePy VideoClip 56 | 57 | # Or you can just save it 58 | sb.save_video('example.mp4') 59 | 60 | # If any settings were changed, the video will be regenerated the next time you access it. 61 | sb.line_color = (128, 0, 255) 62 | video_purple = sb.video 63 | ``` 64 | 65 | #### `StickBug` properties 66 | * `image` the source PIL Image. You can set this when initializing `StickBug`, or at any time by accessing the property. 67 | If you want, you can leave this parameter empty while initializing. 68 | * `segments` a numpy array of the 9 line segments detected. If the line segment detector hasn't run yet, that's done the 69 | first time this is accessed. The line segment detector will run again if any other properties have changed. This can 70 | also be set manually if you want. Each row of the array must contain the values `[x1, y1, x2, y2, width]`. 71 | * `video` (readonly) the MoviePy VideoClip generated by the script. If the video hasn't been generated yet, that's 72 | done the first time this is accessed. The video will be regenerated if any other properties have changed. 73 | 74 | * `video_resolution` the resolution of the video as a tuple 75 | * `lsd_scale` the image scale passed to the line segment detector. Slightly lowering this may improve results in large 76 | images. This does not affect the image scale in the video. 77 | * `img_bg_color` the background color of the video while the image is visible 78 | * `line_color` the color of the line segments in the video 79 | * `line_bg_color` the background color of the video after the image disappears 80 | 81 | ## Building 82 | Install the required packages: 83 | 84 | pip install -r requirements.txt 85 | 86 | Install this package directly: 87 | 88 | cd [path to repository] 89 | pip install . 90 | 91 | Or build a wheel: 92 | 93 | cd [path to repository] 94 | python3 setup.py bdist_wheel 95 | 96 | ## License 97 | This package is available under the MIT License. See [LICENSE](LICENSE) for more info. 98 | 99 | This package makes use of the following external libraries: 100 | * [pylsd-nova](https://github.com/AndranikSargsyan/pylsd-nova) 101 | * [NumPy](https://numpy.org) 102 | * [Pillow](https://python-pillow.org) 103 | * [MoviePy](https://github.com/Zulko/moviepy) 104 | -------------------------------------------------------------------------------- /gsbl/stick_bug.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Union 3 | 4 | import numpy as np 5 | from PIL import Image, ImageDraw 6 | from moviepy import editor 7 | from pylsd.lsd import lsd 8 | 9 | # static media files 10 | pkg_path = os.path.dirname(os.path.realpath(__file__)) 11 | video_stick_bug = editor.VideoFileClip(os.path.join(pkg_path, 'media/stick_bug.mp4')) 12 | audio_notes = [ 13 | editor.AudioFileClip(os.path.join(pkg_path, 'media/note0.wav')), 14 | editor.AudioFileClip(os.path.join(pkg_path, 'media/note1.wav')), 15 | editor.AudioFileClip(os.path.join(pkg_path, 'media/note2.wav')), 16 | editor.AudioFileClip(os.path.join(pkg_path, 'media/note3.wav')), 17 | editor.AudioFileClip(os.path.join(pkg_path, 'media/note4.wav')), 18 | editor.AudioFileClip(os.path.join(pkg_path, 'media/note5.wav')), 19 | editor.AudioFileClip(os.path.join(pkg_path, 'media/note6.wav')), 20 | editor.AudioFileClip(os.path.join(pkg_path, 'media/note7.wav')), 21 | editor.AudioFileClip(os.path.join(pkg_path, 'media/note8.wav')), 22 | editor.AudioFileClip(os.path.join(pkg_path, 'media/note9.wav')), 23 | ] 24 | audio_transform = editor.AudioFileClip(os.path.join(pkg_path, 'media/transform.wav')) 25 | 26 | 27 | class StickBug: 28 | def __init__(self, img: Union[Image.Image, str] = None, video_resolution=(720, 720), lsd_scale=0.8, 29 | img_bg_color=(0, 0, 0), line_color=(255, 255, 211), line_bg_color=(125, 115, 119)): 30 | """ 31 | Class that generates a stick bug meme from an image. 32 | :param img: The source image. Can be a PIL Image, a filepath, or left empty. 33 | :param video_resolution: The resolution of the generated video. 34 | :param lsd_scale: The image scaled passed to the LSD. This does not affect the image scale in the video. 35 | :param img_bg_color: The background color of the video while the image is visible. 36 | :param line_color: The color of the line segments. 37 | :param line_bg_color: The background color of the video when the image disappears. 38 | """ 39 | if type(img) == str: 40 | self._img = Image.open(img) 41 | else: 42 | self._img = img 43 | self._img_processed = False 44 | self._img_scaled = None 45 | self._img_offset = (0, 0) 46 | 47 | self._segments = np.empty((9, 5), float) 48 | self._segments_processed = False 49 | self._lsd_scale = lsd_scale 50 | 51 | self._video = None 52 | self._video_processed = False 53 | self._video_resolution = tuple(video_resolution) 54 | self._img_bg_color = tuple(img_bg_color) 55 | self._line_color = tuple(line_color) 56 | self._line_bg_color = tuple(line_bg_color) 57 | self._stick_bug_video_offset = (0, 0) 58 | 59 | # end segment points 60 | self._end_segments = np.array([ 61 | [315, 212, 408, 262, 8], 62 | [408, 262, 511, 263, 8], 63 | [236, 266, 332, 223, 8], 64 | [170, 339, 236, 266, 8], 65 | [268, 351, 320, 228, 8], 66 | [319, 297, 372, 247, 8], 67 | [306, 353, 319, 297, 8], 68 | [364, 360, 398, 260, 8], 69 | [494, 370, 489, 264, 8], 70 | ]) 71 | 72 | if self._img is not None: 73 | self.process_image() 74 | 75 | @property 76 | def image(self): 77 | """The source image as a PIL Image object""" 78 | return self._img 79 | 80 | @image.setter 81 | def image(self, im: Image.Image): 82 | """ 83 | Set the image. This resets the line segments and video. 84 | :param im: PIL Image 85 | """ 86 | self._img = im 87 | if self._img is not None: 88 | self.process_image() 89 | self.clear_segments() 90 | 91 | @property 92 | def segments(self): 93 | """ 94 | The array of line segment points. Each row is a segment with the values [x1, y1, x2, y2, width]. 95 | If the array hasn't been processed yet, that's done the next time this is called. 96 | """ 97 | if not self._segments_processed: 98 | self.process_segments() 99 | return self._segments 100 | 101 | @segments.setter 102 | def segments(self, arr: np.ndarray): 103 | """ 104 | Set the line segments array. This clears the video. 105 | :param arr: numpy array of 9 rows containing [x1, y1, x2, y2, width] 106 | """ 107 | self._segments = arr 108 | self._segments_processed = True 109 | self.clear_video() 110 | 111 | @property 112 | def video(self): 113 | """The moviepy VideoClip. If the video hasn't been generated yet, that's done the next time this is called.""" 114 | if not self._video_processed: 115 | self.process_video() 116 | return self._video 117 | 118 | @property 119 | def lsd_scale(self): 120 | """ 121 | The scale of the image passed in the line segment detector. Lowering this may improve results, especially with 122 | large images. This does not affect the image scale in the video. 123 | """ 124 | return self._lsd_scale 125 | 126 | @lsd_scale.setter 127 | def lsd_scale(self, value): 128 | """Set the LSD scale. This clears the segments and video.""" 129 | self._lsd_scale = value 130 | self.clear_segments() 131 | 132 | @property 133 | def img_bg_color(self): 134 | """The background color of the video while the image is visible.""" 135 | return self._img_bg_color 136 | 137 | @img_bg_color.setter 138 | def img_bg_color(self, rgb): 139 | """ 140 | Set the image background color. This clears the video. 141 | :param rgb: tuple (R, G, B) 142 | """ 143 | self._img_bg_color = tuple(rgb) 144 | self.clear_video() 145 | 146 | @property 147 | def line_color(self): 148 | """The color of the line segments in the video.""" 149 | return self._line_color 150 | 151 | @line_color.setter 152 | def line_color(self, rgb): 153 | """ 154 | Set the color of the line segments. This clears the video. 155 | :param rgb: tuple (R, G, B) 156 | """ 157 | self._line_color = tuple(rgb) 158 | self.clear_video() 159 | 160 | @property 161 | def line_bg_color(self): 162 | """The background color of the video after the image disappears.""" 163 | return self._line_bg_color 164 | 165 | @line_bg_color.setter 166 | def line_bg_color(self, rgb): 167 | """ 168 | Set the video background color after the image disappears. This clears the video. 169 | :param rgb: tuple (R, G, B) 170 | """ 171 | self._line_bg_color = tuple(rgb) 172 | self.clear_video() 173 | 174 | @property 175 | def video_resolution(self): 176 | """The resolution of the generated video.""" 177 | return self._video_resolution 178 | 179 | @video_resolution.setter 180 | def video_resolution(self, res): 181 | """ 182 | Set the video resolution. This clears the video and segments, since the segments have to be adjusted to the new 183 | resolution. 184 | :param res: video width and height as a tuple 185 | """ 186 | self._video_resolution = tuple(res) 187 | self.process_image() 188 | self.clear_segments() 189 | 190 | def clear_segments(self): 191 | """Resets line segments to be calculated again. Called whenever self.image is set.""" 192 | self._segments_processed = False 193 | self.clear_video() 194 | 195 | def clear_video(self): 196 | """Resets the video to be generated again. Called whenever self.image or self.segments are set.""" 197 | self._video_processed = False 198 | 199 | def process_image(self): 200 | """Calculate the image's offset and scale in the video.""" 201 | # resize image to fit video resolution without cropping 202 | scale = min(self._video_resolution[0] / self._img.width, self._video_resolution[1] / self._img.height) 203 | self._img_scaled = self._img.resize((int(self._img.width * scale), int(self._img.height * scale))) 204 | 205 | # calculate image offset to center image in video 206 | self._img_offset = ((self._video_resolution[0] - self._img_scaled.width) // 2, 207 | (self._video_resolution[1] - self._img_scaled.height) // 2) 208 | 209 | self._img_processed = True 210 | 211 | def process_segments(self): 212 | """Run the line segment detector.""" 213 | if self._img is None: 214 | raise ValueError('image must be set before running the line segment detector') 215 | 216 | img_gray = np.array(self._img_scaled.convert('L')) 217 | 218 | lsd_result = lsd(img_gray, scale=self._lsd_scale) 219 | 220 | # sort by distance and keep the 9 longest segments 221 | # add a column to store distance 222 | rows, cols = lsd_result.shape 223 | segments_d = np.empty((rows, cols + 1)) 224 | segments_d[:, :-1] = lsd_result 225 | 226 | # find distance of each line segment 227 | for i in range(segments_d.shape[0]): 228 | x1, y1, x2, y2, *_ = segments_d[i] 229 | segments_d[i, 5] = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) # distance formula 230 | 231 | # sort and remove distance column 232 | lsd_sorted = segments_d[segments_d[:, 5].argsort()[::-1]][:, :-1] 233 | 234 | # keep only the 9 longest segments 235 | self.segments = lsd_sorted[:9] 236 | 237 | self._segments_processed = True 238 | 239 | def process_video(self): 240 | """ 241 | Process everything needed to generate the video. This includes calculating the line interpolation, drawing the 242 | frames, and creating the VideoClip object. The video will be in the following format: 243 | 1. The source image 244 | 2. Line segments start appearing 245 | 3. Source image disappears and background changes 246 | 4. Line segments interpolate to form a stick bug 247 | 5. The stick bug video 248 | """ 249 | if self._img is None: 250 | raise ValueError('image must be set before the video can be generated') 251 | 252 | # scale stick bug video and end segments 253 | stick_bug_scale = min(self._video_resolution[0] / video_stick_bug.w, 254 | self._video_resolution[1] / video_stick_bug.h) 255 | 256 | num_frames = 52 # number of frames that the lines will be moving 257 | 258 | # CALCULATE LINE INTERPOLATION 259 | # array to store the points of each line segment in each frame. 260 | segment_frames = np.empty((self.segments.shape[0], num_frames, 5), float) 261 | 262 | # add image offset to segment points so segments are aligned with centered image 263 | segments_centered = self._segments 264 | segments_centered[:, 0:3:2] += self._img_offset[0] 265 | segments_centered[:, 1:4:2] += self._img_offset[1] 266 | 267 | end_segments_offset = ((self._video_resolution[0] - video_stick_bug.w * stick_bug_scale) // 2, 268 | (self._video_resolution[1] - video_stick_bug.h * stick_bug_scale) // 2) 269 | end_segments_centered = self._end_segments * stick_bug_scale 270 | end_segments_centered[:, 0:3:2] += end_segments_offset[0] 271 | end_segments_centered[:, 1:4:2] += end_segments_offset[1] 272 | 273 | for i in range(self._segments.shape[0]): 274 | segment_frames[i] = np.array([ 275 | np.linspace(self._segments[i][0], end_segments_centered[i][0], num_frames, dtype=int), 276 | np.linspace(self._segments[i][1], end_segments_centered[i][1], num_frames, dtype=int), 277 | np.linspace(self._segments[i][2], end_segments_centered[i][2], num_frames, dtype=int), 278 | np.linspace(self._segments[i][3], end_segments_centered[i][3], num_frames, dtype=int), 279 | np.linspace(self._segments[i][4], end_segments_centered[i][4], num_frames, dtype=int), 280 | ]).transpose() 281 | 282 | segment_frames = np.transpose(segment_frames, (1, 0, 2)) # [frame, segment, point] 283 | 284 | # GENERATE VIDEO 285 | # list of clips in the video 286 | clips = [] 287 | 288 | # first clip is just the source image 289 | # center the image on a black background 290 | frame = Image.new('RGB', self._video_resolution, self._img_bg_color) 291 | frame.paste(self._img_scaled, tuple(self._img_offset)) 292 | clips.append(editor.ImageClip(np.array(frame), duration=1)) 293 | 294 | # line segments start appearing 295 | # add an ImageClip for each segment 296 | draw = ImageDraw.Draw(frame) 297 | for i in range(segment_frames[0].shape[0]): 298 | x1, y1, x2, y2, w = segment_frames[0][i] 299 | draw.line((x1, y1, x2, y2), fill=self._line_color, width=int(w)) 300 | clips.append(editor.ImageClip(np.array(frame), duration=0.33).set_audio(audio_notes[i])) 301 | 302 | # one more slightly longer clip for the last segment 303 | clips.append(editor.ImageClip(np.array(frame), duration=1)) 304 | 305 | # redraw lines with the line background color 306 | draw.rectangle([(0, 0), self._video_resolution], self._line_bg_color) 307 | for segment in segment_frames[0]: 308 | x1, y1, x2, y2, w = segment 309 | draw.line((x1, y1, x2, y2), fill=self._line_color, width=int(w)) 310 | clips.append(editor.ImageClip(np.array(frame), duration=0.75).set_audio(audio_notes[9])) 311 | 312 | # use an ImageSequenceClip for the line interpolation 313 | interp_frames = [] 314 | for i in range(segment_frames.shape[0]): 315 | draw.rectangle([(0, 0), self._video_resolution], self._line_bg_color) 316 | for segment in segment_frames[i]: 317 | x1, y1, x2, y2, w = segment 318 | draw.line((x1, y1, x2, y2), fill=self._line_color, width=int(w)) 319 | interp_frames.append(np.asarray(frame)) 320 | interp_clip = editor.ImageSequenceClip(interp_frames, 30) 321 | clips.append(interp_clip.set_audio(audio_transform.set_end(interp_clip.end))) 322 | 323 | # concatenate all the clips and add the audio 324 | all_clips = editor.concatenate_videoclips(clips) 325 | # all_clips = all_clips.set_audio(editor.AudioFileClip(audio_path)) 326 | 327 | # add stick bug video to the end 328 | stick_bug_clip = video_stick_bug.resize(stick_bug_scale).set_start(all_clips.end).set_position('center') 329 | self._video = editor.CompositeVideoClip([all_clips, stick_bug_clip]) 330 | 331 | self._video_processed = True 332 | 333 | def save_video(self, fp: str): 334 | """Save the video file""" 335 | self.video.write_videofile( 336 | fp, 337 | codec='libx264', 338 | audio_codec='aac', 339 | temp_audiofile='temp-audio.m4a', 340 | remove_temp=True 341 | ) 342 | --------------------------------------------------------------------------------