diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..91c2484 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,108 @@ +# Git +.git +.gitignore +.github + +# CI +.codeclimate.yml +.travis.yml +.taskcluster.yml + +# Docker +docker-compose.yml +.docker + +# Byte-compiled / optimized / DLL files +__pycache__/ +*/__pycache__/ +*/*/__pycache__/ +*/*/*/__pycache__/ +*.py[cod] +*/*.py[cod] +*/*/*.py[cod] +*/*/*/*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg +.eggs/ + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml +.tmp.* + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Virtual environment +.env/ +.venv/ +venv/ + +# PyCharm +.idea + +# Python mode for VIM +.ropeproject +*/.ropeproject +*/*/.ropeproject +*/*/*/.ropeproject + +# Vim swap files +*.swp +*/*.swp +*/*/*.swp +*/*/*/*.swp + +tox.ini +Dockerfile +Makefile +img/ +build/ +*.md +*.md.j2 +[.]* +docs/ +tests/ +LICENSE diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..7b2dcca --- /dev/null +++ b/.flake8 @@ -0,0 +1,28 @@ +[flake8] +exclude = .venv,venv,.tox,dist,doc,build,*.egg,docs,setup.py,*/migrations/ +ignore = W605,W291,E401,E203,E303,E225,E201,E202,E121,E123,E125,E126,E127,E128,E131,E222,E226,E231,E251,E252,E261,E265,E266,E302,E305,E306,E402,E501,E714,E722,F403,F405,F841,W391,W503 +max-line-length = 160 +# E121 continuation line under-indented for hanging indent +# E123 closing bracket does not match indentation of opening bracket's line +# E125 continuation line with same indent as next logical line +# E126 continuation line over-indented for hanging indent +# E127 continuation line over-indented for visual indent +# E128 continuation line under-indented for visual indent +# E131 continuation line unaligned for hanging indent +# E222 multiple spaces after operator +# E226 missing whitespace around arithmetic operator +# E231 missing whitespace after +# E251 unexpected spaces around keyword / parameter equals +# E261 at least two spaces before inline comment +# E265 block comment should start with '# ' +# E302 expected 2 blank lines, found 1 +# E305 expected 2 blank lines after class or function definition, found 1 +# E402 module level import not at top of file +# E714 test for object identity should be 'is not' +# E722 do not use bare except +# F401 Imported but unused +# F403 'from module import *' used; unable to detect undefined names +# F405 foo may be undefined, or defined from star imports +# F841 local variable 'foo' is assigned to but never used +# W391 blank line at end of file +# W605 invalid escape sequence diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml new file mode 100644 index 0000000..0b339cf --- /dev/null +++ b/.github/workflows/docker-build-push.yml @@ -0,0 +1,26 @@ +name: Publish Docker image +on: + release: + types: [published] + push: + branches: [ "master" ] + paths-ignore: + - '**.md' + - '**.md.j2' + - '**.png' +jobs: + push_to_registry: + name: Push Docker image to Docker Hub + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v3 + - name: Build + run: make docker.build + - name: Log in to Docker Hub + uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Push + run: make docker.push \ No newline at end of file diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..9988c84 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,17 @@ +name: Docker Build +on: + workflow_call: {} + pull_request: + branches: [ "master" ] + paths-ignore: + - '**.md' +permissions: + contents: read +jobs: + test: + name: Docker Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Build + run: make clean build test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6e9cfbd..6072aaf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,137 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class -output/ -*.pyc +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ +src/*/_version.py +.DS_Store + +# Ignore dynaconf secret files +.secrets.* +tests/fixtures/graph.png +.tmp.* +_version.py diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..345638d --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,7 @@ +# https://github.com/PyCQA/isort +[isort] +#profile="black" +length_sort=True +# changing import order here can introduce import-errors. +no_inline_sort=True +honor_noqa=True diff --git a/.pynchon.json5 b/.pynchon.json5 new file mode 100644 index 0000000..702780b --- /dev/null +++ b/.pynchon.json5 @@ -0,0 +1,142 @@ +{ // BEGIN: top-level pynchon config + // Everything here is optional. + // Literals only; no templating please! + "plugins": [ + // defaults; these are included anyway + 'render', 'gen', 'project', 'plugins', + 'jinja', 'json', 'git', 'core', 'gripe', + 'dockerhub', + "makefile", // makefile parsing + + // Recommended for projects with source-code + "docs", "src", "test","github", + "vhs", // vhs rendering + + // For projects with intermediate representations + "dot", // graphviz dot-files + "jinja", // projects with templated docs + // Docs-Generation + + "pandoc", "mkdocs", "deck", + + // Experimental + "griffe", // ast tool + "pattern", // replacement for scaffold, probably + ], + // END: top-level config + + // Config for `docs` plugin: + // Runs a webserver to open docs-files + "docs": { + "root": "{{pynchon.root}}/docs", + "include_patterns": [ + "{{pynchon.root}}/*.md", + "{{docs.root}}/**/*.md", + "{{docs.root}}/**/*.html", + ], + "apply_hooks": ["open-after"], + }, + // Config for `dockerhub` plugin: + "dockerhub":{ + "org_name": "robotwranglers", + "repo_name": "imgrot", + }, + + // END: core plugins-config + // BEGIN: other plugins-config + + // Config for `deck` plugin: + // Renders slide-decks from markdown + "deck": { + "root": "{{docs.root}}/slides", + "apply_hooks": ["open-after"], + "pandoc_engine":"dzslides", + "pandoc_args": ["--css dzslides.css"], + "pandoc_docker": "pandoc/core", + }, + + // Config for `github` plugin: + "github": {}, + + // Config for `hooks` plugin: + "hooks": {}, + + + // Config for `jinja` plugin: + // Planner for finding/rendering project .j2 files + "jinja":{ + "vars": {}, + + "filter_includes": [ + // where to load jinja filters from + // allows {tpls|paths|globs} + ], + + "exclude_patterns": [ + // Describes files that shouldn't show up in plans + // `globals.exclude_patterns` will be appended. + "src/**/*.j2", + ], + + "template_includes": [ + // Paths to load includes from. + // (Used in templates as `{% include .. %}`) + "{{docs.root}}/includes", + ], + }, + + // Config for `dot` plugin: + // Tool for working with dot (aka graphviz) + "dot": { + "output_format": "png", + }, + + // Config for `fixme` plugin: + // `globals.exclude_patterns` will be appended. + "fixme": { + "exclude_patterns": [ + "**/*.egg-info/**", + "{{src.root}}/**/fixme/**", + "{{src.root}}/**/fixme.py", + "{{src.root}}/**/python/api/**", + "{{src.root}}/pynchon/annotate.py", + ], + }, + // Config for `python-api` plugin: + // This generates API docs for python-packages + "python":{}, + // Config for `python-api` plugin: + // This generates API docs for python-packages + "python-api":{ + "skip_private_methods": true, + "skip_patterns": [], + }, + + // Config for `scaffolding` plugin: + // This provides ways to synchronize/diff project boilerplate. + // (Similar to cookie-cutter[], but more simple to use.) + "scaffolding":{ + "exclude_patterns": [ + // includes globals + ], + "scaffolds":[ + // list of Scaffolds-objects + { + "name": "subproject tox.ini's", + "pattern": "**/tox.ini", + "scope":"*", + "src":"pyproject.toml", + }, + ], + }, + + // Config for `pypi` plugin: + // Provider for details about the PyPI this project uses. + // (This is only used in rendering docs; `pynchon` does not manage releases. + // You can probably leave this blank for public PyPI but pynchon's own + // config has some values just1to ensure the plugin is exercised.) + "pypi": { + "name":"THE public PyPI" + }, + +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3247bdb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +# syntax=docker/dockerfile:1 +FROM debian:bookworm +WORKDIR /workspace +RUN apt-get update && apt-get -y install python3-dev python3-pip chafa imagemagick ffmpeg libsm6 libxext6 +RUN mkdir /opt/imgrot +COPY . /opt/imgrot +RUN pip3 install -r /opt/imgrot/requirements.txt --break-system-packages +ENTRYPOINT [ "python3", "/opt/imgrot/imgrot.py" ] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4aa4d93 --- /dev/null +++ b/Makefile @@ -0,0 +1,106 @@ +## +# Docker project makefile. +## +.SHELL := bash +MAKEFLAGS += --warn-undefined-variables +# .SHELLFLAGS := -euo pipefail -c +.DEFAULT_GOAL := none + +THIS_MAKEFILE := $(abspath $(firstword $(MAKEFILE_LIST))) +THIS_MAKEFILE := `python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' ${THIS_MAKEFILE}` +SRC_ROOT := $(shell dirname ${THIS_MAKEFILE}) + +DOCKER_IMAGE_NAME?=imgrot +DOCKER_ORG?=robotwranglers +SHORT_SHA=$(shell git rev-parse --short HEAD) +PYNCHON_CLI_VERSION=baf56b7 +# pynchon.run=docker run -v `pwd`:/workspace -w/workspace robotwranglers/pynchon:${PYNCHON_CLI_VERSION} +pynchon.run=pynchon + +.PHONY: build docs + +init: +build: docker.build +clean: docker.clean py-clean + +docs: docs.vhs docs.jinja docs.rotations + python imgrot.py img/icon.png --stream > img/demo.gif +docs.jinja: + ${pynchon.run} jinja render README.md.j2 +docs.vhs:; PS1="$$ " sh -c "${pynchon.run} vhs apply" +docs.rotations: + python imgrot.py img/graph.png --bg lightblue --rotation x --stream > img/rx.gif + python imgrot.py img/graph.png --bg lightblue --rotation y --stream > img/ry.gif + python imgrot.py img/graph.png --bg lightblue --rotation s --stream > img/rs.gif + python imgrot.py img/graph.png --bg lightblue --rotation j --stream > img/rj.gif + python imgrot.py img/graph.png --bg lightblue --rotation w --stream > img/rw.gif + python imgrot.py img/graph.png --bg lightblue --rotation f --stream > img/rf.gif + python imgrot.py img/graph.png --bg lightblue --rotation exit-ul --stream > img/rul.gif + python imgrot.py img/graph.png --bg lightblue --rotation exit-ur --stream > img/rur.gif + python imgrot.py img/graph.png --bg lightblue --rotation exit-lr --stream > img/rlr.gif + python imgrot.py img/graph.png --bg lightblue --rotation exit-ll --stream > img/rll.gif + + +docker.clean: + docker rmi $(DOCKER_IMAGE_NAME) >/dev/null || true + +docker.build build.docker: + docker build -t $(DOCKER_IMAGE_NAME) . + docker tag $(DOCKER_IMAGE_NAME) ${DOCKER_ORG}/imgrot:latest + docker tag $(DOCKER_IMAGE_NAME) ${DOCKER_ORG}/imgrot:${SHORT_SHA} + +docker.push: + docker push ${DOCKER_ORG}/imgrot:latest + docker push ${DOCKER_ORG}/imgrot:${SHORT_SHA} + +docker.shell: + docker run -it --rm -v `pwd`:/workspace -w /workspace \ + --entrypoint bash $(DOCKER_IMAGE_NAME) + +docker.base=docker run --rm -v `pwd`:/workspace -w /workspace +docker.test: + set -x \ + && ${docker.base} \ + --entrypoint sh $(DOCKER_IMAGE_NAME) \ + -x -c "ls /opt/imgrot > /dev/null" \ + && ${docker.base} $(DOCKER_IMAGE_NAME) \ + img/icon.png \ + --range 360 --img-shape 200x200 \ + --stream > .tmp.output.gif \ + && (which imgcat && .tmp.output.gif || true) \ + && ${docker.base} $(DOCKER_IMAGE_NAME) \ + img/icon.png --display + +test: docker.test + +py-clean: + rm -rf tmp.pypi* dist/* build/* \ + && rm -rf src/*.egg-info/ + find . -name '*.tmp.*' -delete + find . -name '*.pyc' -delete + find . -name __pycache__ -delete + find . -type d -name .tox | xargs -n1 -I% bash -x -c "rm -rf %" + rmdir build || true +# version: +# @python setup.py --version +# pypi-release: +# PYPI_RELEASE=1 make build \ +# && twine upload \ +# --user $${PYPI_USER} \ +# --password $${PYPI_TOKEN} \ +# dist/* + +release: clean normalize static-analysis test + +tox-%: + tox -e ${*} + +normalize: tox-normalize +lint static-analysis: tox-static-analysis +# smoke-test stest: tox-stest +# test-integrations itest: tox-itest +# utest test-units: tox-utest +# dtest: tox-dtest +# docs-test: dtest +# test: test-units test-integrations smoke-test +# iterate: clean normalize lint test \ No newline at end of file diff --git a/README.md b/README.md index a71802b..1597b74 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,161 @@ -# Perspective Transformation along specific axes +
-## Animation + + + + + + + + + +
+ imgrot + + + +
+
+ Tool for creating 3D rotation gifs from 2D images
+ +
+
+
-![](example/rotate_x_dx5.gif) -Rotate along X axis and translate 5 pixel along X axis +## Overview -![](example/rotate_xz.gif) -Rotate along XZ axis +A fork / update for the excellent original work at [eborboihuc/rotate_3d](https://github.com/eborboihuc/rotate_3d). -## Prerequisites +The original uses `opencv` to rotate 2d -> 3d. This version adds better CLI parsing, support for python3, newer opencv, ability to animate and render animations with `ffmpeg`, and works via docker. -- Linux -- Python 2.7 with numpy -- OpenCV 2.4.9 +Different kinds of rotation are supported as well (see the end of this page for [a gallery](#changing-axis-of-rotation)). Besides creating gifs, it can display them inside a terminal, using [chafa](https://github.com/hpjansson/chafa). + +See also [the original docs](docs/README.original.md) + +------------------------------------- + +## Installation + +Nothing on pypi. See [dockerhub](https://hub.docker.com/r/robotwranglers/imgrot) for available releases. + +``` +pip install -r requirements.txt +``` + +------------------------------------- ## Usage -Change main function with ideal [arguments](#parameters) +**Basic usage info follows:** ```bash -python demo.py [path of the image] [degree to rotate] ([ideal width] [ideal height]) -``` -e.g., -Example of rotating an image along yz-axis from 0 to 360 degree with a 5 pixel shift in +X direction -```python -- rotated_img = it.rotate_along_axis(phi = ang, dx = 5) -+ #rotated_img = it.rotate_along_axis(phi = ang, dx = 5) - -- #rotated_img = it.rotate_along_axis(phi = ang, gamma = ang) -+ rotated_img = it.rotate_along_axis(phi = ang, gamma = ang) +Usage: imgrot.py [OPTIONS] IMG_PATH + +Options: + --bg TEXT Background color to pass to chafa + --display Display output with chafa + --invert Pass --invert to chafa + --img-shape TEXT Ideal image shape in WxH format (optional) + --duration TEXT Duration argument to pass to chafa + --output-dir TEXT Output directory for frames + --output-file TEXT Output file for animated gif + --range TEXT Range to rotate through + --stream / --no-stream Stream output in raw format (for use with pipes). + Implies --animate + --verbose Whether or not ffmpeg-stderr is displayed + --view View a file with chafa (generates nothing) + --rotation TEXT One of { x | y | yz } + --speed TEXT Speed factor to pass to ffmpeg (default=.08) + --stretch Whether to pass --stretch to chafa + --help Show this message and exit. + ``` -Then + +You can also set `LOGLEVEL=debug` for more info. + +------------------------------------- + +## Usage from Docker + +A few examples of usage from docker: + +#### Saving an Animation + ```bash -python demo.py images/000001.jpg 360 +$ docker run -it --rm -v `pwd`:/workspace -w /workspace robotwranglers/imgrot img/icon.png --range 360 --img-shape 200x200 --stream > demo.gif ``` -## Parameters: +

+ +

+ +#### Terminal-Friendly Display -```python -it = ImageTransformer(img_path, img_shape) -it.rotate_along_axis(theta=0, phi=0, gamma=0, dx=0, dy=0, dz=0): +```bash +$ docker run -it --rm -v `pwd`:/workspace -w /workspace robotwranglers/imgrot img/icon.png --display --stretch --bg lightblue ``` -- img_path : the path of image that you want rotated -- shape : the ideal shape of input image, None for original size. -- phi : the rotation around the y axis -- gamma : the rotation around the z axis (basically a 2D rotation) -- dx : translation along the x axis -- dy : translation along the y axis -- dz : translation along the z axis (distance to the image) +

+ +

+ +Note that this tries to respect transparency in the original image, but for more contrast with black images on black terminals, you can effectively add highlights by passing '--bg' arguments that go through to chafa. -## Acknowledgments +------------------------------ -Code ported and modified from [jepson](http://jepsonsblog.blogspot.tw/2012/11/rotation-in-3d-using-opencvs.html) and [stackoverflow](http://stackoverflow.com/questions/17087446/how-to-calculate-perspective-transform-for-opencv-from-rotation-angles). Thanks for their excellent work! +#### Changing Axis of Rotation -## Author +The rotation can be controlled to create a bunch of different effects: + +```bash +$ docker run -it --rm -v `pwd`:/workspace \ + -w /workspace robotwranglers/imgrot \ + img/icon.png \ + --display --stretch \ + --bg darkgreen \ + --rotation +``` -Hou-Ning Hu / [@eborboihuc](https://eborboihuc.github.io/) +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
xys,swivel
+ + +
j,jitterw,wobblef,flip
+ + +
exit-ulexit-urexit-lr
+ + +
+

diff --git a/README.md.j2 b/README.md.j2 new file mode 100644 index 0000000..458afbb --- /dev/null +++ b/README.md.j2 @@ -0,0 +1,141 @@ +
+ + + + + + + + + +
+ imgrot + + + +
+
+ Tool for creating 3D rotation gifs from 2D images
+ +
+
+
+ +## Overview + +A fork / update for the excellent original work at [eborboihuc/rotate_3d](https://github.com/eborboihuc/rotate_3d). + +The original uses `opencv` to rotate 2d -> 3d. This version adds better CLI parsing, support for python3, newer opencv, ability to animate and render animations with `ffmpeg`, and works via docker. + +Different kinds of rotation are supported as well (see the end of this page for [a gallery](#changing-axis-of-rotation)). Besides creating gifs, it can display them inside a terminal, using [chafa](https://github.com/hpjansson/chafa). + +See also [the original docs](docs/README.original.md) + +------------------------------------- + +## Installation + +Nothing on pypi. See [dockerhub]({{dockerhub.repo_url}}) for available releases. + +``` +pip install -r requirements.txt +``` + +------------------------------------- + +## Usage + +**Basic usage info follows:** + +```bash +{{bash('python imgrot.py --help')}} +``` + +You can also set `LOGLEVEL=debug` for more info. + +------------------------------------- + +## Usage from Docker + +A few examples of usage from docker: + +#### Saving an Animation + +```bash +$ docker run -it --rm -v `pwd`:/workspace -w /workspace robotwranglers/imgrot img/icon.png --range 360 --img-shape 200x200 --stream > demo.gif +``` + +

+ +

+ +#### Terminal-Friendly Display + +```bash +$ docker run -it --rm -v `pwd`:/workspace -w /workspace robotwranglers/imgrot img/icon.png --display --stretch --bg lightblue +``` + +

+ +

+ +Note that this tries to respect transparency in the original image, but for more contrast with black images on black terminals, you can effectively add highlights by passing '--bg' arguments that go through to chafa. + +------------------------------ + +#### Changing Axis of Rotation + +The rotation can be controlled to create a bunch of different effects: + +```bash +$ docker run -it --rm -v `pwd`:/workspace \ + -w /workspace robotwranglers/imgrot \ + img/icon.png \ + --display --stretch \ + --bg darkgreen \ + --rotation +``` + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
xys,swivel
+ + +
j,jitterw,wobblef,flip
+ + +
exit-ulexit-urexit-lr
+ + +
+

diff --git a/demo.py b/demo.py deleted file mode 100644 index 011eca5..0000000 --- a/demo.py +++ /dev/null @@ -1,61 +0,0 @@ -from image_transformer import ImageTransformer -from util import save_image -import sys -import os - -# Usage: -# Change main function with ideal arguments -# then -# python demo.py [name of the image] [degree to rotate] ([ideal width] [ideal height]) -# e.g., -# python demo.py images/000001.jpg 360 -# python demo.py images/000001.jpg 45 500 700 -# -# Parameters: -# img_path : the path of image that you want rotated -# shape : the ideal shape of input image, None for original size. -# theta : the rotation around the x axis -# phi : the rotation around the y axis -# gamma : the rotation around the z axis (basically a 2D rotation) -# dx : translation along the x axis -# dy : translation along the y axis -# dz : translation along the z axis (distance to the image) -# -# Output: -# image : the rotated image - - -# Input image path -img_path = sys.argv[1] - -# Rotation range -rot_range = 360 if len(sys.argv) <= 2 else int(sys.argv[2]) - -# Ideal image shape (w, h) -img_shape = None if len(sys.argv) <= 4 else (int(sys.argv[3]), int(sys.argv[4])) - -# Instantiate the class -it = ImageTransformer(img_path, img_shape) - -# Make output dir -if not os.path.isdir('output'): - os.mkdir('output') - -# Iterate through rotation range -for ang in xrange(0, rot_range): - - # NOTE: Here we can change which angle, axis, shift - - """ Example of rotating an image along y-axis from 0 to 360 degree - with a 5 pixel shift in +X direction """ - rotated_img = it.rotate_along_axis(phi = ang, dx = 5) - - """ Example of rotating an image along yz-axis from 0 to 360 degree """ - #rotated_img = it.rotate_along_axis(phi = ang, gamma = ang) - - """ Example of rotating an image along z-axis(Normal 2D) from 0 to 360 degree """ - #rotated_img = it.rotate_along_axis(gamma = ang) - - save_image('output/{}.jpg'.format(str(ang).zfill(3)), rotated_img) - - diff --git a/docs/README.original.md b/docs/README.original.md new file mode 100644 index 0000000..731cb7f --- /dev/null +++ b/docs/README.original.md @@ -0,0 +1,60 @@ +# Perspective Transformation along specific axes + +## Animation + +![](example/rotate_x_dx5.gif) +Rotate along X axis and translate 5 pixel along X axis + +![](example/rotate_xz.gif) +Rotate along XZ axis + +## Prerequisites + +- Linux +- Python 2.7 with numpy +- OpenCV 2.4.9 + +## Usage + +Change main function with ideal [arguments](#parameters) + +```bash +python imgrot.py [path of the image] [degree to rotate] ([ideal width] [ideal height]) +``` +e.g., +Example of rotating an image along yz-axis from 0 to 360 degree with a 5 pixel shift in +X direction +```python +- rotated_img = it.rotate_along_axis(phi = ang, dx = 5) ++ #rotated_img = it.rotate_along_axis(phi = ang, dx = 5) + +- #rotated_img = it.rotate_along_axis(phi = ang, gamma = ang) ++ rotated_img = it.rotate_along_axis(phi = ang, gamma = ang) +``` +Then +```bash +python imgrot.py img/000001.jpg 360 +``` + +## Parameters: + +``` +it = ImageTransformer(img_path, img_shape) +it.rotate_along_axis(theta=0, phi=0, gamma=0, dx=0, dy=0, dz=0): +``` + +- img_path : the path of image that you want rotated +- shape : the ideal shape of input image, None for original size. +- phi : the rotation around the y axis +- gamma : the rotation around the z axis (basically a 2D rotation) +- dx : translation along the x axis +- dy : translation along the y axis +- dz : translation along the z axis (distance to the image) + + +## Acknowledgments + +Code ported and modified from [jepson](http://jepsonsblog.blogspot.tw/2012/11/rotation-in-3d-using-opencvs.html) and [stackoverflow](http://stackoverflow.com/questions/17087446/how-to-calculate-perspective-transform-for-opencv-from-rotation-angles). Thanks for their excellent work! + +## Author + +Hou-Ning Hu / [@eborboihuc](https://eborboihuc.github.io/) diff --git a/docs/tape/demo.tape b/docs/tape/demo.tape new file mode 100644 index 0000000..f5629d3 --- /dev/null +++ b/docs/tape/demo.tape @@ -0,0 +1,16 @@ +# This file describes terminal videos with the `vhs` tool +# See https://github.com/charmbracelet/vhs for docs +Output img/demo.chafa.gif +Require "docker" +Set Shell "sh" +Set FontSize 16 +Set Width 800 +Set Height 800 +Set TypingSpeed .05 +Set PlaybackSpeed 1 +Set CursorBlink false +Type "python imgrot.py img/icon.png --display --stretch --bg lightblue" +Sleep 1.1 +Enter +Sleep 30 + diff --git a/image_transformer.py b/image_transformer.py index 2c611bb..bc154ee 100644 --- a/image_transformer.py +++ b/image_transformer.py @@ -1,8 +1,4 @@ -from util import * -import numpy as np -import cv2 - -# Usage: +# Usage: # Change main function with ideal arguments # Then # from image_tranformer import ImageTransformer @@ -19,31 +15,136 @@ # # Output: # image : the rotated image -# +# # Reference: # 1. : http://stackoverflow.com/questions/17087446/how-to-calculate-perspective-transform-for-opencv-from-rotation-angles # 2. : http://jepsonsblog.blogspot.tw/2012/11/rotation-in-3d-using-opencvs.html +import math +import random +from math import pi + +import cv2 +import numpy as np + + +def get_rad(theta, phi, gamma): + return (deg_to_rad(theta), deg_to_rad(phi), deg_to_rad(gamma)) + + +def get_deg(rtheta, rphi, rgamma): + return (rad_to_deg(rtheta), rad_to_deg(rphi), rad_to_deg(rgamma)) + + +def deg_to_rad(deg): + return deg * pi / 180.0 -class ImageTransformer(object): - """ Perspective transformation class for image - with shape (height, width, #channels) """ + +def rad_to_deg(rad): + return rad * 180.0 / pi + + +class ImageTransformer: + """ + Perspective transformation class for image + with shape (height, width, #channels) + """ + + def load_image(self, img_path, shape=None): + """ """ + img = cv2.imread(img_path, cv2.IMREAD_UNCHANGED) + if shape is not None: + img = cv2.resize(img, shape) + return img def __init__(self, image_path, shape): + """ """ self.image_path = image_path - self.image = load_image(image_path, shape) - + self.image = self.load_image(image_path, shape) + self.height = self.image.shape[0] self.width = self.image.shape[1] self.num_channels = self.image.shape[2] + def get_rotation_args(self, rotation: str, ang: int): + """ + Generates the arguments used for 'rotate_along_axis' + This takes a rotation-mode 'rotation' and an angle + named 'ang' in (0 - rot_range), and is called from a loop. + """ + if rotation in ["y"]: + # y-axis from 0-360 degree, 5 pixel shift in +X + rotargs = dict(phi=ang, dx=5) + elif rotation in ["x"]: + rotargs = dict(gamma=ang) + elif rotation in ["s", "swivel"]: + # yz-axis from 0 to 360 degree + rotargs = dict(phi=ang, gamma=ang) + elif rotation in ["jitter", "j"]: + rotargs = dict( + dx=random.choice([5, 0, 10]), + phi=random.choice([ang, -ang]), + gamma=random.choice([0, ang, -ang]), + ) + elif rotation in ["wobble", "w"]: + rotargs = dict( + dx=random.choice([0, 10, 30]), + dy=random.choice([25, 0, 10]), + phi=random.choice([ang, -ang, math.sin(ang)]), + gamma=random.choice([ang, -ang]), + ) + elif rotation in ["f", "flip"]: + rotargs = dict(dx=ang, dy=-ang, phi=math.tan(ang), gamma=ang) + elif rotation.startswith("q"): + rotargs = dict( + dx=random.choice([0, 5, 10]), + dy=random.choice([25, 0, 10]), + phi=random.choice([ang, -ang, math.sin(ang)]), + gamma=random.choice([ang, -ang]), + ) + elif rotation.startswith("exit"): + direction = rotation.split("-")[1] + if direction == "ul": + rotargs = dict( + dx=-ang, + dy=-ang, + ) + elif direction == "ur": + rotargs = dict( + dx=ang, + dy=-ang, + ) + elif direction == "lr": + rotargs = dict( + dx=ang, + dy=ang, + ) + elif direction == "ll": + rotargs = dict( + dx=-ang, + dy=ang, + ) + else: + raise ValueError(f"unknown rotation: {rotation}") + rotargs.update(phi=math.tan(ang), gamma=math.tan(ang)) + else: + raise ValueError(f"Not sure how to perform rotation {rotation}") + return rotargs + + @staticmethod + def save_image(img_path, img): + """ + Saves an output image + This is usually one that's resulted from a rotation, i.e a single frame in a larger animation + """ + cv2.imwrite(img_path, img) - """ Wrapper of Rotating a Image """ def rotate_along_axis(self, theta=0, phi=0, gamma=0, dx=0, dy=0, dz=0): - + """Returns the new image that results from rotating self.image""" + # Get radius of rotation along 3 axes rtheta, rphi, rgamma = get_rad(theta, phi, gamma) - + # Get ideal focal length on z axis # NOTE: Change this section to other axis if needed d = np.sqrt(self.height**2 + self.width**2) @@ -52,53 +153,53 @@ def rotate_along_axis(self, theta=0, phi=0, gamma=0, dx=0, dy=0, dz=0): # Get projection matrix mat = self.get_M(rtheta, rphi, rgamma, dx, dy, dz) - return cv2.warpPerspective(self.image.copy(), mat, (self.width, self.height)) - - """ Get Perspective Projection Matrix """ def get_M(self, theta, phi, gamma, dx, dy, dz): - + """Get Perspective Projection Matrix""" w = self.width h = self.height f = self.focal # Projection 2D -> 3D matrix - A1 = np.array([ [1, 0, -w/2], - [0, 1, -h/2], - [0, 0, 1], - [0, 0, 1]]) - + A1 = np.array([[1, 0, -w / 2], [0, 1, -h / 2], [0, 0, 1], [0, 0, 1]]) + # Rotation matrices around the X, Y, and Z axis - RX = np.array([ [1, 0, 0, 0], - [0, np.cos(theta), -np.sin(theta), 0], - [0, np.sin(theta), np.cos(theta), 0], - [0, 0, 0, 1]]) - - RY = np.array([ [np.cos(phi), 0, -np.sin(phi), 0], - [0, 1, 0, 0], - [np.sin(phi), 0, np.cos(phi), 0], - [0, 0, 0, 1]]) - - RZ = np.array([ [np.cos(gamma), -np.sin(gamma), 0, 0], - [np.sin(gamma), np.cos(gamma), 0, 0], - [0, 0, 1, 0], - [0, 0, 0, 1]]) + RX = np.array( + [ + [1, 0, 0, 0], + [0, np.cos(theta), -np.sin(theta), 0], + [0, np.sin(theta), np.cos(theta), 0], + [0, 0, 0, 1], + ] + ) + + RY = np.array( + [ + [np.cos(phi), 0, -np.sin(phi), 0], + [0, 1, 0, 0], + [np.sin(phi), 0, np.cos(phi), 0], + [0, 0, 0, 1], + ] + ) + + RZ = np.array( + [ + [np.cos(gamma), -np.sin(gamma), 0, 0], + [np.sin(gamma), np.cos(gamma), 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ] + ) # Composed rotation matrix with (RX, RY, RZ) R = np.dot(np.dot(RX, RY), RZ) # Translation matrix - T = np.array([ [1, 0, 0, dx], - [0, 1, 0, dy], - [0, 0, 1, dz], - [0, 0, 0, 1]]) + T = np.array([[1, 0, 0, dx], [0, 1, 0, dy], [0, 0, 1, dz], [0, 0, 0, 1]]) # Projection 3D -> 2D matrix - A2 = np.array([ [f, 0, w/2, 0], - [0, f, h/2, 0], - [0, 0, 1, 0]]) + A2 = np.array([[f, 0, w / 2, 0], [0, f, h / 2, 0], [0, 0, 1, 0]]) # Final transformation matrix return np.dot(A2, np.dot(T, np.dot(R, A1))) - diff --git a/images/000001.jpg b/images/000001.jpg deleted file mode 100755 index 5a37d29..0000000 Binary files a/images/000001.jpg and /dev/null differ diff --git a/img/demo.chafa.gif b/img/demo.chafa.gif new file mode 100644 index 0000000..1b3c1bf Binary files /dev/null and b/img/demo.chafa.gif differ diff --git a/img/demo.gif b/img/demo.gif new file mode 100644 index 0000000..388a831 Binary files /dev/null and b/img/demo.gif differ diff --git a/img/graph.png b/img/graph.png new file mode 100644 index 0000000..e0a084c Binary files /dev/null and b/img/graph.png differ diff --git a/img/icon.png b/img/icon.png new file mode 100644 index 0000000..7b744ec Binary files /dev/null and b/img/icon.png differ diff --git a/img/rf.gif b/img/rf.gif new file mode 100644 index 0000000..4032eab Binary files /dev/null and b/img/rf.gif differ diff --git a/img/rj.gif b/img/rj.gif new file mode 100644 index 0000000..a9d87d8 Binary files /dev/null and b/img/rj.gif differ diff --git a/img/rll.gif b/img/rll.gif new file mode 100644 index 0000000..31fbeb2 Binary files /dev/null and b/img/rll.gif differ diff --git a/img/rlr.gif b/img/rlr.gif new file mode 100644 index 0000000..c85398f Binary files /dev/null and b/img/rlr.gif differ diff --git a/img/rs.gif b/img/rs.gif new file mode 100644 index 0000000..e48d5dd Binary files /dev/null and b/img/rs.gif differ diff --git a/img/rul.gif b/img/rul.gif new file mode 100644 index 0000000..e4740b3 Binary files /dev/null and b/img/rul.gif differ diff --git a/img/rur.gif b/img/rur.gif new file mode 100644 index 0000000..01876f9 Binary files /dev/null and b/img/rur.gif differ diff --git a/img/rw.gif b/img/rw.gif new file mode 100644 index 0000000..dab30aa Binary files /dev/null and b/img/rw.gif differ diff --git a/img/rx.gif b/img/rx.gif new file mode 100644 index 0000000..14351bf Binary files /dev/null and b/img/rx.gif differ diff --git a/img/ry.gif b/img/ry.gif new file mode 100644 index 0000000..1660a7a Binary files /dev/null and b/img/ry.gif differ diff --git a/imgrot.py b/imgrot.py new file mode 100644 index 0000000..9f56ed2 --- /dev/null +++ b/imgrot.py @@ -0,0 +1,168 @@ +""" +# Usage: +# Change main function with ideal arguments +# then +# python imgrot.py [name of the image] [degree to rotate] ([ideal width] [ideal height]) +# e.g., +# python imgrot.py img/000001.jpg 360 +# python imgrot.py img/000001.jpg 45 500 700 +# +# Parameters: +# img_path : path of image that you want rotated +# shape : ideal shape of input image, None for original size. +# theta : rotation around the x axis +# phi : rotation around the y axis +# gamma : rotation around the z axis (basically a 2D rotation) +# dx : translation along the x axis +# dy : translation along the y axis +# dz : translation along the z axis (distance to the image) +""" + +import os +import sys +import logging +import subprocess + +import click + +from image_transformer import ImageTransformer + +# Setup logging +log_level = os.getenv("LOGLEVEL", "INFO").upper() +logging.basicConfig( + level=getattr(logging, log_level, logging.INFO), + format="%(asctime)s - imgrot - %(levelname)s - %(message)s", + handlers=[logging.StreamHandler(sys.stderr)], +) +logger = logging.getLogger(__name__) + + +# Setup CLI interface +@click.command() +@click.option("--bg", default="black", help="Background color to pass to chafa") +@click.option( + "--display", is_flag=True, default=False, help="Display output with chafa" +) +@click.option("--invert", is_flag=True, default=False, help="Pass --invert to chafa") +@click.option( + "--img-shape", default=None, help="Ideal image shape in WxH format (optional)" +) +@click.option( + "--duration", + default="", + help="Duration argument to pass to chafa", +) +@click.option("--output-dir", default="/tmp/imgrot", help="Output directory for frames") +@click.option( + "--output-file", default="/tmp/imgrot.gif", help="Output file for animated gif" +) +@click.option("--range", "rot_range", default="360", help="Range to rotate through") +@click.option( + "--stream/--no-stream", + default=False, + help="Stream output in raw format (for use with pipes). Implies --animate", +) +@click.option( + "--verbose", + is_flag=True, + default=False, + help="Whether or not ffmpeg-stderr is displayed", +) +@click.option( + "--view", + is_flag=True, + default=False, + help="View a file with chafa (generates nothing)", +) +@click.option("--rotation", default="y", help="One of { x | y | yz }") +@click.option( + "--speed", default=".08", help="Speed factor to pass to ffmpeg (default=.08)" +) +@click.option( + "--stretch", + is_flag=True, + default=False, + help="Whether to pass --stretch to chafa", +) +@click.argument("img_path", required=True) +def run( + img_path: str, + stretch: bool = False, + duration: str = "", + display: bool = False, + invert: bool = False, + verbose: bool = False, + img_shape=None, + speed: str = "0.08", + rotation: str = "y", + bg: str = "black", + output_dir: str = "/tmp", + output_file: str = "/tmp/imgrot.gif", + rot_range: str = "360", + stream: bool = False, + view: bool = False, +): + stretch = "--stretch" if stretch else "" + duration = f"--duration {duration}" if duration else "" + invert = invert and "--invert" or "" + bg = f"--bg {bg}" + + if not os.path.exists(img_path): + logger.debug(f"{img_path} does not exist!") + raise SystemExit(1) + + rot_range = int(rot_range) + img_shape = img_shape and list(map(int, img_shape.split("x"))) + it = ImageTransformer(img_path, img_shape) + if not os.path.isdir(output_dir): + os.mkdir(output_dir) + logger.debug(f"Rotating {img_path} .. ") + for ang in range(0, rot_range): + rotated_img = it.rotate_along_axis(**it.get_rotation_args(rotation, ang)) + fname = f"{output_dir}/{str(ang).zfill(3)}.png" + it.save_image(fname, rotated_img) + logger.debug("Done") + + command = f"ls {output_dir}/|wc -l" + result = subprocess.run(command, shell=True, capture_output=True, text=True) + if result.returncode != 0: + logger.debug(f"Command failed with return code {result.returncode}\n") + raise SystemExit(result.returncode) + else: + logger.debug(f"Total Frames: {result.stdout.strip()}") + logger.debug("Animating..") + quiet_maybe = "" if verbose else "2> /dev/null" + commands = [ + f"ffmpeg -y -i {output_dir}/000.png -vf palettegen=reserve_transparent=1 /tmp/palette.png {quiet_maybe}", + f"ffmpeg -y -framerate 140 -pattern_type glob -i '{output_dir}/*.png' -i /tmp/palette.png -lavfi paletteuse=alpha_threshold=128 -gifflags -offsetting /tmp/.tmp.gif {quiet_maybe}", + f"""ffmpeg -y -i /tmp/.tmp.gif -vf "split[s0][s1];[s0]palettegen[p];[s1]setpts={speed}*PTS[v];[v][p]paletteuse" {output_file} {quiet_maybe}""", + ] + for cmd in commands: + logger.debug(cmd) + result = subprocess.run(cmd, shell=True, stdout=sys.stderr, stderr=sys.stderr) + if result.returncode != 0: + logger.critical(f"Command failed with return code {result.returncode}") + raise SystemExit(result.returncode) + if view: + logger.debug(f"Viewing {img_path}") + os.system(f"chafa {stretch} {duration} {img_path}") + elif display: + logger.debug("Displaying animated gif..") + os.system( + f"chafa {bg} {duration} {stretch} {invert} --symbols 'braille' {output_file}" + ) + elif stream: + logger.debug("Streaming animation..") + os.system( + f"convert {output_file} -fuzz 10% -transparent white {output_file} {quiet_maybe}" + ) + with open(output_file, "rb") as binary_file: + content = binary_file.read() + sys.stdout.buffer.write(content) + else: + logger.critical("No instructions, not sure what to do") + raise SystemExit(1) + + +if __name__ == "__main__": + run() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7337b47 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +opencv-python +click \ No newline at end of file diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..a0dca94 --- /dev/null +++ b/tox.ini @@ -0,0 +1,99 @@ +## +# tox automation for ssm +# https://tox.wiki/en/latest/config.html +## +[tox] +skipsdist = True +usedevelop = True +recreate = False + +[testenv:ssort] +description=Sorts the source code +setenv = + {[testenv]setenv} +deps = + ssort==0.11.6 +commands = + bash -x -c "env|grep TOX && ssort {toxinidir}/*.py" + +[testenv] +allowlist_externals = + bash + pytest + ipython +deps = + -e .[testing] +install_command= + python -m pip install {packages} +setenv = + PYNCHON_TOX_RUNTIME=True + +[testenv:shell] +description=Debugging shell for this project +commands = + python -m ssm shell +deps = + IPython + +# [testenv:type-check] +# description= +# Type checking for this project. Informational; this is not enforced yet +# recreate = False +# env_dir={toxinidir}/../.tox/{env_name} +# deps= +# -e .[typing] +# commands = +# bash -x -c "\ +# (mypy --install-types --non-interactive src/||true)" + +# [testenv:utest] +# description=Unit tests +# setenv = +# {[testenv]setenv} +# TEST_SUITE=units +# commands = +# bash -x -c "env && pytest -s tests/units" + +# [testenv:stest] +# description=Smoke tests +# setenv = +# {[testenv]setenv} +# TEST_SUITE=smoke +# commands = +# bash -x -c "env && bash -x tests/smoke/test.sh && pytest -s tests/smoke" + +# [testenv:itest] +# description=Integration tests +# setenv = +# {[testenv]setenv} +# TEST_SUITE=integration +# commands = +# bash -x -c "env && bash -x tests/integration/test.sh && pytest --exitfirst -s tests/integration" + +[testenv:normalize] +description = Normalizes code for this project +deps = + shed==2023.5.1 + autopep8==1.7.0 + isort==5.11.5 +commands = + bash -x -c "\ + shed; \ + autopep8 --recursive --in-place {toxinidir}/*.py; \ + isort --settings-file {toxinidir}/.isort.cfg {toxinidir}/*.py;" + # bash -x -c "\ + # autopep8 --recursive --in-place {toxinidir}/tests; \ + # isort --settings-file {toxinidir}/.isort.cfg {toxinidir}/tests;" + +[testenv:static-analysis] +description = Runs Flake8 +skip_install = True +recreate = False +deps = + flake8==5.0.4 + vulture==2.6 +commands = + bash -x -c "\ + flake8 --config {toxinidir}/.flake8 *.py \ + && vulture {toxinidir}/*.py --min-confidence 90 \ + " diff --git a/util.py b/util.py deleted file mode 100644 index 9be24bf..0000000 --- a/util.py +++ /dev/null @@ -1,30 +0,0 @@ -from math import pi -import cv2 - -""" Utility Functions """ - -def load_image(img_path, shape=None): - img = cv2.imread(img_path) - if shape is not None: - img = cv2.resize(img, shape) - - return img - -def save_image(img_path, img): - cv2.imwrite(img_path, img) - -def get_rad(theta, phi, gamma): - return (deg_to_rad(theta), - deg_to_rad(phi), - deg_to_rad(gamma)) - -def get_deg(rtheta, rphi, rgamma): - return (rad_to_deg(rtheta), - rad_to_deg(rphi), - rad_to_deg(rgamma)) - -def deg_to_rad(deg): - return deg * pi / 180.0 - -def rad_to_deg(rad): - return deg * 180.0 / pi