diff --git a/.gitignore b/.gitignore index 3bb56d4..afd8bc7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Created by https://www.gitignore.io/api/osx,python,pycharm,windows,visualstudio,visualstudiocode # Edit at https://www.gitignore.io/?templates=osx,python,pycharm,windows,visualstudio,visualstudiocode +./scripts ### OSX ### # General diff --git a/README.md b/README.md index 4b3104d..471e5f7 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@
-[![Build status](https://github.com/rspy/rspy/workflows/build/badge.svg?branch=master&event=push)](https://github.com/rspy/rspy/actions?query=workflow%3Abuild) -[![Python Version](https://img.shields.io/pypi/pyversions/rspy.svg)](https://pypi.org/project/rspy/) +[![Python Version](https://img.shields.io/badge/python-3.8-blue +)](https://img.shields.io/badge/python-3.9-pink) [![Python Version](https://img.shields.io/badge/python-3.9-pink +)](https://img.shields.io/badge/python-3.8-blue) [![Python Version](https://img.shields.io/badge/python-3.10-green +)](https://img.shields.io/badge/python-3.10-green) [![Dependencies Status](https://img.shields.io/badge/dependencies-up%20to%20date-brightgreen.svg)](https://github.com/rspy/rspy/pulls?utf8=%E2%9C%93&q=is%3Apr%20author%3Aapp%2Fdependabot) - [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Security: bandit](https://img.shields.io/badge/security-bandit-green.svg)](https://github.com/PyCQA/bandit) [![Pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/rspy/rspy/blob/master/.pre-commit-config.yaml) @@ -14,9 +15,13 @@ ![Coverage Report](assets/images/coverage.svg) The core implementation of Fast Rolling Shutter Correction in the Wild, TPAMI 2023 and Towards Nonlinear-Motion-Aware and Occlusion-Robust Rolling Shutter Correction, ICCV 2023. -
+| **3GS** | **Gpark** | +| :---------------------------: | :-------------------------------: | +| ![3gs](assets/images/3gs.gif) | ![gpark](assets/images/gpark.gif) | + + ## Very first steps ### Initialize your code @@ -88,55 +93,9 @@ Building a new version of the application contains steps: - Create a `GitHub release`. - And... publish 🙂 `poetry publish --build` -## 🎯 What's next - -Well, that's up to you 💪🏻. I can only recommend the packages and articles that helped me. - -- [`Typer`](https://github.com/tiangolo/typer) is great for creating CLI applications. -- [`Rich`](https://github.com/willmcgugan/rich) makes it easy to add beautiful formatting in the terminal. -- [`Pydantic`](https://github.com/samuelcolvin/pydantic/) – data validation and settings management using Python type hinting. -- [`Loguru`](https://github.com/Delgan/loguru) makes logging (stupidly) simple. -- [`tqdm`](https://github.com/tqdm/tqdm) – fast, extensible progress bar for Python and CLI. -- [`IceCream`](https://github.com/gruns/icecream) is a little library for sweet and creamy debugging. -- [`orjson`](https://github.com/ijl/orjson) – ultra fast JSON parsing library. -- [`Returns`](https://github.com/dry-python/returns) makes you function's output meaningful, typed, and safe! -- [`Hydra`](https://github.com/facebookresearch/hydra) is a framework for elegantly configuring complex applications. -- [`FastAPI`](https://github.com/tiangolo/fastapi) is a type-driven asynchronous web framework. - -Articles: - -- [Open Source Guides](https://opensource.guide/). -- [A handy guide to financial support for open source](https://github.com/nayafia/lemonade-stand) -- [GitHub Actions Documentation](https://help.github.com/en/actions). -- Maybe you would like to add [gitmoji](https://gitmoji.carloscuesta.me/) to commit names. This is really funny. 😄 - ## 🚀 Features - -### Development features - -- Supports for `Python 3.8` and higher. -- [`Poetry`](https://python-poetry.org/) as the dependencies manager. See configuration in [`pyproject.toml`](https://github.com/rspy/rspy/blob/master/pyproject.toml) and [`setup.cfg`](https://github.com/rspy/rspy/blob/master/setup.cfg). -- Automatic codestyle with [`black`](https://github.com/psf/black), [`isort`](https://github.com/timothycrosley/isort) and [`pyupgrade`](https://github.com/asottile/pyupgrade). -- Ready-to-use [`pre-commit`](https://pre-commit.com/) hooks with code-formatting. -- Type checks with [`mypy`](https://mypy.readthedocs.io); docstring checks with [`darglint`](https://github.com/terrencepreilly/darglint); security checks with [`safety`](https://github.com/pyupio/safety) and [`bandit`](https://github.com/PyCQA/bandit) -- Testing with [`pytest`](https://docs.pytest.org/en/latest/). -- Ready-to-use [`.editorconfig`](https://github.com/rspy/rspy/blob/master/.editorconfig), [`.dockerignore`](https://github.com/rspy/rspy/blob/master/.dockerignore), and [`.gitignore`](https://github.com/rspy/rspy/blob/master/.gitignore). You don't have to worry about those things. - -### Deployment features - -- `GitHub` integration: issue and pr templates. -- `Github Actions` with predefined [build workflow](https://github.com/rspy/rspy/blob/master/.github/workflows/build.yml) as the default CI/CD. -- Everything is already set up for security checks, codestyle checks, code formatting, testing, linting, docker builds, etc with [`Makefile`](https://github.com/rspy/rspy/blob/master/Makefile#L89). More details in [makefile-usage](#makefile-usage). -- [Dockerfile](https://github.com/rspy/rspy/blob/master/docker/Dockerfile) for your package. -- Always up-to-date dependencies with [`@dependabot`](https://dependabot.com/). You will only [enable it](https://docs.github.com/en/github/administering-a-repository/enabling-and-disabling-version-updates#enabling-github-dependabot-version-updates). -- Automatic drafts of new releases with [`Release Drafter`](https://github.com/marketplace/actions/release-drafter). You may see the list of labels in [`release-drafter.yml`](https://github.com/rspy/rspy/blob/master/.github/release-drafter.yml). Works perfectly with [Semantic Versions](https://semver.org/) specification. - -### Open source community features - -- Ready-to-use [Pull Requests templates](https://github.com/rspy/rspy/blob/master/.github/PULL_REQUEST_TEMPLATE.md) and several [Issue templates](https://github.com/rspy/rspy/tree/master/.github/ISSUE_TEMPLATE). -- Files such as: `LICENSE`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, and `SECURITY.md` are generated automatically. -- [`Stale bot`](https://github.com/apps/stale) that closes abandoned issues after a period of inactivity. (You will only [need to setup free plan](https://github.com/marketplace/stale)). Configuration is [here](https://github.com/rspy/rspy/blob/master/.github/.stale.yml). -- [Semantic Versions](https://semver.org/) specification with [`Release Drafter`](https://github.com/marketplace/actions/release-drafter). +- A light weight library for rolling shutter correction which is easy to use. +- Support linear, qudratic and cubic motion model. ## Installation @@ -144,15 +103,7 @@ Articles: pip install -U rspy ``` -or install with `Poetry` - -```bash -poetry add rspy -``` - - - -### Makefile usage +### Usage [`Makefile`](https://github.com/rspy/rspy/blob/master/Makefile) contains a lot of functions for faster development. @@ -357,8 +308,8 @@ We use [`Release Drafter`](https://github.com/marketplace/actions/release-drafte ### List of labels and corresponding titles -| **Label** | **Title in Releases** | -| :-----------------------------------: | :---------------------: | +| **Label** | **Title in Releases** | +| :-----------------------------------: | :--------------------: | | `enhancement`, `feature` | 🚀 Features | | `bug`, `refactoring`, `bugfix`, `fix` | 🔧 Fixes & Refactoring | | `build`, `ci`, `testing` | 📦 Build System & CI/CD | diff --git a/assets/images/3gs.gif b/assets/images/3gs.gif new file mode 100644 index 0000000..cafdfe2 Binary files /dev/null and b/assets/images/3gs.gif differ diff --git a/assets/images/coverage.svg b/assets/images/coverage.svg index 0644a48..e5db27c 100644 --- a/assets/images/coverage.svg +++ b/assets/images/coverage.svg @@ -9,13 +9,13 @@ - + coverage coverage - 26% - 26% + 100% + 100% diff --git a/assets/images/gpark.gif b/assets/images/gpark.gif new file mode 100644 index 0000000..8a51ed7 Binary files /dev/null and b/assets/images/gpark.gif differ diff --git a/rspy/__init__.py b/rspy/__init__.py index 784771c..c9443f8 100644 --- a/rspy/__init__.py +++ b/rspy/__init__.py @@ -1,9 +1,11 @@ # type: ignore[attr-defined] """The core implementation of Fast Rolling Shutter Correction in the Wild, TPAMI 2023 and Towards Nonlinear-Motion-Aware and Occlusion-Robust Rolling Shutter Correction, ICCV 2023.""" -import sys from importlib import metadata as importlib_metadata +from .solver import cubic_flow, linear_flow, quadratic_flow +from .utils import feats_sampling + def get_version() -> str: try: @@ -13,3 +15,5 @@ def get_version() -> str: version: str = get_version() + +__all__ = ["linear_flow", "quadratic_flow", "cubic_flow", "feats_sampling", "version"] diff --git a/rspy/demo.py b/rspy/demo.py new file mode 100644 index 0000000..aeeb910 --- /dev/null +++ b/rspy/demo.py @@ -0,0 +1,52 @@ +import argparse +from pathlib import Path + +import torch +from mmflow.apis import inference_model, init_model +from mmflow.datasets import visualize_flow +from PIL import Image +from torchvision import transforms +from torchvision.utils import save_image + +from rspy.solver import cubic_flow, linear_flow, quadratic_flow +from rspy.utils import feats_sampling + +parser = argparse.ArgumentParser() +parser.add_argument("--input", type=str, help="input file or directory") +parser.add_argument("--output", type=str, help="output directory") +parser.add_argument("--model", type=str, default="linear", help="linear | quadratic | cubic") +parser.add_argument("--gamma", type=float, default=0.9, help="the readout reatio") +parser.add_argument("--tau", type=int, default=0, help="the timestamp warping to") +parser.add_argument("--fconfig", type=str, default="raft_8x2_100k_mixed_368x768", help="mmflow config file") +parser.add_argument("--device", type=str, default="cuda:0", help="cpu | cuda:0") +args = parser.parse_args() + + +def main(): + assert args.model in ["linear", "quadratic", "cubic"] + input, output = Path(args.input), Path(args.output) + image_paths = sorted(list(input.iterdir())) + + # init a optical-flow model + config_file, checkpoint_file = f"{args.fconfig}.py", f"{args.fconfig}.pth" + model = init_model(config_file, checkpoint_file, device=args.device) + + # * inference flow + num = {"linear": 2, "quadratic": 3, "cubic": 4}[args.model] + flows = inference_model(model, image_paths[: num - 1], image_paths[1:num]) # list of numpy.ndarray + torch_flows = [torch.from_numpy(flow).unsqueeze(0).to(args.device) for flow in flows] # * list (1,h,w,2) + for i, flow in enumerate(flows): + visualize_flow(flow, output / f"{i:04d}.png") + + # * rolling shutter correctin + solver = eval(f"{args.model}_flow") + F0tau = solver(*torch_flows[:num], args.gamma, args.tau) # * (1,h,w,2) + + # * warp image + rs_path = image_paths[num // 2] + tsfm = transforms.Compose([transforms.ToTensor()]) + rs_image = tsfm(Image.open(rs_path).convert("RGB")).unsqueeze(0).to(args.device) # * (1,3,h,w) + rsc_image = feats_sampling(rs_image, -F0tau) + + # * save image + save_image(rsc_image, output / f"rsc_{rs_path.stem}.png") diff --git a/rspy/demo/rs_00093.jpg b/rspy/demo/rs_00093.jpg new file mode 100755 index 0000000..e8c698f Binary files /dev/null and b/rspy/demo/rs_00093.jpg differ diff --git a/rspy/demo/rs_00094.jpg b/rspy/demo/rs_00094.jpg new file mode 100755 index 0000000..4add0d3 Binary files /dev/null and b/rspy/demo/rs_00094.jpg differ diff --git a/rspy/demo/rs_00095.jpg b/rspy/demo/rs_00095.jpg new file mode 100755 index 0000000..c192999 Binary files /dev/null and b/rspy/demo/rs_00095.jpg differ diff --git a/rspy/demo/rs_00096.jpg b/rspy/demo/rs_00096.jpg new file mode 100755 index 0000000..350993d Binary files /dev/null and b/rspy/demo/rs_00096.jpg differ diff --git a/rspy/example.py b/rspy/example.py deleted file mode 100644 index 3df5ba1..0000000 --- a/rspy/example.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Example of code.""" - - -def hello(name: str) -> str: - """Just an greetings example. - - Args: - name (str): Name to greet. - - Returns: - str: greeting message - - Examples: - .. code:: python - - >>> hello("Roman") - 'Hello Roman!' - """ - return f"Hello {name}!" diff --git a/rspy/solver.py b/rspy/solver.py new file mode 100644 index 0000000..9580eab --- /dev/null +++ b/rspy/solver.py @@ -0,0 +1,119 @@ +import torch +from einops import rearrange + + +def linear_flow(F01: torch.Tensor, gamma: float, tau: float) -> torch.Tensor: + """solve the linear motion matrix and predict the correction feild. + Args: + F01: torch.Tensor (b,h,w,2), flow 0 -> 1 + gamma: float, the readout reatio + tau: float, the timestamp warping to + Returns: + torch.Tensor: the correction feild to tau. + """ + h, w = F01.shape[1:3] + t01 = 1 + gamma / h * F01[:, :, :, 1] # * (b, h, w) + + # solve the linear motion matrix + M = F01 / rearrange(t01, "b h w -> b h w 1") # * (b, h, w, 2) + + # predict the correction feild + grid_y, _ = torch.meshgrid( + torch.arange(0, h, device=F01.device, requires_grad=False), + torch.arange(0, w, device=F01.device, requires_grad=False), + ) # * (h, w) + + t0tau = tau - gamma / h * grid_y # * (h, w) + F0tau = rearrange(t0tau, "h w -> h w 1") * M # * (b, h, w, 2) + + return F0tau + + +def quadratic_flow(F0n1: torch.Tensor, F01: torch.Tensor, gamma: float, tau: float) -> torch.Tensor: + """solve the quadratic motion matrix and predict the correction feild. + Args: + F0n1: torch.Tensor (b,h,w,2), flow 0 -> -1 + F01: torch.Tensor (b,h,w,2), flow 0 -> 1 + gamma (float): the readout reatio + tau (float): the timestamp warping to + Returns: + torch.Tensor: the correction feild to tau. + """ + h, w = F0n1.shape[1:3] + t0n1 = -1 + gamma / h * F0n1[:, :, :, 1] # * (b, h, w) + t01 = 1 + gamma / h * F01[:, :, :, 1] # * (b, h, w) + + # solve the quadratic motion matrix + A = rearrange( + torch.stack([t0n1, 0.5 * t0n1 ** 2, t01, 0.5 * t01 ** 2], dim=-1), + "b h w (m n) -> b h w m n", + m=2, + n=2, + ) # * (b, h, w, 2, 2) + + B = torch.stack([F0n1, F01], dim=-2) # * (b, h, w, 2, 2) + M = torch.linalg.solve(A, B) # * (b, h, w, 2, 2) + + # predict the correction feild + grid_y, _ = torch.meshgrid( + torch.arange(0, h, device=F0n1.device, requires_grad=False), + torch.arange(0, w, device=F0n1.device, requires_grad=False), + ) + t0tau = tau - gamma / h * grid_y # * (h, w) + + Atau = rearrange(torch.stack([t0tau, 0.5 * t0tau ** 2], dim=-1), "h w m -> h w 1 m") # * (h, w, 1, 2) + F0tau = rearrange(Atau @ M, "b h w 1 n -> b h w n") # * (b, h, w, 2) + + return F0tau + + +def cubic_flow(F0n2: torch.Tensor, F0n1: torch.Tensor, F01: torch.Tensor, gamma: float, tau: float) -> torch.Tensor: + """solve the cubic motion matrix and predict the correction feild. + Args: + F0n1: torch.Tensor (b,h,w,2): flow 0 -> -1 + F01: torch.Tensor (b,h,w,2): flow 0 -> 1 + F02: torch.Tensor (b,h,w,2): flow 0 -> 2 + gamma: (float): the readout reatio + tau: (float): the timestamp warping to + Returns: + torch.Tensor: the correction feild to tau. + """ + h, w = F0n1.shape[1:3] + t0n2 = -2 + gamma / h * F0n2[:, :, :, 1] + t0n1 = -1 + gamma / h * F0n1[:, :, :, 1] + t01 = 1 + gamma / h * F01[:, :, :, 1] + + # solve the quadratic motion matrix + A = rearrange( + torch.stack( + [ + t0n2, + 0.5 * t0n2 ** 2, + 1 / 6 * t0n2 ** 3, + t0n1, + 0.5 * t0n1 ** 2, + 1 / 6 * t0n1 ** 3, + t01, + 0.5 * t01 ** 2, + 1 / 6 * t01 ** 3, + ], + dim=-1, + ), + "b h w (m n) -> b h w m n", + m=3, + n=3, + ) # * (b, h, w, 3, 3) + B = torch.stack([F0n2, F0n1, F01], dim=-2) # * (b, h, w, 3, 2) + M = torch.linalg.solve(A, B) # * (b, h, w, 3, 2) + + # predict the correction feild + grid_y, _ = torch.meshgrid( + torch.arange(0, h, device=F0n1.device, requires_grad=False), + torch.arange(0, w, device=F0n1.device, requires_grad=False), + ) + t0tau = tau - gamma / h * grid_y # * (h, w) + + Atau = rearrange(torch.stack([t0tau, 0.5 * t0tau ** 2, 1 / 6 * t0tau ** 3], dim=-1), "h w m -> h w 1 m") + F0tau = rearrange(Atau @ M, "b h w 1 n -> b h w n") # * (b, h, w, 2) + + return F0tau diff --git a/rspy/utils.py b/rspy/utils.py new file mode 100644 index 0000000..945a5c5 --- /dev/null +++ b/rspy/utils.py @@ -0,0 +1,41 @@ +import numpy as np +import torch +import torch.nn.functional as F + + +def feats_sampling( + x, + flow, + interpolation="bilinear", + padding_mode="zeros", + align_corners=True, +): + """return warped images with flows in shape(B, C, H, W) + Args: + x: shape(B, C, H, W) + flow: shape(B, H, W, 2) + """ + if x.size()[-2:] != flow.size()[1:3]: + raise ValueError( + f"The spatial sizes of input ({x.size()[-2:]}) and " f"flow ({flow.size()[1:3]}) are not the same." + ) + h, w = x.shape[-2:] + + # create mesh grid + grid_y, grid_x = torch.meshgrid(torch.arange(0, h), torch.arange(0, w)) + grid = torch.stack((grid_x, grid_y), 2).type_as(x) #! (h, w, 2) + grid.requires_grad = False + grid_flow = grid + flow + + # scale grid_flow to [-1,1] + grid_flow_x = 2.0 * grid_flow[:, :, :, 0] / max(w - 1, 1) - 1.0 + grid_flow_y = 2.0 * grid_flow[:, :, :, 1] / max(h - 1, 1) - 1.0 + grid_flow = torch.stack((grid_flow_x, grid_flow_y), dim=3) + output = F.grid_sample( + x, + grid_flow, + mode=interpolation, + padding_mode=padding_mode, + align_corners=align_corners, + ) + return output diff --git a/scripts/GPark_QRST_seq_cropped.mp4 b/scripts/GPark_QRST_seq_cropped.mp4 new file mode 100644 index 0000000..fb57726 Binary files /dev/null and b/scripts/GPark_QRST_seq_cropped.mp4 differ diff --git a/scripts/GPark_rs_seq_cropped.mp4 b/scripts/GPark_rs_seq_cropped.mp4 new file mode 100644 index 0000000..2888b0a Binary files /dev/null and b/scripts/GPark_rs_seq_cropped.mp4 differ diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100644 index 0000000..ca13889 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,29 @@ +# python video_to_gif.py \ +# --rsv=/home/qdl/Desktop/PJLAB/pj/QRST/RSC/videos/3GS_rs_seq.mp4 \ +# --rscv=/home/qdl/Desktop/PJLAB/pj/QRST/RSC/videos/3GS_QRST_seq.mp4 \ +# --gif=3gs.gif \ +# --step=1 \ +# --fps=10 \ +# --rs_delay=2 \ +# --start=50 + +# * GPark +# python video_crop.py \ +# --input=/home/qdl/Desktop/PJLAB/pj/QRST/RSC/videos/GPark_QRST_seq.mp4 \ +# --output=GPark_QRST_seq_cropped.mp4 \ +# --ratio=0.025 + +# python video_crop.py \ +# --input=/home/qdl/Desktop/PJLAB/pj/QRST/RSC/videos/GPark_rs_seq.mp4 \ +# --output=GPark_rs_seq_cropped.mp4 \ +# --ratio=0.025 + +python video_to_gif.py \ + --rsv=GPark_rs_seq_cropped.mp4 \ + --rscv=GPark_QRST_seq_cropped.mp4 \ + --gif=gpark.gif \ + --step=1 \ + --fps=0.5 \ + --rs_delay=2 \ + --start=25 \ + --end=-10 diff --git a/scripts/video_crop.py b/scripts/video_crop.py new file mode 100644 index 0000000..a3ce28f --- /dev/null +++ b/scripts/video_crop.py @@ -0,0 +1,33 @@ +# use opencv read video and center crop +import argparse + +import cv2 as cv + +parser = argparse.ArgumentParser() +parser.add_argument("--input", type=str, default="video.mp4", help="video path") +parser.add_argument("--ratio", type=float, default=0.01, help="crop ratio") +parser.add_argument("--output", type=str, default="video.mp4", help="output path") + +args = parser.parse_args() + +if __name__ == "__main__": + video = cv.VideoCapture(args.input) + FPS = video.get(cv.CAP_PROP_FPS) + frames = [] + while True: + ret, frame = video.read() + if ret: + h, w = frame.shape[:2] + h_crop = int(h * args.ratio) + w_crop = int(w * args.ratio) + frame = frame[:, w_crop : w - w_crop * 2] + frame = cv.resize(frame, (640, 480)) + frames.append(frame) + else: + break + video.release() + + writer = cv.VideoWriter(args.output, cv.VideoWriter_fourcc(*"mp4v"), FPS, (frames[0].shape[1], frames[0].shape[0])) + for frame in frames: + writer.write(frame) + writer.release() diff --git a/scripts/video_to_gif.py b/scripts/video_to_gif.py new file mode 100644 index 0000000..17b9056 --- /dev/null +++ b/scripts/video_to_gif.py @@ -0,0 +1,43 @@ +# use opencv read video and convert to gif +import argparse + +import cv2 as cv +import imageio + +parser = argparse.ArgumentParser() +parser.add_argument("--rsv", type=str, default="video.mp4", help="RS video path") +parser.add_argument("--rscv", type=str, default="video.mp4", help="RSC video path") +parser.add_argument("--gif", type=str, default="video.gif", help="gif path") +parser.add_argument("--fps", type=float, default=8, help="gif fps") +parser.add_argument("--start", type=int, default=0, help="start frame") +parser.add_argument("--end", type=int, default=-1, help="end frame") +parser.add_argument("--rs_delay", type=int, default=0, help="RS delay frame") +parser.add_argument("--step", type=int, default=10, help="step frame") +parser.add_argument("--downsample", type=int, default=2, help="downsample") + +args = parser.parse_args() + +if __name__ == "__main__": + gif_path = args.gif + fps = args.fps + + rs_video = cv.VideoCapture(args.rsv) + rsc_video = cv.VideoCapture(args.rscv) + frames = [] + resz = lambda x: cv.resize(x, (x.shape[1] // args.downsample, x.shape[0] // args.downsample)) + + for i in range(args.rs_delay): + rs_video.read() + + while True: + ret_rs, frame_rs = rs_video.read() + ret_rsc, frame_rsc = rsc_video.read() + if ret_rs and ret_rsc: + frame_rs = resz(cv.cvtColor(frame_rs, cv.COLOR_BGR2RGB)) + frame_rsc = resz(cv.cvtColor(frame_rsc, cv.COLOR_BGR2RGB)) + frame = cv.hconcat([frame_rs, frame_rsc]) + frames.append(frame) + else: + break + + imageio.mimsave(gif_path, frames[args.start : args.end : args.step], "GIF", duration=1.0 / fps)