diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..8985aeb --- /dev/null +++ b/Pipfile @@ -0,0 +1,14 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +opencv-python = "*" +ffmpeg-python = "*" +wand = "*" + +[dev-packages] + +[requires] +python_version = "3.12" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..0e44ffe --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,99 @@ +{ + "_meta": { + "hash": { + "sha256": "eb0d94e97384a638fdaf11adf4625c48a1c5899cba528d3dfca36f4ecc3870b7" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.12" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "ffmpeg-python": { + "hashes": [ + "sha256:65225db34627c578ef0e11c8b1eb528bb35e024752f6f10b78c011f6f64c4127", + "sha256:ac441a0404e053f8b6a1113a77c0f452f1cfc62f6344a769475ffdc0f56c23c5" + ], + "index": "pypi", + "version": "==0.2.0" + }, + "future": { + "hashes": [ + "sha256:34a17436ed1e96697a86f9de3d15a3b0be01d8bc8de9c1dffd59fb8234ed5307" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", + "version": "==0.18.3" + }, + "numpy": { + "hashes": [ + "sha256:02f98011ba4ab17f46f80f7f8f1c291ee7d855fcef0a5a98db80767a468c85cd", + "sha256:0b7e807d6888da0db6e7e75838444d62495e2b588b99e90dd80c3459594e857b", + "sha256:12c70ac274b32bc00c7f61b515126c9205323703abb99cd41836e8125ea0043e", + "sha256:1666f634cb3c80ccbd77ec97bc17337718f56d6658acf5d3b906ca03e90ce87f", + "sha256:18c3319a7d39b2c6a9e3bb75aab2304ab79a811ac0168a671a62e6346c29b03f", + "sha256:211ddd1e94817ed2d175b60b6374120244a4dd2287f4ece45d49228b4d529178", + "sha256:21a9484e75ad018974a2fdaa216524d64ed4212e418e0a551a2d83403b0531d3", + "sha256:39763aee6dfdd4878032361b30b2b12593fb445ddb66bbac802e2113eb8a6ac4", + "sha256:3c67423b3703f8fbd90f5adaa37f85b5794d3366948efe9a5190a5f3a83fc34e", + "sha256:46f47ee566d98849323f01b349d58f2557f02167ee301e5e28809a8c0e27a2d0", + "sha256:51c7f1b344f302067b02e0f5b5d2daa9ed4a721cf49f070280ac202738ea7f00", + "sha256:5f24750ef94d56ce6e33e4019a8a4d68cfdb1ef661a52cdaee628a56d2437419", + "sha256:697df43e2b6310ecc9d95f05d5ef20eacc09c7c4ecc9da3f235d39e71b7da1e4", + "sha256:6d45b3ec2faed4baca41c76617fcdcfa4f684ff7a151ce6fc78ad3b6e85af0a6", + "sha256:77810ef29e0fb1d289d225cabb9ee6cf4d11978a00bb99f7f8ec2132a84e0166", + "sha256:7ca4f24341df071877849eb2034948459ce3a07915c2734f1abb4018d9c49d7b", + "sha256:7f784e13e598e9594750b2ef6729bcd5a47f6cfe4a12cca13def35e06d8163e3", + "sha256:806dd64230dbbfaca8a27faa64e2f414bf1c6622ab78cc4264f7f5f028fee3bf", + "sha256:867e3644e208c8922a3be26fc6bbf112a035f50f0a86497f98f228c50c607bb2", + "sha256:8c66d6fec467e8c0f975818c1796d25c53521124b7cfb760114be0abad53a0a2", + "sha256:8ed07a90f5450d99dad60d3799f9c03c6566709bd53b497eb9ccad9a55867f36", + "sha256:9bc6d1a7f8cedd519c4b7b1156d98e051b726bf160715b769106661d567b3f03", + "sha256:9e1591f6ae98bcfac2a4bbf9221c0b92ab49762228f38287f6eeb5f3f55905ce", + "sha256:9e87562b91f68dd8b1c39149d0323b42e0082db7ddb8e934ab4c292094d575d6", + "sha256:a7081fd19a6d573e1a05e600c82a1c421011db7935ed0d5c483e9dd96b99cf13", + "sha256:a8474703bffc65ca15853d5fd4d06b18138ae90c17c8d12169968e998e448bb5", + "sha256:af36e0aa45e25c9f57bf684b1175e59ea05d9a7d3e8e87b7ae1a1da246f2767e", + "sha256:b1240f767f69d7c4c8a29adde2310b871153df9b26b5cb2b54a561ac85146485", + "sha256:b4d362e17bcb0011738c2d83e0a65ea8ce627057b2fdda37678f4374a382a137", + "sha256:b831295e5472954104ecb46cd98c08b98b49c69fdb7040483aff799a755a7374", + "sha256:b8c275f0ae90069496068c714387b4a0eba5d531aace269559ff2b43655edd58", + "sha256:bdd2b45bf079d9ad90377048e2747a0c82351989a2165821f0c96831b4a2a54b", + "sha256:cc0743f0302b94f397a4a65a660d4cd24267439eb16493fb3caad2e4389bccbb", + "sha256:da4b0c6c699a0ad73c810736303f7fbae483bcb012e38d7eb06a5e3b432c981b", + "sha256:f25e2811a9c932e43943a2615e65fc487a0b6b49218899e62e426e7f0a57eeda", + "sha256:f73497e8c38295aaa4741bdfa4fda1a5aedda5473074369eca10626835445511" + ], + "markers": "python_version >= '3.10'", + "version": "==1.26.3" + }, + "opencv-python": { + "hashes": [ + "sha256:1a9f0e6267de3a1a1db0c54213d022c7c8b5b9ca4b580e80bdc58516c922c9e1", + "sha256:3f16f08e02b2a2da44259c7cc712e779eff1dd8b55fdb0323e8cab09548086c0", + "sha256:71dfb9555ccccdd77305fc3dcca5897fbf0cf28b297c51ee55e079c065d812a3", + "sha256:7b34a52e9da36dda8c151c6394aed602e4b17fa041df0b9f5b93ae10b0fcca2a", + "sha256:7e5f7aa4486651a6ebfa8ed4b594b65bd2d2f41beeb4241a3e4b1b85acbbbadb", + "sha256:dcf000c36dd1651118a2462257e3a9e76db789a78432e1f303c7bac54f63ef6c", + "sha256:e4088cab82b66a3b37ffc452976b14a3c599269c247895ae9ceb4066d8188a57" + ], + "index": "pypi", + "version": "==4.9.0.80" + }, + "wand": { + "hashes": [ + "sha256:e5dda0ac2204a40c29ef5c4cb310770c95d3d05c37b1379e69c94ea79d7d19c0", + "sha256:f5013484eaf7a20eb22d1821aaefe60b50cc329722372b5f8565d46d4aaafcca" + ], + "index": "pypi", + "version": "==0.6.13" + } + }, + "develop": {} +} diff --git a/cas.py b/cas.py new file mode 100755 index 0000000..383f7e7 --- /dev/null +++ b/cas.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from argparse import ArgumentParser +from tempfile import TemporaryDirectory +import logging +import cv2 +import asyncio +from pathlib import Path +from wand.image import Image + +log = logging.getLogger('distortioner') + +ap = ArgumentParser( + description = 'a script that makes memetic distorted videos' +) + +class TicketedDict(dict): + def __init__(self, *args, **kwargs): + self._ni = 0 + self._log = logging.getLogger('TicketedDict') + self.event = asyncio.Event() + super().__init__(*args, **kwargs) + + def has_next(self): + self._log.debug("has_next is %s for %i", self._ni in self, self._ni) + return self._ni in self + + async def wait(self): + self._log.debug("requested wait") + while not self.has_next(): + await self.event.wait() + self._log.debug("wait broke") + self.event.clear() + + async def pop(self): + self._log.debug("requested pop") + await self.wait() + ret = super().pop(self._ni) + self._ni += 1 + return ret + + def notify(self, *args, **kwargs): + self._log.debug("requested notify") + return self.event.set(*args, **kwargs) + +ap.add_argument('input') +ap.add_argument('output') +ap.add_argument( + '--distort-percentage','--distort-pct', '-d', + default = 60.0, + type = float, + help = 'Percentage of image distortion.' +) +ap.add_argument( + '--distort-percentage-end','--distort-pct-end', '-D', + default = None, + type = float, + help = 'If specified, distortion percentage will gradually change towards specified percentage.' +) +ap.add_argument( + '--distort-end','--distort', '-E', + default = None, + type = float, + help = 'If specified, distortion change will happen only up to specific video progress.' +) +ap.add_argument( + '--vibrato-frequency','--vibrato-freq', '-f', + default = 5.0, + type = float, + help = 'Modulation frequency in Hertz. Range is 0.1 - 20000.0. Default value is 5.0 Hz.' +) +ap.add_argument( + '--vibrato-modulation-depth','--vibrato-depth', '-m', + default = 0.5, + type = float, + help = 'Depth of modulation as a percentage. Range is 0.0 - 1.0. Default value is 0.5.' +) +ap.add_argument( + '--debug', + action = 'store_true', + default = False, + help = 'Print debugging messages.' +) +def process_image(source, destination, distort): + log.debug("distorting: src:'%s', dst:'%s' ", source, destination) + with Image(filename=source) as original: + dst_width = int(original.width*(distort / 100.)) + dst_height = int(original.height*(distort / 100.)) + log.debug("dst:'%s' size:%ix%i", destination, dst_width, dst_height) + with original.clone() as distorted, \ + open(destination, mode='wb') as out: + distorted.liquid_rescale(dst_width, dst_height) + distorted.resize(original.width, original.height) + distorted.save(out) + +async def process_frames(coro, queue_in, out_pile): + while True: + frame_data = await queue_in.get() + nr = frame_data.pop('nr') + log.debug("processing frame '%s'", frame_data) + await asyncio.to_thread(coro, **frame_data) + queue_in.task_done() + out_pile[nr]=frame_data['destination'] + log.debug("put item to output pile, notifying") + out_pile.notify() + log.debug("notified") + + +async def read_frames(capture, frames_distorted, frames_original, queue, tasks, distort_start, distort_end=None, distort_end_frame=None): + frames_read = 0 + if distort_end_frame is None: + distort_end_frame = int(capture.get(cv2.CAP_PROP_FRAME_COUNT)) + while True: + read_ok, frame = capture.read() + if not read_ok: + break + log.debug("reading frame %i", frames_read) + if distort_end is not None: + distort = distort_start \ + + (distort_end - distort_start) \ + * (min(frames_read, distort_end_frame) / distort_end_frame ) + frame_filename = f'frame_{str(frames_read).zfill(32)}.png' + frame_original = str(Path(frames_original)/frame_filename) + frame_distorted = str(Path(frames_distorted)/frame_filename) + cv2.imwrite(frame_original, frame) + log.debug("saving frame %i: filename: %s", frames_read, frame_filename) + await queue.put({ + 'source': frame_original, + 'destination': frame_distorted, + 'distort': distort, + 'nr': frames_read + }) + frames_read += 1 + log.info("waiting for queue to empty") + await queue.join() + log.info("quitting") + for worker in tasks: + worker.cancel() + +async def write_frames(output, pile): + while True: + log.debug("getting next item ...") + frame_distorted = await pile.pop() + log.info("writing frame '%s'", frame_distorted) + newframe = cv2.imread(frame_distorted) + output.write(newframe) + log.debug("finished frame '%s'", frame_distorted) + + +async def distort_video(capture, output, distort_start, distort_end=None, distort_end_frame=None): + distort = distort_start + video_width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH)) + video_height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) + output_pile = TicketedDict() + with \ + TemporaryDirectory() as frames_distorted, \ + TemporaryDirectory() as frames_original: + log.debug(frames_distorted) + log.debug(frames_original) + log.debug("creating queues") + capture_queue = asyncio.Queue(20) + output_queue = asyncio.PriorityQueue() + workers = [ asyncio.create_task(process_frames(process_image, capture_queue, output_pile)) for i in range(10) ] + workers += [ asyncio.create_task(write_frames(output, output_pile)) ] + generator = asyncio.create_task(read_frames( + capture, frames_distorted, frames_original, capture_queue, workers, distort_start, distort_end, distort_end_frame + )) + await asyncio.gather(generator, *workers, return_exceptions=True) + + log.debug('done with distorting video frames') + +def main(): + args = ap.parse_args() + if args.debug: + logging.basicConfig(level=logging.DEBUG) + else: + logging.basicConfig(level=logging.INFO) + log.debug(args) + if args.distort_percentage <= 0.0: + raise ValueError("--distort_percentage must be positive number") + + capture = cv2.VideoCapture(args.input) + fps = capture.get(cv2.CAP_PROP_FPS) + video_width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH)) + video_height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) + frames = args.distort_end or int(capture.get(cv2.CAP_PROP_FRAME_COUNT)) + log.info("video: name:'%s', fps:%i, frames:%i, size:%ix%i", args.input, fps, frames, video_width, video_height) + output = cv2.VideoWriter(args.output, cv2.VideoWriter_fourcc(*'mp4v'), fps, (video_width, video_height)) + asyncio.run(distort_video(capture, output, args.distort_percentage, args.distort_percentage_end, frames-1)) + capture.release() + output.release() + + +if __name__ == '__main__': + main() + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3afccb1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +-i https://pypi.org/simple +ffmpeg-python==0.2.0 +future==0.18.3 ; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2' +numpy==1.26.3 ; python_version >= '3.10' +opencv-python==4.9.0.80 +wand==0.6.13